С ростом популярности JavaScript команды разработчиков начали использовать его поддержку на многих уровнях своего стека — во фронтенде, бэкенде, гибридных приложениях, встраиваемых устройствах и многом другом. В этой статье мы хотим более глубоко рассмотреть JavaScript и то, как он работает.
Введение
Почти все уже слышали о концепции движка V8 и большинство людей знает, что язык JavaScript однопотоковый или что он использует очередь обратных вызовов.
В этом посте мы подробно разберём эти концепции и объясним, как же работает JavaScript. Благодаря знанию этих подробностей вы сможете писать более оптимальные приложения, надлежащим образом использующие API. Если вы работаете с JavaScript относительно недавно, этот пост поможет вам понять, почему JavaScript настолько «странный» по сравнению с другими языками. А если вы опытный разработчик на JavaScript, то он позволит вам по-новому взглянуть на внутреннее устройство JavaScript Runtime, с которым вы работаете каждый день.
В этой статье мы обсудим внутреннюю работу JavaScript в среде выполнения и в браузере. Это будет краткий обзор всех базовых компонентов, задействованных в исполнении кода на JavaScript. Мы рассмотрим следующие компоненты:
- JavaScript Engine.
- JavaScript Runtime Environment.
- Стек вызовов.
- Параллельность и цикл событий.
Давайте начнём с движка JavaScript.
JavaScript Engine
Как вы, возможно, слышали ранее, JavaScript — это интерпретируемый язык программирования. Это означает, что перед исполнением исходный код не компилируется в двоичный код.
Как же компьютер может понять, что ему делать с простым тестовым скриптом? В этом и заключается работа движка JavaScript. Движок — это просто компьютерная программа, исполняющая код JavaScript. Движки JavaScript встроены во все современные браузеры. Когда файл скрипта загружается в браузер, движок исполняет каждую строку файла сверху вниз (чтобы упростить объяснение, мы не будем рассматривать поднятие (hoisting) в JS). Движок строка за строкой парсит код, преобразует его в машинный код, а затем исполняет.
У каждого браузера есть собственный движок JavaScript, но самым известным является Google V8. Движок V8 лежит в основе Google Chrome, а также Node.js.
Движки браузеров
Движок состоит из двух основных компонентов:
- Куча памяти — здесь происходит распределение памяти.
- Стек вызовов — здесь находятся стековые кадры в процессе исполнения кода.
Любой движок JavaScript всегда содержит стек вызовов и кучу. Именно в стеке вызовов исполняется наш код. А куча — это пул неструктурированной памяти, хранящий все объекты, необходимые приложению.
Runtime
Пока мы говорили только о движке JavaScript, но он не работает в изоляции. Он выполняется в среде под названием JavaScript Runtime Environment наряду со множеством других компонентов. JRE отвечает за обеспечение асинхронности JavaScript. Именно благодаря ей JavaScript способен добавлять события и выполнять HTTP-запросы асинхронно. JRE похожа на контейнер, состоящий из следующих компонентов:
- JS Engine;
- Web API;
- Очередь обратных вызовов или очередь сообщений;
- Таблица событий;
- Цикл событий.
Стек вызовов
JavaScript — это однопоточный язык программирования, то есть он имеет один стек вызовов. Следовательно, он может выполнять по одной задаче за раз.
Стек вызовов — это структура данных, по сути, записывающая, в каком месте программы мы находимся. Если мы заходим в функцию, то помещаем её в вершину стека. Если мы возвращаемся из функции, то извлекаем вершину стека. Вот и всё, что может делать стек. Давайте рассмотрим пример. Взгляните на следующий код:
function multiply(x, y) {
return x * y;
}function printSquare(x) {
var s = multiply(x, x);
console.log(s);
}printSquare(5);
Когда движок начинает исполнять код, стек вызовов будет пуст. Далее выполняются следующие шаги:
Каждая запись в стеке вызовов называется стековым кадром.
Выполнение кода в одном потоке может быть довольно простым процессом, потому что не приходится иметь дело со сложными ситуациями, возникающими в многопотоковых средах, например, с взаимными блокировками. Однако выполнение в одном потоке ещё и сильно ограничивает.
Параллельность и цикл событий
Что произойдёт, когда в стеке вызовов есть вызовы функций, для обработки которых нужно огромное количество времени? Например, если нам нужно выполнить в браузере при помощи JavaScript какое-то сложное преобразование изображения.
Вы можете спросить: а почему это вообще проблема? Проблема в том, что пока в стеке вызовов есть функции, которые нужно исполнять, браузер не может делать ничего другого, он блокируется. Это значит, что браузер не может рендерить, не может выполнять любой другой код, он просто простаивает. И это создаёт проблемы, если UI в приложении должен быть отзывчивым.
Но это не единственная проблема. Как только браузер начинает обрабатывать такое количество задач в стеке вызовов, он может оказаться неотзывчивым на достаточно долгое время. И большинство браузеров предпринимают действия, выдавая ошибку, спрашивая пользователя, хочет ли он завершить выполнение веб-страницы.
Часть 2: внутреннее устройство движка
Компиляция и интерпретация
Для начала нам нужно рассказать о разнице между компиляцией и интерпретацией. Мы знаем, что процессор компьютера понимает только нули и единицы, поэтому в конечном итоге каждая компьютерная программа должна быть преобразована в машинный код, и это можно сделать при помощи компиляции или интерпретации.
При компиляции весь исходный код за раз преобразуется в машинный код. А затем этот машинный код записывается в портируемый файл, который может исполняться на любом совместимом компьютере. Здесь есть два этапа. Сначала машинный код собирается, а затем исполняется в процессоре. И исполнение может происходить намного позже компиляции.
При интерпретации используется интерпретатор, проходящий по исходному коду и исполняющий его строка за строкой. То есть двух описанных ранее этапов здесь нет. Вместо них код считывается и исполняется одновременно. Разумеется, исходный код всё равно нужно преобразовать в машинный код, но это просто происходит прямо перед исполнением, и не раньше.
JavaScript был полностью интерпретируемым языком, но проблема интерпретируемых языков заключается в том, что они гораздо медленнее скомпилированных языков. Раньше для JavaScript это было нормально, но для современного JavaScript и полнофункциональных приложений низкая производительность более неприемлема.
Многие люди по-прежнему считают, что JavaScript — это интерпретируемый язык, но это уже не так. Вместо простой интерпретации современные движки JavaScript используют комбинацию компиляции и интерпретации, называемую компиляцией Just-in-time(JIT).
Компиляция Just-in-time
При таком подходе весь код компилируется в машинный код и сразу же исполняется. То есть присутствуют два этапа обычной предварительной компиляции, но портируемого исполняемого файла нет. И исполнение происходит сразу же после компиляции. Это идеально подходит для JavaScript, потому что это намного быстрее, чем просто исполнение строка за строкой.
Современная компиляция Just-in-time языка JavaScript
В дело вступает элемент кода JavaScript, называемый движком. Его первый этап — это парсинг кода, то есть, по сути, его чтение. В процессе парсинга код преобразуется в структуру данных под названием Abstract Syntax Tree (AST). Каждая строка кода разделяется на значимые для языка элементы, например, ключевые слова
const
или function
, а затем все эти элементы структурированным образом сохраняются в дерево. На этом этапе также выполняются проверки синтаксических ошибок. Получившееся дерево позже будет использовано для генерации машинного кода.Допустим, у нас есть очень простая программа, показанная на рисунке слева. Она лишь объявляет переменную, а справа показано, как выглядит дерево AST для этой одной строки кода. Итак, у нас есть объявление переменной, которая должна быть константой с именем «a» и значением «23». Как вы видите, кроме этого в дереве есть ещё много другого. Представьте, насколько объёмнее оно будет для реального большого приложения.
Следующий этап — это компиляция, при которой берётся сгенерированное AST и компилируется в машинный код. Этот машинный код исполняется сразу, так как в современном JavaScript используется компиляция Just-in-time. При этом стоит помнить, что исполнение происходит в стеке вызовов движка JavaScript. Подробнее об этом мы поговорим в следующей статье.
Отлично, наш код работает, так что на этом можно закончить, правда? Ну, не совсем, потому что в движках современного JavaScript есть продуманные стратегии оптимизации. Сначала они создают очень неоптимизированную версию машинного кода, чтобы он мог начать исполняться как можно быстрее. Затем в фоновом режиме этот код оптимизируется и рекомпилируется, пока программа уже исполняется. Это можно делать почти всегда и после каждой оптимизации. Неоптимизированный код просто заменяется новым, более оптимизированным кодом, даже без приостановки исполнения. И именно благодаря этому процессу современные движки наподобие V8 так быстры.
Все эти операции парсинга, компиляции и оптимизации выполняются в специальных потоках внутри движка, доступ к которым мы не можем получить из кода. То есть они отделены от основного потока, который управляет стеком вызовов, исполняющим наш код. В разных движках это реализовано немного по-разному, но в целом, именно так выглядит современная компиляция Just-in-time для JavaScript.