Привет, с вами снова я – Дмитрий, React-разработчик, хотелось бы сегодня затронуть тему среды выполнения JS. Многие знают, другие уже подзабыли, а новички — вовсе не в курсе. В общем, эта статья точно найдёт своих читателей. Постараюсь простыми словами и по делу.
Что такое среда выполнения JavaScript?
Среда выполнения JavaScript, также её называют runtime — это окружение, в котором выполняется JS-код. Оно включает в себя: движок JavaScript (например, V8 или SpiderMonkey), который компилирует и исполняет код, цикл событий (Event Loop), очереди задач (Task Queue, Microtask Queue), а ещё API, предоставляемые окружением (например, Web API в браузере или системные модули в Node.js).
Без среды выполнения JS-код — это просто текст. Runtime обеспечивает его взаимодействие с системой, браузером или сервером.
Кстати, многие путают и говорят, что runtime и движок — это одно и то же, но runtime это не просто движок, а вся экосистема, которая обеспечивает выполнение JavaScript-кода. Движок (например, V8) — лишь её часть.
Вот, например, движки V8 (Chrome, Node.js), SpiderMonkey (Firefox) только компилируют и выполняют JS-код (парсинг, оптимизация, исполнение), а в Runtime в виде, например, браузера Chrome или Firefox или Node.js включает в себя движок плюс всё остальное, что нужно для работы JS (API, Event Loop, очереди задач, механизмы ввода-вывода). Это важно не путать.
Примеры компонентов runtime (кроме движка)
В браузере это:
Web API: setTimeout, fetch, DOM. Эти API реализуются не в самом движке, а предоставляются браузером.
Event Loop и очереди задач (микрозадачи, макрозадачи).
Рендеринг: взаимодействие с движком страницы (Blink, Gecko).
В Node.js это:
C++ bindings и модули Node.js (например, fs, http).
Libuv: библиотека для асинхронного I/O (лежит в основе Event Loop).
Почему это важно для разработчиков?
Понимание среды выполнения помогает:
Писать производительный код
Избегать "подводных камней" (например, блокировок Event Loop).
Оптимизировать работу с памятью (утечки, сборка мусора).
Работать с асинхронностью
Правильно использовать Promise, setTimeout, async/await.
Понимать, почему Promise выполняется раньше setTimeout
Эффективно отлаживать приложения
Анализировать стек вызовов (Call Stack).
Находить узкие места в производительности (например, долгие циклы).
Архитектура среды выполнения JavaScrip
Давайте начнём с общей архитектуры, чтобы понять вообще, что там есть.
Движок JavaScript
Движок — это "сердце" среды выполнения, отвечающее за компиляцию и исполнение JS-кода. Самые популярные – это: V8 используется в Chrome, Node.js и Deno SpiderMonkey в Firefox и JavaScriptCore в Safari.
Движки занимаются парсингом, компиляцией и выполнением кода. В парсинге код разбивается на токены и строится AST (Abstract Syntax Tree), так называемое синтаксическое дерево. В компиляции интерпретатор преобразует AST в байт-код, а JIT-компилятор оптимизирует "горячие" функции.
Стоит пояснить, что AST дерево — это древовидное представление структуры исходного кода, где каждый узел соответствует определённой синтаксической конструкции, общая иерархия отражает вложенность кода, а исходный текст преобразуется в формализованную структуру, удобную для анализа.
А под «горячими» функциями подразумеваются функции, которые вызываются часто, выполняются длительное время или обрабатывают большие объёмы данных.
Event Loop (цикл событий)
Event Loop — это механизм, который позволяет JavaScript, несмотря на формальную однопоточность, выполнять асинхронные операции без блокировки основного потока. Последовательность работы следующая:
Первым делом в Call Stack попадают синхронные вызовы, когда встречается асинхронная операция (например, setTimeout, fetch), она передаётся в Web API. После завершения асинхронной операции её колбэк помещается в соответствующую очередь задач — Task Queue для setTimeout, fetch или Microtask Queue для Promise.then, queueMicrotask. Event Loop проверяет: если Call Stack пуст, он берёт задачи из очереди и помещает их в стек для выполнения.
Очереди задач
Что касается очередей задач, упомянутых выше, в JavaScript используются несколько типов очередей:
Call Stack — стек вызовов. В него помещаются все выполняемые функции. Работает по принципу LIFO (последним пришёл — первым вышел).
Task Queue (macrotasks) — очередь "макрозадач": сюда попадают setTimeout, setInterval, события DOM, fetch и т.п.
Microtask Queue (микрозадачи) — содержит Promise.then, queueMicrotask, MutationObserver. Выполняется сразу после текущего стека вызовов и до следующей макрозадачи.
Важно помнить, что все микрозадачи из Microtask Queue выполняются сразу после текущего вызова стека, до перехода к следующей задаче из Task Queue — это влияет на порядок выполнения кода.
Внешние API
Эти API не реализованы внутри движка JavaScript, они предоставляются окружением. Например, в браузере среда выполнения предоставляет Web API: setTimeout, fetch, DOM, console, localStorage и др.
В Node.js окружение включает C++ API и библиотеку Libuv, реализующие модули fs, http, net, crypto и Event Loop.
Как взаимодействуют компоненты
Мы рассмотрели все компоненты по отдельности, теперь давайте посмотрим, как они все взаимодействуют между собой и в какой последовательности.
1. Синхронный код: Call Stack
Всё начинается со стека вызовов. Когда движок JavaScript встречает вызов функции, он кладёт её в стек. После завершения выполнения функция удаляется из стека. Как уже упоминалось - принцип LIFO (последним пришёл — первым вышел).
2. Асинхронные вызовы
Когда движок встречает асинхронную операцию, он не обрабатывает её сам. Вместо этого, как уже говорилось, если JS исполняется в браузере, задача передаётся в Web API, если в Node.js — в C++ API и Libuv. Соответственно, операция выполняется вне движка и не блокируя Call Stack.
3. Завершение операции: передача колбэков в очередь
После завершения асинхронной операции её обработчик помещается в соответствующую очередь:
В Task Queue (макрозадачи): setTimeout, setInterval, события DOM, таймеры Node.js.
В Microtask Queue: Promise.then, catch, finally, queueMicrotask.
Примечание: fetch() возвращает Promise, а значит, его обработка (then, catch) всегда будет в Microtask Queue.
4. Координация выполнения
И конечно же, как батя на районе Event Loop следит за всем этим делом, за Call Stack и очередями и организовывает весь движ. Пока в Call Stack есть код — он выполняется. Когда стек пуст, Event Loop сначала выполняет все задачи из Microtask Queue, пока она не станет пустой. Затем берёт одну задачу из Task Queue и помещает её в Call Stack. Далее цикл повторяется.
Внутреннее устройство JS-движка
Давайте попробуем в общих чертах разобраться, как отрабатывает движок JavaScript. В целом, это сложная система, цель которой быстро и эффективно исполнять JS-код.
Этапы выполнения кода
Поэтапно:
Этап 1. Парсинг
Исходный код сначала разбивается на токены (лексический анализ), затем строится абстрактное синтаксическое дерево (AST — Abstract Syntax Tree). AST представляет структуру кода в виде иерархии, удобной для анализа.
Этап 2. JIT-компиляция (Just-In-Time)
На основе AST движок сначала интерпретирует код в байт-код — промежуточное представление, которое исполняется быстрее, чем работа напрямую с AST.
Затем, по мере выполнения, "горячие" функции компилируются в машинный код средствами JIT-компилятора. JIT-компиляция частично улучшает производительность, за счёт кеширования кода.
3. Оптимизация
Во время JIT-компиляции движок может делать оптимизации: инлайнинг функций, удаление "мёртвого" кода, предсказание типов и т.д. Однако, если предположения окажутся неверными (например, тип данных изменился), движок может сделать деоптимизацию (откатить машинный код обратно к байт-коду).
4. Выполнение
После компиляции байт-код или машинный код исполняется внутри виртуальной машины движка.
Управление памятью
JS использует автоматическое управление памятью, и основную роль здесь играет сборщик мусора, который называется Garbage Collector.
Память в момент, когда в наш Call Stack помещается функция и после завершения удаляется, управляется строго и освобождается сразу по завершении вызова.
Есть ещё область памяти, называется Heap — это область памяти для хранения динамически создаваемых объектов, массивов и функций. Управление здесь сложнее, потому что ссылки на объекты могут сохраняться и после завершения функции.
При этом происходит сборка мусора неким сборщиком (GС), который отслеживает, какие объекты больше не используются, и освобождает память.
Основные подходы:
Mark-and-Sweep: помечаются все доступные объекты, затем всё неотмеченное удаляется.
Reference Counting: объект удаляется, если на него больше нет ссылок (хотя этот подход сам по себе уязвим к циклическим ссылкам, поэтому используется в комбинации).
GC может запускаться при нехватке свободной памяти, при достижении порога по количеству аллокаций.
Сборка мусора может приводить к паузам, когда выполнение JS временно приостанавливается. Современные движки (V8, SpiderMonkey) используют инкрементальные и параллельные GC, чтобы минимизировать задержки.
Event Loop и асинхронность
Вот пример иллюстрации Event Loop, я не буду подробно останавливаться на нём, ибо куча статей уже об этом в сети присутствует, но всё же процесс опишу. В данной статье мы разбираем полную среду выполнения JS.

