23 вересня 2023 р.

Прив’язка контексту до функції

Передаючи методи об’єкту в якості колбеків, наприклад в setTimeout, існує відома проблема: "втрата this".

В цьому розділі ми розглянемо способи як це можливо виправити.

Втрата “this”

Ми вже розглядали приклади втрати this. Якщо метод передавався окремо від об’єкта – this втрачається.

В прикладі наведено як це відбувається з setTimeout:

let user = {
  firstName: "Іван",
  sayHi() {
    alert(`Привіт, ${this.firstName}!`);
  }
};

setTimeout(user.sayHi, 1000); // Привіт, undefined!

Як ми можемо спостерігати, модальне вікно браузера відображає не “Іван” як this.firstName, а undefined!

Це тому що setTimeout отримав функцію user.sayHi окремо від об’єкта. Останній рядок може бути переписаний наступним чином:

let f = user.sayHi;
setTimeout(f, 1000); // втрата контексту об’єкта user

Метод setTimeout в браузері трохи особливий: він встановлює this=window під час виклику функції (для Node.js, this стане об’єкт таймеру, але це не дуже важливо у цьому випадку). Таким чином для this.firstName метод намагається отримати window.firstName, що не існує. В інших схожих випадках, зазвичай this просто стає undefined.

Задача досить типова – ми хочемо передати метод об’єкту деінде (в цьому випадку – в планувальник) де він буде викликаний. Як бути впевненими в тому, що він буде викликаний з правильним контекстом?

Рішення 1: обгортка

Найпростіше рішення – використати функцію обгортку:

let user = {
  firstName: "Іван",
  sayHi() {
    alert(`Привіт, ${this.firstName}!`);
  }
};

setTimeout(function() {
  user.sayHi(); // Привіт, Іван!
}, 1000);

Тепер це працює, тому що ми отримали user з зовнішнього лексичного оточення, і потім викликали метод.

Аналогічний запис, тільки коротший:

setTimeout(() => user.sayHi(), 1000); // Привіт, Іван!

Виглядає чудово, але з’являється легка вразливість в структурі нашого коду.

Що якщо перед спрацюванням setTimeout (з однією секундою затримки!) user змінить значення? Тоді, неочікувано, він викличе неправильний об’єкт!

let user = {
  firstName: "Іван",
  sayHi() {
    alert(`Привіт, ${this.firstName}!`);
  }
};

setTimeout(() => user.sayHi(), 1000);

// ...значення user змінюється впродовж 1 секунди
user = {
  sayHi() { alert("Інший user в setTimeout!"); }
};

// Інший user в setTimeout!

Наступне рішення гарантує, що така ситуація не трапиться.

Рішення 2: прив’язка

Функції надають нам вбудований метод bind, що дозволяє зберегти правильний this.

Основний синтаксис:

// більш складний синтаксис буде трохи пізніше
let boundFunc = func.bind(context);

Результатом func.bind(context) буде певний “екзотичний об’єкт”, який може бути викликаний як функція та передає виклику func встановлений контекст this=context.

Іншими словами, виклик boundFunc це як виклик func з виправленим this.

Наприклад, тут funcUser передає виклик func з this=user:

let user = {
  firstName: "Іван"
};

function func() {
  alert(this.firstName);
}

let funcUser = func.bind(user);
funcUser(); // Іван

Тут func.bind(user) як “прив’язаний варіант” функції func, з виправленим this=user.

Всі аргументи передаються початковій функції func “як є”, наприклад:

let user = {
  firstName: "Іван"
};

function func(phrase) {
  alert(phrase + ', ' + this.firstName);
}

// прив’язка до user
let funcUser = func.bind(user);

funcUser("Привіт"); // Привіт, Іван (переданий аргумент "Привіт" та this=user)

Тепер спробуємо з методом об’єкту:

let user = {
  firstName: "Іван",
  sayHi() {
    alert(`Привіт, ${this.firstName}!`);
  }
};

let sayHi = user.sayHi.bind(user); // (*)

// можемо викликати без об’єкту
sayHi(); // Привіт, Іван!

setTimeout(sayHi, 1000); // Привіт, Іван!

// навіть якщо значення user зміниться впродовж 1 секунди
// sayHi використовує прив’язане значення, яке посилається на старий об’єкт user
user = {
  sayHi() { alert("Інший user в setTimeout!"); }
};

В строчці (*) ми взяли метод user.sayHi та прив’язали його до user. sayHi є “прив’язаною” функцією, що може бути викликана окремо або передана до setTimeout – не важливо, контекст буде правильний.

