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

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

Соберём, что уже есть. Agent — это мир с потоком. Realm — набор встроенных объектов и глобалей, и таких реалмов у одного агента может быть несколько. Дальше пойдём по пяти составляющим агента, тем более что первая ниточка уже в руках: [[AgentSignifier]] ведёт нас прямиком в Agent Record.
Agent Record — свойства и настройки агента
По этой ниточке, через [[AgentSignifier]], мы и вышли на Agent Record — одну из тех самых пяти составляющих. С неё и начнём.
Agent Record (§9.6) хранит свойства и настройки агента, ровно одна запись на агента. Смысл её в том, что некоторые вещи не принадлежат ни коду, ни конкретному контексту — они про самого агента: кто он вообще такой, вправе ли блокироваться, какой порядок байтов считает родным. Всё это и складывается сюда.
Полей девять, но большая часть служебные:
[[Signifier]] — идентификатор агента: уникально идентифицирует агента в пределах его кластера. По нему Realm Record находит владельца ([[AgentSignifier]]);
[[CanBlock]] — может ли агент блокироваться. Сама таблица тут скупа: определение «заблокирован» — что running-контекст синхронно и неограниченно ждёт внешнего события — лежит отдельно, в разделе про forward progress (§9.8). Кто выставляет значение, стандарт не проговаривает — это остаётся хосту; а в примечании к
AgentCanSuspend(§9.6.2) он лишь замечает, что запрещать приостановку главному потоку документа, разрешая её воркерам, — разумно. Это ровно то, почемуAtomics.wait()кидает ошибку на главном потоке, но работает в воркере — не «браузер так решил из вредности», а прямое следствие [[CanBlock]];
Остальные семь полей — служебные
[[LittleEndian]] — порядок байтов, который агент считает родным: он подставляется по умолчанию при чтении и записи в
ArrayBuffer;[[IsLockFree1]], [[IsLockFree2]], [[IsLockFree8]] — lock-free ли атомарные операции над значениями в 1, 2 и 8 байт;
[[CandidateExecution]] — состояние модели памяти (нужно для рассуждений о разделяемой памяти);
[[KeptAlive]] — объекты, которые нельзя собрать до конца текущего джоба; на этом держится
WeakRef;[[ModuleAsyncEvaluationCount]] — счётчик, задающий порядок асинхронного вычисления модулей.
Execution context — кадр исполнения
С Agent Record разобрались. Дальше всё интереснее: оставшиеся составляющие агента крутятся вокруг execution context, так что займёмся им.
Execution context (контекст исполнения, в народе «кадр») — это запись, где лежит всё состояние, нужное для выполнения одного куска кода. Движок исполняет функцию, скрипт или модуль — и ему надо где-то держать ответ на «где я сейчас, какие тут переменные, в каком мире я вообще работаю». Место, где этот ответ хранится, и есть execution context.
Новый контекст рождается в момент, когда управление уходит к коду, не связанному с текущим контекстом (§9.4). На практике это вызов функции, старт скрипта или модуля, eval.
Это самый большой и самый любопытный кусок модели, поэтому его внутренности мы отложим на потом. Сначала добьём оставшиеся составляющие агента — они всё равно построены вокруг контекста.
execution context stack
Только что созданный контекст пушится на execution context stack и становится running. Execution context stack держит контексты в порядке вложенности вызовов, и по нему сразу видно две вещи: кто исполняется прямо сейчас (это вершина) и кому вернуть управление, когда текущий кадр снимется. Собственно, это и есть тот самый call stack из твоего дебаггера, только названный по-спецификационному, — вот и ответ на первый вопрос из вступления: за ним стоит execution context stack, одна из пяти составляющих агента.
set of execution contexts
А вот про set of execution contexts рассказывать почти нечего (§9.6) — и это не я поленился, это так в самой спецификации. Вот всё, что можно сказать про него наверняка:
он есть — перечислен среди пяти составляющих агента;
точная формулировка — «set of ECMAScript execution contexts», и встречается она во всём документе ровно один раз, в этом самом перечне (можешь проверить грепом сам);
стоит он в списке отдельно от стека, то есть по замыслу это разные вещи;
принадлежит агенту эксклюзивно, как и всё прочее кроме потока, — значит, наборы разных агентов не пересекаются;
ни одна операция его не называет и с ним не работает: нет ни добавления, ни удаления, ни перечисления;
хост его тоже не берёт: спецификация HTML импортирует из ECMA-262 агента, кластер агентов, стек контекстов, running-контекст и даже surrounding agent — а набор нет.
движок его тоже не строит: в V8 такой структуры просто нет. Единственному коду, которому набор реально понадобился, — отладочному LiveEdit — приходится собирать его на лету: обходом всей кучи ради приостановленных генераторов плюс проход по текущему стеку и по стекам архивированных потоков.
И на этом всё. Показательно, что сходятся все три стороны: язык объявляет набор и больше к нему не возвращается, хост его не импортирует, движок не материализует. Зачем он в перечне, стандарт не объясняет. Так или иначе, вся реальная механика висит на стеке.
Running execution context — кто активен сейчас
running execution context (§9.4) — тот контекст, что прямо сейчас исполняет код. Спецификация гарантирует: в каждый момент на агента их не больше одного. Либо ровно один (значит, код исполняется), либо ноль (стек пуст, исполнять нечего). Двух и более не бывает в принципе. И running-контекст — это всегда вершина стека, без исключений.
Исполнение можно приостановить, тогда running-ом становится другой контекст. А приостановленный позже вполне может снова стать running и продолжить ровно с той точки, где его прервали.
Обычно смена running идёт по LIFO, как в любом стеке вызовов: доработал верхний кадр — управление вернулось соседу снизу. Но спецификация честно предупреждает, что кое-какие фичи языка требуют переходов не по LIFO. Как это вообще возможно, если кадр со стека уже сняли?
А вот так: снятый кадр умирает не всегда. Обычно — да: функция доработала, кадр сняли, и на этом всё. Но кадр, который не доработал, а приостановился, — другое дело. Спецификация говорит об этом в примечании к [[Call]] (§10.2.1) — правда, только про генератор: снятый контекст не уничтожается, если он приостановлен и его удерживает достижимый генератор. Для async-функций такого предложения нет, но там то же самое следует из операции Await (§27.7.5.3). Операциями в стандарте называют внутренние алгоритмы самой спецификации: в языке их нет, из кода не вызвать. Держит кадр не стек, а ссылка со стороны — со стороны того, кто потом вернёт его к исполнению.
У генератора этого держателя видно прямо в коде — это сам объект генератора:
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-функции всё то же самое, только держатель невидимый: на промис вешаются два обработчика, onFulfilled и onRejected. Первый возобновит кадр значением, второй выбросит исключение прямо в точке await. И вернёт кадр на стек не твой вызов, а джоб — тот дождётся, пока стек опустеет.
Вот и ответ на второй вопрос из вступления: в честном LIFO снятый кадр был бы уже мёртв, а управление ушло бы строго соседу снизу. Отсюда же и то, что await не «блокирует поток»: кадр физически ушёл со стека, а поток тем временем занят другим кодом.
Это легко увидеть по порядку вывода:
async function f() { console.log(1); await 0; // здесь кадр f снимается со стека console.log(3); // а это уже отдельный джоб — стек к тому моменту пуст } f(); console.log(2); // печатает: 1, 2, 3
3 печатается после 2, хотя в коде стоит раньше: на await кадр f ушёл со стека, управление вернулось наружу (console.log(2)), и только когда стек опустел, джоб-продолжение вернул f дописывать себя.
Executing thread — кто исполняет
Осталась последняя составляющая агента — тот, кто всю эту машинерию и крутит. executing thread (§9.6) — тот, кто прогоняет шаги алгоритмов на контекстах агента. Иначе говоря, поток, который физически исполняет код кадр за кадром. В каждый момент он занят running-контекстом, но принадлежит агенту целиком, а не какому-то одному кадру.
Поток — единственная составляющая, которую агент не держит только для себя. Стек, набор контекстов, running-контекст, Agent Record — всё это про свой агент и больше ничей. А поток запросто бывает общим на несколько агентов. Изоляцию это, вопреки первому впечатлению, не ломает, и вот в чём соль: общий поток — это общий исполнитель, но не общие данные. Поработал он в одном агенте на его стеке, переключился на другой — и работает уже на стеке второго. Чужие кадры для агента как не существовали, так и не существуют.
В каждый момент бежит максимум один кадр, тот самый running. Две функции внутри одного агента одновременно выполняться просто не могут. А делить поток разрешено только тем агентам, у кого [[CanBlock]] не выставлен в true, — иначе один заблокировавшийся заморозил бы всех соседей по потоку.
И вот он, обещанный ответ про вкладки. Некоторые браузеры сажают несколько несвязанных вкладок (то есть агентов) на один поток именно потому, что блокироваться им запрещено. Получается один исполнитель на пачку изолированных миров — и всё держится ровно до тех пор, пока никто не пытается синхронно встать. Разреши им
[[CanBlock]] = true— и одинAtomics.wait()в любой из вкладок заморозил бы соседей: поток общий, а ждать он будет неограниченно.Заметь, что «заблокирован» в стандарте — это именно синхронное неограниченное ожидание внешнего события, а не «долго считает». Бесконечный
while(true)формально исправно выполняет шаги вычисления, то есть двигается вперёд, — соседям по потоку от этого не легче, но к[[CanBlock]]их беда отношения не имеет.
Строение execution context
Со всеми составляющими агента закончили — теперь возвращаемся к контексту и смотрим, из чего он сам собран. Здесь и вскрывается третий вопрос из вступления: что за состояние движок тащит в одном кадре стека. А вместе с ним закроются и остальные обещания: var против let и const, scope chain, функциональная и блочная области видимости.
Компонентов три группы. Базовые есть у любого контекста; дополнительные добавляются у кода на ECMAScript; и есть отдельный компонент, который живёт только у контекста генератора.

Базовые компоненты (есть у любого контекста)
code evaluation state — «на каком шаге исполнения я сейчас». Позволяет приостановить контекст и продолжить с того же места. Работает при любом вложенном вызове: когда одна функция вызывает другую, контекст фиксирует здесь своё состояние, отдаёт управление, а после возврата продолжает с этого места. Тот же механизм лежит в основе генераторов (
yield) иawait.Function — сам объект функции, чей код сейчас исполняется: не имя, а именно объект со всеми его внутренними слотами. Вызови функцию десять раз — получишь десять контекстов, но Function во всех десяти смотрит на один и тот же объект. Через него движок и узнаёт всё про саму вызванную функцию: в каком реалме она создана, где искать родительский метод для
super. У скрипта и модуля здесь null.Realm — ссылка на Realm Record, с которым связан исполняемый код: оттуда берутся Array, Object, глобальный объект.
ScriptOrModule — из какого скрипта или модуля пришёл код, или null, если исходного скрипта нет.
Environment Record — окружение
Два из трёх оставшихся компонентов — это ссылки на окружения, поэтому сначала стоит понять, что такое окружение вообще. Сущность это самая нагруженная во всей модели: из неё напрямую растут и замыкания, и разница между var и let, и сами области видимости — функциональная с блочной.
Environment Record (§9.1) — тип из спецификации, который связывает идентификаторы с конкретными переменными и функциями, опираясь на лексическую вложенность кода. Если попроще — это табличка «имя → значение», отвечающая на вопрос «а что вообще означает вот это имя вот в этом месте».
Как правило, окружение привязано к какой-то синтаксической конструкции: объявлению функции, блоку, catch-клаузе. И каждый раз, когда такой код вычисляется, под его привязки заводится новое окружение. Тут важно не проскочить: окружение создаётся одно на каждое вычисление, а не одно на конструкцию в исходнике. Два вызова одной функции — два разных окружения. Вот откуда берётся независимость локальных переменных у одновременно живущих вызовов, будь они вложенными, рекурсивными или приостановленными.
У каждого окружения есть поле [[OuterEnv]] — ссылка на внешнее окружение либо null. Так моделируется логическая вложенность: внешнее окружение логически охватывает внутреннее, а у него самого тоже может быть своё внешнее, и так далее. Причём одно окружение легко оказывается внешним сразу для многих: две вложенные функции внутри одной внешней получат в [[OuterEnv]] один и тот же объект — окружение текущего вызова этой внешней функции.
Именно по этой цепочке и ищется имя: движок идёт по [[OuterEnv]], пока не найдёт нужное или пока не упрётся в null у глобального окружения. Вот, кстати, и что такое 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 (§9.1.1.4) — дно цепочки: [[OuterEnv]] тут равен null, дальше идти некуда. Одно на весь реалм, а не на скрипт — все скрипты реалма делят его между собой. И единственное из всех, что склеено из двух половинок: var и объявления функций уходят в [[ObjectRecord]], то есть становятся свойствами глобального объекта (там же лежат и все встроенные глобалы вроде Array), а let, const и class — в [[DeclarativeRecord]]. Вот и объяснение старой загадки: var x на верхнем уровне скрипта создаёт globalThis.x, а let x — нет. В модуле так не будет: там своё Module Environment Record, и var остаётся внутри модуля (в Node то же самое из-за CJS-обёртки).
Function Environment Record (§9.1.1.3) — по одному на каждый вызов функции, написанной на ECMAScript (у встроенных функций окружения нет вовсе: кадр для их вызова создаётся, а окружение — нет). Особенной её делают две вещи. Первая — [[OuterEnv]] указывает на [[Environment]] вызванной функции, то есть на место, где функцию объявили, а не откуда её позвали. Отсюда и замыкания, и то, что область видимости в JS лексическая, а не динамическая. Вторая — это единственное окружение с собственной привязкой this, которая заводится на каждый вызов, а с ней new.target и то, что нужно super. (this дают ещё глобальное окружение и окружение модуля, но там он один на весь реалм или модуль, а не свой на каждый вызов.) Причём спецификация формулирует так: Function ER даёт привязку this, если функция не стрелочная. У стрелочной такой привязки просто не заводят — поэтому this в ней ищется снаружи, по той же цепочке [[OuterEnv]]. Никакого «стрелки запоминают контекст» тут нет: им просто нечего искать у себя.
Declarative Environment Record (§9.1.1.1) — самый простой: привязки лежат прямо в записи, без всяких объектов. Порождают его блок, catch-клауза, каждая итерация цикла с let, класс и eval. Он же лежит в основе Function и Module: оба раздела спецификация открывает словами «это Declarative Environment Record, который…». И заодно служит второй половиной Global. Именно эту запись выбрасывают на выходе из блока, отсюда и блочность let с const.
Ещё два вида, которые дальше не понадобятся: Object и Module
Object Environment Record (§9.1.1.2) — тут привязок как таковых нет, их роль играют свойства объекта из поля [[BindingObject]], причём и собственные, и унаследованные. Таких записей в языке две, и они разные: одну создаёт оператор with — она становится LexicalEnvironment кадра на время блока; другая — это [[ObjectRecord]] глобального окружения. Различает их флаг [[IsWithEnvironment]]. А из «поиск привязки = поиск свойства» растёт неочевидное следствие: разрешение имени умеет уходить в прототипную цепочку. Object.prototype.foo = 1; foo работает без всякого with — глобальные переменные ищутся именно так. За with же не любят другое: он позволяет подсунуть в цепочку произвольный объект.
Module Environment Record (§9.1.1.5) — одно на модуль, не на вызов; создаётся ещё на фазе линковки, до того как выполнится хоть строчка, а [[OuterEnv]] у него всегда глобальное окружение. Своя особенность одна: неизменяемые import-привязки, дающие косвенный доступ к привязке в другом окружении. Поэтому import { x } — не копия значения: читаешь ты привязку исходного модуля, а переприсвоить её нельзя. Заодно вот и «хойстинг импортов»: связи готовы раньше, чем побежал код.
Дополнительные компоненты (у контекстов кода на ECMAScript)
Через них контекст видит переменные. Первые два — ссылки на Environment Record, третий — на запись другого типа.
LexicalEnvironment — по определению стандарта это окружение, по которому разрешаются идентификаторы в этом контексте: с него движок начинает искать любое имя, в том числе объявленное через
var, и, не найдя, шагает наружу по [[OuterEnv]]. Заодно именно в него попадают объявления, привязанные к текущей области:let,const,class, параметрcatch, функции внутри блока. Меняется по ходу исполнения: вход в блок илиcatchподменяет его на новое окружение, вложенное в прежнее.VariableEnvironment — а этот определён наоборот, через содержимое: окружение, которое держит привязки, созданные
var-объявлениями (плюс функции, объявленные на верхнем уровне). В отличие от предыдущего, при входе в блок не меняется: остаётся тем, что было заведено на входе в функцию или скрипт.PrivateEnvironment — ссылка на PrivateEnvironment Record (§9.2): в нём лежат приватные имена класса (
#field,#method), или null вне класса. Отдельный тип, не Environment Record, но устроен похоже — своя запись на каждое вычисление класса, своя цепочка наружу для вложенных классов. Приватность тут настоящая, а не по соглашению:#x— не строковый ключ, а особое имя, и обращение к нему вне класса — ошибка ещё при разборе кода, а не в рантайме. Никакойobj['#x']не поможет — это принципиально другой механизм, чем старое_privateпо договорённости.
Все три слота — это именно ссылки, а не хранилища: сами имена лежат внутри записей, на которые слоты показывают. Поэтому «var пишется в VariableEnvironment» значит только одно — что он попал в то окружение, на которое этот слот показывал. А искать его потом всё равно будут от LexicalEnvironment и дальше по цепочке.
Зачем тогда два слота под окружения, если читают всё равно через один? Чтобы в одной функции работали два разных правила видимости сразу: одно — на блок, другое — на всю функцию. На входе в блок Lexical уезжает на свежее окружение, а Variable остаётся на прежнем — поэтому let умирает вместе с блоком, а var нет. Одним указателем так не выйдет: он либо уезжает, либо стоит, и одно из двух поведений пришлось бы сломать. Так что второй слот — это, по сути, плата за обратную совместимость с var.
Теперь посмотрим, как эти слоты ведут себя в динамике.
function f() { var a = 1; a; // 1 { let b = 2; a; b; // 2 } a; b; // 3 }

Разберём по шагам.
Вошли в блок. Своего кадра блок не создаёт — мы остаёмся в кадре функции: кадр рождается только при передаче управления коду, не связанному с текущим контекстом (вызов функции, в том числе встроенной, старт скрипта или модуля, eval). Зато создаётся новая запись, причём всегда — даже если внутри нет ни одного let, она просто останется пустой. LexicalEnvironment переезжает на неё, VariableEnvironment остаётся на прежней.
Вышли из блока. LexicalEnvironment возвращается не обязательно на запись функции, а на ту, на которую смотрел до входа: для вложенных блоков это будет запись внешнего блока. Запись блока при этом уничтожается — на неё больше никто не ссылается. Если, конечно, внутри не родилось замыкание: его [[Environment]] эту запись держит, и она блок переживёт, ровно как окружение в примере с makeCounter выше.
Две оговорки к сказанному. Первая: у совсем пустого {} в спецификации своя продукция, которая возвращает empty и не создаёт вообще ничего. Вторая: если у функции есть дефолты в параметрах, под тело заводится отдельная запись — стандарт прямо пишет, что при наличии инициализаторов параметров создаётся вторая запись для объявлений тела. Так что «оба слота смотрят на одну запись» верно не всегда.
И оговорка посерьёзнее, уже ко всему разделу: спецификация описывает наблюдаемое поведение, а не структуры данных в памяти. Движку никто не запрещает запись не создавать, если её никто не захватывает, — 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, просто запомни». Запоминать, как видишь, было нечего. В спецификации сущности «область видимости» нет вообще — ни термина, ни записи, ни поля. То, что зовут функциональной областью, — это Function Environment Record; то, что блочной, — Declarative Environment Record блока. А сама «видимость» — это достижимость: имя видно ровно оттуда, откуда до его записи дотягивается цепочка [[OuterEnv]].
Из той же механики, между прочим, растёт и классика с циклами: у for (let …) своя запись заводится на каждую итерацию — потому замыкания из цикла и ловят разные i.
Компонент генератора (только у контекста генератора)
Generator — ссылка на объект генератора, чьё тело вычисляется в этом контексте. Такой компонент есть у
function*и уasync function*(async-генераторов), а вот у обычного вызова функции его нет. Связь двусторонняя: контекст ссылается на генератор, а генератор на контекст — через слот [[GeneratorContext]] (§27.5.2), а у async-генераторов — [[AsyncGeneratorContext]] (§27.6.2).
Jobs — граница синхронного мира
Весь текст мы то и дело роняли слово «джоб»: async-функцию возвращает на стек джоб-продолжение, кадр придерживает джоб, промис зарезолвился — джоб. Ни разу при этом не объяснив, что это за джоб такой. Пора закрыть долг — тем более что джоб это ровно та граница, где синхронная модель из всей статьи и заканчивается.
Job (§9.5) — это Abstract Closure без параметров, то есть просто отложенное вычисление. Особенность у него одна, зато определяющая: джоб запускается не «когда до него дойдёт очередь», а тогда, когда стек контекстов пуст — когда в агенте нет ни одного running-контекста. Не на вершине стека, не в середине чьего-то вызова, а на голом дне, когда исполнять больше нечего.
Вот теперь сходится всё, что мы говорили про async. Помнишь: на await кадр снимается со стека, стек опустевает целиком, а ссылку на кадр придерживает обработчик, повешенный на промис. Так вот — этот обработчик и уезжает джобом. Пока стек не пуст, джоб ждёт. Стек опустел — движок берёт джоб, тот зовёт обработчик, обработчик пушит кадр обратно на стек. Тот самый возврат кадра мимо LIFO из второго вопроса — это буквально «джоб дождался пустого стека и вернул кадр в running».
На пальцах: джобы это список дел, к которому движок притрагивается только тогда, когда доделал всё остальное и стек опустел. Никакой джоб не влезет в середину исполняющегося кода — это гарантия, а не совпадение.
Правил у джоба всего четыре, и все про изоляцию во времени (§9.5):
пустой стек — про него уже сказали;
один за раз — в каждый момент в агенте активен максимум один джоб;
run-to-completion — начавшийся джоб доигрывает до конца, прежде чем стартует следующий. Та же гарантия, что и у обычного кадра: никто не прервёт на середине;
сам разбирается с ошибками — замыкание джоба обязано вернуть normal completion. Исключению попросту некуда выйти наружу: стек пуст, ловить его на уровне вызова некому.

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