Решаем популярные задачи с асинхронным кодом на JavaScript: часть первая
На собеседованиях начинающим Frontend-разработчикам часто попадаются задачи на асинхронный код. Преподаватель Эльбрус Буткемп Денис Образцов выбрал несколько популярных задач, с которыми наши выпускники часто сталкиваются на интервью, и разобрал логику их решения.
В первой части текста вспомним, как устроен цикл событий и разберём несколько базовых задач на логику и внимательность. Во второй части перейдем к более сложным примерам на порядок попадания задач в Event Loop и оптимизацию кода.
Как устроен цикл событий
Машина читает код дважды: сначала в память компьютера записываются переменные, потом происходит непосредственное выполнение кода. Часто задач в коде несколько: они попадают в цикл событий (Event Loop) и выполняются в определённой последовательности.
Последовательность задается типом кода: синхронным или асинхронным. В случае с синхронным кодом все задачи попадают сразу в Call Stack и выполняются по очереди.
Асинхронный код используется, например, когда программе нужно обратиться к базе данных или другому внешнему источнику информации. Этот процесс можно сравнить с телефонным звонком: когда вы звоните кому-то, вы заранее не знаете, когда вам ответят — после первого гудка, после пятого или вообще не возьмут трубку.
С асинхронным кодом сложнее: во-первых, он всегда выполняется после синхронного, а во-вторых, делится еще на две очереди — макро- и микрозадачи.
Микрозадачи — в основном, промисы, которые выполняются в первую очередь. Большие задачи (например, таймеры, AJAX-запросы) попадают в самый конец стека и выполняются последними.
Схема выполнения асинхронного кода
Теперь, когда мы вспомнили теорию, перейдем к разбору задач на понимание асинхронного кода, которые могут попасться на собеседовании. Все задачи рассчитаны на джунов и собраны командой Elbrus Bootcamp на реальных интервью.
Задача первая
// В каком порядке будут выведены консоли и какие именно?
const p = new Promise((resolve, reject) => {
reject(Error('Всё сломалось :('));
})
.catch((error) => console.log('1-я', error.message))
.catch((error) => console.log('2-я', error.message));
Прежде чем разбирать код, рассмотрим пример, к которому мы будем возвращаться на протяжении всей статьи. Он поможет глубже понять принцип работы асинхронного кода.
Представьте, что вы пришли в фастфуд, сделали заказ, получили специальный пульт и ждете, пока пульт завибрирует и можно будет пойти и забрать заказ.
В первой строке кода мы видим promise — это специальный объект, который даёт обещание, что в будущем будет выполнено то или иное действие. Promise выступает аналогом такого пульта и в данном случае обещает уведомить не о готовности заказа, а об ошибке, если она возникнет.
Под promise прописан вариант развития событий — reject, который выводит в консоль сообщение «Всё сломалось» в случае, если что-то пошло не так. Второй, положительный, вариант resolve в этой задаче не указан.
Вернёмся к примеру с фастфудом: вы сели за столик и ждёте заказ. Через некоторое время пульт завибрировал. Дальше может быть несколько вариантов развития событий: вы успешно получите заказ или кассир позовёт вас сообщить, что какого-то ингредиента нет и блюдо не смогут приготовить. На этот случай и нужны resolve и reject.
Следующий шаг — встать и подойти к стойке. За него отвечают обработчики .catch, которые в коде идут в цепочке друг за другом. Это важный момент: между обработчиками нет точки с запятой, и цепочка идёт сразу после объявления переменной ‘p’, поэтому выполняется только первый .catch. Второй выполняет те же действия и не срабатывает.
Это сравнительно простая задача: в ней нет смешивания синхронного и асинхронного кода. В консоли мы получим результат выполнения promise, а затем — вывод первого .catch.
Дополнение первое
const p2 = new Promise((resolve, reject) => {
reject(Error('Всё сломалось :('));
});
// тут обе консоли, потому что нет цепочки, каждый catch отрабатывает отдельно
p2.catch((error) => console.log('3-я', error.message));
p2.catch((error) => console.log('4-я', error.message));
Здесь происходит то же самое, что и в базовом варианте задачи, но с исключениями. Есть два обращения к константе ‘p2’, нет цепочки, между .catch появилась точка с запятой, поэтому в консоль выводится результат обоих обработчиков.
Стоит отметить, что смысла в этом немного: обработчики отлавливают одну и ту же ошибку. Но эта задача скорее на внимательность, чем на логику.
Дополнение второе
const p3 = new Promise((resolve, reject) => {
reject(Error('Всё сломалось :('));
})
.then((error) => console.log('5-я', error.message)) // ? бесполезный обработчик положительного ответа
.catch((error) => console.log('6-я', error.message)); // ? будет отлов ошибки
В этой версии задачи есть then. Здесь это обработчик положительного результата (resolve), который не выполняет никакую функцию, в этом коде он бесполезен. В тексте задачи по-прежнему упоминается только негативное развитие событий. Поэтому вывод в консоль будет тот же, что и в предыдущей задаче.
Задача два
// в каком порядке будут выведены консоли и что в них будет?
setTimeout(() => {
console.log('timeout')
}, 0);
const p = new Promise((resolve, reject) => {
console.log('Promise creation');
resolve()
})
const p2 = new Promise((resolve, reject) => {
console.log(123)
})
p.then(() => {
console.log('Promise resolving');
})
console.log('End')
console.log('p2 =>>', p2)
Разберём текст задачи. В первой строчке указан таймер setTimeout с нулевой задержкой, следом идут два promise: c пустой функцией обработки положительного ответа и без функции.
Здесь then — обработчик первого promise, который получает результат выполнения resolve. В последних строчках — консоль завершения и консоль, которая выводит результат выполнения второго promise.
Вспомним, в каком порядке код попадает в Call Stack. В первую очередь выполняется синхронный код: console.log или promise. По дефолту они не асинхронные, пока вы не сделаете их таковыми (например, добавите .catch или .then).
Таким образом, вывод консоли будет иметь следующий порядок:
setTimeout(() => {
console.log('timeout') // 6) макрозадача, timeout
}, 0);
const p = new Promise((resolve, reject) => {
console.log('Promise creation'); // 1) синхронно, Promise creation
resolve()
})
const p2 = new Promise((resolve, reject) => {
console.log(123) // 2) синхронно, 123
})
p.then(() => {
console.log('Promise resolving'); // 5) пришёл из микрозадачи после всего синхронного кода, Promise resolving
})
console.log('End') // 3) синхронно, End
console.log('p2 =>>', p2) // 4) синхронно, Promise { }
Строки с консолями внутри promise выполнятся в первую очередь, поскольку в них нет ничего асинхронного, так как promise сам по себе изначально синхронный. Затем выполняются синхронная консоль ‘End’ и консоль, в которой показывается второй promise, находящийся в стадии ожидания (<pending>).
Далее выполняется .then. В базовом варианте promise выполняется синхронно. Только после того, как весь синхронный код отработал, выполняется его асинхронный обработчик. В последнюю очередь выполнится макрозадача с setTimeout.
Задача три
// todo в каком порядке будут выведены консоли и что в них будет?
console.log('script start'); // ? 1) синхронно, script start
setTimeout(function() {
console.log('setTimeout'); // ? 5) макрозадача, setTimeout
}, 0);
Promise
.resolve()
.then(function() {
console.log('promise1'); // ? 3) микрозадача, promise1
})
.then(function() {
console.log('promise2'); // ? 4) микрозадача, promise2
});
console.log('script end'); // ? 2) синхронно, script end
В этой задаче первый и последний console.log синхронные, поэтому они выполнятся сразу. Следом идёт promise с двумя обработчиками, которые выстроены в цепочку и выполняются друг за другом. Обратите внимание, что между ними нет точки с запятой. В последнюю очередь выполняется setTimeout, поскольку это макрозадача.
Эти задачи рассчитаны на базовое понимание работы асинхронного кода. В следующей части статьи разберём более сложные кейсы и оптимизируем скорость выполнения задач в Call Stack.