Рассмотрим приведенный вами пример:
async function somePromise() {
Promise.resolve()
.then(() => {
console.log("then1");
})
.then(() => {
console.log("then2");
})
.then(() => {
console.log("then3");
});
return "Completed";
}
Promise.all([somePromise()]).then((data) => {
console.log(data);
});
Чисто с первого взгляда на него я ожидал бы увидеть один из следующих порядков выполнения (точнее, вывода):
then1
then2
then3
["Completed"]
Либо
["Completed"]
then1
then2
then3
Если немножко поразмыслить, то логично предположить, что первый вариант более логичен. Даже особо не вдаваясь в подробности: есть некий внутренний код, который сразу выполнится, это приведет к тому, что зарезолвится наружний промис и в итоге выполнится внешний console.log
.
Что мы видим на деле (Chome/Node.js/Firefox - в принципе, везде):
then1
then2
["Completed"]
then3
Что-то тут не так. Почему именно после второго "внутреннего" промиса? Почему не после первого, например? То есть уже странно что ни в начале, ни в конце, так мало того еще и где-то вообще непонятно где.
Давайте разбираться. Для начала, уберем один лишний "слой" промисов (а именно, Promise.all
:
async function somePromise() {
Promise.resolve()
.then(() => {
console.log("then1");
})
.then(() => {
console.log("then2");
})
.then(() => {
console.log("then3");
});
return "Completed";
}
somePromise().then((data) => {
console.log(data);
});
then1
["Completed"]
then2
then3
Ага! Супер! Можем сделать вывод, что Promise.all
добавляет дополнительное действие в очередь, тем самым отодвигая выполнения кода в его then
ветви. Пока что все выглядит довольно логично. Но остается вопрос: почему наружний then
"прерывает" выполнение очереди внутренних? Причем прерывает вроде бы, на первый взгляд, непрерывную цепочку.
Предлагаю для начала немного изменить код для наглядности вывода. Сделаем выводы inner 1
, inner 2
и inner 3
для "внутренних" промисов (внутренними я их называю потому что они создаются внутри функции, вызываемой снаружи) и вывод наружнего промиса обозначим как outer
. Это все просто чтобы было проще рассуждать, по факту это ничего в работе скрипта не меняет.
Кроме того, вместо того чтобы выводить снаружи значение, с которым зарезолвился промис, возвращенный функцией, я буду выводить просто outer
, потому что нам не важно значение, с которым тот промис зарезолвился, а важно лишь когда это произошло.
async function somePromise() {
Promise.resolve()
.then(() => {
console.log("inner 1");
})
.then(() => {
console.log("inner 2");
})
.then(() => {
console.log("inner 3");
});
return;
}
somePromise().then((data) => {
console.log("outer");
});
inner 1
outer
inner 2
inner 3
Вывод все еще тот же (точнее, его порядок).
Теперь давайте пройдемся по коду и по порядку его исполнения.
- Первым делом вызывается
somePromise
.
- Выполняется первая строка этой функции, и мы создаем промис, который сразу же зарезолвится.
- Следующая строка,
then
- добавит коллбэек в очередь задач. Внимание: добавит, а не инициирует исполнение.
- Поскольку код у нас все-таки выполняется синхронно, следующим на очереди будет второй
then
, а потом - третий. Только огромная раз с первым у них заключается в том, что они пока что ниичего в очередь не добавят. Почему? Потому то добавить что-то в очередь второй сможет только после того, как зарезолвится первый, а третий сможет добавить в очередь свою задачу только после того, как зарезолвится второй.
Таким образом, на данном этапе у нас очередь состоит из одного коллбека, первого: [inner 1]
.
- Идем дальше. Выполнение доходит до
return
, и функция, будучи асинхронной, возвращает промис.
- Выполняется наружний
then
, который, опять же регистрирует коллбэк (задачу), но еще не инициирует ее исполнение.
Очередь задач на данном этапе: [inner 1, outer]
.
- Синхронный стак закончился, event loop начинает обращаться к задачам из очередей. Из очереди микро задач, в нашем случае (поскольку только она что-то содержит).
- Берем первую задачу из очереди и исполняем ее (
console.log("inner 1")
).
Очередь задач на данном этапе: [outer]
.
- По завершении, поскольку это все-таки
then
, будет возвращен разрешенный промис, который разрешит добавить (но не сразу же исполнить) следующую задачу (из следующего then
) в очередь.
Очередь задач на данном этапе: [outer, inner 2]
.
- Выполняется следующая задача -
outer
(console.log("inner 1")
).
Очередь задач на данном этапе: [inner 2]
.
- Выполняется следующая задача из очереди (
console.log("inner 2")
).
Очередь задач на данном этапе: []
- пустая!
- Поскольку предыдущая задача вернула разрешенный промис, а дальше по коду у нас был еще один
then
, то эта новая задача добавляется в очередь.
Очередь задач на данном этапе: [inner 3]
.
Основной стак пустой, поэтому берем эту задачу inner 3
из очереди микро задач и выполняем ее.
Очередь пустая, выполнение окончено. Вывод скрипта - inner 1
, outer
, inner 2
, inner 3
.
Если мы добавим больше задач во внутреннюю цепочку, ничего не изменится и вывод наружнего никуда не "съедет":
async function somePromise() {
Promise.resolve()
.then(() => {
console.log("inner 1");
})
.then(() => {
console.log("inner 2");
})
.then(() => {
console.log("inner 3");
})
.then(() => {
console.log("inner 4");
})
.then(() => {
console.log("inner 5");
})
.then(() => {
console.log("inner 6");
});
return;
}
somePromise().then((data) => {
console.log("outer");
});
outer
все еще остается вторым в выводе.
Еще нагляднее станет, если добавить больше внешних then
'ов:
async function somePromise() {
Promise.resolve()
.then(() => {
console.log("inner 1");
})
.then(() => {
console.log("inner 2");
})
.then(() => {
console.log("inner 3");
});
return;
}
somePromise()
.then((data) => {
console.log("outer 1");
})
.then((data) => {
console.log("outer 2");
})
.then((data) => {
console.log("outer 3");
});
inner 1
outer 1
inner 2
outer 2
inner 3
outer 3
С одной стороны, непонятно, почему наружние стали чередоваться, а не запускаться после внутренних (как было бы логично предположить, глядя на код). С другой стороны, подумайте вот о чем: точно такое же поведение было и в прошлом случае, просто поскольку у нас был всего один наружний промис, чередоваться ему было попросту не с чем! Точнее, не с чем кроме первого внутреннего, именно поэтому мы и видели такой вывод: inner 1
, outer 1
, inner 2
, <outer 2, которого на самом деле не было
>, inner 3
, <outer 3, которого на самом деле не было
>.
Но все-таки у меня лично остается открытым вопрос, почему же они чередуются. Давайте разберемся и с этим:
По началу все идет как и в предыдущем примере, до 10-го пункта. На 10 пункте появляется важное отличие: в данном случае после outer 1
у нас следует outer 2
, который добавляется в очередь, и она становится [inner 2, outer 2]
. Дальше выполняется inner 2
, который регистрирует inner 3
, но следующий в очереди на выполнение - outer 2
. outer 2
регистрирует выполнение outer 3
, но выполняется следующим inner 3
(потому что он был следующий в очереди). Отсюда и чередование.
Возвращаясь к Promise.all
. Добавим его к нашему последнему примеру:
async function somePromise() {
Promise.resolve()
.then(() => {
console.log("inner 1");
})
.then(() => {
console.log("inner 2");
})
.then(() => {
console.log("inner 3");
});
return;
}
Promise.all([somePromise()])
.then((data) => {
console.log("outer 1");
})
.then((data) => {
console.log("outer 2");
})
.then((data) => {
console.log("outer 3");
});
inner 1
inner 2
outer 1
inner 3
outer 2
outer 3
На первый взгляд, все сильно интереснее, но если капнуть чуть глубже, то не очень.
Было:
in1 in2 in3
out1 out2 out3
А стало просто:
in1 in2 in3
out1 out2 out3
То есть выполнение всех "наружних" промисов просто сместилось на одну "ячейку" вправо. Почему? Из-за Promise.all
, понятно. Но все-таки, почему?
А потому, что Promise.all
сам возвращает промис, который тоже добавляет выполнение своего "коллбэка" в очередь. Глядя на вывод с дыркой из последнего блока кода (вывода), несложно догадаться, куда именно:
in1 in2 in3
(СЮДА) out1 out2 out3
Что вполне логично. Перед выполнением первого then
из списка наружних, нужно будет для начала разрешить Promise.all
. Поэтому его "разрешение" становится следующей задачей после inner 1
, тем самым "отодвигая" выполнение наружних then
'ов.
Вроде бы все должно быть понятно.
Важные напоминания (для кого-то, возможно, будут выводы):
- Асинхронный код на то и асинхронный, и в том и заключается его особая сложность, что иногда бывает довольно сложно предсказать, в каком именно порядке он будет выполняться.
- Что на самом деле важно, это не абсолютный порядок, а относительный, а именно:
- Код во второй ветви
then
, следующей за первой ветвью then
всегда выполнится позже. То есть их относительный порядок выполнения будет всегда соблюдаться и будет понятен, и будет читаться "из кода": всегда будет сначала then1
, потом then2
, потом then3
. Никогда then3
не начнет выполняться раньше чем then1
, напрмер. Поэтому они и называются then
- "потом"/"после"/"(по)следовательно".
- Цепочка
then
ов никогда не гарантирует вам, что действия в ней будут выполняться непрерывно. Это не так. Цепочка промисов может быть прервана другими действиями, добавленными в очередь. Если бы это было не так, то асинхронность как концепт не имела бы никакого смысла.