Все привыкли, что есть какой-то call stack и работает он по LIFO: последним зашёл - первым вышел. Вызвал функцию - кадр лёг на вершину, функция вернулась - кадр сняли. Дальше этого знание обычно и не идёт.
Ровно та же история - с асинхронной половиной: про event loop, микротаски и макротаски слышал почти каждый, все знают, что .then срабатывает раньше setTimeout, даже если таймер поставлен первым. А на вопрос «почему именно так и где это вообще записано» ответа обычно нет. До асинхронной части мы тоже доберёмся, но в самом конце: она начинается там, где синхронная модель заканчивается. И сразу предупрежу: ни event loop, ни очереди микрозадач в стандарте языка нет - они описаны совсем в другом документе.
Пока же начнём со стека. Стоит копнуть - и вопросов набегает сразу три:
что вообще стоит за этим «стеком вызовов» на уровне спецификации? из чего он собран и частью чего является?
почему LIFO оказывается не совсем LIFO? как
awaitилиyieldснимают кадр со стека, дают ему опустеть чуть ли не до дна, а потом возвращают функцию к исполнению - откуда она берётся, если сверху её уже ничто не держит?и что вообще лежит в одном таком кадре? какое состояние движок тащит с собой на каждый вызов?
Все три ответа собраны в одной главе спецификации ECMAScript - той, где описана модель исполнения; за её пределы мы выйдем всего пару раз. Не в исходниках V8, не в очередной статье про event loop, а именно в стандарте. Там задан каркас из пяти сущностей, через который любой твой код и превращается в исполнение. И привычный «call stack» - лишь верхушка этого каркаса; под ним деталей заметно больше, чем один стек. А вот event loop, задачи и микрозадачи в этот каркас не входят: их описывает уже не язык, а браузер, и разбираем их мы во второй статье серии - про модель исполнения по спецификации HTML.
А заодно станет понятно, откуда берутся правила, которые обычно просто заучивают:
как на самом деле устроена scope chain;
почему
var,letиconstведут себя именно так;откуда берётся замыкание и почему переменная переживает свою функцию;
и что такое «область видимости» на самом деле - та самая функциональная с блочной.
Ни одно из этих правил не придётся запоминать: всё это следствия одной и той же анатомии.
Карта статьи:
Agent - что такое агент и из каких пяти частей он состоит.
Realm Record - встроенные объекты и глобальное окружение, к которым привязан код.
Части агента по очереди - Agent Record, execution context, стек контекстов, набор контекстов, running execution context и executing thread.
Строение execution context - что лежит в одном кадре: окружения, scope chain, замыкания, разница между
varиlet.Jobs - что происходит, когда стек опустел, и где заканчивается синхронная модель.
Agent - вершина всего
Агент - это то, что исполняет JS-код, причём независимо от других агентов: например, у страницы и у worker’а, которого она запустила, агенты разные. Создаёт агентов не язык, а хост (браузер или Node): стандарт лишь описывает, из чего агент состоит. А состоит он из пяти частей:
set of execution contexts - контексты агента;
execution context stack - стек контекстов;
running execution context - тот, что исполняется сейчас;
Agent Record - свойства и настройки агента;
executing thread - поток, исполняющий код.

Четыре составляющие из пяти у каждого агента свои и ни с кем не делятся: другой агент не видит чужой стек и не может положить туда свой контекст. Исключение одно - поток: один поток может обслуживать несколько агентов (на схеме он выделен).
Агентов в системе обычно несколько: по одному на каждого независимого исполнителя, который зачем-то понадобился хосту. В браузере агенты есть у окон, у каждого worker’а (Web, Shared и Service) и у worklet; в Node - у главного потока и у каждого worker_threads. При этом внутри одного агента в каждый момент бежит не больше одного кадра, никакого параллелизма; по-настоящему параллельно работают только разные агенты между собой.
Если совсем на пальцах: агент - это отдельный JS-мир с единственным потоком-исполнителем внутри. В браузере свой агент есть у каждого worker’а, а связанные между собой окна и iframe одного сайта делят один агент на всех.
Дальше пойдём по составляющим. Но сначала разберём Realm Record: в состав агента он не входит, но на него ссылается каждый execution context.
Realm Record - среда, с которой связан код
Realm Record - это отдельная запись, в состав агента она не входит. Живёт рядом и переживает сам код: код приходит и уходит, а запись остаётся на месте.
Спецификация требует, чтобы перед вычислением любой код был к какому-нибудь realm’у привязан. Каждый realm объединяет набор встроенных объектов, глобальное окружение, весь код, загруженный в этом окружении, и связанное с ними состояние. Всего семь полей:
[[Intrinsics]] - все встроенные объекты (Array, Object, Math, Promise…);
[[GlobalObject]] - глобальный объект, на котором висят глобальные свойства:
Array,setTimeout, твоиvarи функции верхнего уровня;[[GlobalEnv]] - глобальное окружение, дно поиска переменных;
[[AgentSignifier]] - id агента-владельца (то же значение, что [[Signifier]] в Agent Record);
Плюс три служебных поля
[[TemplateMap]] - кэш теговых шаблонов;
[[LoadedModules]] - кэш модулей для
import(), когда нет активного скрипта или модуля;[[HostDefined]] - данные хоста.
Проще всего это увидеть на iframe. Каждый <iframe> на странице заводит свой realm, а значит и свой набор [[Intrinsics]]. Из-за этого возникает классическая подстава:
const iframe = document.createElement('iframe'); document.body.append(iframe); // массив, созданный конструктором Array из ДРУГОГО realm'а const arr = new iframe.contentWindow.Array(1, 2, 3); arr instanceof Array; // false - это не «наш» Array, а Array из realm'а iframe Array.isArray(arr); // true - а этот способ realm'ы не различает
Всё логично: Array в двух realm’ах - это два физически разных объекта, хотя пишутся одинаково, и instanceof сверяет прототип именно с «нашим». А Array.isArray для того и существует - проверяет внутренний тип, а не прототип конкретного realm’а.
Сам Realm Record ни во что не вложен. С остальной моделью его связывают две ссылки: одна идёт от него, другая к нему:
запись знает своего агента-владельца - через собственное поле [[AgentSignifier]];
на саму запись ссылаются контексты - через компонент Realm: у каждого execution context есть ссылка на тот Realm Record, с которым связан исполняемый им код.

