Вихревой тур по движкам JavaScript от стека вызовов, глобальной памяти, цикла событий, очереди обратного вызова до обещаний и асинхронного / ждущего! Приятного чтения!

Вы когда-нибудь задумывались, как браузеры читают и запускают код JavaScript? Это кажется волшебным, но вы можете понять, что происходит под капотом.

Давайте начнем наше погружение в язык с знакомства с удивительным миром движков JavaScript.

Откройте консоль браузера в Chrome и посмотрите на вкладку «Источники». Вы увидите несколько полей, одно из наиболее интересных под названием Стек вызовов (в Firefox вы можете увидеть стек вызовов после вставки точки останова в код):

Что такое стек вызовов? Похоже, происходит много всего, даже если запустить пару строк кода. Фактически, JavaScript не входит в стандартную комплектацию каждого веб-браузера.

Есть большой компонент, который компилирует и интерпретирует наш код JavaScript: это движок JavaScript. Самыми популярными движками JavaScript являются V8, используемый Google Chrome и Node.js, SpiderMonkey для Firefox и JavaScriptCore, используемый Safari / WebKit.

Сегодня движки JavaScript - это блестящие инженерные разработки, и невозможно охватить все их аспекты. Но в каждом двигателе есть небольшие детали, которые делают за нас тяжелую работу.

Один из этих компонентов - Стек вызовов, вместе с Глобальной памятью и Контекстом выполнения позволяет запускать наш код. Готовы встретиться с ними?

Движки JavaScript и глобальная память

Я сказал, что JavaScript является одновременно компилируемым и интерпретируемым языком. Вы не поверите, JavaScript-движки на самом деле компилируют ваш код всего за микросекунды перед его выполнением.

Звучит волшебно, правда? Магия называется JIT (своевременная компиляция). Это большая тема сама по себе, одной книги было бы недостаточно, чтобы описать, как работает JIT. Но пока мы можем просто пропустить теорию компиляции и сосредоточиться на фазе выполнения, что, тем не менее, интересно.

Для начала рассмотрим следующий код:

var num = 2;
function pow(num) {
    return num * num;
}

Если бы я спросил вас, как вышеуказанный код обрабатывается в браузере? Что ты скажешь? Вы можете сказать «браузер читает код» или «браузер выполняет код».

Реальность более тонкая, чем это. Во-первых, этот фрагмент кода читает не браузер. Это двигатель. Механизм JavaScript считывает код и, как только встречает первую строку, помещает пару ссылок в глобальную память.

Глобальная память (также называемая кучей) - это область, в которой механизм JavaScript сохраняет переменные и объявления функций. Итак, вернемся к нашему примеру, когда движок считывает приведенный выше код, глобальная память заполняется двумя привязками:

Даже если в примере есть только переменная и функция, учтите, что ваш код JavaScript работает в более крупной среде: в браузере или в Node.js. В этой среде есть много предопределенных функций и переменных, называемых глобальными переменными. Глобальная память будет содержать гораздо больше, чем просто число и мощность. Просто запомни это.

На этом этапе ничего не выполняется, но что, если мы попытаемся запустить нашу функцию следующим образом:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);

Что случится? Теперь все становится интересно. Когда функция вызывается, движок JavaScript освобождает место для еще двух блоков:

Посмотрим, что они из себя представляют в следующем разделе.

Движки JavaScript: как они вообще работают? Глобальный контекст выполнения и стек вызовов

Вы узнали, как движок JavaScript читает переменные и объявления функций. Они попадают в глобальную память (кучу).

Но теперь мы выполнили функцию JavaScript, и движок должен позаботиться об этом. Как? В каждом движке JavaScript есть фундаментальный компонент, называемый стеком вызовов.

Стек вызовов - это структура данных стека: это означает, что элементы могут входить сверху, но не могут уходить, если над ними есть какой-то элемент. Функции JavaScript точно такие же.

После выполнения они не могут покинуть стек вызовов, если какая-то другая функция остается зависшей. Обратите внимание, потому что эта концепция полезна для понимания предложения «JavaScript является однопоточным».

А пока вернемся к нашему примеру. Когда функция вызывается, движок помещает эту функцию внутрь стека вызовов:

Мне нравится думать о стеке вызовов как о груде Pringles. Мы не можем съесть колючки внизу кучи, не съев предварительно все колючки наверху! К счастью, наша функция синхронна: это простое умножение, и оно быстро вычисляется.

