Потеря контекста и как её чинить

контекст≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Значение this определяется не тем, где функция написана, а тем, как её позвали. Стоит оторвать метод от объекта — положить в переменную, передать в setTimeout, повесить на кнопку — и контекст пропадает; разберём, как это выглядит и чем лечится.

Разделы темы:·

Метод — обычная функция, которая просто лежит в свойстве объекта. Она не помнит, кому принадлежит: связь возникает в момент вызова, когда слева от скобок стоит точка. Присваивание const greet = user.hello копирует функцию, а точку — нет. Само правило «контекст задаёт вызов» разобрано в статье «Что такое this и откуда он берётся» — здесь смотрим, что с ним идёт не так.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Вопросительный знак в this?.login здесь не для красоты. В строгом режиме (а модули строгие всегда) this внутри оторванного метода равен undefined, и обычная точка уронила бы пример с TypeError. Мы подстраховались, чтобы увидеть потерю, а не аварию.

this отвечает не на вопрос «где написано», а на вопрос «как позвали».

setTimeout(timer.ring, 0) — то же самое присваивание, только в чужие руки: в очередь попадает голая функция. Через положенное время браузер вызовет её сам, и никакого объекта слева от вызова уже не будет.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Разница между двумя последними строками — пара скобок. В первой мы отдали таймеру саму функцию, во второй — стрелку, которая позовёт метод через точку, когда придёт время. То же самое случается с promise.then(obj.method) и arr.forEach(obj.method): всюду, где функцию передают по имени.

В настоящем браузере всё ломается ещё интереснее. Там функции подписывают на события: button.addEventListener("click", counter.inc) просит браузер вызывать counter.inc при каждом клике по кнопке. Вызывает её уже сам браузер, и обычная функция-обработчик получает в this элемент, на котором висит подписка, — саму кнопку. Контекст не исчезает — он подменяется, и это коварнее, потому что ошибки может не быть вовсе.

Сами события и подписку на них подробно разбирает статья «Что такое событие и как на него подписаться» в части II. А в песочнице document недоступен, поэтому соберём мини-кнопку руками — механика та же.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Счётчик остался нулём, зато у кнопки завёлся посторонний count со значением NaN. Запомните эту связку: NaN почти всегда означает, что this.что-то было undefined, а мы прибавили к нему число.

Все три лечения делают одно и то же — возвращают вызову объект. Первое: стрелочная функция. У неё нет собственного this, она берёт его из той функции, внутри которой написана, — а там контекст ещё цел.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Второе: bind. Он не вызывает функцию, а возвращает новую — с намертво приклеенным объектом. Её можно спокойно передавать куда угодно, контекст поедет вместе с ней. Сам bind и его родня разобраны в статье «call, apply и bind».

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Третье: функция-обёртка. Самое честное и самое читаемое — мы просто откладываем нормальный вызов «через точку» до нужного момента. Обёртка удобна, когда методу надо передать аргументы или что-то посчитать перед вызовом.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Потеря контекста редко объявляет о себе прямо. Чаще вы видите один из четырёх симптомов — и тогда стоит вернуться к строке, где функция была передана без скобок.

Что видноЧто случилосьГде искать
TypeError: Cannot read properties of undefinedметод вызвали без объекта, this равен undefinedфункция передана по имени, без скобок
undefined вместо значения поляthis оказался глобальным объектом, поля в нём нетsetTimeout, промисы, колбэки массивов
NaN после сложенияthis.поле было undefined, к нему прибавили числосчётчики и накопители
объект не меняется, зато меняется чужойthis указывает на другой объектобработчики событий: this — это элемент

Стрелка берёт this оттуда, где написана. Внутри метода это спасение, а прямо в объекте — ловушка: объект не создаёт области видимости, и стрелка смотрит мимо него, в окружающий код.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Правило простое: методы объекта пишем коротким синтаксисом show() {}, а стрелки оставляем для колбэков внутри них. Почему у стрелки вообще нет своего this и где это ещё вылезает, разбирает статья «Стрелочные функции и this».

bind создаёт функцию с уже принятым решением о this. Приклеить поверх второй объект не выйдет — новая обёртка просто позовёт старую, а та проигнорирует переданный контекст.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Отсюда же растёт частая беда с событиями: снять обработчик через removeEventListener("click", obj.handler.bind(obj)) невозможно — bind создаст третью функцию, не равную той, что вешали. Привязанную версию нужно один раз сохранить в переменную и использовать её и при подписке, и при отписке.

  • this выбирается в момент вызова: важно, что стоит слева от точки, а не то, где функция написана.
  • Метод, положенный в переменную или переданный колбэком по имени, теряет объект: this становится undefined в строгом режиме или глобальным объектом в нестрогом.
  • С обработчиками событий хуже: this не пропадает, а подменяется на элемент — данные тихо уезжают не туда.
  • Лечение одно из трёх: стрелка берёт this снаружи, bind приклеивает объект навсегда, функция-обёртка откладывает нормальный вызов через точку.
  • Симптомы потери: TypeError на undefined, undefined вместо значения поля и NaN в счётчиках.

1/3Метод положили в переменную и вызвали как f(). Что произойдёт с this?

2/3Чему равно cart.report.bind(cart) === cart.report?

3/3Почему стрелка внутри метода видит нужный this?

Разбудить будильник

Код должен через таймер напечатать Пора вставать, Аня, но печатает не то. Почините вызов, не трогая сам метод wake. Найдите два разных способа.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Вариант со стрелкой-обёрткой: настоящий вызов «через точку» откладывается до срабатывания таймера.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вариант с bind: объект приклеивается к функции заранее.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

А вот setTimeout(alarm.wake(), 0) — не решение, а другая ошибка: метод выполнится немедленно, а таймер получит его результат, то есть undefined.

Вернуть префикс логгеру

Логгер должен печатать LOG: раз и LOG: два, но префикс потерялся. Объясните, почему, и почините — постарайтесь придумать три варианта. Подсказка для третьего: загляните в документацию forEach, у него есть второй параметр.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

В forEach ушла голая функция: массив вызывает её сам, без объекта слева, поэтому this теряется и prefix взять неоткуда. Три рабочих варианта:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Последний — особенность самого метода: вторым аргументом forEach принимает значение, которое станет this. Такой же параметр есть у map, filter и some. А у setTimeout его нет — там остаются обёртка и bind.