При передаче методов объекта в качестве колбэков, например для 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
всё время будет одинаков для нашей задачи, то мы можем создать частично применённую функцию и дальше работать с ней.