На собеседовании вопрос про this звучит как теория: четыре способа вызова, стрелочные функции, привязка через bind. Спустя неделю тот же разработчик встречает в журнале ошибок строку про чтение свойства у undefined, и за ней сидит тот самый this, потерянный в обработчике кнопки. Разрыв между формальным знанием механики и реальными авариями существует, хотя речь всегда про одно: this определяется вызовом, а замыкание живёт дольше, чем кажется. Ниже разбор обеих сторон на примерах кода, который написан по мотивам настоящих падений.
Формальная механика this от способа вызова, а не от места объявления
Значение this в JavaScript определяется не местом объявления функции, а способом её вызова. Один и тот же метод, вызванный по-разному, получает разные контексты:
const user = {
name: "Анна",
greet() { console.log(this.name); }
};
function show() { console.log(this); }
user.greet(); // Анна
show(); // undefined в модуле
user.greet.call({name: "Борис"}); // Борис
const bound = user.greet.bind({name: "Вера"});
bound(); // Вера
Вызов как метода подставляет в this объект слева от точки. Голый вызов функции в строгом режиме оставляет this равным undefined, а в нестрогом коде подсовывает глобальный объект, поэтому ошибка тихо маскируется в легаси и громко падает в модулях, которые строгие по умолчанию. Методы call и apply задают контекст на один вызов, а bind создаёт новую функцию с контекстом навсегда: повторный bind уже ничего не изменит. Конструктор, вызванный через new, получает в this новый объект.
Отдельной строкой стоит стрелочная функция. Собственного this у неё нет, она берёт его из окружающего кода в момент объявления. Ни bind, ни call, ни new на это не влияют, поэтому стрелка не украшение синтаксиса, а инструмент с узкой задачей: сохранить внешний контекст там, где обычная функция его бы потеряла.
Единое имя globalThis появилось в ES2020 как способ добраться до глобального объекта из любого окружения: браузера, воркера, Node.js. Для диагностики потерянного this это первый ориентир: если вместо undefined в консоль печатается глобальный объект, код исполняется в нестрогом режиме, и ошибка ещё ничем себя не выдала. Классы всегда строгие, поэтому метод класса, вызванный без объекта, падает сразу, а не пишет молча в глобальную область.
Продакшен-сбой из-за потерянного this в обработчике кнопки
Среда ошибки выглядит невинно. Виджет корзины вешает собственный метод на кнопку покупки:
class CartWidget {
constructor(root) {
this.items = [];
document.querySelector("#buy").addEventListener("click", this.onBuy);
}
onBuy() {
this.items.push({id: Date.now()});
this.render();
}
render() {}
}
Конструктор отрабатывает, обработчик повешен, тесты зелёные, потому что в тестах метод обычно зовут напрямую. Продакшен вызывает его иначе: клик по кнопке это вызов функции без объекта слева от точки, поэтому this внутри onBuy оказывается самой кнопкой, поле items в ней отсутствует, и обращение к push у несуществующего массива роняет обработчик.
Хуже того, кнопка не ломается заметно. Сценарий с таймером ещё коварнее: передача метода в setInterval часами не напоминает о себе, а когда интервал срабатывает, падение уносит проверку сессии, и пользователь не узнаёт, что его разлогинило. Ошибка ждёт неустойчивого сочетания: передать метод, забыть про контекст, дождаться события.
Невидимость ошибки усиливается сборкой. Локально виджет запускается обычным скриптом, где нестрогий режим прячет потерю this за глобальным объектом, и кнопка даже работает, попутно засоряя глобальную область. В продакшене тот же код минифицируется, заворачивается в модуль со строгим режимом, и this становится undefined. Разница сред выглядит как плавающий дефект: у автора всё работает, у пользователя падает.
Лечение известно давно, три варианта на выбор:
document.querySelector("#buy").addEventListener("click", this.onBuy.bind(this));
// либо один раз в конструкторе:
this.onBuy = this.onBuy.bind(this);
// либо стрелочное поле класса:
onBuy = () => { this.items.push({id: Date.now()}); };
Ошибка вылезает только на реальном событии, поэтому и тест обязан жать кнопку, а не звать метод.
Стрелочные поля классов как рабочая норма и их границы применения
Стрелочные поля классов, вошедшие в стандарт ES2022, закрыли историю с привязкой в большинстве команд. Запись вида onBuy = () => {} создаёт функцию с лексическим this ещё при работе конструктора, и думать о контексте обработчиков больше не нужно. До этого стандарта писали связки bind в конструкторах, и экосистема React с классовыми компонентами обогатилась целым жанром: конструктор из двадцати строк, где половина это привязка обработчиков, скопированная из компонента в компонент.
У стрелочных полей есть границы. Как всякая стрелка, такое поле не вызывается через new, не поддаётся повторной привязке и всегда видит this класса. Если же нужен динамический контекст вызывающей стороны, стрелка вредит: паттерны с поздней привязкой, где this назначает объект-хозяин, перестают работать. В функциональных компонентах с хуками действуют другие правила: каждый рендер создаёт свежие функции, и там, где класс хранил один привязанный метод, хук создаёт новый на каждое обновление. Это не дефект, а прямая дорога к ловушкам из раздела про замыкания.
Стандарту ES2022 предшествовали годы трансляции: плагин компилятора превращал стрелочные поля в привязки внутри конструктора, поэтому приём успел обрасти зрелыми привычками и руководствами. Команда, переходящая на стрелочные поля сегодня, экономит не одну строку кода, а целый класс правок: исчезают случаи, когда при добавлении обработчика забывают скопировать bind.
Замыкания, которые удерживают память и устаревшие значения в живом приложении
Замыкание удерживает не только нужные значения, но и всё окружение, в котором родилось: пока жива функция, живы и переменные вокруг неё. В коротком скрипте это незаметно. В одностраничном приложении, которое часами живёт в одной вкладке, эффект накапливается. Типовой сценарий: переход между экранами вешает новый слушатель и не снимает старый. Дерево DOM старого экрана уже удалено из документа, но слушатель держит на него ссылку, и снимок кучи в инструментах разработчика показывает десятки узлов с пометкой Detached, а память вкладки растёт от сотни мегабайт до многих.
Память утекает и через значения. Долгоживущий интервал захватывает токен доступа, полученный при входе, и продолжает слать его в запросы, когда токен давно обновлён. Симптом выглядит загадочно: часть запросов получает отказ, хотя свежий токен в хранилище лежит, и только трассировка показывает, что в заголовок уходит копия недельной давности.
Снятие слушателей пачкой решает AbortController:
const controller = new AbortController();
document.addEventListener("resize", this.recalc, {signal: controller.signal});
// при уходе со страницы
controller.abort();
Один вызов abort снимает все слушатели, подписанные на сигнал, и замыкания вместе с ними освобождаются. Поддержка параметра signal давно не экзотика, поэтому механика годится для основного кода, а не только для экспериментов.
Утечка не обязана быть экзотичной. Модальное окно открывается сотню раз за сессию, и на каждый вызов вешается свежий слушатель:
function openModal(id) {
const modal = document.querySelector("#modal");
modal.addEventListener("click", () => {
modal.classList.toggle("open");
});
}
Слушатель не только накапливается, но и держит замыкание с modal, а клик обрабатывается столько раз, сколько окно открывалось. Пользователь видит странное дрожание интерфейса, профилировщик видит сотню вызовов одного обработчика, и только трассировка объясняет, откуда взялась толпа.
Старая ловушка цикла с var и её наследие в хуках React
Старейшая ловушка замыканий описана в каждом учебнике, отчего не стала менее живучей:
var buttons = document.querySelectorAll(".tab");
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", function () {
console.log("вкладка", i);
});
}
var создаёт одну переменную на весь цикл, все замыкания делят её между собой, и любой клик печатает последний индекс. Замена var на let заводит отдельную связь на каждую итерацию, и каждое замыкание захватывает собственную копию счётчика. Принцип тот же, что и в авариях продакшена: замыкание хранит значение с момента создания, а не с момента вызова.
Историческое решение той же задачи выглядело как обёртка с немедленным вызовом:
for (var i = 0; i < buttons.length; i++) {
(function (k) {
buttons[k].addEventListener("click", function () {
console.log("вкладка", k);
});
})(i);
}
Каждая итерация создаёт свою функцию-обёртку, копирует текущее значение в аргумент k, и внутреннее замыкание захватывает уже личную копию. Приём до сих пор встречается в легаси, поэтому узнавать его в чужом коде полезно: он не неправильный, просто написан до появления let.
Современное наследие той же ловушки живёт в хуках:
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
Пустой список зависимостей создаёт интервал один раз, колбэк навсегда запоминает count из первого рендера, и счётчик застревает на единице. Лечится передачей функции обновления вида setCount(c => c + 1), которая не зависит от захваченного значения, либо честным списком зависимостей. Формально здесь нет никакого this, но механика та же: функция удерживает прошлое и не догадывается о настоящем.
Инструменты браузера для поиска утечек и потерянного контекста
Диагноз утечки не ставится на глаз. В инструментах разработчика снимок кучи делается одним действием, а поиск по снимку по слову Detached перечисляет узлы, удалённые из документа, но удержанные ссылками. Если после одних и тех же действий пользователя число таких узлов растёт от снимка к снимку, утечка подтверждена числами, а не интуицией. Рядом живёт трекер выделений, показывающий, какие объекты создаются чаще всего, а график памяти со временем рисует характерную пилу с поднимающимся основанием.
Потерянный this диагностируется проще. Одно слово console.log в начале метода показывает прямо в момент падения, чем оказался контекст: кнопкой, undefined или глобальным объектом. Правила линтера вида no-invalid-this и проверка компилятора с флагом noImplicitThis подсвечивают рискованные функции ещё на этапе сборки, до того как код доберётся до браузера.
Отдельная привычка окупается быстро: раз в спринт проходить по списку слушателей на тяжёлых элементах и считать обработчики. Число больше трёх почти всегда означает забытые подписки, а вместе с ними и замыкания, которые не отпускают память.
Правила работы с this и замыканиями для код-ревью и тестов
Формальное понимание превращается в продакшен-безопасность только через правила, закреплённые проверками:
- весь новый код живёт в модулях, где строгий режим включён по умолчанию, и потеря this падает в разработке, а не у пользователя;
- обработчики событий в классах объявляются стрелочными полями, вопрос привязки снимается целиком;
- массовое снятие слушателей выполняется через AbortController, а не ручным перебором removeEventListener;
- стрелочная функция выбирается, когда нужен внешний this, и обычная, когда контекст приходит от вызывающей стороны;
- ссылки на обработчики сохраняются в свойствах объекта, чтобы снять именно тот слушатель, что был повешен;
- тесты вызывают события через dispatchEvent, а не методы напрямую, иначе потеря this остаётся незамеченной до релиза.
Механика this умещается в четыре строки: вызов как метода, голый вызов, явная привязка, конструктор. Замыкание описывается одной фразой про удержание окружения. Сложность не в правилах, а в том, что они нарушаются в момент, когда никто не смотрит: обработчик передаётся сторонней библиотеке, интервал создаётся чужим модулем, экран перерисовывается в фоне. Продакшен наказывает не незнание, а уверенность, что формального знания достаточно. Собеседование спрашивает механику, продакшен спрашивает привычки, и второе проверяется дороже.
Практический вывод скромнее любых схем: обработчики идут через стрелочные поля, слушатели снимаются через сигнал, тесты жмут реальные события, а журнал ошибок читают еженедельно. Тогда this и замыкания из источника ночных падений превращаются в то, чем и должны быть, в предсказуемую часть языка, которая работает, пока её не трогают вслепую.