Контекст выполнения и Lexical Environment
Если бы я мог оставить тебе только одну ментальную модель из всего JavaScript — я бы оставил эту. Контекст выполнения и лексическое окружение — это то, из чего состоит сам язык. Замыкания, this, области видимости, hoisting, TDZ — все эти слова описывают следствия одного механизма: у каждого куска кода есть своё окружение с переменными, и у каждого окружения есть ссылка на внешнее.
В краткой версии учебника мы уже видели замыкания «в лицо». Теперь поднимаем капот: как интерпретатор создаёт окружения, как они складываются в стек, почему var ведёт себя иначе, чем let, и что такое «временная мёртвая зона» на самом деле. В продакшене это знание экономит часы: ты перестанешь гадать, почему переменная undefined вместо значения, и начнёшь читать код так, как его видит движок.
Стек вызовов (Call Stack)
Заголовок раздела «Стек вызовов (Call Stack)»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).
Контекст выполнения (Execution Context)
Заголовок раздела «Контекст выполнения (Execution Context)»Каждый кадр стека — это контекст выполнения: пакет всего, что нужно функции для работы. У контекста есть три части:
- Lexical Environment — лексическое окружение (переменные + ссылка наружу).
- Variable Environment — отдельное окружение для
var(исторически; в современном стандарте это частный случай лексического). - ThisBinding — значение
this(подробно в следующей главе).
Контекстов три вида:
- Глобальный — создаётся при старте скрипта. Единственный, чей
thisвне функций — глобальный объект (windowв браузере). - Функциональный — создаётся при каждом вызове функции. Два вызова одной функции — два разных контекста.
- Контекст eval — игнорируем, eval не используем.
Ключевое слово здесь — «при каждом вызове». Окружение не привязано к тексту функции, оно создаётся заново на каждом запуске. Именно поэтому два вызова createCounter() дают два независимых счётчика — каждый получает свежее окружение со своей переменной count.
Lexical Environment: ментальная модель
Заголовок раздела «Lexical Environment: ментальная модель»Лексическое окружение (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──▶ nullHoisting: подъём объявлений
Заголовок раздела «Hoisting: подъём объявлений»Движок проходит код функции дважды: сначала фаза создания (создаётся окружение и регистрируются объявления), потом фаза выполнения (строчки исполняются). Результат: объявления «поднимаются» к началу своей области видимости — это и есть hoisting. Но поднимаются они по-разному.
function: поднимается целиком
Заголовок раздела «function: поднимается целиком»Объявление функции поднимается вместе со значением, поэтому вызов до объявления работает:
greet(); // «Привет» — функция уже есть в окружении
function greet() { console.log('Привет');}var: поднимается только имя
Заголовок раздела «var: поднимается только имя»var поднимается как имя со значением undefined. Присваивание происходит там, где написано:
console.log(a); // undefined — имя есть, значения ещё нетvar a = 42;console.log(a); // 42
// Движок видит примерно так:// var a; ← фаза создания// console.log(a); ← undefined// a = 42; ← фаза выполненияlet/const: поднимаются, но в TDZ
Заголовок раздела «let/const: поднимаются, но в TDZ»let и const тоже поднимаются (окружение знает об их именах с фазы создания), но обращение к ним до строки объявления запрещено — это временная мёртвая зона (Temporal Dead Zone, TDZ):
console.log(b); // ReferenceError: Cannot access 'b' before initializationlet 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
Заголовок раздела «Параметры — тоже часть Environment Record»Параметры функции не «магические переменные» — они регистрируются в том же 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 имя живёт в собственном окружении функции (позволяя рекурсию без внешней переменной), но наружу не экспортируется.
Блочные function-объявления и режимы
Заголовок раздела «Блочные function-объявления и режимы»Объявление функции внутри блока — область тонкая: в строгом режиме оно блочное (видно только в блоке), в нестрогом — ведёт себя непредсказуемо (поднимается в функцию с флагом совместимости):
'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 одна и та же по коду. Это и есть та самая «дешёвая изоляция», которую замыкания дают без классов и приватных полей.
TDZ глазами движка: почему typeof ломается
Заголовок раздела «TDZ глазами движка: почему typeof ломается»Разберём фокус, который часто показывают на собеседованиях:
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в модуле не становится свойствомwindow(аvarв классическом скрипте — становится). - Окружение функции/блока — как разбирали выше.
Это объясняет распространённую путаницу:
<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 — то, что происходит, когда стек вызовов пустеет: движок берёт задачу из очереди и создаёт для неё новый контекст.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
Путать hoisting
varс «объявлением без значения».var x = 1внутри функции поднимается какvar x;, перекрывая внешнююxна всю функцию:const x = 'глобальная';function f() {console.log(x); // undefined, а не 'глобальная'!var x = 'локальная';}f();Правило: объявляй переменные в начале функции или используй
let/const. -
Считать, что hoisting работает для присваиваний.
var a = 1поднимает толькоvar a. Функциональные выражения не поднимаются:sayHi(); // TypeError: sayHi is not a functionvar sayHi = function () { console.log('hi'); }; -
Думать, что TDZ — «неинициализированное значение». В TDZ переменной просто нет в словаре доступных имён: любое обращение —
ReferenceError, включаяtypeof:console.log(typeof undeclaredVar); // 'undefined' — переменной нет вообщеconsole.log(typeof tdzVar); // ReferenceError!let tdzVar = 1; -
Забывать, что параметры — тоже локальные переменные окружения. Переопределение параметра меняет только локальную копию, кроме случаев с объектами (передача по ссылке).
-
Рассчитывать на «снимок» переменных в замыканиях. Замыкание видит живое значение через
Outer-ссылку. Если ожидаешь копию — делай её явно (IIIFE, дополнительная функция-фабрика).
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое контекст выполнения и какие части он включает?
Контекст — кадр стека вызовов со всем, нужным функции: Lexical Environment (переменные + внешняя ссылка), ThisBinding и (исторически) Variable Environment для
var. - Чем hoisting
varотличается от hoistinglet/function?varподнимается со значениемundefined;let/constподнимаются, но находятся в TDZ до строки инициализации;functionподнимается целиком вместе с телом. - Что такое Temporal Dead Zone?
Промежуток между входом в область видимости и выполнением строки объявления
let/const. Обращение к переменной в TDZ бросаетReferenceError. - Как устроена цепочка областей видимости?
Каждое лексическое окружение хранит ссылку
Outerна окружение места, где код написан. Поиск переменной идёт по цепочке до глобального окружения. - Почему одинаковые вызовы одной функции не делят переменные? Окружение создаётся при каждом вызове, а не при объявлении функции.
- Что выведет код и почему?
console.log(a); var a = 1;—undefined: имя поднято, присваивание ещё не выполнилось.
Практика
Заголовок раздела «Практика»-
Без запуска предскажи вывод и объясни через окружения:
console.log(x); var x = 5; console.log(x); let y = x; console.log(y); -
Напиши функцию
snapshot, которая принимает функцию-производитель значения и возвращает объект с методамиget()(возвращает актуальное значение) иsnapshot()(возвращает копию значения на момент вызова). Критерий: изменение значения послеsnapshot()не влияет на сохранённую копию. -
Продемонстрируй TDZ намеренно: напиши код, где
try/catchвокруг обращения кlet-переменной до её объявления ловитReferenceError, и выведи сообщение об ошибке. -
Рефакторинг: в функции ниже переменная
varведёт себя неожиданно. Перепиши наlet, сохранив поведение, и объясни, как меняется структура окружений:function report() {if (false) { var debug = true; }console.log(debug); // undefined — почему?} -
Реализуй «цепочку окружений» руками: функция
env(outer)возвращает объект с методамиdefine(name, value)иlookup(name), гдеlookupищет по цепочке внешних окружений, аdefineкладёт только в текущее. Имитируй Lexical Environment.