Главная / Статьи / Как на самом деле работает цепочка областей видимости JavaScript для поиска переменных

Как на самом деле работает цепочка областей видимости JavaScript для поиска переменных

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

1323 слов

Поиск переменных не связан с фигурными скобками

Типичный способ, с помощью которого люди впервые узнают о диапазоне видимости, — это утверждение, что «диапазон видимости — это всё, что находится внутри скобок». Такое описание помогает пройти тест, но оно не поможет предсказать, что на самом деле делает фрагмент кода. Реальный механизм более механический и предсказуемый, чем позволяет предположить это краткое описание, и как только вы его поймёте, закрытия перестанут казаться чем-то волшебным.

Диапазон видимости определяется местом написания кода, а не способом его выполнения

JavaScript разрешает переменные лексически. Это означает, что диапазон видимости переменной фиксируется на основе её физического положения в исходном файле в момент его написания, а не в зависимости от того, какая функция вызывает другую функцию во время выполнения программы.

const value = "outer";

function readValue() {
  console.log(value);
}

function runWithDifferentValue() {
  const value = "inner";
  readValue(); // still logs "outer", not "inner"
}

runWithDifferentValue();

readValue совершенно не интересуется тем, кто его вызывает и какие переменные находятся в окружении вызывающего кода. Единственное, что имеет для него значение, — это место его физической задания рядом с строкой const value = „outer“. Это единственная переменная value, которую он сможет увидеть, независимо от того, откуда в программе происходит его вызов. Именно здесь обычно путаются разработчики, привыкшие к языкам с динамическим областию видимости (или те, кто никогда раньше не задумывался об этом, поскольку лексическая область видимости является стандартом практически везде). Область видимости задается с момента написания кода; она не меняется в зависимости от стека вызовов во время выполнения.

Настоящий механизм поиска: прослеживание цепочки областей видимости

Когда движок должен разрешить обращение к переменной, он не ищет во всем кодовом базисе. Он начинает поиск именно в том месте, где используется переменная, и движется наружу, по одному вложенному области видимости за раз, останавливаясь сразу при нахождении соответствия:

const a = "global";

function outer() {
  const b = "outer";

  function inner() {
    const c = "inner";
    console.log(a, b, c); // "global outer inner"
  }

  inner();
}

outer();

inner сначала проверяет наличие c и находит его прямо здесь, без необходимости поиска в других областях. Затем она проверяет b — он не определен в локальной области, поэтому поиск переходит в область видимости outer, где он и находится. Далее проверяется a — его нет ни в inner, ни в outer, поэтому поиск продолжается, переходя на более высокие уровни областей видимости, пока наконец не достигнет глобальной области, где он и будет найден. Вся эта последовательность: локальная область, затем область, её окружающая, и так далее до глобальной — это и есть весь алгоритм поиска. Здесь нет ничего более сложного, кроме принципа «сначала посмотреть здесь, затем перейти на уровень выше и повторять до нахождения».

Тот же механизм объясняет явление затенения переменных без необходимости отдельного правила. Если бы inner объявил собственную переменную const b, поиск прекратился бы сразу после нахождения этой локальной переменной b и вообще не добрался бы до переменной b, определённой в outer. В этом сценарии ничего не перезаписывается; просто сначала находится более близкий по значению вариант, поэтому поиск не имеет причин продолжаться.

Почему var ведёт себя иначе и почему это приводит к хорошо известной ошибке

let и const привязываются к ближайшему окружающему блоку, то есть к любой паре фигурных скобок, будь то оператор if или тело цикла. var совсем не следует этому правилу: вместо этого он привязывается к ближайшей окружающей функции, полностью игнорируя границы блоков.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3

В этом фрагменте кода есть ровно одна переменная i, доступная только внутри функции, и каждая итерация цикла использует эту же переменную. К тому моменту, когда срабатывают обратные вызовы setTimeout, цикл уже завершился, и значение i уже стало равным 3. Все три обратных вызова оперируют одной и той же переменной, вместо того чтобы иметь свои собственные копии, поэтому все они возвращают окончательное значение этой переменной.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2

