Главная / Статьи

Закрытие переменных, подъем и это: правила JavaScript, приводящие к ошибкам

Поднятие переменных, замыкания и ключевое слово this в JavaScript: объяснение через ошибки, которые помогают их лучше понять — ошибки TDZ, общие связи переменных в циклах и методы без связи с объектом.

Издатель: REACTAPP.TOP5 мин чтения1108 слов

Источники: 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.

Четыре правила, касающиеся места вызова, охватывают большинство повседневных случаев:

  1. Обычный вызов функции → в строгом режиме значение this равно undefined, в противном случае — глобальный объект.
  2. obj.method() → значение this равно obj.
  3. .call(), .apply() или .bind() → значение this — это то, что было явно передано.
  4. Функции-стрелки → у них нет собственного this; они наследуют его из окружающего области видимости.

Именно поэтому в классах и компонентах, работающих с полями экземпляра, часто используются функции-стрелки в качестве стандартного решения: это помогает избежать ошибок случайной потери связи с объектом.

Что действительно помогло

Одного лишь прочтения определений редко бывает достаточно. Эффективны следующие практики:

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

Предсказайте вывод console.log до запуска файла. Принудительное прогнозирование превращает заученное правило в проверяемую гипотезу.

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

Вывод

Эффект хоистинга, замыкания и переменная this — это не тривиальные вещи. Они объясняют сбои в работе программ: общие переменные циклов, ошибки TDZ и обработчики, теряющие свой экземпляр. Кроме того, они плохо переносятся на многие другие языки, поэтому путаница на начальном этапе — это нормально. Умышленное создание сбоев, прогнозирование и переформулирование сокращают время, необходимое для того, чтобы поведение стало предсказуемым. Ведите файл с примерами сбоев; повторный анализ их через неделю часто более полезен, чем перечитывание длинной главы.