Risen.dev

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 (только в коде, который принадлежит текущему лексическому окружению).

Если сканирование идет для функции, происходит следующее:

  1. Для всех varDeclarations в лексическом окружении создаются записи. При этом, помимо создания, сразу же происходит инициализация записей: для var - со значением undefined, для function declaration - со значением их function object. Именно поэтому мы можем обращаться к таким переменным еще до того, как выполнение дошло до их обьявления.
  2. Для всех 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 },
   * ]
   */
}