JS в деталях [Часть 1]
30 апреля 2021 г.
Навигация
Вступление
Изначально, я хотел также написать и про Event Loop, но для полноты информации нужно исследовать исходники движка Blink, что затруднительно и может занять довольно много времени. Поэтому, данная статья содержит только лексические окружения и контекст выполнения. Возможно, в будущем я напишу и про Event Loop.
Лексические окружения
Различные переменные нужно где-то хранить. Эту задачу выполняют лексические окружения.
Они - специальные объекты, которые создаются по мере выполнения программы: при заходе в тело функции или блок кода, при каждой новой итерации цикла for, и так далее.
При обьявлении новой переменной или чтении уже существующей, движок как раз обращается к лексическим окружениям.
Давайте разберемся, как конкретно это происходит.
Переменные
Рассмотрим следующий код:
/* 1 лексическое окружение */
const one = 1
const bool = true
if (bool) {
/* 2 лексическое окружение */
const two = 2
if (one + two === 3) {
/* 3 лексическое окружение */
const three = 3
}
if (two - one === one) {
/* 4 лексическое окружение */
const four = 4
}
}
Вполне очевидно, что во 2 лексическом окружении, помимо переменной two
у нас также есть доступ к one
и bool
, которые находятся в 1 лексическом окружении.
А в 3 и 4 лексических окружениях у нас так же есть доступ к 1 и 2. Но 3 и 4 не имеют доступа друг к другу: 3 не может прочитать переменную four
, а 4 не может прочитать three
.
Но почему? Как все это устроено под капотом?
Дело в том, что у каждого лексического окружения (кроме глобального) есть “родитель” - другое лексическое окружение, в котором текущее было создано. Этого “родителя” также можно назвать внешним лексическим окружением.
И каждое лексическое окружение хранит ссылку на своего “родителя”. Таким образом, в совокупности, из множества лексических окружений образуется дерево. Вершиной этого дерева является глобальное лексическое окружение, которое создается для всего скрипта.
Для кода выше дерево выглядит так:
1
\
2
/ \
3 4
При обращении к переменной идет поиск вверх по дереву, начиная от текущего лексического окружения. Соседние ветки не могут быть задеты, поэтому их локальные переменные нам не доступны.
Таким образом:
- при обращении к переменным в 3 лексическом окружении, идет поиск в ветке
1 -> 2 -> 3
. - при обращении к переменным в 4 лексическом окружении, идет поиск в ветке
1 -> 2 -> 4
. - при обращении к переменным во 2 лексическом окружении, но не внутри 3 или 4, идет поиск в ветке
1 -> 2
.
Внешние лексические окружения функций
Теперь случай посложнее:
/* 1 лексическое окружение */
const v = 1
function A() {
/* 2 лексическое окружение */
console.log(v)
}
function B() {
/* 3 лексическое окружение */
const v = 2
A()
}
Функция A
вызвана в 3 лексическом окружении. Но значение v
получает из 1, и в целом не имеет доступа к переменным из 3. Или представим ситуацию, что мы создали функцию в одном модуле, а потом импортировали и вызвали в другом. Не перестанет же она от этого иметь доступ к переменным, объявленным в модуле? Те, кто писал что-то на JS, понимают это на подсознательном уровне. Но почему так происходит?
Дело в том, что для функции внешним (родительским) лексическим окружением всегда является область, в которой она была объявлена. Место вызова никак на это не влияет.
Объявление/запись переменных
В случае с let
и const
все просто. Запись всегда происходит в текущее лексическое окружение, так как у них блочная область видимости.
Но с var
происходит иначе. У них область видимости ограничена функцией, внутри которой они находится, или скриптом, если такой функции нет. Поэтому они игнорируют обычные блоки и устанавливаются в ближайшее в дереве лексическое окружение, созданное для функции, или же в глобальное лексическое окружение, если они были объявлены за пределами любых функций.
Еще есть function declaration - их область видимости зависит от режима. В strict mode
у них блочная область видимости, иначе - такая же, как и у var
- ограниченная функцией, в которой находится объявление, или скриптом.
Здесь могут появиться вопросы:
- как определяется докуда “всплывать”?
- может идет поиск вверх по дереву?
- но как мы узнаем, какое лексическое окружение принадлежит блоку, а какое телу функции?
Отвечая с последнего вопроса:
- узнать для какой конструкции было создано лексическое окружение - возможно, но в данном случае это не нужно. Тем не менее, мы рассмотрим этот вопрос чуть подробнее ниже.
- поиск по дереву для этого не производится.
- информация о том куда “всплывать” имеется изначально. Но она хранится в контексте выполнения. Поэтому данный вопрос будет подробно рассмотрен в главе о контексте выполнения.
Спецификация
Каждое лексическое окружение - это Environment Record.
Все Environment Record имеют некоторое базовое API для взаимодействия с их содержимым:
CreateMutableBinding(N)
иCreateImmutableBinding(N)
- создать свойство с именемN
(мутабельное/иммутабельное)InitializeBinding(N, V)
- инициализировать свойство с именемN
и просвоить ему значениеV
SetMutableBinding(N, V)
- установить значениеV
мутабельному свойствуN
HasBinding(N)
- проверить, содержится ли в лексическом окружении свойство с именемN
GetBindingValue(N)
- получить значение свойства с именемN
DeleteBinding(N)
- удалить свойство с именемN
HasThisBinding()
- содержит ли лексическое окружение данные оthis
. Это будет рассмотрено в дальнейших главах.HasSuperBinding()
- содержит ли лексическое окружение данные оsuper
. Это будет рассмотрено в дальнейших главах.WithBaseObject()
- связано с использованием конструкцииwith
. Мы не будем углубляться в эту информацию.
Также внутри содержится поле [[OuterEnv]]
, равное либо null
, либо ссылке на внешнее лексическое окружение.
Виды Environment Record
- Declarative Environment Record - базовый тип, используется для простых блоков кода, для конструкции switch/case, для итераций цикла for, и так далее.
-
Function Environment Record - подкласс Declarative Environment Record, использующийся для верхнего лексического окружения функции.
Помимо обычных свойств также содержит:
[[ThisValue]]
- значениеthis
. Оно хранится именно тут.[[ThisBindingStatus]]
- статус привязкиthis
. Может иметь 3 значения:lexical
/uninitialized
/initialized
.lexical
- означает, чтоthis
берется из внешнего лексического окружения. Именно такое значение устанавливается для лексических окружений стрелочных функций.uninitialized
- означает, чтоthis
еще не установлен. Такое может быть на этапе создания контекста выполнения функции.initialized
-this
установлен.[[FunctionObject]]
- функция, чье выполнение инициировало создание данного лексического окружения.[[NewTarget]]
- функция-конструктор. Мы не будем углубляться в эту информацию.
-
Module Environment Record - подкласс Declarative Environment Record, использующийся для верхнего лексического окружения модуля.
Помимо обычных методов также содержит:
CreateImportBinding(N, M, N2)
- создает иммутабельную непрямую привязку к свойству с именемN2
из модуляM
. Для текущего модуля она будет иметь имяN
. Кстати, по той причине, что она иммутабельна, мы не можем при обращении кN
переопределять переменную из модуля, если она была обьявлена черезlet
.
И переопределяет метод
GetThisBinding()
. Модуль всегда находится вstrict
режиме, и егоthis
равенundefined
. ПоэтомуGetThisBinding()
в лексическом окружении модуля всегда возвращаетundefined
. -
Object Environment Record - используется для прямой связи с определенным объектом.
По сути, обращаясь к данному лексическому окружению, мы работаем со связанным с ним объектом. Поэтому Object Environment Record является своеобразной оберткой над объектом и нужен чтобы представлять его в качестве лексического окружения. Например, такое лексическое окружение создается для работы с глобальным обьектом, или для конструкции
with
.Имеет следующие дополнительные поля:
[[BindingObject]]
- собственно обьект, к которому привязано данное лексическое окружение.[[IsWithEnvironment]]
- создано ли оно для конструкцииwith
.
Также здесь переопределены некоторые методы - для того, чтобы работа происходила напрямую со связанным объектом. Подробное рассмотрение не требуется, достаточно примерно представлять, что происходит.
-
Global Environment Record - используется для самого верхнего лексического окружения. Создается только для скрипта.
[[OuterEnv]]
такого лексического окружения всегда равенnull
.Сам по себе не хранит переменные и значения, но внутри содержит Object Environment Record и Declarative Environment Record.
Содержит дополнительные поля:
[[ObjectRecord]]
- Object Environment Record, связанный с глобальным объектом. Сюда записываютсяvar
, объявленные на уровне скрипта (и function declaration в случае безstrict mode
).[[DeclarativeRecord]]
- Declarative Environment Record. Сюда записывается все остальное.[[GlobalThisValue]]
- глобальное значениеthis
. Обычно указывает на глобальный объект.[[VarNames]]
- список имен переменных, обьявленных на уровне скрипта с помощьюvar
(и function declaration в случае безstrict mode
).
В том числе методы:
GetThisBinding()
- возвращает глобальныйthis
.HasVarDeclaration(N)
- имеет ли[[VarNames]]
элементN
.HasLexicalDeclaration(N)
- имеет ли[[DeclarativeRecord]]
свойство с именемN
.HasRestrictedGlobalProperty(N)
- имеется ли в глобальном обьекте свойство с именемN
, которое запрещено переопределять.CanDeclareGlobalVar(N)
- можно ли создать глобальную переменнуюvar
с именемN
.CanDeclareGlobalFunction(N)
- можно ли создать глобальную функцию с именемN
(с помощью function declaration).CreateGlobalVarBinding(N, D)
- создать переменную с именемN
в[[ObjectRecord]]
(дляvar
).CreateGlobalFunctionBinding(N, V, D)
- создать функцию с именемN
в[[ObjectRecord]]
(для function declaration).
Внешние лексические окружения функций
Выше мы говорили про родительское окружение функций. Давайте подробнее разберемся как это работает.
Для хранения информации об этом у объекта функции есть специальное свойство [[Environment]]
. При объявлении функции туда записывается ссылка на текущее лексическое окружение.
При вызове этой функции значение свойства [[Environment]]
записывается в поле “родителя” нового лексического окружения. Таким образом, если переменная не находится в функции, поиск продолжается в области, где она была объявлена.
Статус переменных
Записи в лексических окружениях могут иметь 2 состояния:
- не инициализирована
- инициализирована
Пока запись не инициализирована - соответствующую переменную нельзя использовать. Это важно знать, чтобы понять как переменные записываются в лексическое окружение и как читаются из него.
В спецификации нет конкретной информации о том как определяется статус инициализации записи. Скорее всего, не инициализированные записи имеют какое-то особое значение, или внутреннее поле (на усмотрение движка).
Создание переменных
Перед выполнением блока происходит сканирование кода - для найденных переменных и обьявлений функций, если они подходят по области видимости к текущему лексическому окружению, создаются записи в соответствующем Environment Record. Но, в зависимости от способа объявления, могут происходить или не происходить дополнительные действия.
Переменные делятся на 2 группы:
varDeclarations
-var
, function declaration безstrict mode
(во всем коде функции, кроме вложенных функций).lexDeclarations
-let
,const
,class
, function declaration соstrict mode
(только в коде, который принадлежит текущему лексическому окружению).
Если сканирование идет для функции, происходит следующее:
- Для всех
varDeclarations
в лексическом окружении создаются записи. При этом, помимо создания, сразу же происходит инициализация записей: дляvar
- со значениемundefined
, для function declaration - со значением их function object. Именно поэтому мы можем обращаться к таким переменным еще до того, как выполнение дошло до их обьявления. - Для всех
lexDeclarations
создаются записи, но не инициализируются. Из-за этого при обращении к ним до объявления мы получим ошибкуReferenceError: Cannot access before initialization
. Только когда выполнение дойдет до объявления переменной - она будет инициализирована с соответствующим значением.
Если сканирование идет для скрипта - происходит все то же самое, но varDeclarations
записываются в [[ObjectRecord]]
глобального лексического окружения, а lexDeclarations
- в [[DeclarativeRecord]]
.
При сканировании для любого другого блока выполняется только 2 шаг, так как varDeclarations
актуальны только для скрипта и функций.
Чтение переменных
Здесь все проще.
При обращении к переменной выполняется операция ResolveBinding. Внутри она вызывает GetIdentifierReference. Эта функция производит рекурсивный поиск значения по всему дереву, начиная от переданного лексического окружения и заканчивая глобальным лексическим окружением.
В этой операции нет проверки, была ли переменная инициализирована. Скорее всего данную функциональность движок реализует на свое усмотрение.
Контекст выполнения
Рассмотрим такой код:
/* 1 лексическое окружение */
function A() {
/* 2 лексическое окружение */
const one = 1
B()
return one
}
function B() {
/* 3 лексическое окружение */
const two = 2
}
Дерево лексических окружений здесь выглядит так:
1
/ \
2 3
При выполнении фукции A
происходит вызов функции B
, при этом новое лексическое окружение (3), созданное для функции B
указывает родителем не то лексическое окружение, откуда была вызвана функция (2), а то, где она была создана (1).
Таким образом, когда функция B
завершает свое выполнение и управление возвращается обратно в функцию A
, как мы можем определить какое лексическое окружение использовать? По логике, мы должны вернуться назад через ссылку на родительское лексическое окружение, но ведь родитель для 3 лексического окружения - 1 лексическое окружение. Значит, если мы попробуем сделать так - мы будем использовать неправильное лексическое окружение.
И вообще, как определяется, какое в текущий момент лексическое окружение активно? И более того, куда записать переменную при создании ее через var
(или function declaration в случае без strict mode
)?
Как раз для этих целей был придуман контекст выполнения - он создается для скрипта, модулей и для каждого вызова какой-либо функции и содержит в себе состояние выполнения соответствующего участка кода, а также ссылки на актуальные лексические окружения. Также контекст выполнения создается для вызовов eval
, но этот случай мы не будем рассматривать.
Вместе все контексты выполнения образуют LIFO стек, с помощью которого движок может отслеживать выполнение всего кода. При запуске скрипта первым элементом, добавленным в стек, всегда является глобальный контекст окружения, созданный для скрипта. Далее, по мере выполнения кода, в стек могут добавляться контексты выполнения модулей или функций. Последний элемент в стеке - всегда активный контекст выполнения, код которого выполняется в текущий момент.
Таким образом, при вызове функции создается новый контекст выполнения, который становится последним в стеке элементом (и соответственно активным), но старый контекст выполнения никуда не пропадает - он становится предпоследним элементом. После выполнения функции контекст, который был создан для ее вызова, удаляется из стека и активным становится предыдущий элемент. Таким образом управление переходит обратно в предыдущую функцию и код продолжает выполняться.
Спецификация
Ссылки:
Контекст выполнения содержит следующие элементы:
- Состояние выполнения кода, ассоциированного с данным контекстом.
Function
- функция, код которой выполняется (для скрипта или модуля -null
).Realm
- специальный объект, содержащий базовые сущности среды выполнения. Для нас это не очень важно.ScriptOrModule
- специальный объект скрипта/модуля, если код выполняется для них. Нам это также не нужно.
И самое интересное:
LexicalEnvironment
- текущее лексическое окружение контекста выполнения. Изначально здесь находится лексическое окружение, созданное для вызова функции. Но важно то, что это поле не статично - оно может меняться по мере выполнения кода. Например, при заходе в блокif
будет создано новое лексическое окружение, которое и станет новымLexicalEnvironment
. После выполнения этого блока кода восстановится предыдущее значениеLexicalEnvironment
(с помощью[[OuterEnv]]
текущего).VariableEnvironment
- всегда корневое лексическое окружение контекста выполнения, например, для функций - лексическое окружение функции. Используется при создании переменных черезvar
(и function declaration в случае безstrict mode
). Как раз с помощью данного лексического окружения можно однозначно определить куда записывать переменную, не ходя по дереву лексических окружений.
При этом LexicalEnvironment
и VariableEnvironment
могут указывать на одно и то же лексическое окружение. Например, изначально они оба указывают на лексическое окружение вызова функции.
Теперь немного грубая пошаговая демонстрация (VE
= VariableEnvironment
, LE
= LexicalEnvironment
):
///////
// 1 //
///////
/*
* Стек: [
* script: { VE: 1, LE: 1 }
* ]
*/
A()
function A() {
///////
// 2 //
///////
/*
* Стек: [
* script: { VE: 1, LE: 1 },
* A: { VE: 2, LE: 2 }
* ]
*/
if (3 > foo) {
///////
// 3 //
///////
/*
* Стек: [
* script: { VE: 1, LE: 1 },
* A: { VE: 2, LE: 3 }
* ]
*/
const foo = 2 // записывается в LE
var zoo = 3 // записывается в VE
B()
}
/*
* Стек: [
* script: { VE: 1, LE: 1 },
* A: { VE: 2, LE: 2 }
* ]
*/
D()
}
function B() {
///////
// 4 //
///////
/*
* Стек: [
* script: { VE: 1, LE: 1 },
* A: { VE: 2, LE: 3 },
* B: { VE: 4, LE: 4 }
* ]
*/
console.log('Hello!')
C()
}
function C() {
///////
// 5 //
///////
/*
* Стек: [
* script: { VE: 1, LE: 1 },
* A: { VE: 2, LE: 3 },
* B: { VE: 4, LE: 4 },
* C: { VE: 5, LE: 5 }
* ]
*/
const bar = 'baz'
if (bar) {
///////
// 6 //
///////
/*
* Стек: [
* script: { VE: 1, LE: 1 },
* A: { VE: 2, LE: 3 },
* B: { VE: 4, LE: 4 },
* C: { VE: 5, LE: 6 }
* ]
*/
return 1
}
return 2
}
function D() {
///////
// 7 //
///////
/*
* Стек: [
* script: { VE: 1, LE: 1 },
* A: { VE: 2, LE: 2 },
* D: { VE: 7, LE: 7 },
* ]
*/
}