Как работает JavaScript: часть третья

Петля событий, асинхронный JavaScript, ES6 и коллбеки.
27 октября 2017225057Андрей Никифоров27182212

Здравствуйте!

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

Оригинал статьи: «How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await».

Почему один поток — ограничение

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

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

Приложение зависло.

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

Строительные блоки JavaScript программ

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

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

Давайте посмотрим пример.

// ajax(..) is some arbitrary Ajax function given by a library
var response = ajax('https://example.com/api');
console.log(response);
// `response` won't have the response

Вы скорее всего знаете, что стандартный AJAX запрос не выполняется синхронно, что значит, что в момент выполнения функция ajax(..) еще ничего не возвращает.

Простой путь «ожидания» результата выполнения асинхронной функции — использование функции под названием коллбек.

ajax('https://example.com/api', function(response) {
    console.log(response); // `response` is now available
});

Просто заметка: вы можете делать синхронные AJAX запросы. Никогда не делайте этого. Если вы сделаете синхронный вызов, интерфейс вашего приложения блокируется, и пользователь не сможет ничего делать. Это ужасная практика.

Вот как это выглядит, но пожалуйста, никогда так не делайте:

// This is assuming that you're using jQuery
jQuery.ajax({
    url: 'https://api.example.com/endpoint',
    success: function(response) {
        // This is your callback.
    },
    async: false // And this is a terrible idea
});

Мы используем AJAX просто для примера. У вас может быть любой фрагмент кода, выполняющийся асинхронно.

Это можно реализовать с помощью функции setTimeout(callback, milliseconds). Она вызывает коллбек через определенное параметром timeout время. Взгляните:

function first() {
    console.log('first');
}
function second() {
    console.log('second');
}
function third() {
    console.log('third');
}
first();
setTimeout(second, 1000); // Invoke `second` after 1000ms
third();

Вывод в консоль будет таким:

first
third
second

Разбор петли событий

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

Если вас интересуют детали и организация движков JavaScript, в частности V8 — посмотрите предыдущую статью.

Итак, кто говорит JavaScript выполнять блоки кода? В реальности движок не работает изолированно, он всегда выполняется в определенной среде, для большинства разработчиков это браузер или Node.js. Сейчас JavaScript встроен в множество устройств, от роботов до лампочек. Каждое конкретное устройство предоставляет среду выполнения кода.

Общий знаменатель всех сред выполнения — встроенный механизм под названием петля событий, который выполняет блоки кода во время работы программы, вызывая движок JavaScript. Это значит, что движок выполняет конкретный участок кода по необходимости, а петля событий управляет порядком выполнения.

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

«Эй, я пока приостановлю выполнение, но когда ты закончишь с этим запросом, вызови эту функцию».

Браузер ожидает ответа от сети, и когда ему вернутся какие-то данные, он поставит вызов коллбека в очередь петли событий.

Вот диаграмма:

Мы говорили о куче и стеке вызовов в первой статье цикла. А что такое эти Web API? В общем, это потоки, к которым у вас нет прямого доступа, но вы можете делать к ним вызовы. Они — часть браузера, или в случае Node.js, это C++ API.

Итак, после всего этого, что такое петля событий?

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

Такая итерация называется тиком в петле событий. Каждое событие — просто коллбек. Давайте «выполним» код и посмотрим, что произойдет:

  1. Чистое состояние: пустая консоль, пустой стек.
  2. console.log('Hi') добавляется в стек.
  3. console.log('Hi') выполняется.
  4. console.log('Hi') удаляется из стека.
  5. setTimeout(function cb1() { ... }) добавляется в стек.
  6. setTimeout(function cb1() { ... }) выполняется. Браузер создает таймер как часть Web API. Он будет делать отсчет для вас.
  7. The setTimeout(function cb1() { ... }) сама по себе закончена и удаляется из стека.
  8. console.log('Bye') добавлена в стек.
  9. console.log('Bye') выполняется.
  10. console.log('Bye') удалена из стека.
  11. После как минимум 5000 мсек, таймер заканчивается и отправляет коллбек cb1 в очередь.
  12. Петля забирает cb1 из очереди и добавляет в стек.
  13. cb1 выполняется и добавляет console.log('cb1') в стек.
  14. console.log('cb1') выполняется.
  15. console.log('cb1') удаляется из стека.
  16. cb1 удаляется из стека.

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

Все по порядку:

Интересно заметить, что ES6 определяет, как петля событий должна работать, и технически это вне зоны ответственности движка JS, который больше не играет роль просто среды выполнения. Главная причина этого — введение промисов в ES6, поскольку последние требуют прямого и хорошо выверенного контроля над планированием операций в петле событий. Мы еще поговорим об этом позже.

Как работает setTimeout

Важно понимать, что setTimeout не ставит коллбек в очередь автоматически. Когда таймер заканчивается, окружение помещает коллбек в очередь событий, и в какой-то из будущих тиков он будет выполнен. Взгляните на код:

setTimeout(myCallback, 1000);

Он не означает, что myCallback будет выполнен через 1000 мсек. Вместо этого через 1000 мсек коллбек будет добавлен в очередь. Однако в очереди могут быть и другие события, и коллбеку придется подождать.

Множество статей и туториалов по асинхронному выполнению кода в JavaScript рекомендуют использовать setTimeout(callback, 0). Теперь вы знаете, как работает петля событий и setTimeout: вызов setTimeout с нулевым таймером просто вызовет коллбек после того, как очистится стек вызовов. Взгляните:

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');

Вот какой результат мы получим:

Hi
Bye
callback

Что такое Jobs в ES6

Новая концепция, названная «Job Queue», была представлена в ES6. Это слой поверх петли событий. Вы столкнетесь с этим, когда будете знакомиться с промисами. О них мы поговорим позже. Сейчас мы коснемся этого, чтобы потом не возвращаться к этому вопросу.

Представьте себе, что Job Queue — очередь, которая каждый тик присоединяется к петле событий. Некоторые асинхронные задачи, которые могут возникать во время тика, не приведут к добавлению целого нового элемента в петлю, а вместо этого добавят элемент (Job) в конец текущей очереди заданий тика. Это значит, что вы можете добавить задачу, которая будет выполнена позже, и вы можете быть уверены, что она будет выполнена сразу после всего остального.

В теории, возможно реализовать бесконечную петлю, в которой Job добавляет в очередь еще Job, и так до бесконечности. Это приведет к расходу ресусов на перенос очереди между тиками, и по сути аналогично бесконечному циклу в коде, например while(true).

Во многом это похоже на хак с setTimeout(callback, 0), но реализовано так, что в итоге Job гарантированно выполняется после всего, но как можно скорее.

Коллбеки

Как вы уже знаете, коллбеки — наиболее распространенный вариант для асинхронного выполнения кода в JS. Определенно, коллбеки — самый фундаментальный паттерн асинхронного кода в JS. Бесчисленные программы, даже очень сложные и запутанные, используют только коллбеки.

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

В следующей статье мы исследуем некоторые из абстракций, чтобы показать, почему более сложные паттерны, которые мы еще обсудим, необходимы или даже рекомендованы.

На этом эта часть статьи заканчивается. Оставшиеся главы — на следующей неделе.

Новые комментарии