В то же время движок также выделяет глобальный контекст выполнения, который представляет собой глобальную среду, в которой выполняется наш код JavaScript. Вот как это выглядит:

Представьте себе глобальный контекст выполнения как море, в котором глобальные функции JavaScript плавают, как рыбы. Как мило! Но это только половина дела. Что, если в нашей функции есть вложенные переменные или одна или несколько внутренних функций?

Даже в таком простом варианте, как следующий, механизм JavaScript создает локальный контекст выполнения:

var num = 2;
function pow(num) {
    var fixed = 89;
    return num * num;
}
pow(num);

Обратите внимание, что я добавил переменную с именем fixed внутри функции pow. В этом случае локальный контекст выполнения будет содержать поле для фиксации.

Я не очень хорошо рисую маленькие коробочки внутри других коробочек! А пока вы должны задействовать свое воображение.

Локальный контекст выполнения появится рядом с pow внутри более зеленого поля, содержащегося в глобальном контексте выполнения. Вы также можете представить, что для каждой вложенной функции вложенной функции механизм создает больше локальных контекстов выполнения. Эти коробки могут зайти так быстро! Как матрешка!

А теперь как насчет того, чтобы вернуться к этой однопоточной истории? Что это значит?

JavaScript однопоточный и другие забавные истории

Мы говорим, что JavaScript является однопоточным, потому что есть один стек вызовов, обрабатывающий наши функции. То есть функции не могут покинуть стек вызовов, если есть другие функции, ожидающие выполнения.

Это не проблема при работе с синхронным кодом. Например, сумма двух чисел синхронна и выполняется за микросекунды. Но как насчет сетевых вызовов и других взаимодействий с внешним миром?

К счастью, движки JavaScript по умолчанию спроектированы как асинхронные. Даже если они могут выполнять по одной функции за раз, есть способ выполнить более медленную функцию внешним объектом: в нашем случае браузером. Мы рассмотрим эту тему позже.

Тем временем вы узнали, что когда браузер загружает некоторый код JavaScript, движок читает строку за строкой и выполняет следующие шаги:

  • заполняет глобальную память (кучу) переменными и объявлениями функций
  • помещает каждый вызов функции в стек вызовов
  • создает глобальный контекст выполнения, в котором выполняются глобальные функции
  • создает множество крошечных локальных контекстов выполнения (если есть внутренние переменные или вложенные функции)

К настоящему моменту у вас должно быть полное представление о синхронной механике, лежащей в основе каждого движка 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 секунд срабатывает таймер, и функция обратного вызова готова к запуску. Но сначала он должен пройти через очередь обратного вызова. Очередь обратного вызова представляет собой структуру данных очереди и, как следует из названия, представляет собой упорядоченную очередь функций.

Каждая асинхронная функция должна пройти через очередь обратного вызова, прежде чем будет помещена в стек вызовов. Но кто продвигает эту функцию вперед? Есть еще один компонент под названием Цикл событий.

У цикла событий есть только одна задача: он должен проверять, пуст ли стек вызовов. Если в очереди обратного вызова есть какая-то функция и стек вызовов свободен, то пора отправить обратный вызов в стек вызовов.

После этого функция выполняется. Это общая картина движка JavaScript для обработки асинхронного и синхронного кода:

Представьте, что callback () готов к выполнению. Когда функция pow () завершается, стек вызовов пуст, а цикл событий вставляет обратный вызов (). Вот и все! Даже если я немного упрощаю ситуацию, если вы понимаете иллюстрацию выше, значит, вы готовы понимать весь JavaScript.

Помните: API-интерфейсы браузера, очередь обратного вызова и цикл событий являются столпами асинхронного JavaScript.

А если вам нравятся видео, я предлагаю посмотреть «Что, черт возьми, такое петля событий» Филиппа Робертса. Это одно из лучших объяснений цикла событий.

Но подождите, потому что мы еще не закончили с асинхронным JavaScript. В следующих разделах мы более подробно рассмотрим обещания ES6.

Ад обратного вызова и обещания ES6

Функции обратного вызова везде в JavaScript. Они используются как для синхронного, так и для асинхронного кода. Рассмотрим, например, метод карты:

function mapper(element){
    return element * 2;
}
[1, 2, 3, 4, 5].map(mapper);

mapper - это функция обратного вызова, переданная внутри карты. Приведенный выше код синхронный. Но рассмотрим вместо этого интервал:

function runMeEvery(){
    console.log('Ran!');
}
setInterval(runMeEvery, 5000);