Как мы знаем, JS номинально однопоточный язык, но благодаря Event Loop он способен обрабатывать асинхронные задачи без блокировки основного потока. Давайте ещё раз про него подытожим.
Event Loop — это бесконечный цикл, который координирует выполнение синхронного и асинхронного кода. Его задача — следить за стеком вызовов, очередями задач и передавать задачи на исполнение, когда стек освобождается.
Последовательность, как мы говорили уже - следующая:
Выполняется весь синхронный код, пока стек вызовов не станет пустым.
Затем выполняются все микрозадачи из Microtask Queue в порядке очереди.
После этого берётся одна макрозадача из Task Queue и помещается в Call Stack.
Далее происходит рендеринг (в браузере) — если нужно обновить DOM.
Цикл повторяется.
К макрозадачам относятся:
setTimeout, setInterval
события DOM (click, input)
I/O-операции (fs.readFile в Node.js)
fetch (колбэк попадает в Task Queue, не в microtask)
К микро:
Promise.then, .catch, .finally
queueMicrotask
MutationObserver
Разбор асинхронной работы
Давайте на примере разберём, как всё работает. Это будет выглядеть как типичная задача на собеседовании. Итак, у нас есть код, и как вы думаете, в какой последовательности выведутся логи в консоль?
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
Разбираемся:
Шаг 1: Выполнение синхронного код
Интерпретатор JS начинает выполнение сверху вниз, выполняя синхронный код сразу.
console.log("Start") — выводит Start.
setTimeout(...) — регистрирует колбэк в очереди macrotask (в Web APIs), но не выполняет его сейчас.
Promise.resolve().then(...) — создаёт microtask, которая будет выполнена после завершения текущего стека вызовов.
console.log("End") — выводит End.
Шаг 2: Очередь микрозадач
После завершения основного потока (call stack пуст) движок переходит к очереди микрозадач:
Выполняется then(...) из промиса, соответственно, console.log("Promise")
выводит Promise
Шаг 3: Очередь макрозадач (macrotasks)
Теперь, когда микрозадачи обработаны, движок переходит к макрозадачам, в данном случае — setTimeout. Выполняется отложенный колбэк из setTimeout, и console.log("Timeout") выводит Timeout
Итоговый порядок вывода в консоль будет выглядеть так:

