Понимание среды выполнения JavaScript
Однажды во время собеседования, где вместе с несколькими коллегами я был в жюри интервьюеров, один из них задал вопрос кандидату:
Как JavaScript работает под капотом? Можете ли вы кратко объяснить, как вы понимаете такие элементы времени выполнения, как куча, стек вызовов, цикл событий и так далее?
Я был поражён тем, что подобная тема остаётся загадкой для многих разработчиков JavaScript. И, честно говоря, нельзя претендовать на высокий профессионализм, не имея представления об этом предмете.
Чтобы писать качественный код, необходимо понимать, как этот код работает.
Всегда помните: Ваше программирование может быть настолько хорошим, насколько хорошо вы его понимаете
.
Итак, не могли бы вы ответить на вопрос интервью?
JavaScript, занимающий значительное место в мире программирования, функционирует на основе уникальной модели выполнения.
Благодаря Miro (моя первая попытка создать собственные диаграммы), я проиллюстрировал среду выполнения следующим образом:
Теперь давайте разберёмся в каждой его части.
Среда выполнения JavaScript (движок)
Среда выполнения, часто называемая движком JavaScript, — это сложное пространство, в котором происходит вся магия выполнения вашего JavaScript-кода. Различные браузеры и платформы имеют свои специализированные движки: V8 используется в Google Chrome и Node.js; SpiderMonkey стоит за Firefox; JavaScriptCore работает в Safari.
В этой среде выполнения два основных компонента играют важную роль в том, как выполняется ваш код: Куча (Heap) и Стек вызовов (Call Stack).
Куча/Heap
- Суть: Это область компьютерной памяти, которая не имеет структуры и предоставляет место для хранения переменных и экземпляров, создаваемых вашей программой.
- Динамика: Когда вы создаёте экземпляр нового объекта или объявляете большой массив, они сохраняются в Куче. Это распределение происходит динамически, то есть место выделяется или удаляется по мере необходимости во время выполнения программы.
- Сборка мусора: Одним из важных аспектов Кучи является механизм "Сборка мусора". Этот автоматический процесс определяет, когда память больше не используется, и освобождает её. Он гарантирует, что приложения не будут потреблять неиспользуемую память, что в противном случае может привести к утечкам памяти и неэффективности.
Если вы хотите больше узнать о сборке мусора и о том, как избежать утечек памяти, у меня есть ещё одна статья, которая вам поможет:
Стек Вызовов/Call Stack
- Суть: Стек Вызовов — это структура данных LIFO, записывающая точку в программе, в которой выполняются операции - в основном, где вызываются функции, чтобы выполнение могло вернуться в нужное место после выполнения этих функций или при возникновении ошибки.
- Динамика: Когда вы вызываете функцию, новый фрейм (представляющий контекст выполнения этой функции) помещается в Стек Вызовов. Когда функция завершает своё выполнение, её фрейм выгружается из стека, и управление возвращается туда, откуда она была вызвана.
- Переполнение стека: Если в Стеке Вызовов слишком много фреймов (например, из-за рекурсивной функции, которая никогда не завершается), это может привести к переполнению стека, и браузер выдаст ошибку.
- Однопоточная природа: Важно отметить, что JavaScript является однопоточным. Это означает, что в каждый момент времени обрабатывается только одна операция. Если функция находится в процессе выполнения, она занимает Стек Вызовов до тех пор, пока не закончит свою работу, блокируя выполнение любой другой функции.
В качестве иллюстрации:
function multiply(x, y) {
return x * y;
}
function calculate() {
const value = multiply(5, 3);
console.log(value);
}
calculate();
В данном примере:
- Вызывается функция
calculate()
, помещая свой фрейм в Стек Вызовов. - Внутри
calculate()
вызывается функцияmultiply()
, добавляющая свой фрейм на вершину стека. - После завершения работы функции
multiply()
она возвращает значение15
, а её фрейм удаляется из Стека Вызовов. - Функция
calculate()
продолжает выполнение и записывает значение15
в консоль. После завершения выполнения её фрейм также удаляется из Стека Вызовов.
Асинхронные механизмы JavaScript
В синхронном мире каждая инструкция должна дождаться завершения предыдущей. Однако для выполнения операций, которые могут занять непредсказуемое количество времени (например, чтение файла или получение данных с сервера), JavaScript использует неблокирующую, асинхронную модель. Эффективность этой модели обеспечивается сочетанием Web API, Очереди Обратных Вызовов (Callback Queue) и Цикла Событий (Event Loop).
Web API
- Суть: Это функциональные возможности, предоставляемые браузером (или средой, в случае Node.js/Deno/Bun), находящиеся вне движка JavaScript, но доступ к которым можно получить с помощью JavaScript.
- Назначение: Web API обрабатывают задачи, которые обычно блокируют операции, если выполняются непосредственно на движке JavaScript. Например, таймеры (
setTimeout
иsetInterval
), вызовы AJAX (например,fetch
) и задачи манипулирования DOM управляются здесь. - Взаимодействие: После завершения Web API задачи её функция обратного вызова отправляется в Очередь Обратных Вызовов, готовая к выполнению.
Очередь Обратных Вызовов/Callback Queue (или Очередь Задач/Task Queue)
- Суть: Как следует из названия, это очередь (структура FIFO), в которой хранятся все функции обратного вызова, готовые к выполнению после завершения соответствующих Web API задач.
- Динамика: Функции обратного вызова выстраиваются в эту очередь в том порядке, в каком связанные с ними задачи завершаются в Web API. Однако они не перемещаются автоматически в Стек Вызовов. Это работа Цикла Событий.
Цикл Событий/Event Loop
- Роль: Его основная роль — следить за Стеком Вызовов и Очередью Обратных Вызовов. Если Стек Вызовов пуст, а в Очереди Обратных Вызовов есть функция, ожидающая выполнения, Цикл Событий снимает её с очереди и помещает в Стек Вызовов для выполнения.
- Обеспечение Неблокирующего Поведения: Цикл Событий гарантирует, что JavaScript остаётся неблокирующим. Даже если асинхронная операция занимает много времени в Web API, другие функции всё равно могут выполняться и завершаться в Стеке Вызовов.
Рассмотрим последовательность:
console.log('First');
setTimeout(function() {
console.log('Second');
}, 0);
console.log('Third');
Как вы думаете, что выводит этот код?
console.log('First')
добавляется в Стек Вызовов и выполняется.- Встречается
setTimeout
. Работа с таймером передаётся в Web API. - Сразу после этого
console.log('Third')
добавляется в Стек Вызовов и выполняется. - Несмотря на то, что длительность таймера равна 0 миллисекунд, обратный вызов
setTimeout
(который выводит 'Second') помещается в Очередь Обратных Вызовов. - Цикл Событий, заметив, что Стек Вызовов пуст, а в Очереди Обратных Вызовов есть функция, передаёт обратный вызов в Стек Вызовов.
- Наконец, выполняется
console.log('Second')
.
Тогда вывод этого кода будет следующим:
First
Third
Second
Весь этот механизм гарантирует, что даже для асинхронного кода поток выполнения программы остаётся последовательным и неблокирующим.
Посмотрите, как на диаграмме показан этот случай:
А как насчёт Очереди Микрозадач/Microtask Queue
- Роль: Очередь Микрозадач содержит список микрозадач, которые возникают из промисов,
MutationObserver
и других специфических асинхронных операций. - Приоритет: Микрозадачи имеют более высокий приоритет, чем задачи. Поэтому после выполнения каждой задачи среда выполнения JavaScript проверяет Очередь Микрозадач, и если в ней есть невыполненные микрозадачи, она выполняет их все, прежде чем перейти к следующей задаче из Очереди Обратных Вызовов.
Чтобы понять это, рассмотрим следующий пример:
console.log('Start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
Как вы думаете, каким будет результат?
Вот он:
Start
End
Promise
setTimeout
Такой порядок объясняется тем, что после выполнения синхронного кода (Start
и End
) Цикл Событий видит, что в Очереди Микрозадач есть промис, и выполняет его раньше, чем setTimeout
в Очереди Задач, даже несмотря на то, что задержка setTimeout
равна 0
.
Давайте посмотрим на диаграмму. Обратите внимание, что промис добавляется движком Javascript, так как это прямой промис, а не созданный через fetch
, в этом случае он бы сначала прошёл через Web API.
Мой заключительный совет
В заключение хочу сказать, что за годы работы с JavaScript я понял следующее: быть отличным разработчиком — это не просто писать код. А действительно понимать, как этот код работает.
Когда вы поймёте, как работают такие вещи, как цикл событий, очередь микрозадач и стек вызовов, вы будете чувствовать себя более связанным со своим кодом. Каждый написанный вами фрагмент кода будет иметь больше смысла, потому что вы будете знать, что происходит за кулисами.
Тем, кто только начинает работать с этим, может показаться, что вначале будет трудновато. И это совершенно нормально. Обучение требует времени. Но по мере того, как вы будете проводить больше времени с JavaScript, не пытайтесь просто запомнить что-то. Вместо этого постарайтесь представить, как все работает вместе. Это поможет вам решать проблемы и понимать странные вещи, которые могут происходить в вашем коде.
Поэтому, если вы только начинаете или даже если вы уже давно занимаетесь программированием, уделите время тому, чтобы понять, как работает JavaScript. Чем больше вы знаете об основах, тем легче вам будет программировать. И всегда помните: важно понимать свои инструменты, чтобы хорошо их использовать.