Внимание! Эта статья написана не для того, чтобы возводить на пьедестал теорию, равно как и практику. Рекомендую найти баланс между тем и другим. Я же хочу предостеречь вас от тех ошибок, которые совершил сам.
В двух словах
Ли: Ударь меня.
[Ученик пытается ударить Ли].
Ли: Что это было? Демонстрация? Нам нужен эмоциональный контент. Попробуй еще раз.
[Ученик снова наносит удар Ли, но с большей агрессией].
Ли: Я сказал: «Эмоциональный контент». Не гнев! Попробуй еще раз… но с учетом моих слов.
[Ученик делает еще одну попытку и добивается успеха].
Ли: Уже лучше! Что ты почувствовал?
Ученик: Сейчас подумаю…
Ли: Не думай. Чувствуй! Это как с пальцем, указывающим на Луну. Не концентрируйся на пальце, иначе пропустишь небесное зрелище. Ты все понял?
© Выход Дракона (1973).
Нелегко, глядя на код, проследить его путь и понять, что он делает. Но, как правило, именно этим мы и занимаемся большую часть времени. Мы визуализируем в своем воображении все те человеческие действия, которые выполняет код.
Бэкенд — мы видим строку кода и понимаем, что она предназначена для подключения к базе данных; другая строка принимает запрос, третья — ответ и т. д.
Фронтенд — мы видим строку кода и понимаем, что она предназначена для анимации какого-то объекта, и создаем эту анимацию в своем воображении.
А все, что трудно визуализировать, можем записать в журнал. Но в этот раз попробуем пойти другим путем.
Уровень 1
function getSum(a, b) {
return a + b;
}
let a = 1;
let b = 2;
let sum = getSum(a, b);
console.log(sum);
Я спрашиваю: «Что вы видите в этом фрагменте?»
Вы можете ответить:
- Объявление функции — чистой функции, которая вернет сумму своих аргументов.
- Объявление трех переменных — значение третьей переменной является результатом суммы двух предыдущих, для чего используется функция
getSum.
- Вывод в консоли значения переменной
sum(будет выведено число 3).
Довольно просто.
Уровень 1,5
function getSum(a) {
return function(b) {
return a + b;
};
}
let a = 1;
let b = 2;
let sumWithA = getSum(a);
let sum = sumWithA(b);
console.log(sum);
«А как насчет этого?»
Вроде бы то же самое. Разница в том, что, глядя на этот фрагмент, мы вспоминаем термин «замыкание». Сейчас мы видим:
- Объявление функции — функции, которая принимает один аргумент и возвращает другой. Эта внутренняя функция при вызове вычисляет сумму двух аргументов.
- Объявление четырех переменных — третья переменная содержит возвращаемую функцию с уже установленным первым слагаемым, благодаря замыканию, которое позволяет внутренней функции «запомнить» значение
a. Значение четвертой переменной — результат суммы, для чего используется вложенная функцияgetSum.
- Вывод в консоли значения переменной
sum(будет выведено число 3).
Уровень 2
function getSum(a) {
return function(b) {
return a + b;
};
}
let a = 1;
let b = 2;
let sumWithA = getSum(a);
let sum = sumWithA(b);
console.log(sum);
А вот теперь «нам нужен эмоциональный контент».
Здесь есть многое из того, что мы не увидели с первого взгляда. Разобьем код на две фазы и посмотрим на него еще раз.
Время компиляции (глубокое погружение)
- Парсинг — исходный код читается, разбивается на токены (
function,(,a,)и т. д.) и, если ошибок не обнаружено, создается абстрактное синтаксическое дерево (далее AST).
- Настройка объявлений и памяти — движок определяет объявления функций и переменных (например,
function getSum(a) {...}), выделяет для них память и регистрирует их в соответствующих областях видимости.
- Временная мертвая зона (далее TDZ) — переменные, объявленные с помощью
let, будут существовать в памяти с момента создания их области видимости, но останутся «неинициализированными».
Время выполнения (глубокое погружение)
- Инициализация переменных — переменные выходят из TDZ. Это означает, что им присваиваются значения, и они могут быть использованы после выполнения присваивания.
- Выполнение функции — для двух последних присваиваний вызываются функции, и для каждой из них создается новый контекст выполнения. Он включает локальные переменные, аргументы и ссылку на внешнюю лексическую область видимости. Таким образом, вызов
getSumсоздает контекст выполнения, в которомaдоступно, а все неразрешенные переменные надо искать во внешних областях.
- Замыкание —
getSum, возвращая внутреннюю функцию, сохраняет ссылку на свою родительскую область видимости (через замыкание). Это происходит потому, что на этапе компиляции движок определил лексическую область видимостиgetSum. Внутренняя функция «помнит» эту область благодаря замыканию, сохраняющему среду, в которой была создана функция. Поэтому при вызовеsumWithAсохраненная ссылка открывает доступ кaи выполняется сложениеa + b. Затем результат3присваивается переменнойsum.
- Значение, хранящееся в
sum, а именно3, извлекается и отправляется на консоль, предоставленную средой.
Уровень 3
function getSum(a) {
return function(b) {
return a + b;
};
}
let a = 1;
let b = 2;
let sumWithA = getSum(a);
let sum = sumWithA(b);
console.log(sum);
Я сказал: «Эмоциональный контент».
Он никогда не будет идеальным. Но каждая попытка может на шаг приблизить нас к идеалу. И на данный момент это будет последняя попытка.
Время компиляции (более глубокое погружение)
Лексический анализ
Лексический анализ состоит из следующих частей:
- Токенизация — процесс разделения исходного кода на отдельные токены.
- Пробельные символы и комментарии — такие несущественные части, как пробельные символы и комментарии, игнорируются, поскольку они не имеют никакого отношения к структуре кода.
- Категоризация токенов — каждый токен идентифицируется и классифицируется в соответствующую категорию (ключевые слова, операторы, литералы и т. д.).
- Обнаружение ошибок — если в процессе работы выявляются недопустимые или нераспознанные токены (
@,#и т. д.), будут сгенерированы лексические ошибки. Таким образом, в итоге дляlet a = 1;у нас будут такие категоризированные токены:let (ключевое слово),a (идентификатор),= (оператор),1 (литерал),; (пунктуация).
Парсинг
Мы упоминали AST на уровне 2. Для меня это способ сообщить движку о «грамматике» языка. Но есть еще многое, о чем стоит упомянуть:
- AST — после успешного лексического анализа (токенизации) парсер запускает процесс создания AST. Он представляет отношения между выражениями, операторами и другими компонентами кода в виде древовидной структуры. Проверьте наличие
let a = 1;в AST-исследователе.
- Обнаружение синтаксических и статических ошибок — синтаксический анализатор, создавая AST-узлы, следит за тем, чтобы код не нарушал правил «грамматики» языка. Последовательность токенов должна образовывать допустимые конструкции. Он не может продолжить работу, если что-то не так: пропущенные круглые скобки, неправильное использование ключевого слова, синтаксически некорректные ссылки и т. д.
- Разрешение области видимости и переменных — в процессе построения AST парсер также создает лексическое окружение (цепочку областей видимости) переменных и функций, чтобы они были сопоставлены/подняты в соответствующие области видимости (глобальную, локальную и т. д.). Это очень важно для формирования замыканий. Вот почему можно выполнить
getSum(a)даже до объявленияfunction getSum(a) {без ошибки.
- TDZ — важно отметить, что все объявления (
let,const,var,function) поднимаются, и для всех переменных выделяется память.
let и const будут оставаться неинициализированными до тех пор, пока не будет выполнена строка их объявления. Это то, что мы называем TDZ.
Объявление var получает значение undefined.
У var также технически есть TDZ, но эта зона ненаблюдаема для наших программ. Только let и const имеют наблюдаемую TDZ.
Если поместим console.log(a); над строкой let a = 1;, то получим ошибку. Но эта ошибка не будет говорить о том, что переменная a не существует. И если затем заменить let a = 1; на var a = 1;, то ошибки не будет.
Байт-код
Знаю: это слишком глубокое погружение. Но мы еще не закончили. После успешного парсинга AST преобразуется в байт-код. Байт-код — низкоуровневое представление кода, который пока не оптимизирован и не является машинным кодом, но все же может быть выполнен движком напрямую.
Прежде чем продолжить, хочу дать совет.
Погрузитесь еще глубже. Это поможет лучше понять замыкание. Предвидя вопрос «почему?», сошлюсь на высказывание Кайла Симпсона — автора многих книг по программированию:
Понимание роли замыкания необходимо для освоения JS и эффективного использования многих важных шаблонов проектирования в вашем коде.
Кайл Симпсон
Время выполнения (более глубокое погружение)
Пока движок построчно выполняет байт-код, «под капотом» происходит настоящее волшебство.
Прежде всего, функция getSum уже поднята и хранится в памяти, поэтому во время выполнения движок не парсит ее повторно. Ее байт-код готов к выполнению.
Переменные a и b инициализируются значениями 1 и 2, выходя из TDZ.
Затем движок создает контекст выполнения для getSum(a) по мере ее вызова. Возвращенная внутренняя функция сохраняет ссылку на a=1 из области видимости getSum и присваивается переменной sumWithA. a=1 сохраняется в лексическом окружении замыкания.
JIT-компиляция (компиляция в нужное время)
Это процесс быстрой построчной проверки и выполнения кода, позволяющий ускорить начало работы. В ходе этого процесса многократно используемые части (например, циклы) оптимизируются до более быстрых версий.
- Базовый компилятор: ищет фрагменты кода, которые выполняются чаще всего («горячий код»). Находя их, компилирует в оптимизированный машинный код, чтобы программа выполнялась быстрее.
- Оптимизирующий компилятор идет на шаг дальше: находит часто используемые пути кода, если таковые имеются, и выполняет с ними такие оптимизации, как инлайнинг функций (помещает код непосредственно на место, а не вызывает его) и удаление неиспользуемого кода. Но вот в чем загвоздка: если ситуация неожиданно изменится (узнайте больше о приведении типов, если еще этого не сделали), ему, возможно, придется отступить и вернуться к более простому байт-коду, потому что его оптимизация больше не имеет смысла.
Когда вызывается getSum(a), движок интерпретирует байт-код для getSum и внутреннего замыкания. При первом выполнении sumWithA(b) интерпретируется медленно (базовая компиляция). Если функция sumWithA вызывается многократно (например, в цикле), движок определяет ее как «горячий код».
Затем для внутренней функции создается новый контекст выполнения, поскольку мы находимся в строке let sum = sumWithA(b);. Движок обращается к a=1 из замыкания и b=2 для вычисления a + b. На этот раз для функции sumWithA происходит JIT-оптимизация (базовая компиляция).
Сбор мусора
Движок позаботится об очистке памяти, которая больше не нужна. В JavaScript вам не нужно беспокоиться об этом. Но следует беспокоиться о случаях, когда есть замыкание. Переменные во внешней области видимости, на которые ссылается замыкание, не будут собираться в мусор.
Внутренняя функция сохраняет a=1 из области видимости getSum даже после завершения работы getSum. a=1 остается в памяти, потому что на нее ссылается замыкание. Если бы у getSum были другие неиспользуемые переменные, они были бы очищены.
Это как с пальцем, указывающим на Луну. Не концентрируйся на пальце, иначе пропустишь небесное зрелище.
Брюс Ли
P.S. Я не забыл про ASI, цикл событий и console.log на уровне 3. Оставляю их для вашего мозгового штурма. Несмотря на то, что в нашем фрагменте нет ничего асинхронного, цикл событий все равно занимает свое место «под капотом».
Читайте также:
- Сбор мусора в JavaScript
- Ключевые понятия JavaScript, которые должен знать каждый разработчик — часть 1
- 10 однострочников, позволяющих профессионально писать JavaScript-код
Читайте нас в Telegram, VK и Дзен
Перевод статьи Gevorg Gepenyan: In a JavaScript date