У одного агента может быть несколько realm’ов. В браузере новый realm появляется с каждым iframe или окном из window.open(), в Node - с каждым vm.createContext(). Но в одном агенте со страницей окажется только iframe с того же сайта, а для iframe с чужого сайта браузер заведёт отдельного агента. Это правило задаёт уже HTML Standard: ECMA-262 про iframe ничего не знает.

Agent Record - свойства и настройки агента
Теперь пойдём по пяти составляющим агента. Первая из них - Agent Record.
Agent Record хранит свойства и настройки агента, ровно одна запись на агента. Смысл её в том, что некоторые вещи не принадлежат ни коду, ни конкретному контексту - они про самого агента: кто он вообще такой, вправе ли блокироваться, какой порядок байтов считает родным. Всё это и складывается сюда.
Полей девять, но большая часть служебные:
[[Signifier]] - идентификатор агента: уникально идентифицирует агента в пределах его кластера. По нему Realm Record находит владельца ([[AgentSignifier]]);
[[CanBlock]] - может ли агент блокироваться, то есть синхронно ждать внешнего события. Значение выставляет хост: браузер, например, запрещает это главному потоку страницы и разрешает обычному worker’у из
new Worker(). ПоэтомуAtomics.wait()на главном потоке кидает ошибку, а в таком worker’е работает;
Остальные семь полей - служебные
[[LittleEndian]] - порядок байтов, в котором typed array читают и пишут многобайтовые числа;
[[IsLockFree1]], [[IsLockFree2]], [[IsLockFree8]] - lock-free ли атомарные операции над значениями в 1, 2 и 8 байт (их возвращает
Atomics.isLockFree);[[CandidateExecution]] - состояние модели памяти для
SharedArrayBuffer;[[KeptAlive]] - объекты, которые нельзя собрать до конца текущего Job’а, например цель
WeakRefпослеderef();[[ModuleAsyncEvaluationCount]] - счётчик, задающий порядок вычисления модулей с top-level
await.
Execution context - кадр исполнения
С Agent Record разобрались. Дальше всё интереснее: оставшиеся составляющие агента крутятся вокруг execution context, так что займёмся им.
Execution context (контекст исполнения, в народе «кадр») - это запись, где лежит всё состояние, нужное для выполнения одного куска кода. Движок исполняет функцию, скрипт или модуль - и ему надо где-то держать ответ на «где я сейчас, какие тут переменные, в каком мире я вообще работаю». Место, где этот ответ хранится, и есть execution context.
Новый контекст рождается в момент, когда управление уходит к коду, не связанному с текущим контекстом. На практике это вызов функции, старт скрипта или модуля, eval.
Устройство самого контекста разберём отдельной главой после составляющих агента: стек, набор и running-контекст работают с контекстами как с целыми записями, и для них его внутренности не важны.
execution context stack
Только что созданный контекст кладётся на execution context stack и становится running. Execution context stack держит контексты в порядке вложенности вызовов, и по нему сразу видно две вещи: кто исполняется прямо сейчас (это вершина) и кому вернуть управление, когда текущий кадр снимется. Собственно, это и есть то, что обычно называют call stack: в спецификации этого слова нет, она говорит execution context stack. Вот и ответ на первый вопрос из вступления: за ним стоит execution context stack, одна из пяти составляющих агента.
set of execution contexts
А вот про set of execution contexts рассказывать почти нечего - и это не я поленился, это так в самой спецификации. Вот всё, что можно сказать про него наверняка:
он есть - перечислен среди пяти составляющих агента;
точная формулировка - «set of ECMAScript execution contexts», и встречается она во всём документе ровно один раз, в этом самом перечне
стоит он в списке отдельно от стека, то есть по замыслу это разные вещи;
принадлежит агенту эксклюзивно, как и всё прочее кроме потока, - значит, наборы разных агентов не пересекаются;
ни одна операция его не называет и с ним не работает: нет ни добавления, ни удаления, ни перечисления;
хост его тоже не берёт: спецификация HTML импортирует из ECMA-262 агента, кластер агентов, стек контекстов, running-контекст и даже surrounding agent - а набор нет.
движок его тоже не строит: в V8 такой структуры просто нет. Единственному коду, которому набор реально понадобился, - отладочному LiveEdit - приходится собирать его на лету: обходом всей кучи ради приостановленных генераторов плюс проход по текущему стеку и по стекам архивированных потоков.
И на этом всё. Показательно, что сходятся все три стороны: язык объявляет набор и больше к нему не возвращается, хост его не импортирует, движок не материализует. Зачем он в перечне, стандарт не объясняет. Так или иначе, вся реальная механика висит на стеке.
Running execution context - кто активен сейчас
running execution context - тот контекст, что прямо сейчас исполняет код. Спецификация гарантирует: в каждый момент на агента их не больше одного. Либо ровно один (значит, код исполняется), либо ноль (стек пуст, исполнять нечего). Двух и более не бывает в принципе. И running-контекст - это всегда вершина стека, без исключений.
Исполнение можно приостановить, тогда running становится другой контекст. А приостановленный позже вполне может снова стать running и продолжить ровно с той точки, где его прервали.
Обычно смена running идёт по LIFO, как в любом стеке вызовов: доработал верхний кадр - управление вернулось соседу снизу. Но спецификация честно предупреждает, что кое-какие фичи языка требуют переходов не по LIFO. Как это вообще возможно, если кадр со стека уже сняли?
Дело в том, что снятый со стека кадр умирает не всегда. Обычно, когда функция доработала, её кадр снимают и выбрасывают. Но если функция не закончилась, а приостановилась на yield или await, кадр продолжает жить вне стека: его держит тот, кто потом вернёт его к исполнению. Для генератора спецификация говорит это прямо, в примечании к алгоритму вызова функции ([[Call]]): снятый контекст не уничтожается, пока его удерживает достижимый генератор. Для async-функций отдельной оговорки нет, но то же самое следует из алгоритма Await.
У генератора этого держателя видно прямо в коде - это сам объект генератора:
function* gen() { console.log('a'); // выполнится на первом .next() yield; // тут кадр gen снимается со стека - но не умирает console.log('b'); // выполнится на втором .next() } const g = gen(); // объект g держит приостановленный кадр gen g.next(); // a - дошли до yield, кадр снят со стека g.next(); // b - кадр вернулся, доиграл до конца и отпущен: генератор завершён
Кадр жив, пока генератор приостановлен: на него ссылается g, а возвращает его на стек твой очередной .next(). Добежит тело до конца (или сделаешь .return()) - и кадр отпущен, хотя объект g никуда не делся.
У async-функции всё так же, только держателя в коде не видно. На await к промису подвешиваются два обработчика: один продолжит функцию полученным значением, другой выбросит исключение прямо в точке await. Вернёт кадр на стек уже не твой вызов, а Job, и запустится он только тогда, когда стек опустеет.
Вот и ответ на второй вопрос из вступления: функция берётся не со стека, а из ссылки, которую держит генератор или обработчик промиса. Поэтому и нарушается LIFO: кадр возвращается на стек не в порядке очереди, а тогда, когда его вернёт держатель. Отсюда же и то, что await не блокирует поток: кадра на стеке нет, и поток тем временем исполняет другой код.
Это легко увидеть по порядку вывода:
async function f() { console.log(1); await 0; // здесь кадр f снимается со стека console.log(3); // а это уже отдельный Job - стек к тому моменту пуст } f(); console.log(2); // печатает: 1, 2, 3
3 печатается после 2, хотя в коде стоит раньше: на await кадр f ушёл со стека, управление вернулось наружу (console.log(2)), и только когда стек опустел, Job-продолжение вернул f дописывать себя.
Executing thread - кто исполняет
Последняя составляющая агента - executing thread. По спецификации это поток, который выполняет шаги алгоритмов на контекстах агента, то есть поток, на котором исполняется весь его код.
Это единственная составляющая, которая может быть общей у нескольких агентов. Стек, набор контекстов, running-контекст и Agent Record у каждого агента свои, а один поток может по очереди обслуживать несколько агентов. Изоляцию это не нарушает: общим оказывается только исполнитель, а не данные. Пока поток работает на агента A, он работает с его стеком, переключился на агента B - работает со стеком B, и кадры одного агента другому не видны.
Внутри одного агента в каждый момент исполняется не больше одного кадра - running-контекст, поэтому две функции одного агента одновременно выполняться не могут. А делить поток спецификация разрешает только агентам, у которых [[CanBlock]] не равен true: иначе один заблокировавшийся агент остановил бы всех, кто делит с ним поток.
Пример из жизни - вкладки браузера. Некоторые браузеры сажают несколько несвязанных вкладок (то есть агентов) на один поток и могут себе это позволить именно потому, что блокироваться им запрещено. Получается один исполнитель на пачку изолированных миров - и всё держится ровно до тех пор, пока никто не пытается синхронно встать. Разреши им
[[CanBlock]] = true- и одинAtomics.wait()в любой из вкладок заморозил бы соседей: поток общий, а ждать он будет неограниченно.Заметь, что «заблокирован» в стандарте - это именно синхронное неограниченное ожидание внешнего события, а не «долго считает». Бесконечный
while(true)для стандарта не блокировка: он продолжает выполнять шаги. Соседей по потоку он тоже остановит, но к[[CanBlock]]это отношения не имеет.
Строение execution context
Теперь разберём, из чего состоит сам execution context. Это и есть ответ на третий вопрос из вступления: какое состояние хранится в одном кадре. Заодно станет понятно, откуда берутся scope chain, разница между var, let и const и деление на функциональную и блочную области видимости.
Компонентов три группы. Базовые есть у любого контекста; дополнительные есть только у контекстов JS-кода, а не встроенных функций; и есть отдельный компонент, который живёт только у контекста генератора.

Базовые компоненты (есть у любого контекста)
code evaluation state - «на каком шаге исполнения я сейчас». Позволяет приостановить контекст и продолжить с того же места. Работает при любом вложенном вызове: когда одна функция вызывает другую, контекст фиксирует здесь своё состояние, отдаёт управление, а после возврата продолжает с этого места. Тот же механизм лежит в основе генераторов (
yield) иawait.Function - сам объект функции, чей код сейчас исполняется: не имя, а именно объект со всеми его внутренними слотами. Вызови функцию десять раз - получишь десять контекстов, но Function во всех десяти смотрит на один и тот же объект. Из него при вызове заполняются остальные части кадра: Realm и окружение, через которое потом находятся
thisиsuper. У скрипта и модуля здесь null.Realm - ссылка на Realm Record, с которым связан исполняемый код: оттуда берутся Array, Object, глобальный объект.
ScriptOrModule - из какого скрипта или модуля пришёл код, или null, если исходного скрипта нет. Так бывает, например, у самого первого контекста, который создаётся вместе с realm’ом: кода в нём ещё нет никакого.
Environment Record - окружение
Два из трёх оставшихся компонентов - это ссылки на окружения, поэтому сначала стоит понять, что такое окружение вообще. Сущность это самая нагруженная во всей модели: из неё напрямую растут и замыкания, и разница между var и let, и сами области видимости - функциональная с блочной.
Environment Record - тип из спецификации, который связывает идентификаторы с конкретными переменными и функциями, опираясь на лексическую вложенность кода. Если попроще - это табличка «имя → значение», отвечающая на вопрос «а что вообще означает вот это имя вот в этом месте».
Как правило, окружение привязано к какой-то синтаксической конструкции: объявлению функции, блоку, ветке catch. И каждый раз, когда такой код вычисляется, под его привязки заводится новое окружение. Тут важно не проскочить: окружение создаётся одно на каждое вычисление, а не одно на конструкцию в исходнике. Два вызова одной функции - два разных окружения. Вот откуда берётся независимость локальных переменных у одновременно живущих вызовов, будь они вложенными, рекурсивными или приостановленными.
У каждого окружения есть поле [[OuterEnv]] - ссылка на внешнее окружение либо null. Так моделируется логическая вложенность: внешнее окружение логически охватывает внутреннее, а у него самого тоже может быть своё внешнее, и так далее. Причём одно окружение легко оказывается внешним сразу для многих: две вложенные функции внутри одной внешней получат в [[OuterEnv]] один и тот же объект - окружение текущего вызова этой внешней функции.
Именно по этой цепочке и ищется имя: движок идёт по [[OuterEnv]], пока не найдёт нужное или пока не упрётся в null у глобального окружения. Это глобальное окружение - тот самый [[GlobalEnv]] из Realm Record, а через него поиск доходит и до [[GlobalObject]]: поэтому Array или setTimeout в итоге находятся как свойства глобального объекта. Вот, кстати, и что такое scope chain - никакая не отдельная магическая сущность. Это просто цепочка ссылок [[OuterEnv]] от одного Environment Record к другому, названная своим настоящим именем.
Важная деталь: окружение не является частью контекста. Это самостоятельная запись, на которую ссылаются со всех сторон - слоты LexicalEnvironment и VariableEnvironment контекста, [[OuterEnv]] соседнего окружения, [[Environment]] объекта функции, [[GlobalEnv]] у Realm Record. Живёт оно ровно до тех пор, пока на него указывает хоть одна ссылка. Кадр давно снят со стека, а окружение преспокойно остаётся.
И заодно тем же самым закрывается замыкание - оно ведь просто продолжение той же scope chain. Внутренняя функция через своё [[Environment]] держит ссылку на окружение внешнего вызова. Внешний кадр уже снят со стека и мёртв, но окружение продолжает жить, потому что на него ещё есть ссылка. Так что переменная переживает свою функцию без всякого волшебства: окружение и кадр - две разные записи, и время жизни у каждой своё. Кадр снимают со стека по возврату из функции, а окружение живёт, пока на него хоть кто-то ссылается. Замыкание в этом смысле и не фича вовсе, а побочный эффект того, что Environment Record отвязан от execution context.
function makeCounter() { let n = 0; return () => ++n; } const next = makeCounter(); // кадр makeCounter уже снят со стека и мёртв next(); // 1 next(); // 2 - n жив: на его окружение до сих пор ссылается возвращённая функция
Видов окружений пять, но нагрузка у них разная. Три несут на себе всё, что мы разбирали, - с них и начнём. Ещё два стоят в стороне, и о них хватит по абзацу.
Global Environment Record - дно цепочки: [[OuterEnv]] тут равен null, дальше идти некуда. Одно на весь realm, а не на скрипт - все скрипты realm’а делят его между собой. И единственное из всех, что склеено из двух половинок: var и объявления функций уходят в [[ObjectRecord]], то есть становятся свойствами глобального объекта (там же лежат и все встроенные глобальные объекты вроде Array), а let, const и class - в [[DeclarativeRecord]]. Вот и объяснение старой загадки: var x на верхнем уровне скрипта создаёт globalThis.x, а let x - нет. В модуле так не будет: там своё Module Environment Record, и var остаётся внутри модуля (в Node то же самое из-за CJS-обёртки).
Function Environment Record - заводится на каждый вызов функции, написанной на JavaScript. У встроенных функций вроде Math.max такого окружения нет: кадр при вызове создаётся, а окружение - нет. От остальных окружений его отличают две вещи. Первая - куда смотрит [[OuterEnv]]: не на окружение того, кто функцию вызвал, а на окружение, где её объявили (функция хранит его у себя в слоте [[Environment]]). Отсюда и замыкания, и то, что область видимости в JS лексическая, а не динамическая. Вторая - свой this: это единственное окружение, где this заводится заново на каждый вызов, а вместе с ним new.target и всё, что нужно для super. Но только если функция не стрелочная. Стрелочной функции свой this не заводят, поэтому this в ней ищется снаружи, по той же цепочке [[OuterEnv]]. Стрелки ничего не «запоминают»: у них просто нет своего this.
Declarative Environment Record - самый простой: привязки лежат прямо в записи, без всяких объектов. Порождают его блок, ветка catch, каждая итерация цикла с let, класс и eval. Он же лежит в основе Function и Module: оба раздела спецификация открывает словами «это Declarative Environment Record, который…». И заодно служит второй половиной Global. Именно эту запись выбрасывают на выходе из блока, отсюда и блочность let с const.
Ещё два вида, которые дальше не понадобятся: Object и Module
Object Environment Record - окружение без собственных привязок: имена в нём ищутся как свойства обычного объекта, причём с учётом прототипов. Используется оно в двух местах: его создаёт with на время своего блока, и из него же сделана часть глобального окружения, [[ObjectRecord]]. Второе даёт неочевидный эффект: раз глобальные имена ищутся как свойства, поиск может дойти до прототипа. После Object.prototype.foo = 1 обращение к foo вернёт 1, хотя такую переменную никто не объявлял. Правда, прототип глобального объекта задаёт хост, так что это поведение браузеров и Node, а не гарантия языка.
Module Environment Record - одно на модуль, а не на вызов. Создаётся при линковке, ещё до того, как выполнится первая строка модуля, а его [[OuterEnv]] всегда указывает на глобальное окружение. Особенность у него одна - import-привязки. import { x } не копирует значение: x ссылается на привязку в исходном модуле, поэтому ты видишь её текущее значение, но присвоить ей ничего не можешь. А раз эти связи готовы до запуска кода, импорты доступны с первой строки модуля - это и называют «хойстингом импортов».
Дополнительные компоненты (у контекстов JS-кода)
Через них контекст видит переменные. Первые два - ссылки на Environment Record, третий - на запись другого типа.
LexicalEnvironment - по определению стандарта это окружение, по которому разрешаются идентификаторы в этом контексте: с него движок начинает искать любое имя, в том числе объявленное через
var, и, не найдя, шагает наружу по [[OuterEnv]]. Заодно именно в него попадают объявления, привязанные к текущей области:let,const,class, параметрcatch, функции внутри блока. Меняется по ходу исполнения: вход в блок илиcatchподменяет его на новое окружение, вложенное в прежнее.VariableEnvironment - а этот определён наоборот, через содержимое: окружение, которое держит привязки, созданные
var-объявлениями (плюс функции, объявленные на верхнем уровне). В отличие от предыдущего, при входе в блок не меняется: остаётся тем, что было заведено на входе в функцию или скрипт.PrivateEnvironment - ссылка на PrivateEnvironment Record: в нём лежат приватные имена класса (
#field,#method), или null вне класса. Отдельный тип, не Environment Record, но устроен похоже - своя запись на каждое вычисление класса, своя цепочка наружу для вложенных классов. Приватность тут настоящая, а не по соглашению:#x- не строковый ключ, а особое имя, и обращение к нему вне класса - ошибка ещё при разборе кода, а не в рантайме. Никакойobj['#x']не поможет - это принципиально другой механизм, чем старое_privateпо договорённости.
Все три слота - это именно ссылки, а не хранилища: сами имена лежат внутри записей, на которые слоты показывают. Поэтому «var пишется в VariableEnvironment» значит только одно - что он попал в то окружение, на которое этот слот показывал. А искать его потом всё равно будут от LexicalEnvironment и дальше по цепочке.
Зачем тогда два слота под окружения, если читают всё равно через один? Чтобы в одной функции работали два разных правила видимости сразу: одно - на блок, другое - на всю функцию. На входе в блок Lexical уезжает на свежее окружение, а Variable остаётся на прежнем - поэтому let умирает вместе с блоком, а var нет. Одним указателем так не выйдет: он либо уезжает, либо стоит, и одно из двух поведений пришлось бы сломать. Так что второй слот - это, по сути, плата за обратную совместимость с var.
Теперь посмотрим, как эти слоты ведут себя в динамике.
function f() { 'use strict'; var a = 1; a; // шаг 1: a = 1 { let b = 2; a; b; // шаг 2: a = 1, b = 2 } a; b; // шаг 3: a = 1, b - ReferenceError }

Разберём по шагам, как на схеме.
Шаг 1: тело функции. При вызове f создаётся кадр и запись функции, в ней лежит a. Оба слота, LexicalEnvironment и VariableEnvironment, смотрят на эту запись.
Шаг 2: вход в блок. Нового кадра нет: блок - не вызов, мы по-прежнему в кадре f. Появляется только новая запись блока, в ней b, а её [[OuterEnv]] указывает на запись функции. LexicalEnvironment переключается на запись блока, VariableEnvironment остаётся на записи функции. Поэтому b находится сразу в записи блока, а за a поиск уходит по [[OuterEnv]] в запись функции.
Шаг 3: выход из блока. LexicalEnvironment возвращается туда, куда смотрел до входа: для функции это её запись, для вложенного блока - запись внешнего блока. На запись блока больше никто не ссылается, и её выбрасывают вместе с b, поэтому обращение к b теперь даёт ReferenceError. Исключение - если в блоке создали замыкание: оно держит запись блока через [[Environment]], и запись переживёт блок, как окружение в примере с makeCounter.
Пара оговорок к сказанному:
пустой блок
{}никакой записи не создаёт;'use strict'в примере стоит не случайно: оба слота смотрят на одну запись только в строгой функции. В обычной дляletиconstсразу заводится отдельная запись поверх записи сvar, так спецификации проще ловить конфликты имён вeval. Поиску имён это не мешает: в цепочке просто на одну запись больше;если у функции есть параметры по умолчанию, добавляется ещё одна запись.
Как это выглядит в движке
Спецификация описывает наблюдаемое поведение, а не структуры данных в памяти. Движку никто не запрещает не создавать запись, если её никто не захватывает. V8 так и делает: выделяет память под контекст только для переменных, которые кто-то замкнул, а остальные держит в регистрах. Снаружи разницы не видно, поэтому спецификация и не возражает.
Вот и весь механизм: кадр один, записей много, слота два - и ездит только один из них.
Заодно закрывается и вечная загадка про var. var протекает из блока наружу, а let с const - нет. Дело не в «так исторически сложилось»: let и const пишутся туда, куда смотрит LexicalEnvironment, а его блок подменяет. var пишется по VariableEnvironment, а его блок не трогает. Одна и та же x = 1 попадает в разные записи в зависимости от ключевого слова перед ней - и всё поведение вытекает отсюда механически.
{ var a = 1; let b = 2; } console.log(a); // 1 - var «протёк» наружу (VariableEnvironment скрипта - глобальное окружение) console.log(b); // ReferenceError - let заперт в блоке (LexicalEnvironment)
И отсюда же рассыпается тема про JS - функциональная и блочная область видимости. Её пересказывают в каждом втором туториале одинаково: «var функционально-scoped, let блочно-scoped, просто запомни». Запоминать, как видишь, было нечего. Отдельной сущности «область видимости» в спецификации нет: слово scope встречается только в описаниях, а всю работу делают Environment Record. То, что зовут функциональной областью, - это Function Environment Record; то, что блочной, - Declarative Environment Record блока. А сама «видимость» - это достижимость: имя видно ровно оттуда, откуда до его записи дотягивается цепочка [[OuterEnv]].
Из той же механики, между прочим, растёт и классика с циклами: у for (let …) своя запись заводится на каждую итерацию - потому замыкания из цикла и ловят разные i.
Компонент генератора (только у контекста генератора)
Generator - ссылка на объект генератора, тело которого исполняет этот контекст. Есть только у контекстов
function*иasync function*. Генератор, в свою очередь, хранит ссылку на свой контекст в слоте [[GeneratorContext]] (у async-генератора - [[AsyncGeneratorContext]]). Именно по этой ссылкеg.next()находит приостановленный кадр и возвращает его на стек.
Jobs - граница синхронного мира
Весь текст мы то и дело роняли слово «Job»: async-функцию возвращает на стек Job-продолжение, промис зарезолвился - Job. Ни разу при этом не объяснив, что это за Job такой. Пора закрыть долг - тем более что Job это ровно та граница, где синхронная модель из всей статьи и заканчивается.
Job - это Abstract Closure без параметров, то есть просто отложенное вычисление. Особенность у него одна, зато определяющая: Job запускается не «когда до него дойдёт очередь», а тогда, когда стек контекстов пуст - когда в агенте нет ни одного running-контекста. Не на вершине стека, не в середине чьего-то вызова, а на голом дне, когда исполнять больше нечего.
Вот теперь сходится всё, что мы говорили про async. Помнишь: на await со стека снимается только кадр async-функции, управление возвращается вызывающему, а ссылку на кадр придерживает обработчик, повешенный на промис. Когда промис выполнится, этот обработчик ставится в очередь Job’ом. Пока стек не пуст, Job ждёт. Стек опустел - движок берёт Job, тот зовёт обработчик, обработчик кладёт кадр обратно на стек. Тот самый возврат кадра мимо LIFO из второго вопроса - это буквально «Job дождался пустого стека и вернул кадр в running».
На пальцах: Job’ы это список дел, к которому движок притрагивается только тогда, когда доделал всё остальное и стек опустел. Никакой Job не влезет в середину исполняющегося кода - это гарантия, а не совпадение.
Правил у Job’а всего четыре, и все про изоляцию во времени:
пустой стек - про него уже сказали;
один за раз - в каждый момент в агенте активен максимум один Job;
run-to-completion - начавшийся Job доигрывает до конца, прежде чем стартует следующий. Та же гарантия, что и у обычного кадра: никто не прервёт на середине;
сам разбирается с ошибками - замыкание Job’а обязано вернуть normal completion. Исключению попросту некуда выйти наружу: стек пуст, ловить его на уровне вызова некому.

Дальше - кто эти Job’ы вообще ставит в очередь. И тут спецификация честно отходит в сторону: это не язык, а хост. Стандарт описывает четыре таких hook’а, все с приставкой Host (и тут же разрешает хостам добавлять свои операции постановки Job’ов):
HostEnqueuePromiseJob - реакции
then/catch/finallyи продолженияawait: именно через него кадр async-функции возвращается на стек;HostEnqueueTimeoutJob - Job, который нужно выполнить не раньше, чем через заданное время. В самом языке им пользуется только
Atomics.waitAsync, когда истекает таймаут ожидания;HostEnqueueGenericJob - Job без требований к порядку и приоритету. В языке он нужен
Atomics.notify: когда один агент будит другой, ждущий черезAtomics.waitAsync, промис второго разрешается Job’ом, поставленным уже в его агент;HostEnqueueFinalizationRegistryCleanupJob - вызвать колбэки
FinalizationRegistryдля объектов, которые уже собрал сборщик мусора. Единственный hook, который хост исполнять не обязан: в стандарте стоит оговорка «if possible».
К промис-Job’ам у стандарта три требования. Первое безусловное: они исполняются в порядке постановки, FIFO. Два других действуют, только если Job’у передали Realm Record, а не null: к моменту вызова среда обязана быть готова выполнять JS-код, и активным скриптом или модулем должен быть тот же, что был активен в момент постановки. Именно поэтому промис-реакция «помнит», какой скрипт был активен, когда её Job поставили в очередь, хотя стек к тому времени давно пуст.
Проверим это на порядке вывода. Таймер добавлен для сравнения: это не Job, и его место в выводе определяет не спецификация языка.
console.log('sync 1'); setTimeout(() => console.log('таймер'), 0); // вообще не Job - task браузера Promise.resolve().then(() => console.log('промис 1')); // промис-Job Promise.resolve().then(() => console.log('промис 2')); // промис-Job console.log('sync 2'); // печатает: // sync 1 // sync 2 ← весь синхронный код сначала, до опустошения стека // промис 1 ← промис-Job'ы в порядке постановки (FIFO) // промис 2 // таймер ← таймер позже, но это решает хост, а не спецификация
Всё, кроме последней строки, следует прямо из правил Job’а: пока стек не пуст, ни один не тронется, а дальше они идут в порядке постановки. А вот почему таймер оказался последним, спецификация не объясняет - и объяснить не может: setTimeout в ней не описан вовсе. Это уже правила хоста, и о них чуть ниже.
То же правило защищает и от чужого кода. Когда промис резолвят thenable-объектом (у которого есть свой then), спецификация не зовёт этот then синхронно - она заворачивает вызов в отдельный Job. Причина ровно та же: чужой then должен сработать после того, как доиграет весь окружающий синхронный код, а не посреди него.
const thenable = { then(res) { console.log('чужой then'); res(42); } }; console.log('до'); Promise.resolve(thenable).then(v => console.log('получили', v)); console.log('после'); // до // после // чужой then ← ушёл в Job, дождался пустого стека // получили 42
А теперь самое неожиданное: того, что ты привык называть «event loop», в современной ECMA-262 нет вообще. Это легко проверить грепом по тексту стандарта - слова microtask, macrotask, event loop, task queue и job queue встречаются там ноль раз. Ни одного.
То есть ни микрозадач с макрозадачами, ни их приоритетов, ни самого факта, что setTimeout бежит позже промиса, язык не описывает. Есть только определение Job’а и условие «пустой стек». Как Job’ы копятся, в каком порядке и с каким приоритетом достаются - целиком на host-defined операциях HostEnqueue…Job.
Живёт всё это в другом документе - в HTML Standard, в его модели исполнения: там и настоящий event loop, и очереди задач и микрозадач. Именно по его правилам промис-Job выполняется раньше таймера, хотя, забегая вперёд, дело там вовсе не в приоритете. Забавно, что сама ECMA-262 при этом говорит именно о приоритете: в примечании к разделу про Job’ы сказано, что браузеры и Node трактуют промис-Job’ы как более приоритетные и что будущие фичи могут добавить Job’ы без такого приоритета. То есть язык признаёт сложившуюся практику, но гарантией её не делает. Разбору этой второй половины - как HTML достраивает над Job’ами полноценный цикл событий - я планирую посвятить отдельную статью, прямое продолжение этой.
Вот, собственно, и вся граница. Синхронная модель - стек, кадры, окружения, running-контекст - заканчивается на пустом стеке. Всё, что происходит «потом», держится на Job’е: отложенном замыкании с единственным условием запуска - чтобы стек опустел. async, .then-цепочки, генераторные продолжения - это структуры данных плюс Job, который в нужный момент вернёт кадр к жизни.
Оговорка напоследок
Всё это на самом деле заметно сложнее, чем выглядит здесь. Модель, которую мы разобрали, держится на сотнях абстрактных операций, внутренних слотов и правил статической семантики, и вызывают они друг друга каскадом: за одной строчкой const x = 1 стоит цепочка шагов, где каждый тянет следующий, тот - третий, и так до самого низа. Разворачивать эту машинерию я сознательно не стал: иначе статья превратилась бы в пересказ алгоритмов, а за названиями операций потерялась бы общая картина.
Поэтому здесь - каркас. Какие сущности несут модель, как они ссылаются друг на друга и почему из этого получается именно такое поведение, а не другое. Если однажды понадобится точность до шага, дорога открыта: можно открыть нужный раздел спецификации и читать его, уже понимая, куда она встраивается.
И напоследок ещё два случая из той же серии. «Temporal dead zone» в стандарте не встречается ни разу - есть лишь неинициализированная привязка и ReferenceError при обращении к ней. С хойстингом тоньше: отдельного механизма «поднятия наверх» в алгоритмах нет - привязки просто заводятся раньше, чем побежит код. Само слово hoist встречается разве что в паре примечаний, как раз в этом смысле.
В статью я это не потащил: к самой модели исполнения ни хойстинг, ни TDZ прямого отношения не имеют - они из неё вытекают, но описаны в другом слое - в алгоритмах, которые заводят привязки в окружении до того, как побежит код. Оба сюжета я разобрал дополнением к этой статье в своём телеграм-канале w3d_coder - там же и остальное про js-разработку.