Этот код является асинхронным, но, как вы можете видеть, мы передаем обратный вызов runMeEvery внутри setInterval. Обратные вызовы широко распространены в JavaScript, поэтому с годами возникла проблема: ад обратных вызовов.

Ад обратных вызовов в JavaScript относится к «стилю» программирования, при котором обратные вызовы вложены внутри обратных вызовов, которые вложены… внутри других обратных вызовов. Из-за асинхронной природы JavaScript программисты с годами попадали в эту ловушку.

Честно говоря, я никогда не сталкиваюсь с экстремальными пирамидами обратных вызовов, возможно, потому, что ценю читаемый код и всегда стараюсь придерживаться этого принципа. Если вы попадаете в ад обратного вызова, это признак того, что ваша функция делает слишком много.

Я не буду здесь рассказывать об обратном вызове, если вам интересно, есть веб-сайт callbackhell.com, который исследует проблему более подробно и предлагает некоторые решения. Сейчас мы хотим сосредоточиться на ES6 Promises. ES6 Promises - это дополнение к языку JavaScript, направленное на устранение ужасного ада обратных вызовов. Но что такое обещание?

Обещание JavaScript - это представление о будущем событии. Обещание может закончиться успехом: на жаргоне мы говорим, что оно выполнено (выполнено). Но если Promise выдает ошибку, мы говорим, что оно находится в состоянии отклонено. Обещания также имеют состояние по умолчанию: каждое новое обещание начинается в состоянии ожидания. Можно ли создать собственное обещание? да. Посмотрим, как это сделать в следующем разделе.

Создание обещаний JavaScript и работа с ними

Для создания нового обещания вы вызываете конструктор обещания, передавая ему функцию обратного вызова. Функция обратного вызова может принимать два параметра: разрешить и отклонить. Давайте создадим новое обещание, которое разрешится через 5 секунд (вы можете попробовать примеры в консоли браузера):

const myPromise = new Promise(function(resolve){
    setTimeout(function(){
        resolve()
    }, 5000)
});

Как видите, resolve - это функция, которую мы вызываем для успешного выполнения обещания. С другой стороны, Reject делает отклоненное обещание:

const myPromise = new Promise(function(resolve, reject){
    setTimeout(function(){
        reject()
    }, 5000)
});

Обратите внимание, что в первом примере вы можете опустить отклонение, потому что это второй параметр. Но если вы собираетесь использовать отклонение, вы не можете пропустить решение. Другими словами, следующий код не будет работать и закончится обработкой обещания:

// Can't omit resolve !
const myPromise = new Promise(function(reject){
    setTimeout(function(){
        reject()
    }, 5000)
});

Теперь обещания не выглядят такими полезными, не так ли? Этот пример ничего не выводит для пользователя. Давайте добавим немного данных. И разрешенные, и отклоненные обещания могут возвращать данные. Вот пример:

const myPromise = new Promise(function(resolve) {
  resolve([{ name: "Chris" }]);
});

Но до сих пор мы не видим никаких данных. Для извлечения данных из обещания вам необходимо связать метод с именем 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) {
  // do stuff and resolve
  // or reject
});

А при необходимости мы также можем создать и разрешить Promise на месте, вызвав Promise.resolve ():

Promise.resolve({ msg: 'Resolve!'})
.then(msg => console.log(msg));

Итак, напомним, обещание JavaScript - это закладка для события, которое произойдет в будущем. Событие запускается в состоянии ожидания и может быть успешным (решено, выполнено) или не выполнено (отклонено). Promise может возвращать данные, и эти данные могут быть извлечены путем присоединения к 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.reject({msg: 'Rejected!'}).catch(err => console.log(err));

Напомним: обработчик then запускается, когда обещание заполнено, а обработчик catch запускается для отклоненных обещаний. Но это еще не конец истории. Позже мы увидим, как async / await прекрасно работает с try / catch.

Комбинаторы ES6 Promises: Promise.all, Promise.allSettled, Promise.any и друзья

Обещания не должны выполняться в одиночку. Promise API предлагает набор методов для объединения обещаний. Один из наиболее полезных - Promise.all, который принимает массив обещаний и возвращает одно обещание. Проблема в том, что Promise.all отклоняет, если отклоняется какое-либо обещание в массиве.

Promise.race разрешается или отклоняется, как только одно из обещаний в массиве рассчитано. Он по-прежнему отклоняет, если одно из обещаний отклоняет.

