JavaScript движки: как они вообще работают? От стека вызовов до Promise, что вам нужно знать
Вы когда-нибудь задумывались, как браузеры читают и запускают код JavaScript? Это кажется волшебным, но вы можете получить подсказку о том, что происходит под капотом.
Давайте начнем наше погружение в язык, представив замечательный мир движков JavaScript.
Откройте консоль браузера в Chrome и посмотрите на вкладку «Источники». Вы увидите несколько блоков, один из наиболее интересных с именем Call Stack (в Firefox вы можете увидеть Call Stack после вставки точки останова в код):
Что такое стек вызовов? Похоже, что происходит много всего, даже для запуска пары строк кода. JavaScript на самом деле не поставляется из коробки с каждым веб-браузером.
Существует большой компонент, который компилирует и интерпретирует наш код JavaScript: это механизм JavaScript. Наиболее популярными механизмами JavaScript являются V8, используемые Google Chrome и Node.js, SpiderMonkey для Firefox и JavaScriptCore, используемые Safari / WebKit.
Сегодня движки JavaScript - это блестящие разработки, и было бы невозможно охватить каждый их аспект. Но в каждом движке есть небольшие детали, которые делают за нас тяжелую работу.
Одним из таких компонентов является стек вызовов, который наряду с глобальным запоминающим устройством и контекстом выполнения позволяет запускать наш код. Готов встретиться с ними?
Движки JavaScript и глобальная память
Я сказал, что JavaScript является одновременно и компилируемым, и интерпретируемым языком. Хотите верьте, хотите нет, но движки JavaScript на самом деле компилируют ваш код всего за микросекунды перед его выполнением.
Это звучит волшебно, верно? Эта магия называется JIT (Just in time compilation). Это большая тема сама по себе, даже книги было бы недостаточно, чтобы описать, как работает JIT. Но сейчас мы можем просто пропустить теорию, лежащую в основе компиляции, и сосредоточиться на фазе выполнения, что, не менее, интересно.
Для начала рассмотрим следующий код:
var num = 2; function pow(num) { return num * num; }
Если бы я спросил вас, как вышеуказанный код обрабатывается в браузере? Что можно ответить? Вы можете сказать «браузер читает код» или «браузер выполняет код».
В реальности все намного сложнее. Во-первых, это не браузер, который читает этот фрагмент кода, а движок. Движок JavaScript читает код и, как только он встречает первую строку, он помещает пару ссылок в глобальную память .
Глобальная память (также называемая Heap) является областью, в которой движок JavaScript сохраняет переменные и объявления функций. Итак, вернемся к нашему примеру, когда движок читает приведенный выше код, глобальная память заполняется двумя привязками:
Даже если в примере есть только переменная и функция, учтите, что ваш код JavaScript работает в более широкой среде: в браузере или в Node.js. В этих средах есть много предопределенных функций и переменных, называемых глобальными. Глобальная память будет хранить гораздо больше, чем просто числа. Просто запомни это.
На данный момент ничего не выполняется, но что если мы попытаемся запустить нашу функцию следующим образом:
var num = 2; function pow(num) { return num * num; } pow(num);
Что в этом случае произойдет? Теперь все становится намного интересней. Когда вызывается функция, механизм JavaScript освобождает место для еще двух блоков:
- глобальный контекст исполнения
- Стек вызовов
Давайте посмотрим, что они из себя представляют.
Глобальный контекст выполнения и стек вызовов
Вы узнали, как движок JavaScript читает переменные и объявления функций, которые в конечном итоге попадают в глобальной памяти (Heap).
Но теперь мы выполнили функцию JavaScript, и движок должен позаботиться об этом. Как? В каждом механизме JavaScript есть фундаментальный компонент, который называется Call Stack .
Call Stack представляет собой структуру данных стека: это означает , что элементы могут входить с вершины, но они не могут оставить, если есть какой - то элемент над ними. Функции JavaScript в точности такие.
После выполнения они не могут покинуть стек вызовов, если какая-либо другая функция остается застрявшей. Обратите на это внимание, потому что эта концепция полезна для понимания предложения “JavaScript является однопоточным”.
Но пока давайте вернемся к нашему примеру. Когда функция вызывается, механизм помещает эту функцию в стек вызовов:
Мне нравится думать о стеке вызовов как о кучке Pringles. Мы не можем съесть чипс на дне кучи, не съев сначала все наверху! К счастью, наша функция синхронна: это простое умножение, и оно рассчитывается быстро.
В то же время механизм выделяет также глобальный контекст выполнения , который является глобальной средой, в которой выполняется наш код JavaScript. Вот как это выглядит:
Представьте себе глобальный контекст выполнения как море, в котором глобальные функции JavaScript плавают как рыбы. Как мило! Но это только половина истории. Что если в нашей функции есть несколько вложенных переменных или одна или несколько внутренних функций?
Даже в простом варианте, подобном следующему, движок JavaScript создает локальный контекст выполнения:
var num = 2; function pow(num) { var fixed = 89; return num * num; } pow(num);
Обратите внимание, что я добавил переменную с именем fixed внутри функции pow. В этом случае локальный контекст выполнения будет содержать поле для фиксированного удержания.
Я не очень хорошо рисую маленькие крошечные коробки внутри других крошечных коробок! На данный момент, вы должны использовать свое воображение.
Локальный контекст выполнения появится рядом с power, внутри более зеленого поля, содержащегося в глобальном контексте выполнения. Можно также представить, что для каждой вложенной функции механизм создает больше локальных контекстов выполнения. Эти коробки могут пойти так далеко! Как матрешка!
А как насчет того, чтобы вернуться к этой однопоточной истории? Что это значит?
JavaScript однопоточный и другие забавные истории
Мы говорим, что JavaScript является однопоточным, потому что есть один стек вызовов, обрабатывающий наши функции. То есть функции не могут покинуть стек вызовов, если есть другие функции, ожидающие выполнения.
Это не проблема при работе с синхронным кодом. Например, сумма между двумя числами является синхронной и выполняется в микросекундах. Но как насчет сетевых вызовов и других взаимодействий с внешним миром?
К счастью, движки JavaScript по умолчанию спроектированы как асинхронные. Даже если они могут выполнять по одной функции за раз, есть способ для более медленной функции быть выполненной внешней сущностью: в нашем случае, браузером. Мы рассмотрим эту тему позже.
Тем временем вы узнали, что когда браузер загружает некоторый код JavaScript, движок читает его построчно и выполняет следующие шаги:
- заполняет глобальную память (Heap) переменными и объявлениями функций
- помещает каждый вызов функции в стек вызовов
- создает глобальный контекст выполнения, в котором выполняются глобальные функции
- создает множество крошечных локальных контекстов выполнения (если есть внутренние переменные или вложенные функции)
Теперь у вас должна быть большая картина синхронной механики в основе каждого движка JavaScript . В следующих разделах вы увидите, как асинхронный код работает в JavaScript и почему он работает именно так.
Асинхронный JavaScript, стек вызовов и цикл событий
Глобальная память, контекст выполнения и стек вызовов объясняют, как в нашем браузере выполняется синхронный код JavaScript. И все же мы что-то упускаем. Что происходит, когда выполняется какая-то асинхронная функция?
Под асинхронной функцией я подразумеваю каждое взаимодействие с внешним миром, которое может занять некоторое время. Вызов REST API или вызов таймера являются асинхронными, потому что для их запуска может потребоваться несколько секунд. Благодаря элементам, имеющимся у нас в движке, теперь есть способ обрабатывать такого рода функции без блокировки стека вызовов и, следовательно, браузера.
Помните, что стек вызовов может выполнять одну функцию за раз, и даже одна блокирующая функция может в буквальном смысле заморозить браузер. К счастью, движки JavaScript умны и с небольшой помощью браузера могут разобраться.
Когда мы запускаем асинхронную функцию, браузер берет эту функцию и запускает ее для нас. Рассмотрим таймер, подобный следующему:
setTimeout(callback, 10000); function callback(){ console.log('hello timer!'); }
Я уверен, что вы видели setTimeout сотни раз, но вы можете не знать, что это не встроенная функция JavaScript. То есть, когда родился JavaScript, в язык не был встроен setTimeout.
На самом деле setTimeout является частью так называемых браузерных API, набора удобных инструментов, которые браузер предоставляет нам бесплатно. Как мило! Что это означает на практике? Поскольку setTimeout является API-интерфейсом браузера, эта функция выполняется браузером напрямую (она на мгновение появляется в стеке вызовов, но мгновенно удаляется).
Затем через 10 секунд браузер берет функцию обратного вызова, которую мы передали, и перемещает ее обратно в очередь обратного вызова. На данный момент у нас есть еще два блока внутри нашего движка 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 Queue. Callback Queue - это структура данных очереди, и, как следует из ее названия, это упорядоченная очередь функций.
Каждая асинхронная функция должна пройти через очередь обратного вызова до того, как она будет помещена в стек вызовов. Но кто продвигает эту функцию вперед? Есть еще один компонент с именем Event Loop.
В цикле событий есть только одно задание: он должен проверить, пуст ли стек вызовов. Если в очереди обратного вызова есть какая-либо функция и если стек вызовов свободен, то пришло время поместить обратный вызов в стек вызовов.
После завершения функция выполняется. Это общая картина движка JavaScript для обработки асинхронного и синхронного кода:
Представьте, что callback() готов к выполнению. Когда pow() завершает работу, стек вызовов пуст, а цикл обработки событий вводит функцию callback(). Вот и все! Даже если я немного упрощаю вещи, если вы понимаете иллюстрацию выше, тогда вы готовы понимать весь JavaScript.
Помните: API браузера, Callback Queue и Event Loop являются опорами асинхронного JavaScript.
Держись, потому что мы не закончили с асинхронным JavaScript. В следующих разделах мы подробнее рассмотрим ES6 Promise.
Обратный вызов ада и ES6 Promise
callback функции везде в JavaScript. Они используются как для синхронного, так и для асинхронного кода. Рассмотрим метод карты, например:
function mapper(element){ return element * 2; } [1, 2, 3, 4, 5].map(mapper);
mapper - это функция обратного вызова, переданная map. Приведенный выше код является синхронным. Но рассмотрим вместо этого интервал:
function runMeEvery(){ console.log('Ran!'); } setInterval(runMeEvery, 5000);
Этот код является асинхронным, но, как вы можете видеть, мы передаем обратный вызов runMeEvery внутри setInterval. Обратные вызовы распространены в JavaScript, так что в течение многих лет возникала проблема: ад обратного вызова.
Ад обратного вызова в JavaScript относится к «стилю» программирования, где обратные вызовы вложены в обратные вызовы, которые вложены… в другие обратные вызовы. Из-за асинхронной природы JavaScript программисты попали в эту ловушку на долгие годы.
Честно говоря, я никогда не сталкиваюсь с экстремальными пирамидами обратного вызова, возможно, потому, что я ценю читаемый код и всегда стараюсь придерживаться этого принципа. Если вы попали в ад обратного вызова, это признак того, что ваша функция делает слишком много.
Я не буду рассказывать об аде обратного вызова, если вам интересно, есть веб-сайт callbackhell.com, который более подробно исследует проблему и предлагает некоторые решения. На чем мы хотим сейчас сосредоточиться - так это ES6 Promises. ES6 Promises - это дополнение к языку JavaScript, целью которого является решение ужасного ада обратного вызова. Но что такое Promise?
Promise JavaScript (или далее обещания) - это представление будущего события. Обещание может закончиться успехом: на жаргоне мы говорим, что оно выполнено (resolved). Но если обещание выдает ошибку, мы говорим, что оно отклонено (rejected). Обещания также имеют состояние по умолчанию: каждое новое обещание начинается в состоянии ожидания (pending). Можно создать собственное обещание? Да. Давайте посмотрим, как в следующем разделе.
Создание и работа с JavaScript Promise
Для создания нового Promise вы вызываете конструктор Promise, передавая в него функцию обратного вызова. Функция обратного вызова может принимать два параметра: 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. Другими словами, следующий код не будет работать и в конечном итоге приведет к выполнению обещания:
// Не могу опустить resolve! const myPromise = new Promise(function(reject){ setTimeout(function(){ reject() }, 5000) });
Обещания не выглядят такими полезными, не правда ли? Эти примеры ничего не печатают для пользователя. Давайте добавим некоторые данные. Как resolve, так и reject могут возвращать данные. Вот пример:
const myPromise = new Promise(function(resolve) { resolve([{ name: "Chris" }]); });
Но все же мы не можем увидеть никаких данных. Для извлечения данных из Promise вам нужно связать метод с именем then. Требуется обратный вызов (ирония!), который получает фактические данные:
const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); myPromise.then(function(data) { console.log(data); });
Как разработчик JavaScript и потребитель чужого кода, вы будете в основном взаимодействовать с Promises извне. Вместо этого создатели библиотек с большей вероятностью оборачивают устаревший код внутри конструктора Promise следующим образом:
const shinyNewUtil = new Promise(function(resolve, reject) { // ... });
А в случае необходимости мы также можем создать и разрешить Promise на месте, вызвав Promise.resolve():
Promise.resolve({ msg: 'Resolve!'}) .then(msg => console.log(msg));
Таким образом, повторение обещания JavaScript - это закладка для события, происходящего в будущем. Событие начинается в состоянии ожидания и может быть либо успешно (resolved), либо завершиться неудачно (rejected). Promise может возвращать данные, и эти данные можно извлечь, подключив then или catch к обещанию. В следующем разделе мы увидим, как бороться с ошибками, возникающими в Promise.
Обработка ошибок в ES6 Promises
Обработка ошибок в 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 работает только с синхронным кодом. Если вам интересно , проблема объясняется в деталях тут Обработка ошибок в Node.js .
К счастью, в Promises есть способ обрабатывать асинхронные ошибки, как если бы они были синхронными. Если вы помните из предыдущего раздела, вызов 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 на месте:
Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err));
Напомним, что обработчик then запускается, когда обещание выполнено, а обработчик catch выполняется для отклоненных обещаний. Но это не конец истории. Позже мы увидим, как async / await прекрасно работает с try / catch.
Комбинаторы ES6 Promises: Promise.all, Promise.allSettled, Promise.any и Promise.race
Обещания не предназначены для одиночества. API Promise предлагает множество методов для объединения Promises. Одним из наиболее полезных является Promise.all, который принимает массив Обещаний и возвращает одно Обещание. Проблема в том, что Promise.all отклоняется, если любое из Promise в массиве отклоняется.
Promise.race разрешает или отклоняет, как только одно из Promise в массиве будет установлено. Он все еще отклоняется, если одно из Обещания возвращает reject.
В новых версиях V8 также будут реализованы два новых комбинатора: Promise.allSettled и Promise.any. Promise.any все еще находится на ранних стадиях предложения: на момент написания этой статьи его еще не было.
Но теория заключается в том, что Promise.any может сигнализировать, выполнено ли какое-либо из обещаний. Отличие от Promise.race состоит в том, что Promise.any не отклоняется, даже если одно из обещаний отклонено.
В любом случае, наиболее интересным из них является Promise.allSettled. Он по-прежнему принимает массив Обещаний, но не замыкает накоротко, если одно из Обещаний отклоняется. Это полезно, когда вы хотите проверить, исчерпан ли массив Обещаний, независимо от возможного отклонения. Думайте об этом как об альтернативе Promise.all.
ES6 Promises и очередь микрозадач
Если вы помните из предыдущих разделов, каждая функция асинхронного обратного вызова в JavaScript попадает в очередь обратного вызова, а затем помещается в стек вызовов. Но функция обратного вызова, переданная в Promise, имеет другую судьбу: они обрабатываются очередью Microtask , а не Callback Queue.
И есть одна интересная особенность, о которой вы должны знать: очередь микрозадач имеет приоритет над очередью обратных вызовов . Обратные вызовы из очереди Microtask имеют приоритет, когда цикл обработки событий проверяет, есть ли какой-либо новый обратный вызов, готовый быть помещенным в стек вызовов.
Механика более подробно представлена Джейком Арчибальдом в « Задачах, микрозадачах, очередях и расписаниях» , это фантастическое чтение.
Асинхронная эволюция: от Promise к async / await
JavaScript движется быстро, и каждый год мы постоянно улучшаем язык. Promise казались точкой прибытия, но с ECMAScript 2017 (ES8) появился новый синтаксис: async / await.
async / await - это просто стилистическое улучшение, которое мы называем синтаксическим сахаром. async / await не изменяет JavaScript любым способом (помните, JavaScript должен быть обратно совместим со старым браузером и не должен нарушать существующий код)
Это просто новый способ написания асинхронного кода на основе Promise. Давайте сделаем пример. Ранее мы сохраняли Promise с соответствующим then:
const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); myPromise.then((data) => console.log(data))
Теперь с помощью async / await мы можем обрабатывать асинхронный код способом, который выглядит синхронно с точки зрения читателя. Вместо использования мы можем обернуть Promise внутри функции, помеченной как async, и затем ожидать результата:
const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); async function getData() { const data = await myPromise; console.log(data); } getData();
Имеет смысл правильно? Самое смешное, что асинхронная функция всегда возвращает Promise, и никто не мешает вам сделать это:
async function getData() { const data = await myPromise; return data; } getData().then(data => console.log(data));
А как насчет ошибок? Одним из благ, предлагаемых async / await, является возможность использовать try / catch. (Вот введение в обработку ошибок в асинхронных функциях и как их тестировать). Давайте снова посмотрим на Promise, где для обработки ошибок мы используем обработчик 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 / 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 является синхронной конструкцией, но наша асинхронная функция создает Promise. Они путешествуют по двум разным путям, как два поезда.
Но они никогда не встретятся! То есть ошибка, вызванная throw, никогда не вызовет обработчик getData() catch. Запуск приведенного выше кода приведет к «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); } }
Теперь ошибка будет обработана, как и ожидалось:
getData() .then(() => console.log("I will NOT run no matter what!")) .catch(() => console.log("Catching err")); "Catching err" // output
Кроме того, async / await кажется лучшим способом структурирования асинхронного кода в JavaScript. Мы лучше контролируем обработку ошибок, и код выглядит чище.
В любом случае, я не советую рефакторинг всего вашего кода JavaScript в async / await. Это выбор, который нужно обсудить с командой. Но если вы работаете в одиночку, используете ли вы простые Promise или async / await, это вопрос личных предпочтений.
В завершение
JavaScript является языком сценариев для Интернета, и его особенностью является то, что он сначала компилируется, а затем интерпретируется движком. Среди самых популярных движков JavaScript - V8 , используемый Google Chrome и Node.js, SpiderMonkey, созданный для веб-браузера Firefox, и JavaScriptCore, используемый Safari.
У движков JavaScript много движущихся частей: стек вызовов, глобальная память, цикл обработки событий, очередь вызовов. Все эти части работают вместе в идеальной настройке для обработки синхронного и асинхронного кода в JavaScript.
Механизмы JavaScript являются однопоточными, это означает, что для выполнения функций существует один стек вызовов. Это ограничение лежит в основе асинхронной природы JavaScript: все операции, требующие времени, должны выполняться внешней сущностью (например, браузером) или функцией обратного вызова.
Для упрощения асинхронного кодового потока ECMAScript 2015 принес нам Promise. Обещание является асинхронным объектом и используется для представления либо отказа, либо успеха любой асинхронной операции. Но улучшения не остановились на этом. В 2017 году родился async / await: это стилистическая составляющая для Promises, позволяющая писать асинхронный код, как если бы он был синхронным.