Перейти к содержимому

Контекст выполнения и Lexical Environment

Если бы я мог оставить тебе только одну ментальную модель из всего JavaScript — я бы оставил эту. Контекст выполнения и лексическое окружение — это то, из чего состоит сам язык. Замыкания, this, области видимости, hoisting, TDZ — все эти слова описывают следствия одного механизма: у каждого куска кода есть своё окружение с переменными, и у каждого окружения есть ссылка на внешнее.

В краткой версии учебника мы уже видели замыкания «в лицо». Теперь поднимаем капот: как интерпретатор создаёт окружения, как они складываются в стек, почему var ведёт себя иначе, чем let, и что такое «временная мёртвая зона» на самом деле. В продакшене это знание экономит часы: ты перестанешь гадать, почему переменная undefined вместо значения, и начнёшь читать код так, как его видит движок.

JS — однопоточный язык. В каждый момент времени выполняется ровно одна функция. Чтобы помнить, «кто кого вызвал» и «куда возвращаться», движок использует стек вызовов — структуру LIFO (последний пришёл — первый ушёл).

function a() {
console.log('Внутри a');
b();
console.log('Снова в a'); // выполнится после завершения b
}
function b() {
console.log('Внутри b');
c();
}
function c() {
console.log('Внутри c');
}
a();
// Внутри a → Внутри b → Внутри c → Снова в a

Ментальная картина стека в момент выполнения c:

┌─────────────────┐
│ c() │ ← выполняется сейчас
├─────────────────┤
│ b() │ ждёт возврата из c
├─────────────────┤
│ a() │ ждёт возврата из b
├─────────────────┤
│ <main script> │ глобальный код ждёт возврата из a
└─────────────────┘

Когда c завершается, её кадр выталкивается, управление возвращается в b, и так далее. Когда стек полностью опустеет — движок может взять следующую задачу из очереди (об этом — в главе про Event Loop).

Каждый кадр стека — это контекст выполнения: пакет всего, что нужно функции для работы. У контекста есть три части:

  1. Lexical Environment — лексическое окружение (переменные + ссылка наружу).
  2. Variable Environment — отдельное окружение для var (исторически; в современном стандарте это частный случай лексического).
  3. ThisBinding — значение this (подробно в следующей главе).

Контекстов три вида:

  • Глобальный — создаётся при старте скрипта. Единственный, чей this вне функций — глобальный объект (window в браузере).
  • Функциональный — создаётся при каждом вызове функции. Два вызова одной функции — два разных контекста.
  • Контекст eval — игнорируем, eval не используем.

Ключевое слово здесь — «при каждом вызове». Окружение не привязано к тексту функции, оно создаётся заново на каждом запуске. Именно поэтому два вызова createCounter() дают два независимых счётчика — каждый получает свежее окружение со своей переменной count.

Лексическое окружение (Lexical Environment) — это внутренняя структура движка, которую удобно представлять как объект с двумя частями:

Lexical Environment = {
EnvironmentRecord: { /* словарь: имя → значение */ },
Outer: ссылка на внешнее окружение (или null)
}
  • Environment Record — «местные» переменные: объявленные в этом блоке/функции/модуле.
  • Outer — ссылка на окружение того места, где код был написан (не вызван — именно написан; это слово важно, оно объясняет замыкания).

Поиск переменной идёт по цепочке областей видимости (Scope Chain): сначала в собственном Environment Record, не нашли — переходим по Outer, потом по Outer внешнего, пока не дойдём до глобального окружения. Не нашли и там — ReferenceError.

const globalVar = 'глобальная';
function outer() {
const outerVar = 'из outer';
function inner() {
const innerVar = 'из inner';
// Поиск: inner → outer → global. Все три переменные видны.
console.log(innerVar, outerVar, globalVar);
}
inner();
}
outer();

Расстановка окружений в момент вызова inner():

innerEnv: { innerVar } ──Outer──▶
outerEnv: { outerVar } ──Outer──▶
globalEnv: { globalVar, outer, ... } ──Outer──▶ null