В цьому прикладі ми можемо бачити що аргументи передані “як є”, тільки this виправлено за допомогою bind:

let user = {
  firstName: "Іван",
  say(phrase) {
    alert(`${phrase}, ${this.firstName}!`);
  }
};

let say = user.say.bind(user);

say("Привіт"); // Привіт, Іван! (Аргумент "Привіт" переданий функції say)
say("Бувай"); // Бувай, Іван! ("Бувай" передане функції say)
Зручний метод: bindAll

Якщо об’єкт містить багато методів та ми плануємо активно їх передавати, тоді ми можемо прив’язати їх всіх за допомогою циклу:

for (let key in user) {
  if (typeof user[key] == 'function') {
    user[key] = user[key].bind(user);
  }
}

JavaScript бібліотеки також пропонуються функції для зручної масової прив’язки, наприклад _.bindAll(object, methodNames) в бібліотеці lodash.

Часткове застосування

До цього моменту ми говорили тільки про прив’язку this. Зробимо крок далі.

Ми можемо прив’язати не тільки this, а також аргументи. Це робиться рідко, проте може бути інколи дуже зручним.

Повний синтаксис bind:

let bound = func.bind(context, [arg1], [arg2], ...);

Це дозволяє прив’язати context як this та початкові аргументи функції.

Наприклад, ми маємо функцію множення mul(a, b):

function mul(a, b) {
  return a * b;
}

Використаємо bind щоб створити функцію double на її основі:

function mul(a, b) {
  return a * b;
}

let double = mul.bind(null, 2);

alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10

Виклик mul.bind(null, 2) створює нову функцію double що передає виклик mul, встановлючи null як контекст та 2 як перший аргумент. Подальші аргументи передаються “як є”.

Це називається часткове застосування – ми створюємо нову функцію виправляючи деякі параметри існуючої.

Зверніть увагу, що ми не використовували this в цьому прикладі. Проте bind вимагає цього, тому ми маємо передати щось як заглушку – null.

Функція triple в коді нижче потроює значення:

function mul(a, b) {
  return a * b;
}

let triple = mul.bind(null, 3);

alert( triple(3) ); // = mul(3, 3) = 9
alert( triple(4) ); // = mul(3, 4) = 12
alert( triple(5) ); // = mul(3, 5) = 15

Чому ми використовуємо часткове застосування?

Перевагою цього є те, що ми можемо створити незалежну функцію з читабельним ім’ям (double, triple). Ми можемо використовувати її та не передавати перший аргумент кожен раз, оскільки це замість нас виконує bind.

В інших випадках, часткове застосування є корисним, коли ми маємо дуже загальну функцію та хочемо менш універсальний її варіант для зручності.

Наприклад, у нас є функція send(from, to, text). Тоді, в середині об’єкту user ми можемо захотіти використати її частковий варіант: sendTo(to, text), яка відправляє текст від поточного користувача.

Використання часткового застосування без контексту

Що якщо ми хочемо виправити деякі аргументи, але не контекст this? Наприклад, для методу об’єкта.

Вбудований метод bind не дозволяє цього. Ми можемо просто опустити контекст та перейти до аргументів.

На щастя, функція partial для прив’язування тільки аргументів може бути реалізована дуже просто.

Як тут:

function partial(func, ...argsBound) {
  return function(...args) { // (*)
    return func.call(this, ...argsBound, ...args);
  }
}

// Використання:
let user = {
  firstName: "Іван",
  say(time, phrase) {
    alert(`[${time}] ${this.firstName}: ${phrase}!`);
  }
};

// та метод partial з виправленим часом
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());

user.sayNow("Привіт");
// Something like:
// [10:00] Іван: Привіт!

Результатом виклику partial(func[, arg1, arg2...]) є обгортка (*) що викликає func з:

  • Те ж this яке воно отримує (для виклику user.sayNow це user)
  • Передає йому ...argsBound – аргументи з виклику partial ("10:00")
  • Потім передає йому ...args – аргументи отримані з обгортки ("Hello")

Як бачите, це легко робиться за допомогою оператору розширення, чи не так?

Також існує реалізація _.partial в бібліотеці lodash.

Підсумки

Метод func.bind(context, ...args) повертає a “прив’язаний варіант” функції func який виправляє контекст this та аргументи, якщо вони передані.

Зазвичай ми застосовуємо bind, щоб виправити this для методу об’єкта та передати його деінде. Наприклад, в setTimeout.