Что может заблокировать Event Loop?
Несмотря на асинхронность, Event Loop может быть заблокирован, если в Call Stack находится долго выполняющийся код, например, бесконечный цикл while(true).
Такой код никогда не выходит из Call Stack, и Event Loop не сможет перейти к микрозадачам, макрозадачам или рендерингу. В итоге: подвисание UI, тормоза и мы получаем "неотвечающую страницу".
Для тяжёлых вычислений или продолжительных операций лучше использовать отдельные потоки:
В браузере для этого можно использовать Web Workers, они позволяют запускать JS-код в отдельном потоке, изолированном от основного, примерно вот так:
const worker = new Worker('worker.js');
worker.postMessage('start');
worker.onmessage = (e) => {
console.log('Ответ от воркера:', e.data);
};
А так выглядит файл worker.js:
onmessage = (e) => {
// здесь можно делать тяжёлые расчёты
postMessage('Готово');
};
В Node.js это делается через модуль worker_threads аналогичным образом:
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-task.js');
worker.on('message', (msg) => console.log('Ответ:', msg));
Файл heavy-task.js:
const { parentPort } = require('worker_threads');
parentPort.postMessage('Результат после вычислений');
Использование воркеров позволяет не блокировать основной Event Loop, сохраняя отзывчивость интерфейса и эффективность выполнения кода.
Введение в AST
Когда вы пишете const x = 5;, вы видите строчку текста. Но JS-движок воспринимает не текст, а структуру — дерево, которое отражает смысл кода. Это дерево называется AST — абстрактное синтаксическое дерево.
Давайте разберёмся, как это работает и зачем нужно.
Каждый элемент кода: переменная, оператор, функция — превращается в узел дерева, описывающий его тип, имя, значение и прочие свойства
const x = 5;
Преобразуется в дерево примерно такого вида:
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "x" },
"init": { "type": "Literal", "value": 5 }
}
]
}
где:
VariableDeclaration — это объявление переменной;
Identifier — имя переменной x;
Literal — значение 5.
А как JS получает такое дерево? Сначала происходит лексический анализ, в котором разбивается текст на токены const, x, =, 5, ;, далее происходит синтактический анализ и строится дерево из полученных токенов и, далее, генерируется байткод, где движок использует AST для компиляции кода или его интерпретации. AST — это промежуточный этап между текстом и исполняемым кодом. Именно из него движок "понимает", что вы хотите сделать.
Если хотите поэкспериментировать с тем, как ваш код превращается в AST — очень удобно это делать здесь: https://astexplorer.net
Я не буду подробно описывать все возможные типы узлов, но базовое понимание дерева — это уже +100 к вашему девелоперскому просветлению.
Что дают знания про AST
Абстрактное синтаксическое дерево — это не просто техническая деталь, нужная компиляторам. Это ключ к тому, как JS «думает» о вашем коде. Понимая, как выглядит AST, вы начинаете видеть, почему язык работает именно так, даже в странных случаях. Давайте поясню, о чём я:
Почему var работает "заранее"
В JavaScript переменные, объявленные с помощью var, поднимаются вверх функции или глобальной области видимости. Это называется hoisting. Имеем вот такой код:
console.log(a); // undefined
var a = 5;
Но AST представляет этот код уже с учётом hoisting'а. Движок видит его так:
let a;
console.log(a); // undefined
a = 5;
Это объясняет, почему console.log(a) не вызывает ошибку: переменная уже существует в области видимости, даже если значение ещё undefined.
А теперь то же самое, но с let:
console.log(b); // ReferenceError
const b = 5;
AST здесь уже не поднимает let-переменную вверх. Её объявление находится строго в той точке, где написано в коде.
AST где ещё?
AST используется в таких популярных инструментах как: Babel, который трансформирует код (например, let → var для совместимости), ESLint — анализирует AST, чтобы находить ошибки и предупреждения и Prettier — форматирует код, изменяя его, но при этом не меняя само дерево.
Web API и Node.js API
JS-движок сам по себе довольно «голый». Всё, что выходит за пределы чистого языка — например, доступ к DOM или файловой системе — предоставляется средой выполнения, будь то браузер или Node.js. Каждая из этих сред добавляет собственные API, с которыми JS-код может взаимодействовать.
В браузере среда выполнения предоставляет Web API — набор функций и объектов, реализованных на C++ или другом низкоуровневом языке, встроенных в браузер (Chrome, Firefox, Safari и т.д.). Эти API не являются частью самого языка JavaScript, но доступны внутри браузерной среды.
DOM — доступ к элементам страницы, создание и изменение HTML-структуры, setTimeout, setInterval, fetch — отправка HTTP-запросов, Geolocation, localStorage, Notification, Canvas, WebSocket и многое другое – все это Web API.
Когда в коде вызывается, например, fetch, движок делегирует выполнение этой задачи Web API. API начинает выполнение, а когда процесс завершится — результат (колбэк или Promise) помещается обратно в очередь, в микрозадачи или макрозадачи для дальнейшей обработки Event Loop’ом.
В Node.js движок V8 работает в другой среде — Node.js runtime, где доступен другой набор API. Здесь нет DOM или fetch (до версии 18), но есть доступ к файловой системе, сети, процессам и другим низкоуровневым возможностям сервера.
Основные компоненты среды:
libuv — библиотека на C, которая реализует Event Loop, асинхронный ввод-вывод и работу с потоками.
C++ Bindings — нативные привязки, через которые Node.js взаимодействует с системными ресурсами.
Модули Node.js: fs (файлы), http, net, crypto, child_process, stream и др.
Node.js использует неблокирующую архитектуру: любые операции, требующие времени (чтение файлов, запросы по сети), обрабатываются асинхронно через libuv. Это позволяет серверу обрабатывать тысячи запросов одновременно без создания отдельных потоков под каждый запрос.
Оптимизация и производительность
Цель движка — исполнить код максимально быстро. Для этого применяются различные стратегии компиляции и оптимизации, а разработчику важно писать код, который «дружит» с этими стратегиями.
Как движок ускоряет выполнение
Современные JS-движки используют JIT-компиляцию (Just-In-Time), преобразуя часто исполняемый код в машинный и применяя ряд оптимизаций:
Inline Caching — движок запоминает, какого типа объект и какое у него свойство чаще всего запрашивается, чтобы ускорить повторный доступ.
Hidden Classes — при создании объектов движок генерирует внутренние "скрытые классы". Если структура объекта предсказуема (одинаковый набор и порядок свойств), доступ к нему ускоряется.
Constant Folding — вычисление константных выражений на этапе компиляции. Например, const x = 2 * 3; может быть сразу преобразовано в const x = 6.
Деоптимизация (Deoptimization)
Если поведение кода становится непредсказуемым (например, объект сначала имел одно свойство, потом динамически добавилось другое), движок откатывает оптимизации и возвращается к медленной интерпретации. Это может сильно повлиять на производительность.
Причинами деоптимизации в V8 может быть: изменение структуры объектов на лету, использование arguments (особенно с присваиванием), try/catch и with, а также смешивание разных типов в одном выражении.
Практические рекомендации
Чтобы помочь движку выполнять код эффективно, советуют придерживаться следующих рекомендаций:
Не использовать arguments и with.
arguments — устаревший способ доступа к аргументам функции, который мешает оптимизации. Используй rest-параметры: (...args) => {}
with — вообще запрещён в strict-режиме и мешает движку предсказать область видимости.TypedArrays и структуры данных
Если работаешь с большим объёмом числовых данных — советуют использовать TypedArray (Uint8Array, Float32Array и др.). Это позволит работать ближе к «железу» и получать прирост производительности.Предсказуемость типов
Всегда старайся, чтобы объекты имели, одинаковые ключи, одинаковый порядок ключей, значения одного типа.
Это позволяет движку сохранять скрытые классы и избегать деоптимизации.
Заключение
Я постарался простым языком рассказать про среду выполнения JS. Надеюсь, что у меня это получилось, ведь понимание среды выполнения JavaScript — это как теория для собеседований, так и реальный инструмент в руках разработчика. Зная, как работает Event Loop, что происходит «под капотом» у движка, как обрабатываются асинхронные задачи и как память управляется в рантайме, можно писать более производительный, стабильный и предсказуемый код.