15 березня 2025 р.

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

Передаючи методи об’єкту в якості колбеків, наприклад в 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)

Функції надають нам вбудований метод 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.

Часткове застосування (partial function)

До цього моменту ми говорили тільки про прив’язку 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 вимагає першим аргументом щось, що буде прив’язане як this, тому ми мусимо передати щось як заглушку – 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) повертає “прив’язаний варіант” функції func який прив’язує контекст this та аргументи, якщо вони передані.

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

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

Часткові функції зручні, коли ми не хочемо повторно передавати одні й ті ж самі аргументи знову і знову. Наприклад, якщо ми маємо функцію 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 без об’єкту.

Коли askPassword викликає їх, їх контекст втрачено 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…)