В новых версиях V8 также будут реализованы два новых комбинатора: Promise.allSettled и Promise.any. Promise.any все еще находится на ранней стадии предложения: на момент написания этого предложения все еще не было поддержки.

Но теория заключается в том, что Promise.any может сигнализировать, выполнено ли какое-либо из обещаний. Отличие от Promise.race состоит в том, что Promise.any не отклоняет, даже если одно из обещаний отклонено.

В любом случае наиболее интересным из двух является Promise.allSettled. Он по-прежнему принимает массив обещаний, но не замыкается, если одно из обещаний отклоняет. Это полезно, когда вы хотите проверить, все ли улажено по массиву обещаний, независимо от возможного отказа. Думайте об этом как об аналоге Promise.all.

ES6 Promises и очередь микрозадач

Если вы помните из предыдущих разделов, каждая асинхронная функция обратного вызова в JavaScript попадает в очередь обратного вызова перед тем, как быть помещена в стек вызовов. Но у функции обратного вызова, переданной в обещании, другая судьба: они обрабатываются Очередью микрозадач, а не Очередью обратного вызова.

И есть интересная особенность, о которой вам следует знать: Очередь микрозадач имеет приоритет над очередью обратного вызова. Обратные вызовы из очереди микрозадач имеют приоритет, когда цикл событий проверяет, есть ли новый обратный вызов, готовый к отправке в стек вызовов.

Механика более подробно раскрыта Джейком Арчибальдом в Задачи, микрозадачи, очереди и расписания, это фантастическое чтение.

Движки JavaScript: как они вообще работают? Асинхронная эволюция: от Promises к async / await

JavaScript быстро развивается, и каждый год мы постоянно улучшаем язык. Обещания казались отправной точкой, но с ECMAScript 2017 (ES8) родился новый синтаксис: async / await.

async / await - это просто стилистическое усовершенствование, то, что мы называем синтаксическим сахаром. async / await никоим образом не изменяет JavaScript (помните, что JavaScript должен быть обратно совместим со старым браузером и не должен нарушать существующий код).

Это просто новый способ написания асинхронного кода на основе обещаний. Приведем пример. Ранее мы сохраняли Promise с соответствующими then:

const myPromise = new Promise(function(resolve, reject) {
  resolve([{ name: "Chris" }]);
});
myPromise.then((data) => console.log(data))

Теперь с помощью async / await мы можем обрабатывать асинхронный код так, чтобы он выглядел синхронным с точки зрения читателя. Вместо использования then мы можем заключить 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 - это синхронная конструкция, но наша асинхронная функция создает обещание. Они едут по двум разным путям, как два поезда.

Но они никогда не встретятся! То есть ошибка, вызванная throw, никогда не вызовет обработчик catch getData (). Выполнение приведенного выше кода приведет к появлению «Поймай меня, если сможешь», а затем «Я побегу, несмотря ни на что!».

В реальном мире мы не хотим, чтобы 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. Это варианты, которые необходимо обсудить с командой. Но если вы работаете в одиночку, используете ли вы простые обещания или async / await, это вопрос личных предпочтений.

Движки JavaScript: как они вообще работают? Подведение итогов

JavaScript - это язык сценариев для Интернета, особенность которого заключается в том, что он сначала компилируется, а затем интерпретируется механизмом. Среди самых популярных движков JavaScript - V8, используемый Google Chrome и Node.js, SpiderMonkey, созданный для веб-браузера Firefox, и JavaScriptCore , используется Safari.

У движков JavaScript есть много движущихся частей: Стек вызовов, Глобальная память, Цикл событий, Очередь обратного вызова. Все эти части работают вместе и идеально подходят для обработки синхронного и асинхронного кода в JavaScript.

Механизмы JavaScript являются однопоточными, что означает, что для запуска функций существует один стек вызовов. Это ограничение лежит в основе асинхронного характера JavaScript: все операции, требующие времени, должны выполняться внешней сущностью (например, браузером) или функцией обратного вызова.

Для упрощения асинхронного потока кода ECMAScript 2015 принес нам обещания. Promise - это асинхронный объект, который используется для обозначения сбоя или успеха любой асинхронной операции. Но на этом улучшения не закончились. В 2017 году родился async / await: это стилистический макияж для Promises, который позволяет писать асинхронный код, как если бы он был синхронным.

Спасибо за чтение и следите за обновлениями моего блога!

Первоначально опубликовано на https://www.valentinog.com 14 мая 2019 г.