При передаче методов объекта в качестве колбэков, например для setTimeout, возникает известная проблема – потеря this.
В этой главе мы посмотрим, как её можно решить.
Потеря «this»
Мы уже видели примеры потери this. Как только метод передаётся отдельно от объекта – this теряется.
Вот как это может произойти в случае с setTimeout:
let user = {
firstName: "Вася",
sayHi() {
alert(`Привет, ${this.firstName}!`);
}
};
setTimeout(user.sayHi, 1000); // Привет, undefined!
При запуске этого кода мы видим, что вызов this.firstName возвращает не «Вася», а undefined!
Это произошло потому, что setTimeout получил функцию sayHi отдельно от объекта user (именно здесь функция и потеряла контекст). То есть последняя строка может быть переписана как:
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 достаётся из замыкания, а затем вызывается его метод sayHi.
То же самое, только короче:
setTimeout(() => user.sayHi(), 1000); // Привет, Вася!
Выглядит хорошо, но теперь в нашем коде появилась небольшая уязвимость.
Что произойдёт, если до момента срабатывания setTimeout (ведь задержка составляет целую секунду!) в переменную user будет записано другое значение? Тогда вызов неожиданно будет совсем не тот!
let user = {
firstName: "Вася",
sayHi() {
alert(`Привет, ${this.firstName}!`);
}
};
setTimeout(() => user.sayHi(), 1000);
// ...в течение 1 секунды
user = { sayHi() { alert("Другой пользователь в 'setTimeout'!"); } };
// Другой пользователь в 'setTimeout'!
Следующее решение гарантирует, что такого не случится.
Решение 2: привязать контекст с помощью bind
В современном JavaScript у функций есть встроенный метод bind, который позволяет зафиксировать this.
Базовый синтаксис bind:
// полный синтаксис будет представлен немного позже
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);
}
// привязка this к 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.sayHi и привязываем его к user. Теперь sayHi – это «связанная» функция, которая может быть вызвана отдельно или передана в setTimeout (контекст всегда будет правильным).
Здесь мы можем увидеть, что bind исправляет только this, а аргументы передаются как есть:
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);
}
}
Некоторые JS-библиотеки предоставляют встроенные функции для удобной массовой привязки контекста, например _.bindAll(obj) в lodash.
Частичное применение
До сих пор мы говорили только о привязывании this. Давайте шагнём дальше.
Мы можем привязать не только this, но и аргументы. Это делается редко, но иногда может быть полезно.
Полный синтаксис bind:
let bound = func.bind(context, [arg1], [arg2], ...);
Это позволяет привязать контекст 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: "John",
say(time, phrase) {
alert(`[${time}] ${this.firstName}: ${phrase}!`);
}
};
// добавляем частично применённый метод с фиксированным временем
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());
user.sayNow("Hello");
// Что-то вроде этого:
// [10:00] John: Hello!
Результатом вызова partial(func[, arg1, arg2...]) будет обёртка (*), которая вызывает func с:
- Тем же
this, который она получает (для вызоваuser.sayNow– это будетuser) - Затем передаёт ей
...argsBound– аргументы из вызоваpartial("10:00") - Затем передаёт ей
...args– аргументы, полученные обёрткой ("Hello")
Благодаря оператору расширения ... реализовать это очень легко, не правда ли?
Также есть готовый вариант _.partial из библиотеки lodash.
Итого
Метод bind возвращает «привязанный вариант» функции func, фиксируя контекст this и первые аргументы arg1, arg2…, если они заданы.
Обычно bind применяется для фиксации this в методе объекта, чтобы передать его в качестве колбэка. Например, для setTimeout.
Когда мы привязываем аргументы, такая функция называется «частично применённой» или «частичной».
Частичное применение удобно, когда мы не хотим повторять один и тот же аргумент много раз. Например, если у нас есть функция send(from, to) и from всё время будет одинаков для нашей задачи, то мы можем создать частично применённую функцию и дальше работать с ней.
Комментарии
<code>, для нескольких строк кода — тег<pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)