Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Вы когда-нибудь задумывались, как браузеры читают и исполняют JavaScript-код? Это выглядит таинственно, но в этом посте вы можете получить представление, что же происходит под капотом.
Начнём наше путешествие в язык с экскурсии в удивительный мир JavaScript-движков.
Откройте консоль в Chrome и перейдите на вкладку Sources. Вы увидите несколько разделов, и один из самых интересных называется Call Stack (в Firefox вы увидите Call Stack, когда поставите брейкпоинт в коде):
Что такое Call Stack? Похоже, тут много чего происходит, даже ради исполнения пары строк кода. На самом деле JavaScript не поставляется в коробке с каждым браузером. Существует большой компонент, который компилирует и интерпретирует наш JavaScript-код — это JavaScript-движок. Самыми популярными являются V8, он используется в Google Chrome и Node.js, SpiderMonkey в Firefox, JavaScriptCore в Safari/WebKit.
Сегодня JavaScript-движки представляют собой прекрасные образцы программной инженерии, и будет практически невозможно рассказать обо всех аспектах. Однако основную работу по исполнению кода делают для нас лишь несколько компонентов движков: Call Stack (стек вызовов), Global Memory (глобальная память) и Execution Context (контекст исполнения). Готовы с ними познакомиться?
Содержание:
- JavaScript-движки и глобальная память
- JavaScript-движки: как они работают? Глобальный контекст исполнения и стек вызовов
- JavaScript является однопоточным, и другие забавные истории
- Асинхронный JavaScript, очередь обратных вызовов и цикл событий
- Callback hell и промисы ES6
- Создание и работа с JavaScript-промисами
- Обработка ошибок в ES6-промисах
- Комбинаторы ES6-промисов: Promise.all, Promise.allSettled, Promise.any и другие
- ES6-промисы и очередь микрозадач
- JavaScript-движки: как они работают? Асинхронная эволюция: от промисов до async/await
- JavaScript-движки: как они работают? Итоги
1. JavaScript-движки и глобальная память
Я говорил, что JavaScript является одновременно компилируемым и интерпретируемым языком. Хотите верьте, хотите нет, но на самом деле JavaScript-движки компилируют ваш код за микросекунды до его исполнения.
Волшебство какое-то, да? Это волшебство называется JIT (Just in time compilation). Она сама по себе является большой темой для обсуждения, даже книги будет мало, чтобы описать работу JIT. Но пока что мы пропустим теорию и сосредоточимся на фазе исполнения, которая не менее интересна.
Для начала посмотрите на этот код:
var num = 2;
function pow(num) {
return num * num;
}
Допустим, я спрошу вас, как этот код обрабатывается в браузере? Что вы ответите? Вы можете сказать: «браузер читает код» или «браузер исполняет код». В реальности всё не так просто. Во-первых, код считывает не браузер, а движок. JavaScript-движок считывает код, и как только он определяет первую строку, то кладёт пару ссылок в глобальную память.
Глобальная память (которую также называют кучей (heap)) — это область, в которой JavaScript-движок хранит переменные и объявления функций. И когда он прочитает приведённый выше код, то в глобальной памяти появятся два биндинга:
Даже если в примере содержится лишь переменная и функция, представьте, что ваш JavaScript-код исполняется в более крупной среде: в браузере или в Node.js. В таких средах есть много заранее определённых функций и переменных, которые называют глобальными. Поэтому глобальная память будет содержать гораздо больше данных, чем просто
num
и pow
, имейте в виду.В данный момент ничего не исполняется. Давайте теперь попробуем исполнить нашу функцию:
var num = 2;
function pow(num) {
return num * num;
}
pow(num);
Что произойдёт? А произойдёт кое-что интересное. При вызове функции JavaScript-движок выделит два раздела:
- Глобальный контекст исполнения (Global Execution Context)
- Стек вызовов (Call Stack)
Что они собой представляют?
2. JavaScript-движки: как они работают? Глобальный контекст исполнения и стек вызовов
Вы узнали, как JavaScript-движок читает переменные и объявления функций. Они попадают в глобальную память (кучу).
Но теперь мы исполняем JavaScript-функцию, и движок должен об этом позаботиться. Каким образом? У каждого JavaScript-движка есть ключевой компонент, который называется стек вызовов.
Это стековая структура данных: элементы могут добавляться в неё сверху, но они не могут исключаться из структуры, пока над ними есть другие элементы. Именно так устроены JavaScript-функции. При исполнении они не могут покинуть стек вызовов, если в нём присутствует другая функция. Обратите на это внимание, поскольку эта концепция помогает понять утверждение «JavaScript является однопоточным».
Но вернёмся к нашему примеру. При вызове функции движок отправляет её в стек вызовов:
Мне нравится представлять стек вызовов в виде стопки чипсов Pringles. Мы не можем съесть чипс снизу стопки, пока не съедим те, что лежат сверху. К счастью, наша функция является синхронной: это всего лишь умножение, которое быстро вычисляется.
В то же самое время движок размещает в памяти глобальный контекст исполнения, это глобальная среда, в которой исполняется JavaScript-код. Вот как это выглядит:
Представьте глобальный контекст исполнения в виде моря, в котором глобальные JavaScript-функции плавают, словно рыбы. Как мило! Но это лишь половина всей истории. Что, если наша функция имеет вложенные переменные, или внутренние функции?
Даже в простом случае, как показано ниже, JavaScript-движок создаёт локальный контекст исполнения:
var num = 2;
function pow(num) {
var fixed = 89;
return num * num;
}
pow(num);
Обратите внимание, что я добавил в функцию
pow
переменную fixed
. В этом случае локальный контекст исполнения будет содержать раздел для fixed
. Я не очень хорошо рисую маленькие прямоугольники внутри других маленьких маленьких прямоугольников, так что используйте своё воображение.Рядом с
pow
появится локальный контекст исполнения, внутри зелёного раздела-прямоугольника, расположенного внутри глобального контекста исполнения. Представьте также, как для каждой вложенной функции внутри вложенной функции движок создаёт другие локальные контексты исполнения. Все эти разделы-прямоугольники появляются очень быстро! Как матрёшка!Давайте теперь вернёмся к истории с однопоточностью. Что это означает?
3. JavaScript является однопоточным, и другие забавные истории
Мы говорим, что JavaScript является однопоточным, потому что наши функции обрабатывает лишь один стек вызовов. Напомню, что функции не могут покинуть стек вызовов, если исполнения ожидают другие функции.
Это не проблема, если мы работаем с синхронным кодом. К примеру, сложение двух чисел является синхронным и вычисляется за микросекунды. А что насчёт сетевых вызовов и других взаимодействий с внешним миром?
К счастью, JavaScript-движки спроектированы так, чтобы по умолчанию работать асинхронно. Даже если они могут исполнять только по одной функции за раз, более медленные функции могут исполняться внешней сущностью — в нашем случае это браузер. Об этом мы поговорим ниже.
В то же время вы знаете, что когда браузер загружает какой-то JavaScript-код, движок считывает этот код строка за строкой и выполняет следующие шаги:
- Помещает в глобальную память (кучу) переменные и объявления функций.
- Отправляет вызов каждой функции в стек вызовов.
- Создаёт глобальный контекст исполнения, в котором исполняются глобальные функции.
- Создаёт много маленьких локальных контекстов исполнения (если есть внутренние переменные или вложенные функции).
Теперь у вас есть общее представление о механике синхронности, лежащей в основе всех JavaScript-движков. В следующей главе мы поговорим о том, как в JavaScript работает асинхронный код и почему он работает именно так.
4. Асинхронный JavaScript, очередь обратных вызовов и цикл событий
Благодаря глобальной памяти, контексту исполнения и стеку вызовов синхронный JavaScript-код исполняется в наших браузерах. Но мы кое о чём забыли. Что происходит, если нужно исполнить какую-нибудь асинхронную функцию?
Под асинхронной функцией я подразумеваю каждое взаимодействие с внешним миром, для завершения которого может потребоваться какое-то время. Вызов REST API или таймера — асинхронны, потому что на их выполнение могут уйти секунды. Благодаря имеющимся в движке элементам мы можем обрабатывать такие функции без блокирования стека вызовов и браузера. Не забывайте, стек вызовов может исполнять одновременно только одну функцию, и даже одна блокирующая функция может буквально остановить браузер. К счастью, JavaScript-движки «умны», и с небольшой помощью браузера могут такие вещи отсортировывать.
Когда мы исполняем асинхронную функцию, браузер берёт её и выполняет для нас. Возьмём такой таймер:
setTimeout(callback, 10000);
function callback(){
console.log('hello timer!');
}
Уверен, что хоть вы и видели
setTimeout
уже сотни раз, однако можете не знать, что эта функция не встроена в JavaScript. Вот так, когда JavaScript появился, в нём не было функции setTimeout
. По сути, она является частью так называемых браузерных API, коллекции удобных инструментов, которые нам предоставляет браузер. Чудесно! Но что это означает на практике? Поскольку setTimeout
относится к браузерным API, эта функция исполняется самим браузером (на мгновение она появляется в стеке вызовов, но сразу оттуда удаляется).Через 10 секунд браузер берёт callback-функцию, которую мы ему передали, и кладёт её в очередь обратных вызовов. В данный момент в JavaScript-движке появилось ещё два раздела-прямоугольника. Посмотрите на этот код:
var num = 2;
function pow(num) {
return num * num;
}
pow(num);
setTimeout(callback, 10000);
function callback(){
console.log('hello timer!');
}
Теперь наша схема выглядит так:
setTimeout
исполняется внутри контекста браузера. Через 10 секунд таймер запускается и callback-функция готова к исполнению. Но для начала она должна пройти через очередь обратных вызовов. Это структура данных в виде очереди, и, как свидетельствует её название, представляет собой упорядоченную очередь из функций.Каждая асинхронная функция должна пройти через очередь обратных вызовов, прежде чем попасть в стек вызовов. Но кто отправляет функции дальше? Это делает компонент под названием цикл событий.
Пока что цикл событий занимается только одним: проверяет, пуст ли стек вызовов. Если в очереди обратных вызовов есть какая-нибудь функция и если стек вызовов свободен, тогда пора отправлять callback в стек вызовов.
После этого функция считается исполненной. Так выглядит общая схема обработки асинхронного и синхронного кода JavaScript-движком:
Допустим,
callback()
готова к исполнению. После завершения исполнения pow()
стек вызовов освобождается и цикл событий отправляет в него callback()
. И всё! Хотя я немного всё упростил, если вы поняли приведённую выше схему, то можете понять и весь JavaScript.Помните: браузерные API, очередь обратных вызовов и цикл событий являются столпами асинхронного JavaScript.
И если интересно, можете посмотреть любопытное видео «What the heck is the event loop anyway» Филипа Робертса. Это одно из лучших объяснений цикла событий.
Но мы ещё не закончили с темой асинхронного JavaScript. В следующих главах мы рассмотрим ES6-промисы.
5. Callback hell и ES6-промисы
Callback-функции используются в JavaScript везде, и в синхронном, и в асинхронном коде. Рассмотрим этот метод:
function mapper(element){
return element * 2;
}
[1, 2, 3, 4, 5].map(mapper);
mapper
— это callback-функция, которая передаётся внутри map
. Приведённый код является синхронным. А теперь рассмотрим этот интервал:function runMeEvery(){
console.log('Ran!');
}
setInterval(runMeEvery, 5000);
Этот код асинхронный, поскольку внутри
setInterval
мы передаём обратный вызов runMeEvery
. Обратные вызовы применяются по всему JavaScript, так что у нас годами существует проблема, получившая название «callback hell» — «ад обратных вызовов».Термин Callback hell в JavaScript применяют к «стилю» программирования, при котором callback’и вкладывают в другие callback’и, которые вложены в другие callback’и… Из-за асинхронной природы JavaScript-программисты уже давно попадают в эту ловушку.
Если честно, я никогда не создавал большие пирамиды callback’ов. Возможно, потому что я ценю читабельный код и всегда стараюсь придерживаться его принципов. Если вы попали в callback hell, это говорит о том, что ваша функция делает слишком много.
Я не будут подробно говорить о callback hell, если вам интересно, то сходите на сайт callbackhell.com, там эта проблема подробно исследована и предложены разные решения. А мы поговорим о ES6-промисах. Это аддон к JavaScript, призванное решить проблему ада обратных вызовов. Но что такое «промисы»?
Промис в JavaScript — это представление будущего события. Промис может завершиться успешно, или на жаргоне программистов промис будет «разрешён» (resolved, исполнен). Но если промис завершается с ошибкой, то мы говорим, что он в состоянии «отклонён» (rejected). Также у промисов есть состояние по умолчанию: каждый новый промис начинается в состоянии «ожидания решения» (pending). Можно ли создать собственный промис? Да. Об этом мы поговорим в следующей главе.
6. Создание и работа с JavaScript-промисами
Для создания нового промиса нужно вызвать конструктор, передав в него callback-функцию. Она может принимать только два параметра:
resolve
и reject
. Давайте создадим новый промис, который будет разрешён через 5 секунд (можете протестировать примеры в браузерной консоли):const myPromise = new Promise(function(resolve){
setTimeout(function(){
resolve()
}, 5000)
});
Как видите,
resolve
— это функция, которую мы вызываем, чтобы промис успешно завершился. А reject
создаст отклонённый промис:const myPromise = new Promise(function(resolve, reject){
setTimeout(function(){
reject()
}, 5000)
});
Обратите внимание, что вы можете игнорировать
reject
, потому что это второй параметр. Но если вы намерены воспользоваться reject
, то не сможете проигнорировать resolve
. То есть следующий код не будет работать и закончится разрешённым промисом:// Can't omit resolve !
const myPromise = new Promise(function(reject){
setTimeout(function(){
reject()
}, 5000)
});
Сейчас промисы не выглядят такими полезными, верно? Эти примеры ничего не выводят для пользователя. Давайте кое-что добавим. И разрешённые, от отклонённые промисы могут возвращать данные. Например:
const myPromise = new Promise(function(resolve) {
resolve([{ name: "Chris" }]);
});
Но мы всё ещё ничего не видим. Для извлечения данных из промиса вам нужно связать промис с методом
then
. Он берёт callback (какая ирония!), который получает актуальные данные:const myPromise = new Promise(function(resolve, reject) {
resolve([{ name: "Chris" }]);
});
myPromise.then(function(data) {
console.log(data);
});
Как JavaScript-разработчик и потребитель чужого кода вы по большей части взаимодействуете с внешними промисами. Создатели библиотек чаще всего обёртывают legacy-код в конструктор промисов, таким образом:
const shinyNewUtil = new Promise(function(resolve, reject) {
// do stuff and resolve
// or reject
});
И при необходимости мы также можем создать и разрешить промис, вызвав
Promise.resolve()
:Promise.resolve({ msg: 'Resolve!'})
.then(msg => console.log(msg));
Итак, напомню: промисы в JavaScript — это закладка на событие, которое произойдёт в будущем. Событие начинается в состоянии «ожидание решения», и может быть успешным (разрешённым, исполненным) или неуспешным (отклонённым). Промис может возвращать данные, которые можно извлечь, прикрепив к промису
then
. В следующей главе мы обсудим, как работать с ошибками, приходящими из промисов.7. Обработка ошибок в ES6-промисах
Обрабатывать ошибки в JavaScript всегда было просто, как минимум в синхронном коде. Взгляните на пример:
function makeAnError() {
throw Error("Sorry mate!");
}
try {
makeAnError();
} catch (error) {
console.log("Catching the error! " + error);
}
Результатом будет:
Catching the error! Error: Sorry mate!
Как и ожидалась, ошибка попала в блок
catch
. Теперь попробуем асинхронную функцию:function makeAnError() {
throw Error("Sorry mate!");
}
try {
setTimeout(makeAnError, 5000);
} catch (error) {
console.log("Catching the error! " + error);
}
Этот код является асинхронным из-за
setTimeout
. Что будет, если мы его исполним? throw Error("Sorry mate!");
^
Error: Sorry mate!
at Timeout.makeAnError [as _onTimeout] (/home/valentino/Code/piccolo-javascript/async.js:2:9)
Теперь результат другой. Ошибка не была поймана блоком
catch
, а свободно поднялась выше по стеку. Причина в том, что try/catch
работает только с синхронным кодом. Если хотите узнать больше, то эта проблема подробно рассмотрена здесь.К счастью, с промисами мы можем обрабатывать асинхронные ошибки, словно они синхронные. В прошлой главе я говорил, что вызов
reject
приводит к отклонению промиса:const myPromise = new Promise(function(resolve, reject) {
reject('Errored, sorry!');
});
В этом случае мы можем обрабатывать ошибки с помощью обработчика
catch
, дёргая (опять) обратный вызов:const myPromise = new Promise(function(resolve, reject) {
reject('Errored, sorry!');
});
myPromise.catch(err => console.log(err));
Кроме того, чтобы для создания и отклонения промиса в нужном месте можно вызывать
Promise.reject()
:Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err));
Напомню: обработчик
then
исполняется, когда промис выполнен, а обработчик catch
выполняется для отклонённых промисов. Но это ещё не конец истории. Ниже мы увидим, как async/await
замечательно работают с try/catch
.8. Комбинаторы ES6-промисов: Promise.all, Promise.allSettled, Promise.any и другие
Промисы не предназначены для работы по одиночке. Promise API предлагает ряд методов для комбинирования промисов. Один из самых полезных — Promise.all, он берёт массив из промисов и возвращает один промис. Только проблема в том, что Promise.all отклоняется, если отклонены все промисы в массиве.
Promise.race разрешает или отклоняет, как только один из промисов в массиве получает соответствующий статус.
В более свежих версиях V8 также будут внедрены два новых комбинатора:
Promise.allSettled
и Promise.any
. Promise.any пока на ранней стадии предложенной функциональности, на момент написания статьи не поддерживается. Однако, в теории, он сможет сигнализировать, был ли исполнен какой-либо промис. Отличие от Promise.race
в том, что Promise.any не отклоняется, даже если отклонён один из промисов.Promise.allSettled
ещё интереснее. Он тоже берёт массив промисов, но не «коротит», если один из промисов отклоняется. Он полезен, когда нужно проверить, все ли промисы в массиве перешли в какую-то стадию, вне зависимости от наличия отклонённых промисов. Его можно считать противоположностью Promise.all
.9. ES6-промисы и очередь микрозадач
Если помните из предыдущей главы, каждая асинхронная callback-функция в JavaScript оказывается в очереди обратных вызовов, прежде чем попадает в стек вызовов. Но у callback-функций, переданных в промис, иная судьба: они обрабатываются очередью микрозадач (Microtask Queue), а не очередью задач.
И здесь вам нужно быть внимательными: очередь микрозадач предшествует очереди вызовов. Обратные вызовы из очереди микрозадач имеют приоритет, когда цикл событий проверяет, готовы ли новые callback’и перейти в стек вызовов.
Подробнее эта механика описана Джейком Арчибальдом в Tasks, microtasks, queues and schedules, замечательное чтиво.
10. JavaScript-движки: как они работают? Асинхронная эволюция: от промисов до async/await
JavaScript быстро развивается и мы каждый год получаем постоянные улучшения. Промисы выглядели как финал, но с ECMAScript 2017 (ES8) появился новый синтаксис:
async/await
.async/await
— всего лишь стилистическое улучшение, которое мы называем синтаксическим сахаром. async/await
никак не меняет JavaScript (не забывайте, язык должен быть обратно совместим со старыми браузерами и не должен ломать существующий код). Это лишь новый способ написания асинхронного кода на основе премисов. Рассмотрим пример. Выше мы уже сохранили проми с соответствующим then
:const myPromise = new Promise(function(resolve, reject) {
resolve([{ name: "Chris" }]);
});
myPromise.then((data) => console.log(data))
Теперь с помощью
async/await
мы можем обработать асинхронный код так, чтобы для читающего наш листинг код выглядел синхронным. Вместо применения then
мы можем обернуть промис в функцию, помеченную как async
, и затем будем ожидать (await
) результат:const myPromise = new Promise(function(resolve, reject) {
resolve([{ name: "Chris" }]);
});
async function getData() {
const data = await myPromise;
console.log(data);
}
getData();
Выглядит здраво, верно? Забавно, что async-функция всегда возвращает промис, и никто не может ей в этом помешать:
async function getData() {
const data = await myPromise;
return data;
}
getData().then(data => console.log(data));
А что насчёт ошибок? Одно из преимуществ
async/await
в том, что эта конструкция может позволить нам воспользоваться try/catch
. Почитайте введение в обработку ошибок в async-функциях и их тестирование. Давайте снова взглянем на промис, в котором мы обрабатываем ошибки с помощью обработчика
catch
:const myPromise = new Promise(function(resolve, reject) {
reject('Errored, sorry!');
});
myPromise.catch(err => console.log(err));
С асинхронными функциями мы можем отрефакторить вот так:
async function getData() {
try {
const data = await myPromise;
console.log(data);
// or return the data with return data
} catch (error) {
console.log(error);
}
}
getData();
Однако ещё не все перешли на этот стиль.
try/catch
может усложнить ваш код. При этом нужно учитывать ещё кое-что. Посмотрите, как в этом коде возникает ошибка внутри блока try
:async function getData() {
try {
if (true) {
throw Error("Catch me if you can");
}
} catch (err) {
console.log(err.message);
}
}
getData()
.then(() => console.log("I will run no matter what!"))
.catch(() => console.log("Catching err"));
Что насчёт двух строк, которые выводятся в консоли? Не забывайте, что
try/catch
— синхронная конструкция, а наша асинхронная функция генерирует промис. Они идут по двум разным путям, словно поезда. Но они никогда не встретятся! Поэтому ошибка, которую подняла throw
, никогда не активирует обработчик catch
в getData()
. Исполнение этого кода приведёт к тому, что сначала появится надпись «Catch me if you can», а за ней «I will run no matter what!».В реальном мире нам не нужно, чтобы
throw
запускал обработчик then
. Решить это можно, скажем, возвращая Promise.reject()
из функции:async function getData() {
try {
if (true) {
return Promise.reject("Catch me if you can");
}
} catch (err) {
console.log(err.message);
}
}
Now the error will be handled as expected:
getData()
.then(() => console.log("I will NOT run no matter what!"))
.catch(() => console.log("Catching err"));
"Catching err" // output
Помимо этого
async/await
выглядит лучшим способом структурирования асинхронного кода в JavaScript. Мы лучше управляем обработкой ошибок и код выглядит чище.В любом случае, я не рекомендую рефакторить весь ваш JS-код под
async/await
. Обсудите это с командой. Но если вы работаете самостоятельно, то выбор между чистыми промисами и async/await
— лишь дело вкуса.11. JavaScript-движки: как они работают? Итоги
JavaScript — это скриптовый язык для веба, он сначала компилируется, а затем интерпретируется движком. Самые популярные JS-движки: V8, применяется в Google Chrome и Node.js; SpiderMonkey, разработан для Firefox; JavaScriptCore, используется в Safari.
JavaScript-движки имеют много «движущихся» частей: стек вызовов, глобальная память, цикл событий, очередь обратных вызовов. Все эти части идеально работают вместе, обеспечивая обработку синхронного и асинхронного кода.
JavaScript-движки являются однопоточными, то есть для исполнения функций применяется единственный стек вызовов. Это ограничение лежит в основе асинхронной природы JavaScript: все операции, для выполнения которых требуется какое-то время, должны управляться внешней сущностью (например, браузером) или функцией обратного вызова.
Для упрощения работы асинхронного кода в ECMAScript 2015 были внедрены промисы. Промис — это асинхронный объект, используемый для представления успешности или неуспешности любой асинхронной операции. Но улучшения на этом не прекратились. В 2017-м появились
async/await
: стилистическое улучшение для промисов, позволяющее писать асинхронный код, как если бы он был синхронным.