Движок проходит код функции дважды: сначала фаза создания (создаётся окружение и регистрируются объявления), потом фаза выполнения (строчки исполняются). Результат: объявления «поднимаются» к началу своей области видимости — это и есть hoisting. Но поднимаются они по-разному.

Объявление функции поднимается вместе со значением, поэтому вызов до объявления работает:

greet(); // «Привет» — функция уже есть в окружении
function greet() {
console.log('Привет');
}

var поднимается как имя со значением undefined. Присваивание происходит там, где написано:

console.log(a); // undefined — имя есть, значения ещё нет
var a = 42;
console.log(a); // 42
// Движок видит примерно так:
// var a; ← фаза создания
// console.log(a); ← undefined
// a = 42; ← фаза выполнения

let и const тоже поднимаются (окружение знает об их именах с фазы создания), но обращение к ним до строки объявления запрещено — это временная мёртвая зона (Temporal Dead Zone, TDZ):

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 42;

TDZ — не магия, а сознательное решение комитета по стандарту: доступ к «ещё не инициализированной» переменной — это почти всегда баг, и лучше получить честный ReferenceError, чем молчаливый undefined.

// Классический пример TDZ в действии
function loop() {
// i поднялось сюда, но сидит в TDZ
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i)); // 0, 1, 2 — каждая итерация своё окружение
}
}

Параметры функции не «магические переменные» — они регистрируются в том же Environment Record, что и локальные let-переменные, в момент создания окружения (фаза создания), ещё до выполнения тела. Отсюда два следствия:

function demo(a) {
// a уже существует и равно переданному аргументу
console.log(a); // 5
var a; // НЕ перезаписывает: var с тем же именем игнорируется
console.log(a); // 5
}
demo(5);
// Именованное Function Expression: имя доступно только внутри самой функции
const fact = function factorial(n) {
return n <= 1 ? 1 : n * factorial(n - 1); // factorial виден здесь
};
// factorial(5); // ReferenceError — снаружи имени нет

Второй пример — тонкость, которую любят на собеседованиях: у именованного function expression имя живёт в собственном окружении функции (позволяя рекурсию без внешней переменной), но наружу не экспортируется.

Объявление функции внутри блока — область тонкая: в строгом режиме оно блочное (видно только в блоке), в нестрогом — ведёт себя непредсказуемо (поднимается в функцию с флагом совместимости):

'use strict';
if (true) {
function blockFn() { return 'из блока'; }
}
// blockFn(); // ReferenceError — в strict mode блочная область
// Без 'use strict' старое поведение: функция поднимается наверх,
// но присваивается только при входе в блок — источник трудноуловимых багов.
// Мораль: не объявляй функции внутри блоков, только на верхнем уровне функции.

Scope Chain в действии: затенение и динамика поиска

Заголовок раздела «Scope Chain в действии: затенение и динамика поиска»

Поиск переменной всегда начинается с ближайшего окружения — это позволяет «затенять» внешние имена локальными:

const value = 'глобальная';
function level1() {
const value = 'уровень 1'; // затеняет глобальную
function level2() {
const value = 'уровень 2'; // затеняет обе внешние
console.log(value); // «уровень 2» — ближайшее окружение выиграло
}
level2();
console.log(value); // «уровень 1» — затенение не действует на уровень 1
}
level1();
console.log(value); // «глобальная»

Затенение — не баг, а инструмент. Оно позволяет свободно называть локальные переменные, не боясь конфликтов с внешним кодом. Но есть и обратная сторона: в глубоко вложенном коде легко случайно затенить переменную и часами искать, почему «не тот» массив. Дисциплина: осознанные имена на границах слоёв (config, userOptions), короткие — только внутри одной функции.

Ещё один практический момент — глобальное затенение через параметры и именованные выражения. Параметр с именем, совпадающим с глобальной переменной, полностью перекрывает её внутри функции, и никакого способа добраться до глобальной (как ::value в C++) в JS нет:

const requestId = 'global-1';
function track(requestId) { // затенение
console.log(requestId); // всегда аргумент, глобал недостижим
}

Поэтому в библиотеках и фреймворках принято префиксы (_, $) или уникальные имена — не эстетика, а защита от пересечений.

Полезно видеть, как окружения создаются и умирают в реальном коде. Пройдём по шагам:

const app = 'launcher';
function boot(mode) {
const startedAt = Date.now();
function log(message) {
const prefix = `[${mode}]`; // своё окружение на КАЖДЫЙ вызов log
console.log(`${prefix} ${message} (${Date.now() - startedAt} мс)`);
}
log('загрузка модулей');
log('подключение к API');
return log; // boot отдала наружу функцию, замыкающую на mode и startedAt
}
const logProd = boot('prod');
const logDev = boot('dev');
logProd('готово'); // [prod] готово (N мс) — окружение boot('prod') живо
logDev('готово'); // [dev] готово (M мс) — своё окружение, своё startedAt

В момент первого logProd('готово') цепочка выглядит так:

logEnv (prefix) ─Outer─▶ bootEnv (mode='prod', startedAt) ─Outer─▶ global (app, boot, logProd, logDev)

Вызов boot('dev') построил параллельную цепочку с mode='dev'. Две ветки не пересекаются, хотя функция log одна и та же по коду. Это и есть та самая «дешёвая изоляция», которую замыкания дают без классов и приватных полей.

Разберём фокус, который часто показывают на собеседованиях:

console.log(typeof notDeclared); // 'undefined' — переменной нет вообще
console.log(typeof tdz); // ReferenceError! (а не 'undefined')
let tdz = 1;

С точки зрения окружений всё логично. Environment Record хранит записи в трёх состояниях: «нет записи» (для notDeclared), «запись есть, но не инициализирована» (TDZ для let tdz) и «запись инициализирована». Операция чтения проверяет не существование записи, а её инициализированность — поэтому typeof в TDZ ведёт себя как обычное чтение и бросает ReferenceError.

Историческая справка: до ES6 typeof для необъявленной переменной возвращал 'undefined', чтобы старый код с фиче-детектами (typeof XMLHttpRequest !== 'undefined') не ломался. Сохранение этого поведения для TDZ сломало бы главный смысл TDZ — ловлю ошибок рано, поэтому для let/const сделали исключение.

Новичков путает, что «глобальное окружение» не одно. На старте скрипта в браузере создаются несколько связанных окружений:

  • Глобальное окружение (global environment)var-объявления, function declarations, this === window. Одно на весь realm.
  • Окружение модуля (module environment) — для ES-модулей: их let/const/импорты живут здесь, а не в глобальном окружении. Именно поэтому let в модуле не становится свойством windowvar в классическом скрипте — становится).
  • Окружение функции/блока — как разбирали выше.

Это объясняет распространённую путаницу:

<script>var classic = 1; let moduleLike = 2;</script>
<script type="module">console.log(classic); // 1 — var в глобальном окружении
// console.log(window.moduleLike); // undefined — let не в глобальном окружении
</script>

При переходе с ES-модулей на бандлеры и обратно помни: top-level let модуля изолирован от других модулей и от глобальной области, а top-level let классического скрипта — часть глобального лексического окружения (общего для всех скриптов страницы).

Словарное представление окружения — упрощение. В V8 Lexical Environment реализован как Context — фиксированный по размеру объект, указывающий на словарь переменных (расширяемый только для редких случаев, типа eval). Ключевые факты из реализации:

  • Окружения создаются быстро: функция с маленьким числом локальных переменных получает контекст почти бесплатно.
  • Пока на окружение ссылается хотя бы одна живая функция (замыкание), оно не удаляется сборщиком мусора.
  • Внутри функции переменные из внешних окружений читаются по ссылке Outer — именно поэтому замыкание «видит» актуальное значение, а не снимок на момент создания:
function makeWatchers() {
let value = 0;
return {
inc: () => { value += 1; },
get: () => value, // всегда актуальное значение, не копия
};
}
const w = makeWatchers();
w.inc();
w.inc();
console.log(w.get()); // 2
  • Замыкания — это функции + сохранённые Outer-ссылки. Следующая глава — практика этой механики.
  • this — третья часть контекста выполнения, которую мы намеренно отложили.
  • Event Loop — то, что происходит, когда стек вызовов пустеет: движок берёт задачу из очереди и создаёт для неё новый контекст.
  1. Путать hoisting var с «объявлением без значения». var x = 1 внутри функции поднимается как var x;, перекрывая внешнюю x на всю функцию:

    const x = 'глобальная';
    function f() {
    console.log(x); // undefined, а не 'глобальная'!
    var x = 'локальная';
    }
    f();

    Правило: объявляй переменные в начале функции или используй let/const.

  2. Считать, что hoisting работает для присваиваний. var a = 1 поднимает только var a. Функциональные выражения не поднимаются:

    sayHi(); // TypeError: sayHi is not a function
    var sayHi = function () { console.log('hi'); };
  3. Думать, что TDZ — «неинициализированное значение». В TDZ переменной просто нет в словаре доступных имён: любое обращение — ReferenceError, включая typeof:

    console.log(typeof undeclaredVar); // 'undefined' — переменной нет вообще
    console.log(typeof tdzVar); // ReferenceError!
    let tdzVar = 1;
  4. Забывать, что параметры — тоже локальные переменные окружения. Переопределение параметра меняет только локальную копию, кроме случаев с объектами (передача по ссылке).

  5. Рассчитывать на «снимок» переменных в замыканиях. Замыкание видит живое значение через Outer-ссылку. Если ожидаешь копию — делай её явно (IIIFE, дополнительная функция-фабрика).

  1. Что такое контекст выполнения и какие части он включает? Контекст — кадр стека вызовов со всем, нужным функции: Lexical Environment (переменные + внешняя ссылка), ThisBinding и (исторически) Variable Environment для var.
  2. Чем hoisting var отличается от hoisting let/function? var поднимается со значением undefined; let/const поднимаются, но находятся в TDZ до строки инициализации; function поднимается целиком вместе с телом.
  3. Что такое Temporal Dead Zone? Промежуток между входом в область видимости и выполнением строки объявления let/const. Обращение к переменной в TDZ бросает ReferenceError.
  4. Как устроена цепочка областей видимости? Каждое лексическое окружение хранит ссылку Outer на окружение места, где код написан. Поиск переменной идёт по цепочке до глобального окружения.
  5. Почему одинаковые вызовы одной функции не делят переменные? Окружение создаётся при каждом вызове, а не при объявлении функции.
  6. Что выведет код и почему? console.log(a); var a = 1;undefined: имя поднято, присваивание ещё не выполнилось.
  1. Без запуска предскажи вывод и объясни через окружения: console.log(x); var x = 5; console.log(x); let y = x; console.log(y);

  2. Напиши функцию snapshot, которая принимает функцию-производитель значения и возвращает объект с методами get() (возвращает актуальное значение) и snapshot() (возвращает копию значения на момент вызова). Критерий: изменение значения после snapshot() не влияет на сохранённую копию.

  3. Продемонстрируй TDZ намеренно: напиши код, где try/catch вокруг обращения к let-переменной до её объявления ловит ReferenceError, и выведи сообщение об ошибке.

  4. Рефакторинг: в функции ниже переменная var ведёт себя неожиданно. Перепиши на let, сохранив поведение, и объясни, как меняется структура окружений:

    function report() {
    if (false) { var debug = true; }
    console.log(debug); // undefined — почему?
    }
  5. Реализуй «цепочку окружений» руками: функция env(outer) возвращает объект с методами define(name, value) и lookup(name), где lookup ищет по цепочке внешних окружений, а define кладёт только в текущее. Имитируй Lexical Environment.