Поскольку ключевое слово let определяет область видимости на уровне блока, и поскольку язык специально создаёт новую переменную i для каждой итерации цикла, каждый кallback сохраняет доступ к своей собственной переменной i, замороженной на том значении, которое у неё было во время конкретной итерации. Код выглядит практически идентично предыдущему примеру, но правила области видимости здесь другие, что приводит к гораздо более предсказуемому результату.

Закрытия — это не отдельная функция, а лишь следствие цепочки областей видимости

Как только вы поймете, как работает цепочка областей видимости, замыкания практически не требуют дополнительных пояснений. Замыкание — это не какой-то дополнительный механизм, добавленный сверху к областям видимости. Это просто то, что естественным образом происходит каждый раз, когда вы определяете функцию внутри другой функции, причем эта внутренняя функция затем используется где-то после того, как внешняя область видимости обычно бы уже устарела.

function debounce(fn, delayMs) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delayMs);
  };
}

const debouncedSearch = debounce((query) => runSearch(query), 300);

debounce выполняется ровно один раз. Если вы привыкли к языкам без замыканий, у вас может сложиться мнение, что timeoutId должен исчезнуть сразу после завершения работы debounce. Однако он не исчезает, поскольку возвращаемая функция debounce была определена внутри неё, а это означает, что цепочка областей видимости возвращаемой функции постоянно включает собственную область видимости debounce, включая timeoutId. Каждый последующий вызов debouncedSearch, независимо от того, насколько поздно он происходит, перемещается по той же цепочке областей видимости к именно тому же timeoutId. Именно поэтому логика debounce работает: необходима единая, постоянная переменная для отслеживания задержанного таймера во всех вызовах, и замыкание обеспечивает это.

Та же самая функция, которая обеспечивает работу замыканий, может также приводить к утечке памяти

То, что делает замыкания полезными, — это та же самая особенность, которая приводит к определённой постоянно возникающей проблеме: замыкание сохраняет всю свою окружающую область видимости, а не только переменные, на которые оно фактически ссылается, и хранит динамическую ссылку на саму переменную, а не замороженную копию её значения в момент создания замыкания.

function createHandlers() {
  let clickCount = 0;
  const massiveDataset = loadHugeArray(); // large, no longer needed after setup

  return {
    onClick: () => {
      clickCount++; // only this variable is actually used
      console.log(clickCount);
    },
  };
}

onClick хранит в своей замыкательной функции весь область видимости, принадлежащую createHandlers, включая massiveDataset, хотя onClick на самом деле никогда её не читает. Пока существует onClick, все элементы области видимости, которые он зафиксировал, также остаются доступными, что представляет собой реальную, хотя и обычно незначительную, проблему с памятью, когда долгоживущая замыкательная функция сохраняет ссылки на большие или ненужные данные. Поскольку замыкательная функция хранит саму переменную, а не её копию, она всегда видит текущее, актуальное значение. Это тот же самый принцип, который позволил примеру с дебаунсом работать корректно и заставил цикл с var выводить 3, 3, 3; это единый механизм, который в одном контексте является полезной особенностью, а в другом — ошибкой.

Понимание механизма лучше, чем запоминание определения

Формулировка из учебника «закрытие — это функция, связанная со своей лексической средой», технически верна, но она редко становится понятной до тех пор, пока вы несколько раз вручную не проследите цепочку областей видимости и не увидите, где именно происходит поиск. Как только прослеживание этой цепочки становится привычкой, закрытиям больше совсем не требуется особого объяснения. Они являются просто обычным следствием того, как всегда функционировали области видимости, применённым к функции, которая случайно доживает до момента, когда исчезает среда, в которой она была создана.

Связанные материалы

  • React 19.2: Обзор компонента Activity, хука useEffectEvent и частичной статической отрисовки — Узнайте, как новый компонент Activity, хук useEffectEvent и возможность частичной статической отрисовки в React 19.2 устраняют скрытые проблемы с производительностью в современных интерфейсах.