Коли ми виправляємо деякі аргументи існуючої функції, в результаті ми отримуємо (менш універсальну) функцію, яка називається частково застосованою або частковою.

Часткові функції зручні, коли ми не хочемо повторно передавати одні й ті ж самі аргументи знову і знову. Наприклад, якщо ми маємо функцію send(from, to) та аргумент from постійно буде однаковим для нашої поточної задачі, ми можемо зробити частково застосовану функцію та використовувати її.

Завдання

важливість: 5

Що виведе функція?

function f() {
  alert( this ); // ?
}

let user = {
  g: f.bind(null)
};

user.g();

Відповідь: null.

function f() {
  alert( this ); // null
}

let user = {
  g: f.bind(null)
};

user.g();

Контекст прив’язаної функції жорстко-фіксований. Немає способу змінити це в подальшому.

Так чином в той час як ми запускаємо user.g(), функція f викликається з this=null.

важливість: 5

Чи можемо ми змінити this за допомогою додаткового прив’язування?

Який результат буде виведено?

function f() {
  alert(this.name);
}

f = f.bind( {name: "Іван"} ).bind( {name: "Христя" } );

f();

Відповідь: Іван.

function f() {
  alert(this.name);
}

f = f.bind( {name: "Іван"} ).bind( {name: "Христя"} );

f(); // Іван

Екзотичний об’єкт прив’язаної функції повертається від f.bind(...), що запам’ятовує контекст (та аргументи, якщо передані) тільки під час створення.

Функція не може бути переприв’язана.

важливість: 5

Функції присвоєна властивість зі значенням. Чи зміниться вона після bind? Чому?

function sayHi() {
  alert( this.name );
}
sayHi.test = 5;

let bound = sayHi.bind({
  name: "Іван"
});

alert( bound.test ); // що виведе функція? Чому?

Відповідь: undefined.

Результатом bind є інший об’єкт. Він не містить властивість test.

важливість: 5

Виклик askPassword() в коді наведеному нижче повинен перевіряти пароль та викликати user.loginOk/loginFail в залежності від відповіді.

Але виконання коду призводить до помилки. Чому?

Виправте виділений рядок, щоб код запрацював правильно (інші рядки не мають бути змінені).

function askPassword(ok, fail) {
  let password = prompt("Пароль?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'Іван',

  loginOk() {
    alert(`${this.name} увійшов`);
  },

  loginFail() {
    alert(`${this.name} виконав невдалу спробу входу`);
  },

};

askPassword(user.loginOk, user.loginFail);

Помилка виникає тому що askPassword отримує функції loginOk/loginFail без об’єкту.

Коли вона викликає їх, їх контекст втрачено this=undefined.

Спробуємо використати bind, щоб прив’язати контекст:

function askPassword(ok, fail) {
  let password = prompt("Пароль?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'Іван',

  loginOk() {
    alert(`${this.name} увійшов`);
  },

  loginFail() {
    alert(`${this.name} виконав невдалу спробу входу`);
  },

};

askPassword(user.loginOk.bind(user), user.loginFail.bind(user));

Тепер це працює.

Альтернативне рішення могло б бути:

//...
askPassword(() => user.loginOk(), () => user.loginFail());

Зазвичай це також працює та чудово виглядає.

Це менш найдіно, так як в більш складних ситуаціях змінна user може змінитися після виклику askPassword, але перед викликом () => user.loginOk().

важливість: 5

Задача трохи складніша ніж Виправте функцію, яка втратила 'this'.

Об’єкт user був змінений. Тепер замість двох функцій loginOk/loginFail, він має одну функцію user.login(true/false).

Що ми маємо передати askPassword в коді нижче, щоб вона викликала user.login(true) при ok та user.login(false) при fail?

function askPassword(ok, fail) {
  let password = prompt("Пароль?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'Іван',

  login(result) {
    alert( this.name + (result ? ' увійшов' : ' виконав невдалу спробу входу') );
  }
};

askPassword(?, ?); // ?

Вносіть зміни тільки у виділений рядок.

  1. Або використовуйте стрілкову функцію як функцію-обгортку для короткого запису:

    askPassword(() => user.login(true), () => user.login(false));

    Тепер user отримується з зовнішніх змінних та код виконується правильно.

  2. Або створіть частково застосовану функцію user.login, що використовує user як контекст та має правильний аргумент:

    askPassword(user.login.bind(user, true), user.login.bind(user, false));
Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)