Закрытие переменных, подъем и это: правила JavaScript, приводящие к ошибкам
Поднятие переменных, замыкания и ключевое слово this в JavaScript: объяснение через ошибки, которые помогают их лучше понять — ошибки TDZ, общие связи переменных в циклах и методы без связи с объектом.
Источники: Closures, Hoisting, and `this`: The JavaScript Concepts That Finally Clicked for Me
Закрытия, подъем и this: концепции JavaScript, которые постоянно всплывают
Многие разработчики месяцами работают с полноценным JavaScript-кодом, продолжая относиться к трём особенностям языка как к незначительным деталям для собеседований: подъему, закрытиям и this. Закреплённые в памяти определения могут помочь пройти отбор. Однако ошибки в продакшене выявляют эти пробелы — таймауты, фиксирующие неверный индекс, обработчики, теряющие свою инстанцию, переменные, имеющие значение undefined вместо того, чтобы выбрасывать исключение. Цена этого обычно — потеря времени: разработчик смотрит на код, который кажется корректным, пока правила места вызова наконец не объясняют происходящее.
Приведённые ниже заметки не являются глоссарием. В них показаны моменты, когда эти три концепции перестают быть абстрактными и начинают объяснять реальные сбои. Каждый раздел начинается с небольшой программы, вызывающей удивление, а затем указывается правило, делающее это удивление неизбежным.
Подъем
Первое удивление выглядит так:
console.log(greet()); // "Hello!"
function greet() {
return "Hello!";
}
Вызов происходит до объявления функции, однако возвращается значение "Hello!". Согласно логике обработки кода сверху вниз это должно быть невозможно. Объявления функций отличаются: перед выполнением операторов движок поднимает на верх всё объявление, включая тело функции, поэтому greet уже можно вызывать.
Схожий пример ведет себя иначе:
console.log(name); // undefined
var name = "Prashant";
Не возникает ошибки ReferenceError, возвращается значение undefined. Движок знал о существовании переменной name ещё раньше, но операция присваивания ещё не выполнилась. Оба случая основаны на одном механизме: объявления поднимаются на верх своей области видимости. При объявлении функции её тело также поднимается вместе с ней. Использование ключевого слова var приводит к тому, что сохраняется только связь с переменной; инициализатор остается на том месте, где он был написан, поэтому начальное значение остается пустым.
Использование ключевого слова let для привязки переменной меняет способ возникновения ошибок:
console.log(name); // ReferenceError: Cannot access 'name' before initialization
let name = "Prashant";
Это приводит к сбою. Распространенное мнение заключается в том, что let «не выполняет операцию хоистинга». Это неверно. Привязка создается заранее, а затем остается недоступной до строки объявления. Имя переменной существует, но обращение к нему до инициализации является недопустимым. Такой подход безопаснее, чем использование var, который тихо возвращает значение undefined, позволяя ошибке распространяться дальше.
Закрытия
Часто проблемы возникают из-за закрытий в циклах, а не из-за простых синтаксических конструкций. Рассмотрим пример задержанной записи в лог, которая должна вывести 1, 2, 3:
for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// what I expected: 1, 2, 3
// what I got: 4, 4, 4
Ожидается последовательность чисел. Результат — 4, 4, 4. Переменная var имеет область видимости на уровне функции, а не блока, поэтому в цикле используется одна и та же переменная i. Когда таймеры срабатывают после завершения цикла, значение этой переменной уже равно 4. Каждый кallback читает это же завершенное значение.
Есть одно ключевое слово, которое решает эту проблему:
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// 1, 2, 3
let имеет область видимости на уровне блока, поэтому каждая итерация обладает своей собственной переменной i. Каждый кallback сохраняет свою собственную версию этой переменной.
Такое сочетание — функция плюс сохранённые переменные — и является практическим определением замыкания. Кallback от setTimeout — это не просто функция; он сохраняет среду, необходимую ему после окончания области видимости цикла. Как только вы начинаете ожидать такого сохранения данных, результат 4, 4, 4 уже не кажется ошибкой платформы, а скорее проявлением общего изменяемого состояния.
Как только они становятся видимыми, закрытия встречаются повсюду в обычном коде React и Node: хуки, возвращающие функции, обработчики событий, читающие состояние компонента, и практически каждый обновитель useState. Эта концепция не связана только с собеседованиями; именно так работает значительная часть кода интерфейса, основанного на обратных вызовах.
this
this вызывает путаницу, потому что тело одной и той же функции может видеть разные значения в зависимости от способа её вызова, а не от места её написания.
Пример классового счётчика иллюстрирует эту проблему:
class Counter {
constructor() {
this.count = 0;
}
increment() {
this.count++;
console.log(this.count);
}
}const counter = new Counter();
const btn = document.querySelector("button");btn.addEventListener("click", counter.increment); // this is undefined
Призыв метода increment через экземпляр работает корректно. Однако передача counter.increment непосредственно в addEventListener часто приводит к ошибкам, поскольку внутри метода значение this оказывается равным undefined. Если отсоединить метод, будет совершаться простой вызов функции; в режиме строгой проверки такой вызов не получает исходный объект в качестве this.
Два способа решения помогают легко запомнить правило:
// bind it explicitly
btn.addEventListener("click", counter.increment.bind(counter));
// or wrap it in an arrow function
btn.addEventListener("click", () => counter.increment());
.bind(counter) фиксирует объект-получатель. Функция-стрелка осуществляет вызов через экземпляр, тем самым сохраняя формат вызова метода. Функции-стрелки не создают собственное значение this; они наследуют связи из окружающего контекста, поэтому для второго способа решения не требуется явного использования bind.
Четыре правила, касающиеся места вызова, охватывают большинство повседневных случаев:
- Обычный вызов функции → в строгом режиме значение
thisравноundefined, в противном случае — глобальный объект. obj.method()→ значениеthisравноobj..call(),.apply()или.bind()→ значениеthis— это то, что было явно передано.- Функции-стрелки → у них нет собственного
this; они наследуют его из окружающего области видимости.
Именно поэтому в классах и компонентах, работающих с полями экземпляра, часто используются функции-стрелки в качестве стандартного решения: это помогает избежать ошибок случайной потери связи с объектом.
Что действительно помогло
Одного лишь прочтения определений редко бывает достаточно. Эффективны следующие практики:
Пишите маленькие, намеренно сломанные фрагменты кода — по пять-шесть строк, направленные на одну конкретную ошибку — чтобы её было легко заметить и разобраться в причинах.
Предсказайте вывод console.log до запуска файла. Принудительное прогнозирование превращает заученное правило в проверяемую гипотезу.
Объясните механизм вслух. Пробелы становятся очевидными сразу, когда речь идет об эффекте хоистинга или когда необходимо описать замыкания новыми словами.
Вывод
Эффект хоистинга, замыкания и переменная this — это не тривиальные вещи. Они объясняют сбои в работе программ: общие переменные циклов, ошибки TDZ и обработчики, теряющие свой экземпляр. Кроме того, они плохо переносятся на многие другие языки, поэтому путаница на начальном этапе — это нормально. Умышленное создание сбоев, прогнозирование и переформулирование сокращают время, необходимое для того, чтобы поведение стало предсказуемым. Ведите файл с примерами сбоев; повторный анализ их через неделю часто более полезен, чем перечитывание длинной главы.