Раз и навсегда: учимся решать задачи на порядок вывода в консоль

JavaScript

Один из распространенных типов вопросов на техническом интервью — порядок вывода в консоль. Вам показывают какой-то абстрактный кусок кода, где обязательно встречаются обычные выводы строк в консоль, выводы в консоль из промисов и всяческих элементов браузерных API.

Сегодня я поделюсь фреймворком мышления, с помощью которого можно
а) Решить любую задачу такого класса
б) Продемонстрировать понимание того, как устроен Event Loop

Статья точно не претендует на научную точность, а вот на доступность и человеческое объяснение — претендует.

Как работает Event Loop

По сути такие задачи дают с целью выяснить, насколько вы понимаете работу Event Loop. Поэтому совершенно логично начать рассказ с того, чтобы кратко проговорить принципы его работы и зачем он вообще нужен. Без лишних подробностей, кратко. Заодно и сами себе напомните принципы, которыми будете руководствоваться при решении задач.

Итак, JS — однопоточный язык, то есть он запускается на одном ядре процессора и выполняет задачи последовательно. Если бы они выполнялись строго по очереди, после запроса каких-то данных с бэкенда, интерфейс бы подвисал до тех пор, пока не получит ответ. И тут на сцену выходит Event Loop.

По сути это бесконечный цикл, который следит за очередью задач/событий. Эта очередь неоднородна. Она делится на три части, которые имеют разный приоритет и даже структуру. Части этии выполняются в разное время в следующем порядке:

  • синхронные задачи
  • микрозадачи
  • макрозадачи

Синхронные задачи

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

Здесь может встретиться ловушка. Допустим, задача.

console.log('a')

const b = new Promise(() => {
    console.log('b')
});

console.log('c')

Этот код последовательно выведет a, b, c, потому что конструктор промиса также синхронный. К нему мы вернемся в следующем блоке.

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

Микрозадачи

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

  • Обычные промисы
  • async/await промисы
  • MutationObserver
  • queueMicrotask

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

  • Каждый промис может перейти из pending состояния в fulfilled или rejected только единожды
  • then/catch/finally порождают новые промисы
  • Вывов resolve/reject внутри конструктора промиса не прерывает исполнение его кода, а вот внутри обработчиков then/catch/finally дальшейший код не исполняется

Например, на собеседовании может встретиться такой кусок кода

new Promise((resolve, reject) => {
    console.log(1);
    resolve();
    console.log(2);
    reject();
}).then((resolve, reject) => {
    console.log(3);
    reject();
    console.log(4)
}).catch(() => {
    console.log(5);
}).catch(() => {
    console.log(6);
}).then(() => {
    console.log(7);
})
  • Сначала синхронно выведется 1
  • Далее мы резолвим промис, но продолжаем выполнять когд конструктора, поэтому 2 мы так же увидим
  • То что мы зареджектили промис после его резолва ничего не меняет, промис остается исполненным и поэтому мы переходим в блок then, откуда выводим 3
  • 4 не попадет в консоль, потому что реджект внутри then/catch/finally переводит промис в соответствующий статус и оставшийся код в функции выполняться не будет
  • Логично что дальше выведется 5, потому что мы «поймали» выброшенную промисом ошибку
  • Так как catch порождает новый промис, и этот промис выполняется без ошибок (мирно выводит цифру в консоль), то следующий catch будет пропущен
  • Тот catch который вывел пятерку считается fulfiled, поэтому сработает ближайший следующий then и последним что попадет в консоль будет 7

Макрозадачи

Последняя по порядку, но не по значению очередь — это макрозадачи. Из того что действительно встречается на интервью, это всяческие элементы браузерного API, например setTimeout, setInterval, события DOM.

До них мы доберемся когда будут выполнены все синхронные операции, опустошена очередь микрозадач. То есть в условиях тех задач которые встречаются на интервью — в самом конце.

setTimeout(() => {
    console.log('Последний вывод')
}, 0);

Promise.resolve().then(() => {
    console.log('Второй вывод')
});

console.log('Первый вывод');

Порядок в async/await коде

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

function a() {
    console.log(1)
};

async function b() {
    console.log(2);
    await a();
    console.log(3);
}

console.log(4);
b();
console.log(5);
  • Если непонятно почему сначала будет выведено 4, перечитайте первый блок статьи
  • Дальше мы вызываем фунцию b, где синхронно выводим в консоль 2
  • Затем мы зачем-то ожидаем исполнения синхронной функции a, но при этом исполняется её тело (и выводит в консоль 1, а результат выполнения (который в данном случае не имеет значения) прокидывается в асинхронный контекст
  • Код внутри функции b, который идет после await a(), попадает в гипотетический then, то есть console.log(3) становится микрозадачей и будет выполнена после того как…
  • Будет выполнена последняя доступная синхронная операция — console.log(5);

Итоговый порядок — 4,2,1,5,3

А где обещанный фреймворк?

Кажется, мы чуть утонули в деталях и нюансах. Так часто бывает с JS. Если вы сами не сформулировали для себя удобную формулу решения подобных задач, то вот моя. На каждом шаге мы фокусируемся на чем-то одном и временно забываем про остальное.

  1. Идем сверху-вниз, ищем глазами весь синхронный код и по порядку вызова, смело нумеруем выводы в консоль
  2. Затем находим цепочки промисов и внимательно, обращая внимание на порядок then и catch разбираемся, что попадет в консоль, а что нет
  3. Осталось только найти таймауты, интервалы и прочие макротаски

Не забываем нюансы. Не торопимся. Все получится.

А нужно ли это в работе?

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

Симо Мофин
Добавить комментарий