Асинхронное программирование в однопоточных средах JavaScript


Моя прошлая обучающая статья Введение в Redux & React-redux набрала больше 100к просмотров. Что же это не может не радовать меня. И поэтому я решил порадовать и вас написав очередную статью по JavaScript. Хотя если честно я не хотел больше писать статьи поскольку это довольно сложно, занимает уйму времени и сил, а еще мне не платят за всю эту научную работу. Так что следующую статью я напишу только если эта наберет 150к просмотров.

Оглавление


1. Введение в асинхронное программирование
2. Цикл событий
3. Отложенное выполнение кода с помощью setTimeout setImmediate и process.nextTick
....3.1 setTimeout
....3.2 setImmediate
....3.3 process.nextTick
4. Устаревшие паттерны асинхронного программирования
5. Promise
....5.1 Основы Promise
....5.2 Методы экземпляра Promise
........5.2.1 Promise.prototype.then
........5.2.2 Promise.prototype.catch
........5.2.3 Promise.prototype.finally
....5.3 Композиция и цепочки промисов
........5.3.1 Графы промисов
........5.3.2 Параллельная композиция промисов с Promise.all и Promise.race
........5.3.3 Серийная композиция промисов
6. Асинхронные функции
....6.1 Остановка и возобновление выполнения
....6.2 Стратегии для асинхронных функций
........6.2.1 Реализация Sleep
........6.2.2 Максимизация распараллеливания
........6.2.3 Серийное выполнение промисов
........6.2.4 Трассировка стека и управление памятью

1. Введение в асинхронное программирование


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

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

Синхронное поведение аналогично последовательным инструкциям процессора в памяти. Каждая инструкция выполняется строго в том порядке, в котором она появляется, и каждая из них также способна немедленно извлекать информацию, которая хранится локально в системе (например, в регистре процессора или в системной памяти). В результате можно легко определить состояние программы (например, значение переменной) в любой заданной точке кода.
let x = 3;
x = x + 4;

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

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

Пример выполнение арифметической операции за время ожидания:
let x = 3
setTimeout(() => x = x + 4, 1000)

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

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

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

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

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

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

image

2. Цикл событий


До ES6 сам JavaScript фактически никогда не имел прямого встроенного понятия асинхронности. Движок JavaScript никогда не делал ничего, кроме выполнения одного фрагмента вашей программы в любой момент времени.

Движок JavaScript (далее. JS Engine) — специализированная программа, обрабатывающая JavaScript. Самый популярный на сегодняшний день считается движок V8 он используется в браузерах Google Chrome и в Node Js.

На самом деле JS Engine не работает изолированно — он работает внутри среды хостинга, которая для большинства разработчиков является типичным веб-браузером или Node.js. На самом деле, в настоящее время JavaScript внедряется во все виды устройств, от роботов до лампочек и каждое отдельное устройство представляет отдельный тип среды выполнения для JS Engine.

Общим знаменателем во всех средах является встроенный механизм, называемый циклом событий, который обрабатывает выполнение нескольких фрагментов вашей программы с течением времени, каждый раз вызывая JS Engine.

Это означает, что JS Engine — это просто среда выполнения по требованию для любого произвольного кода JS. Это окружающая среда, которая планирует события (выполнение кода JS).

Более подробно о работе JS Engine вы можете узнать из серии книг Вы не знаете JS.

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

Такая итерация называется тиком в цикле событий. Каждое событие — это просто обратный вызов функции.

Давайте пошагово рассмотрим работу цикла событий, на этом простом примере:
console.log('Hi')

setTimeout(function cb1() {
  console.log('cb1')
}, 5000)

console.log('Bye')

1. Состояние чисто. Консоль браузера чиста, а стек вызовов пуст.
image
2. console.log('Hi') добавляется в стек вызовов.
image
3. console.log('Hi') выполняется.
image
4. console.log('Hi') удаляется из стека вызовов.
image
5. setTimeout(function cb1() {… }) добавляется в стек вызовов.
image
6. Выполняется setTimeout(function cb1() {… }). Браузер создает таймер как часть веб-API и обрабатывает обратный отсчет.
image
7. Сам setTimeout(function cb1() {… }) завершен и удаляется из стека вызовов.
image
8. console.log('Bye') добавлен в стек вызовов.
image
9. console.log('Bye') выполняется.
image
10. console.log('Bye') удаляется из стека вызовов.
image
11. По прошествии не менее 5000 мс таймер завершает работу и отправляет обратный вызов cb1 в очередь обратных вызовов.
image
12. Цикл событий берет cb1 из очереди обратного вызова и помещает его в стек вызовов.
image
13. cb1 выполняется и добавляет console.log('cb1') в стек вызовов.
image
14. console.log('cb1') выполняется.
image
15. console.log('cb1') удаляется из стека вызовов.
image
16. cb1 удаляется из стека вызовов.
image

Краткий обзор:
image

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

3. Отложенное выполнение кода с помощью setTimeout setImmediate и process.nextTick


3.1 setTimeout


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

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

Передавая вторым аргументом в setTimeout в качестве задержки 0, вы можете подумать что в таком случае функция обратного вызова будет выполнена сразу, как будто setTimeout и вовсе не было. Но это не так. Взгляните на этот простой пример:
let x

setTimeout(() => x = 10, 0)

console.info(x) // undefined

На самом деле вызов setTimeout с 0 в качестве второго аргумента просто откладывает обратный вызов до тех пор, пока стек вызовов не будет очищен. В данном примере x = 10 не может выполнится до того, как вызов console.info(x) произойдет, потому что console.info(x) занимает стек вызовов, и setTimeout не может вызвать колбэк пока там есть что-то.

3.2 setImmediate


let x

setImmediate(() => x = 10)

console.info(x) // undefined

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

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

Например, если мы запускаем следующий скрипт, который не находится в цикле ввода-вывода (т. е. в основном модуле), порядок, в котором выполняются два таймера, не детерминирован, поскольку он связан с производительностью процесса:
// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout


Однако, если вы перемещаете два вызова в пределах цикла ввода-вывода, обратный вызов setImmediate всегда выполняется первым:
// timeout_vs_immediate.js
const fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})

$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

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

setTimeout и setImmediate будут запущены в следующей итерации цикла обработки событий. Но setImmediate всегда будет выполняться перед любыми таймерами, если они запланированы в цикле ввода-вывода, независимо от того, сколько таймеров присутствует.

3.3 process.nextTick


process.nextTick является частью асинхронного API. Но технически он не является частью цикла обработки событий. Вместо этого nextTickQueue будет обработан после завершения текущей операции, независимо от текущей фазы цикла обработки событий. Здесь операция определяется как переход от базового обработчика C/C++ и обработка JavaScript, которой необходимо выполнить.

Каждый раз, когда вы вызываете process.nextTick на определенной фазе, все обратные вызовы, переданные в process.nextTick, будут разрешены до того, как цикл событий продолжится. Это может создать некоторые плохие ситуации, потому что позволяет «голодать» вашему процессу ввода-вывода, выполняя рекурсивные вызовы process.nextTick, которые не позволяют циклу обработки событий достичь фазы опроса.

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

Вызов setTimeout(() => {}, 0) или setImmediate выполнит функцию обратного вызова в конце следующего тика, намного позже, чем при использовании nextTick, который отдает приоритет вызову и выполняет его непосредственно перед началом следующего тика.

По сути, имена должны быть заменены местами. process.nextTick срабатывает быстрее, чем setImmediate, но это ошибки прошлого, которые вряд ли можно исправить. Выполнение этого переключения приведет к поломке большого процента пакетов в npm.

process.nextTick выполняется когда цикл событий завершит обработку текущего стека вызовов и операции завершатся, JS Engine запускает все функции, переданные в вызовы nextTick во время этой операции.

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

Главное отличие process.nextTick от setTimeout и setImmediate в том что nextTick, выполняет обратный вызов непосредственно перед началом следующего тика, а не в следующем тике.

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

Зачем нам вообще нужно что-то подобное? Частично это философия дизайна, согласно которой API всегда должен быть асинхронным, даже если это не обязательно. Возьмем, к примеру, этот фрагмент кода:
function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(
      callback,
      new TypeError('argument should be string'),
    )
}

Фрагмент проверяет аргумент и, если он неверен, передает ошибку обратному вызову. API обновлен совсем недавно, чтобы разрешить передачу аргументов в process.nextTick, что позволяет ему принимать любые аргументы, переданные после обратного вызова, для распространения в качестве аргументов обратного вызова, поэтому вам не нужно вкладывать функции.

Здесь мы возвращаем ошибку пользователю, но только после того, как разрешили выполнение остального кода пользователя. Используя process.nextTick, гарантируется, что apiCall всегда выполняет свой обратный вызов после остального кода пользователя и до того, как цикл обработки событий будет разрешен. Это позволяет выполнять рекурсивные вызовы process.nextTick без достижения RangeError: Maximum call stack size exceeded from v8.

Эта философия может привести к некоторым потенциально проблемным ситуациям. Возьмем, к примеру, этот фрагмент:
let bar

// someAsyncApiCall имеет асинхронную сигнатуру, но синхронно вызывает обратный вызов
function someAsyncApiCall(callback) {
  callback()
}

// обратный вызов вызывается до завершения `someAsyncApiCall`
someAsyncApiCall(() => {
  // так как someAsyncApiCall не завершился, bar не было присвоено никакого значения
  console.log('bar', bar) // bar undefined
})

bar = 1

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

Поместив обратный вызов в process.nextTick, сценарий по-прежнему имеет возможность выполняться до завершения, позволяя инициализировать все переменные, функции и т. д. до вызова callback. Также это дает возможность, не продолжать цикл событий. Это может быть полезно, чтобы пользователь был предупрежден об ошибке до того, как цикл обработки событий будет разрешен. Вот предыдущий пример с использованием process.nextTick:
let bar

function someAsyncApiCall(callback) {
  process.nextTick(callback)
}

someAsyncApiCall(() => {
  console.log('bar', bar) // bar 1
})

bar = 1

Вот еще один пример из реальной жизни:
const server = net.createServer(() => {}).listen(8080)

server.on('listening', () => {})

Когда передается только порт, порт привязывается немедленно. Таким образом, обратный вызов «listening» может быть вызван немедленно. Проблема в том, что к этому времени обратный вызов. on('listening') не будет установлен.

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

Зачем использовать process.nextTick?
Есть две основные причины:
  • Иметь возможножность обрабатывать ошибки, очищать все ненужные ресурсы или, возможно, повторить запрос, прежде чем цикл обработки событий продолжится.
  • Иногда необходимо выполнить обратный вызов после запуска стека вызовов, но до продолжения цикла обработки событий.

Одним из примеров является соответствие ожиданиям пользователя. Простой пример:
const server = net.createServer()
server.on('connection', (conn) => {})

server.listen(8080)
server.on('listening', () => {})

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

Другой пример — запуск конструктора функции, который должен был, скажем, наследоваться от EventEmitter, и он хотел вызвать событие внутри конструктора:
const EventEmitter = require('events')
const util = require('util')

function MyEmitter() {
  EventEmitter.call(this)
  this.emit('event')
}
util.inherits(MyEmitter, EventEmitter)

const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
  console.log('an event occurred!')
})

Вы не можете немедленно сгенерировать событие из конструктора, потому что сценарий не будет обработан до точки, в которой пользователь назначает обратный вызов этому событию. Таким образом, внутри самого конструктора вы можете использовать process.nextTick для установки обратного вызова для отправки события после завершения конструктора, что обеспечивает ожидаемые результаты:
const EventEmitter = require('events')
const util = require('util')

function MyEmitter() {
  EventEmitter.call(this)

  // использование nextTick для генерации события после назначения обработчика
  process.nextTick(() => {
    this.emit('event')
  })
}
util.inherits(MyEmitter, EventEmitter)

const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
  console.log('an event occurred!')
})

Больше примеров смотрите в статье "Цикл событий Node.js, таймеры и process.nextTick()"

4. Устаревшие паттерны асинхронного программирования


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

Предположим, вы работали со следующей асинхронной функцией, которая использует setTimeout для выполнения некоторого поведения через одну секунду:
function double(value) {
  setTimeout(() => setTimeout(() => console.log(value * 2), 0), 1000)
}

double(3)
// 6 (выводится примерно спустя 1000 мс)

setTimeout позволяет определить обратный вызов, который планируется выполнить по истечении заданного промежутка времени. Спустя 1000 мс во время выполнения JavaScript запланирует обратный вызов, поместив его в очередь цикла обработки событий. Этот обратный вызов снимается и выполняется способом, который полностью невидим для кода JavaScript. Более того, функция double завершается сразу после успешного выполнения операции планирования setTimeout.

Предположим, операция setTimeout вернула полезное значение. Как лучше всего вернуть значение туда, где оно необходимо? Широко используемая стратегия заключается в предоставлении обратного вызова для асинхронной операции, где обратный вызов содержит код, требующий доступ к вычисленному значению (предоставляется в качестве параметра).
function double(value, callback) {
  setTimeout(() => callback(value * 2), 1000)
}

double(3, (x) => console.log(`I was given: ${x}`))
// I was given: 6 (выводится примерно спустя 1000 мс)

Здесь при вызове setTimeout команда помещает функцию в очередь сообщений по истечении 1000 мс. Эта функция будет удалена и асинхронно вычислена средой выполнения. Функция обратного вызова и ее параметры все еще доступны в асинхронном исполнении через замыкание функции.

Возможно, вы также хотели бы иметь возможность обработать ошибки в асинхронной функции, поэтому вероятность сбоя также должна быть включена в эту модель обратного вызова, так что обычно она принимает форму обратного вызова в случае успеха и неудачи.
function double(value, success, failure) {
  setTimeout(() => {
    try {
      if (typeof value !== 'number')
        throw new Error(`Unexpected argument. expected number but received: ${typeof value}`)

      success(2 * value)
    } catch (e) {
      failure(e)
    }
  }, 1000)
}
const successCallback = (x) => console.log(`Success: ${x}`)
const failureCallback = (e) => console.log(`Failure: ${e}`)

double(3, successCallback, failureCallback) // Success: 6 (выводится примерно спустя 1000 мс)
double('b', successCallback, failureCallback) // Failure: Error: Unexpected argument. expected number but received: string

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

Ситуация с обратными вызовами еще более усложняется, когда доступ к асинхронным значениям зависит от других асинхронных значений. В мире обратных вызовов это требует вложения обратных вызовов.
function double(value, success, failure) {
  setTimeout(() => {
    try {
      if (typeof value !== 'number')
        throw new Error(`Unexpected argument. expected number but received: ${typeof value}`)

      success(2 * value)
    } catch (e) {
      failure(e)
    }
  }, 1000)
}

const successCallback = (x) => double(x, (y) => console.log(`Success: ${y}`))
const failureCallback = (e) => console.log(`Failure: ${e}`)

double(3, successCallback, failureCallback)
// Success: 12 (выводится примерно спустя 1000 мс)

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

5. Promise


Promise (далее промис) — это суррогатная сущность, которая выступает в качестве замены для результата, который еще не существует. Термин «промис» был впервые предложен Дэниелом Фридманом и Дэвидом Уайзом в их статье 1976 г. «Влияние прикладного программирования на многопроцессорность (The Impact of Applicative Programming on Multiprocessing)», но концептуальное поведение промиса было формализовано лишь десятилетие спустя Барбарой Лисков и Любой Шрира в их статье 1988 г. «Промисы: лингвистическая поддержка эффективных асинхронных процедурных вызовов в распределенных системах (Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems)». Современные компьютерные ученые описали похожие понятия, такие как «возможное», «будущее», «задержка» или «отсроченное»; все они описаны в той или иной форме программным инструментом для синхронизации выполнения программы.

Ранние формы промисов появились в jQuery и Dojo Deferred API, а в 2010 г. растущая популярность привела к появлению спецификации Promises/A внутри проекта CommonJS. Сторонние библиотеки промисов JavaScript, такие как Q и Bluebird, продолжали завоевывать популярность, но каждая реализация немного отличалась от предыдущей. Чтобы устранить разногласия в пространстве промисов, в 2012 г. организация Promises/A + разветвила предложение CommonJS Promises/A и создала одноименную спецификацию промисов Promises/A+. Эта спецификация в конечном итоге определила, то как промисы были реализованы в спецификации ECMAScript 6.

ECMAScript 6 представил первоклассную реализацию совместимого с Promise/A+ типа Promise. За время, прошедшее с момента его введения, промисы пользовались невероятно высоким уровнем поддержки. Все современные браузеры полностью поддерживают тип промисов ES6, и несколько API-интерфейсов браузера, таких как fetch() и Battery API, используют исключительно его.

5.1 Основы Promise


Интерфейс Promise (промис) представляет собой обёртку для значения, неизвестного на момент создания Promise. Он позволяет обрабатывать результаты асинхронных операций так, как если бы они были синхронными: вместо конечного результата асинхронного метода возвращается своего рода обещание (дословный перевод слова «промис») получить результат в некоторый момент в будущем.

Начиная с ECMAScript 6, Promise является поддерживаемым ссылочным типом и может быть создан с помощью оператора new.
let p = new Promise(() => {})

console.log(p) // Promise <pending>

При передаче экземпляра Promise в console.log выводы консоли (которые могут
различаться в разных браузерах) указывают, что этот экземпляр Promise находится
в состоянии ожидания (pending).

Промис — это объект с состоянием, который может существовать в одном из трех состояний:
  • в ожидании (Pending) — начальное состояние;
  • выполнен или решенным (Fulfilled) — операция завершена успешно;
  • отклонен (Rejected) — операция завершена с ошибкой.

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

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

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

В некоторых случаях внутренней машиной состояний является вся полезность, которую промис должен предоставить: одного лишь знания о том, что кусок асинхронного кода завершен, достаточно для информирования о ходе выполнения программы. Например, предположим, что промис отправляет HTTP-запрос на сервер. Запрос, возвращающийся со статусом не 200–299, может быть достаточным для перехода состояния обещания в выполненное. Точно так же запрос, возвращающийся со статусом, который не является 200–299, перевел бы состояние промиса в отклоненное.

В других случаях асинхронное выполнение, которое оборачивает промис, фактически генерирует значение, и поток программы будет ожидать, что это значение будет доступно, когда промис изменит состояние. С другой стороны, если промис отклоняется, поток программы ожидает причину отклонения после изменения состояния промиса. Например, предположим, что промис отправляет HTTP-запрос на сервер и ожидает его возврата в формате JSON. Запрос, возвращающийся со статусом 200–299, может быть достаточным для перевода промиса в выполненное состояние, и JSON-строка будет доступна внутри промиса. Точно так же запрос, возвращаемый со статусом, который не является 200–299, перевел бы состояние промиса в отклоненное, и причиной отклонения может быть объект Error, содержащий текст, сопровождающий HTTP-код статуса.

Для поддержки этих двух вариантов использования каждый промис, который переходит в выполненное состояние, имеет закрытое внутреннее значение. Точно так же каждый промис, который переходит в отклоненное состояние, имеет закрытую внутреннюю причину. И значение, и причина являются неизменной ссылкой на примитив или объект. Оба являются необязательными и по умолчанию будут иметь значение undefined. Асинхронный код, который планируется выполнить после того, как промис достигает определенного установленного состояния, всегда снабжается значением или причиной.

Поскольку состояние промиса является закрытым, им можно манипулировать только изнутри. Эта внутренняя манипуляция выполняется внутри функции-исполнителя промиса. Функция-исполнитель выполняет две основные обязанности: инициализирует асинхронное поведение промиса и контролирует любой возможный переход состояния. Управление переходом между состояниями осуществляется путем вызова одного из двух параметров функции, которые обычно называются resolve и reject. Вызов resolve изменит состояние на выполненное; вызов reject изменит состояние на отклоненное. Вызов rejected() также сгенерирует ошибку.
let p1 = new Promise((resolve, reject) => resolve())
setTimeout(() => console.log(p1), 0) // Promise {<fulfilled>: undefined}
// [[PromiseState]]: "fulfilled"
// [[PromiseResult]]: undefined


let p2 = new Promise((resolve, reject) => reject())
setTimeout(() => console.log(p2), 0) // Promise {<rejected>: undefined}
// [[PromiseState]]: "rejected"
// [[PromiseResult]]: undefined
// Uncaught error (in promise)

В этом примере асинхронное поведение на самом деле не происходит, потому что состояние каждого промиса уже изменяется к моменту выхода из функции-исполнителя. Сама функция-исполнитель будет выполняться синхронно, так как она действует как инициализатор для промиса.
let p = new Promise((resolve, reject) => {
 resolve()
 reject() // Безрезультатно
})

setTimeout(() => console.log(p), 0) // Promise {<fulfilled>: undefined}

Вы можете избежать зависания промиса в состоянии ожидания, добавив запланированное поведение выхода. Например, можно установить тайм-аут, чтобы отклонить промис через 10 секунд:
let p = new Promise((resolve, reject) => {
  setTimeout(reject, 100) // Вызов reject() спустя 1 секунду
  // Код исполнителя
 })
 
 setTimeout(() => console.log(p), 0) // Promise <pending>
 setTimeout(() => console.log(p), 200) // Проверка состояния спустя 2 секунды

 // (Спустя 1 секунду) Uncaught error
 // (Спустя 2 секунды) Promise <rejected>

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

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

Следующие два экземпляра промисов фактически эквивалентны:
let p1 = new Promise((resolve, reject) => resolve())
let p2 = Promise.resolve()

Значение этого разрешенного промиса станет первым аргументом, переданным Promise.resolve. Таким образом можно эффективно «преобразовать» любое значение в промис:
const p1 = Promise.resolve()
console.info(p1) // Promise <resolved>: undefined

const p2 = Promise.resolve(3)
console.info(p2) // Promise <resolved>: 3

// Дополнительные аргументы игнорируются
const p3 = Promise.resolve(4, 5, 6)
console.info(p3) // Promise <resolved>: 4

Возможно, наиболее важным аспектом этого статического метода является его способность действовать как переход, когда аргумент уже является промисом. В результате Promise.resolve является идемпотентным методом:
const p = Promise.resolve(7)
console.log(p === Promise.resolve(p)) // true
console.log(p === Promise.resolve(Promise.resolve(p))) // true

Эта идемпотентность будет учитывать состояние промиса, переданного ему.
let p = new Promise(() => {})

console.log(p) // Promise <pending>
console.log(Promise.resolve(p)) // Promise <pending>
console.log(p === Promise.resolve(p)) // true

Этот статический метод с радостью обернет любой не-промис, включая объект ошибки, как разрешенный промис, что может привести к непреднамеренному поведению.
let p = Promise.resolve(new Error('foo'))

console.log(p)
// Promise <resolved>: Error: foo

В принципе, аналогично Promise.resolve, Promise.reject создает отклоненный промис и генерирует асинхронную ошибку (которая не будет перехвачена try/catch и может быть перехвачена только обработчиком отклонения).

Следующие два экземпляра промисов фактически эквивалентны.
let p1 = new Promise((resolve, reject) => reject())
let p2 = Promise.reject()

Поле «причины» этого разрешенного промиса будет первым аргументом, переданным Promise.reject. Оно также будет ошибкой, переданной обработчику отклонения:
let p = Promise.reject(3)

console.log(p) // Promise <rejected>: 3
p.then(null, e => console.log(e)) // 3

Promise.reject не отражает поведение Promise.resolve в отношении идемпотентности. Если объект промиса передан, он с радостью использует этот промис в качестве поля «причины» отклоненного промиса:
console.log(Promise.reject(Promise.resolve()))
// Promise <rejected>: Promise <resolved>

Большая часть конструкции Promise заключается в создании совершенно отдельного режима вычислений в JavaScript. Это аккуратно инкапсулировано в следующем примере, который выдает ошибки двумя различными способами:
try {
  throw new Error('foo')
} 
catch(e) {
  console.log(e) // Error: foo
}
try {
  Promise.reject(new Error('bar'))
} 
catch(e) {
  console.log(e) // Uncaught (in promise) Error: bar
}

Первый блок try/catch генерирует ошибку и затем перехватывает ее, но второй блок try/catch генерирует ошибку, которая не перехватывается. Это может показаться нелогичным, поскольку кажется, что код синхронно создает отклоненный экземпляр промиса, который затем выдает ошибку при отклонении. Однако причина, по которой второй промис не был перехвачен, заключается в том, что код не пытается перехватить ошибку в соответствующем «асинхронном режиме». Такое поведение подчеркивает, как на самом деле ведут себя промисы: это синхронные объекты, используемые в синхронном режиме выполнения, выступающие в качестве моста к асинхронному режиму выполнения.

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

5.2 Методы экземпляра Promise


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

Для целей асинхронных конструкций ECMAScript считается, что любой объект, который предоставляет метод then(), реализует интерфейс Thenable.
class MyThenable {
  then () {}
}

Тип Promise в ECMAScript реализует интерфейс Thenable. Этот упрощенный интерфейс не следует путать с другими интерфейсами или определениями типов в таких пакетах, как TypeScript, которые представляют собой гораздо более конкретную форму интерфейса Thenable.

5.2.1 Promise.prototype.then


Метод Promise.prototype.then() является основным методом, который используется для подключения обработчиков к экземпляру промиса. Метод then() принимает до двух аргументов: необязательную функцию-обработчик onResolved и необязательную функцию-обработчик onRejected. Каждая из них будет выполнена только тогда, когда промис, по которому они определены, достигнет соответствующего состояния «выполнен» или «отклонен».
function onResolved (id) {
  console.log('resolved')
}

function onRejected (id) {
  console.log('rejected')
}

let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000))
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000))

p1.then(() => onResolved('p1'), () => onRejected('p1')) // p1 resolved
p2.then(() => onResolved('p2'), () => onRejected('p2')) // p2 rejected

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

Оба аргумента обработчика являются полностью необязательными. Любой нефункциональный тип, предоставленный в качестве аргумента then, будет игнорироваться. Если нужно явно передать только обработчик onRejected, каноническим выбором будет передача undefined в качестве аргумента onResolved.

Это позволит избежать создания временного объекта в памяти, который будет игнорироваться интерпретатором, и также порадует системы, которые ожидают наличие необязательного функционального объекта в качестве аргумента.
function onResolved (id) {
  console.log('resolved')
}

function onRejected (id) {
  console.log('rejected')
}

let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000))
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000))

// Нефункциональные обработчики игнорируются, и их использование не рекомендуется
p1.then('gobbeltygook')

// Каноническая форма явного пропуска обработчика onResolved
p2.then(null, () => onRejected('p2'))
// p2 rejected (спустя 3 с)

Метод Promise.prototype.then возвращает новый экземпляр промиса:
let p1 = new Promise(() => {})
let p2 = p1.then()

console.log(p1) // Promise <pending>
console.log(p2) // Promise <pending>
console.log(p1 === p2) // false

Этот новый экземпляр промиса получен из возвращаемого значения обработчика onResolved. Возвращаемое значение обработчика помещается в Promise.resolve() для генерации нового промиса. Если никакой функции-обработчика не предоставлено, метод действует как проход для разрешенного значения исходного промиса.

Если явного возвращаемого выражения нет, возвращаемое значение записано как undefined и обернуто в Promise.resolve().
let p1 = Promise.resolve('foo')

// Вызов then() без функций-обработчиков сработает как проход
let p2 = p1.then()
console.log(p2) // Promise <resolved>: foo

// Эти варианты эквивалентны
let p3 = p1.then(() => undefined)
let p4 = p1.then(() => {})
let p5 = p1.then(() => Promise.resolve())
console.log(p3) // Promise <resolved>: undefined
console.log(p4) // Promise <resolved>: undefined
console.log(p5) // Promise <resolved>: undefined

// Явные возвращаемые значения обернуты в Promise.resolve():
// Эти варианты эквивалентны:
let p6 = p1.then(() => 'bar')
let p7 = p1.then(() => Promise.resolve('bar'))
console.log(p6) // Promise <resolved>: bar
console.log(p7) // Promise <resolved>: bar

// Promise.resolve() сохраняет возвращенный промис
let p8 = p1.then(() => new Promise(() => {}))
let p9 = p1.then(() => Promise.reject())
// Uncaught (in promise): undefined

console.log(p8) // Promise <pending>
console.log(p9) // Promise <rejected>: undefined

// Генерация ошибки вернет отклоненный промис:
let p10 = p1.then(() => { throw new Error('baz') })
// Uncaught (in promise) baz

console.log(p10) // Promise <rejected> baz

// Возвращение ошибки не приведет к тому же поведению отклонения,
// а вместо этого обернет объект ошибки в разрешенный промис:
let p11 = p1.then(() => Error('qux'))
console.log(p11) // Promise <resolved>: Error: qux

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

5.2.2 Promise.prototype.catch


Метод Promise.prototype.catch() можно использовать для присоединения только обработчика отклонения к промису. Он принимает только один аргумент — функцию-обработчик onRejected. Метод является не более чем синтаксическим сахаром и ничем не отличается от использования Promise.prototype.then (null, onRejected)
let p = Promise.reject()

let onRejected = function () {
  console.log('rejected')
}

// Эти обработчики отклонений эквивалентны:
p.then(null, onRejected) // rejected
p.catch(onRejected) // rejected

let p1 = new Promise(() => {})
let p2 = p1.catch()

setTimeout(console.log, 0, p1) // Promise <pending>
setTimeout(console.log, 0, p2) // Promise <pending>
setTimeout(console.log, 0, p1 === p2) // false

Что касается создания нового экземпляра промиса, Promise.prototype.catch() ведет себя идентично обработчику onRejected для Promise.prototype.then().

5.2.3 Promise.prototype.finally


Метод Promise.protoype.finally() можно использовать для подключения обработчика onFinally, который выполняется, когда промис достигает разрешенного или отклоненного состояния. Это полезно для избежания дублирования кода между обработчиками onResolved и onRejected. Важно отметить, что обработчик не имеет никакого способа определить, был ли промис разрешен или отклонен, поэтому этот метод предназначен только для вещей вроде очистки памяти.
let p1 = Promise.resolve()
let p2 = Promise.reject()

let onFinally = function () {
  console.log('Finally!')
}

p1.finally(onFinally) // Finally
p2.finally(onFinally) // Finally

Метод Promise.prototype.finally() возвращает новый экземпляр промиса.
let p1 = new Promise(() => {})
let p2 = p1.finally()

console.log(p1) // Promise <pending>
console.log(p2) // Promise <pending>
console.log(p1 === p2) // false

Этот новый экземпляр промиса получен не так, как из then() или catch(). Поскольку onFinally предназначен для использования, независимого от состояния метода, в большинстве случаев он будет действовать как проход для родительского промиса. Это верно как для разрешенных, так и отклоненных состояний. Единственные исключения из этого правила — когда он возвращает ожидающий промис или выдает ошибку (через явную генерацию или возврат отклоненного промиса). В этих случаях соответствующий промис возвращается (ожидающий или отклоненный).
let p1 = Promise.resolve('foo')

// Все это сработает как проход
let p2 = p1.finally()
let p3 = p1.finally(() => undefined)
let p4 = p1.finally(() => {})
let p5 = p1.finally(() => Promise.resolve())
let p6 = p1.finally(() => 'bar')
let p7 = p1.finally(() => Promise.resolve('bar'))
let p8 = p1.finally(() => Error('qux'))

setImmediate(() => console.log(p2)) // Promise <resolved>: foo
setImmediate(() => console.log(p3)) // Promise <resolved>: foo
setImmediate(() => console.log(p4)) // Promise <resolved>: foo
setImmediate(() => console.log(p5)) // Promise <resolved>: foo
setImmediate(() => console.log(p6)) // Promise <resolved>: foo
setImmediate(() => console.log(p7)) // Promise <resolved>: foo
setImmediate(() => console.log(p8)) // Promise <resolved>: foo

// Promise.resolve() сохраняет возвращенный промис
let p9 = p1.finally(() => new Promise(() => {}))
let p10 = p1.finally(() => Promise.reject()) // Uncaught (in promise): undefined

setImmediate(() => console.log(p9)) // Promise <pending>
setImmediate(() => console.log(p10)) // Promise <rejected>: undefined

let p11 = p1.finally(() => { throw new Error('baz') })
// Uncaught (in promise) baz

setImmediate(() => console.log(p11)) // Promise <rejected>: baz

Возврат ожидающего промиса — необычный случай, так как после разрешения промиса новый промис все равно будет действовать как проход для первоначального.
let p1 = Promise.resolve('foo')

// Возвращаемое значение игнорируется
let p2 = p1.finally(() => new Promise((resolve, reject) => setImmediate(() => resolve('bar'))))

setImmediate(() => console.log(p2)) // Promise <pending>
setImmediate(() => setImmediate(() => (console.log(p2)))) // Promise <resolved>: foo

Когда промис достигает установленного состояния, выполнение обработчиков, связанных с этим состоянием, просто будет запланировано, а не выполнено немедленно. Синхронный код, следующий за вложением обработчика, гарантированно будет выполнен до его вызова. Это остается верным, даже если промис уже существует в состоянии, с которым связан новый присоединенный обработчик. Это свойство, называемое невозвратностью, гарантируется средой выполнения JavaScript.
// Создание разрешенного промиса
let p = Promise.resolve()

// Добавление обработчика разрешенного состояния.
// Интуитивно понятно, что это будет выполнено как можно скорее,
// поскольку p уже разрешен.
p.then(() => console.log('onResolved handler'))

// Синхронная запись, указывающая, что then() вернулся
console.log('then() returns')
// Действительный вывод:
// then() returns
// onResolved handler

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

Обратное поведение в этом сценарии дает тот же результат. Если обработчики уже присоединены к промису, который позже синхронно поменяет состояние, выполнение обработчика не повторяется при этом изменении состояния. Следующий пример демонстрирует, как, даже с уже подключенным обработчиком onResolved, синхронный вызов resolve() будет по-прежнему демонстрировать невозвратное поведение:
let synchronousResolve

// Создание промиса и сохранение функции разрешения в локальной переменной.
let p = new Promise((resolve) => {
  synchronousResolve = function () {
    console.log('1: invoking resolve()')
    resolve()
    console.log('2: resolve() returns')
  }
})

p.then(() => console.log('4: then() handler executes'))
synchronousResolve()
console.log('3: synchronousResolve() returns')

// Действительный вывод:
// 1: invoking resolve()
// 2: resolve() returns
// 3: synchronousResolve() returns
// 4: then() handler executes

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

Невозвратность гарантируется как для обработчиков onResolved, так и для onRejected, обработчиков catch() и finally().
let p1 = Promise.resolve()
p1.then(() => console.log('p1.then() onResolved'))
console.log('p1.then() returns')

let p2 = Promise.reject()
p2.then(null, () => console.log('p2.then() onRejected'))
console.log('p2.then() returns')

let p3 = Promise.reject()
p3.catch(() => console.log('p3.catch() onRejected'))
console.log('p3.catch() returns')

let p4 = Promise.resolve()
p4.finally(() => console.log('p4.finally() onFinally'))
console.log('p4.finally() returns')
// p1.then() returns
// p2.then() returns
// p3.catch() returns
// p4.finally() returns
// p1.then() onResolved
// p2.then() onRejected
// p3.catch() onRejected
// p4.finally() onFinally

Если к промису прикреплено несколько обработчиков, когда промис переходит в установленное состояние, связанные обработчики будут выполняться в том порядке, в котором они были присоединены. Это верно для then(), catch() и finally().
let p1 = Promise.resolve()
let p2 = Promise.reject()

p1.then(() => setImmediate(() => console.log(1)))
p1.then(() => setImmediate(() => console.log(2)))
// 1
// 2

p2.then(null, () => setImmediate(() => console.log(3)))
p2.then(null, () => setImmediate(() => console.log(4)))
// 3
// 4

p2.catch(() => setImmediate(() => console.log(5)))
p2.catch(() => setImmediate(() => console.log(6)))
// 5
// 6

p1.finally(() => setImmediate(() => console.log(7)))
p1.finally(() => setImmediate(() => console.log(8)))
// 7
// 8

При достижении установленного состояния промис предоставит свое разрешенное значение (если он был выполнен) или причину отклонения (если он был отклонен) любым обработчикам, которые присоединены к этому состоянию. Это особенно полезно в тех случаях, когда требуются последовательные блоки вычислений. Например, если для выполнения второго сетевого запроса требуется ответ JSON на первый сетевой запрос, ответ от первого запроса может быть передан в качестве разрешенного значения обработчику onResolved. С другой стороны, неудачный сетевой запрос может передать код состояния HTTP обработчику onRejected.

Разрешенные значения и причины отклонения присваиваются исполнителю в качестве первого аргумента функций resolve() или reject(). Эти значения предоставляются их соответствующим обработчикам onResolved или onRejected в качестве единственного параметра.
let p1 = new Promise((resolve, reject) => resolve('foo'))
p1.then(value => console.log(value)) // foo

let p2 = new Promise((resolve, reject) => reject('bar'))
p2.catch(reason => console.log(reason)) // bar

Promise.resolve() и Promise.reject() принимают аргумент значения/причины при вызове статического метода. Обработчикам onResolved и onRejected предоставляются значение или причина так же, как если бы они были переданы от исполнителя.
let p1 = Promise.resolve('foo')
p1.then(value => console.log(value)) // foo

let p2 = Promise.reject('bar')
p2.catch(reason => console.log(reason)) // bar

Отклонение промиса аналогично выражению throw в том смысле, что оба они представляют состояние программы, которое должно вызвать прекращение любых последующих операций. Сгенерировав ошибку в исполнителе или обработчике промиса, вы отклоните его; соответствующий объект ошибки будет причиной отклонения.
let p1 = new Promise((resolve, reject) => reject(Error('foo')))
let p2 = new Promise((resolve, reject) => { throw Error('foo') })
let p3 = Promise.resolve().then(() => { throw Error('foo') })
let p4 = Promise.reject(Error('foo'))

setImmediate(() => console.log(p1)) // Promise <rejected>: Error: foo
setImmediate(() => console.log(p2)) // Promise <rejected>: Error: foo
setImmediate(() => console.log(p3)) // Promise <rejected>: Error: foo
setImmediate(() => console.log(p4)) // Promise <rejected>: Error: foo
// Также были сгенерированы четыре необрабатываемые ошибки

Промисы могут быть отклонены с любым значением, включая undefined, но настоятельно рекомендуется постоянно использовать объект ошибки. Основной причиной является то, что создание объекта ошибки позволяет браузеру захватывать трассировку стека внутри объекта ошибки, что очень полезно при отладке. Например, трассировка стека для трех ошибок в предыдущем коде должна выглядеть примерно так:
Uncaught (in promise) Error: foo
at Promise (test.html:5)
at new Promise (anonymous)
at test.html:5
Uncaught (in promise) Error: foo
at Promise (test.html:6)
at new Promise (anonymous)
at test.html:6
Uncaught (in promise) Error: foo
at test.html:8
Uncaught (in promise) Error: foo
at Promise.resolve.then (test.html:7)

Все ошибки генерируются асинхронно и не обрабатываются, а трассировка стека, захваченная объектами ошибок, показывает путь, по которому прошел объект ошибки. Также обратите внимание на порядок ошибок: Promise.resolve().then() ошибка генерируется последней — она требует дополнительной записи в очереди сообщений среды выполнения, потому что создает один дополнительный промис до того, как в конечном итоге выдает необрабатываемую ошибку.

Этот пример также раскрывает интересный побочный эффект асинхронных ошибок. Обычно при выдаче ошибки с использованием ключевого слова throw поведение JavaScript во время выполнения будет отклоняться, чтобы выполнить любые инструкции, следующие за выданной ошибкой.
throw Error('foo')
console.log('bar') // Этот код никогда не выполнится
// Uncaught Error: foo

И даже сама IDE как бы намекает:
image
Тем не менее, когда ошибка появляется в промисе, поскольку ошибка фактически генерируется асинхронно из очереди сообщений, это не помешает среде выполнения продолжить выполнение синхронных инструкций.
Promise.reject(Error('foo'))
console.log('bar')
// bar
// Uncaught (in promise) Error: foo

Асинхронная ошибка может быть обнаружена только с помощью асинхронного обработчика onRejection.
// Правильно
Promise.reject(Error('foo')).catch((e) => {})

// Неправильно
try {
  Promise.reject(Error('foo'))
} catch(e) {}

Это не относится к перехвату ошибки, пока она находится внутри исполнителя, где try/catch все равно будет достаточно, чтобы перехватить ошибку, прежде чем она отклонит промис.
let p = new Promise((resolve, reject) => {
  try {
    throw Error('foo')
  } catch(e) {}

  resolve('bar')
})
setImmediate(() => console.log(p)) // Promise <resolved>: bar

Обработчик onRejected для then() и catch() аналогичен семантике try/catch в том, что перехват ошибки должен эффективно ее нейтрализовать и позволить продолжить нормальные вычисления.

Следовательно, нужно иметь в виду, что обработчик onRejected, которому поручено отлавливать асинхронную ошибку, на самом деле возвращает разрешенный промис. Синхронное/асинхронное сравнение демонстрируется в следующем примере:
console.log('begin synchronous execution')

try {
  throw Error('foo')
} catch(e) {
  console.log('caught error', e)
}

console.log('continue synchronous execution')
// begin synchronous execution
// caught error Error: foo
// continue synchronous execution

new Promise((resolve, reject) => {
  console.log('begin asynchronous execution')
  reject(Error('bar'))
}).catch((e) => {
  console.log('caught error', e)
}).then(() => {
  console.log('continue asynchronous execution')
})
// begin asynchronous execution
// caught error Error: bar
// continue asynchronous execution


5.3 Композиция и цепочки промисов


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

Одним из наиболее полезных аспектов промисов в ECMAScript является их способность быть строго упорядоченными. Это добавляется через структуру Promise API: каждый из методов экземпляра промиса — then(), catch() и finally() — возвращает отдельный экземпляр промиса, у которого, в свою очередь, может быть вызван другой метод экземпляра. Последовательный вызов методов таким способом называется «цепочкой промисов».
let p = new Promise((resolve) => {
  console.log('first')
  resolve()
})

p.then(() => console.log('second'))
  .then(() => console.log('third'))
  .then(() => console.log('fourth'))
// first
// second
// third
// fourth

Эта реализация в конечном итоге выполняет цепные синхронные задачи. Из-за этого выполненная работа не очень полезна или интересна, потому что она примерно такая же, как и последовательный вызов четырех функций по очереди.
(() => console.log('first'))();
(() => console.log('second'))();
(() => console.log('third'))();
(() => console.log('fourth'))()

Чтобы связать асинхронные задачи, этот пример можно перенастроить, чтобы каждый исполнитель возвращал экземпляр промиса. Поскольку каждый последующий промис будет ожидать разрешения своего предшественника, такую стратегию можно использовать для сериализации асинхронных задач. Например, это можно использовать для последовательного выполнения нескольких промисов, которые разрешаются после истечения времени ожидания.
let p1 = new Promise((resolve) => {
  console.log('p1 executor')
  setTimeout(resolve, 1000)
})

p1.then(() => new Promise((resolve) => {
  console.log('p2 executor')
  setTimeout(resolve, 1000)
}))
  .then(() => new Promise((resolve) => {
    console.log('p3 executor')
    setTimeout(resolve, 1000)
  }))
  .then(() => new Promise((resolve) => {
    console.log('p4 executor')
    setTimeout(resolve, 1000)
  }))
// p1 executor (спустя 1 с)
// p2 executor (спустя 2 с)
// p3 executor (спустя 3 с)
// p4 executor (спустя 4 с)

Объединение генерации промисов в одну фабричную функцию делает следующее:
function delayedResolve (str) {
  return new Promise((resolve) => {
    console.log(str)
    setTimeout(resolve, 1000)
  })
}

delayedResolve('p1 executor')
  .then(() => delayedResolve('p2 executor'))
  .then(() => delayedResolve('p3 executor'))
  .then(() => delayedResolve('p4 executor'))
// p1 executor (спустя 1 с)
// p2 executor (спустя 2 с)
// p3 executor (спустя 3 с)
// p4 executor (спустя 4 с)

Каждый последующий обработчик ожидает разрешения своего предшественника, создает новый экземпляр промиса и возвращает его. Такая структура способна аккуратно сериализовать асинхронный код без принудительного использования обратных вызовов. Без использования промисов предыдущий код будет выглядеть примерно так:
function delayedExecute (str, callback = null) {
  setTimeout(() => {
    console.log(str)
    callback && callback()
  }, 1000)
}
delayedExecute('p1 callback', () => {
  delayedExecute('p2 callback', () => {
    delayedExecute('p3 callback', () => {
      delayedExecute('p4 callback')
    })
  })
})
// p1 callback (спустя 1 с)
// p2 callback (спустя 2 с)
// p3 callback (спустя 3 с)
// p4 callback (спустя 4 с)

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

Поскольку then(), catch() и finally() возвращают промис, объединить их в цепочку просто. Следующий пример включает в себя все три метода:
let p = new Promise((resolve, reject) => {
  console.log('initial promise rejects')
  reject()
})

p.catch(() => console.log('reject handler'))
  .then(() => console.log('resolve handler'))
  .finally(() => console.log('finally handler'))
// initial promise rejects
// reject handler
// resolve handler
// finally handler


5.3.1 Графы промисов


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

Пример одного возможного типа ориентированного графа промисов — бинарного дерева:
//     A
//    / \
//   B   C
//  / \ / \
// D  E F  G
let A = new Promise((resolve, reject) => {
  console.log('A')
  resolve()
})

let B = A.then(() => console.log('B'))
let C = A.then(() => console.log('C'))

B.then(() => console.log('D'))
B.then(() => console.log('E'))
C.then(() => console.log('F'))
C.then(() => console.log('G'))
// A
// B
// C
// D
// E
// F
// G

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

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

5.3.2 Параллельная композиция промисов с Promise.all и Promise.race


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

Статический метод Promise.all() создает промис с принципом «все или ничего», который разрешается только один раз, когда разрешаются все промисы в коллекции. Статический метод принимает итерируемое значение и возвращает новый промис:
let p1 = Promise.all([
  Promise.resolve(),
  Promise.resolve(),
])

// Элементы в итерируемом параметре приводятся к промису
// с использованием Promise.resolve()
let p2 = Promise.all([3, 4])

// Пустой итерируемый параметр эквивалентен to Promise.resolve()
let p3 = Promise.all([])

// Неверный синтаксис
let p4 = Promise.all()
// TypeError: cannot read Symbol.iterator of undefined

// Составной промис разрешается только после разрешения каждого содержащегося в нем промиса:
let p = Promise.all([
  Promise.resolve(),
  new Promise(resolve => setTimeout(resolve, 1000)),
])

setImmediate(() => console.log(p)) // Promise <pending>

p.then(() => setImmediate(console.log('all() resolved!')))
// all() resolved! (Спустя ~1000 мс)

Если хотя бы один промис из коллекции остается в ожидании, составной промис также останется в ожидании. Если один промис из коллекции отклоняется, отклоняется составной промис:
// Навсегда останется в ожидании
let p1 = Promise.all([new Promise(() => {})])

setImmediate(() => console.log(p1)) // Promise <pending>

// Единственное отклонение вызывает отклонение составного промиса
let p2 = Promise.all([
  Promise.resolve(),
  Promise.reject(),
  Promise.resolve(),
])

setImmediate(() => console.log(p2)) // Promise <rejected>
// Uncaught (in promise) undefined

Если все промисы успешно разрешены, разрешенное значение составного промиса будет массивом всех разрешенных значений содержащихся промисов в порядке обхода:
let p1 = new Promise(resolve => resolve(1))
let p2 = new Promise(resolve => resolve(2))
let p3 = new Promise(resolve => resolve(3))

let all = Promise.all([p1, p2, p3])

all.then(e => console.info(e)) // [ 1, 2, 3 ]

Если один из промисов отклоняется, то тот, что будет отклонен первым, устанавливает причину отклонения составного промиса. Последующие отклонения не влияют на причину отклонения; однако на нормальное поведение отклонения этих содержащихся в нем экземпляров промисов это не влияет. Важно, что составной промис будет просто обрабатывать отклонение всех содержащихся промисов.
let p1 = new Promise(resolve => resolve(1))
let p2 = new Promise((resolve, reject) => reject(2))
let p3 = new Promise((resolve, reject) => reject(3))

let all = Promise.all([p1, p2, p3])

all.catch(e => console.info(e)) // 2


Метод Promise.race(iterable) возвращает выполненный или отклонённый промис, в зависимости от того, с каким результатом завершится первый из переданных промисов, со значением или причиной отклонения этого промиса.

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

Статический метод Promise.race() создает промис, который будет отражать то, что промис в коллекции сначала достигает разрешенного или отклоненного состояния. Статический метод принимает итерируемый параметр и возвращает новый промис:
let p1 = Promise.race([
  Promise.resolve(),
  Promise.resolve(),
])

// Элементы в итерируемом параметре приводятся к промисам
// с использованием Promise.resolve()
let p2 = Promise.race([3, 4])

// Пустой итерируемый объект эквивалентен new Promise(() => {})
let p3 = Promise.race([])

// Неверный синтаксис
let p4 = Promise.race()
// TypeError: cannot read Symbol.iterator of undefined

Метод Promise.race() не предоставляет предпочтения разрешенному или отклоненному промису. Составной промис будет проходить через статус и значение/причину первого установленного промиса.
// Сначала происходит разрешение, отклонение по тайм-ауту игнорируется
let p1 = Promise.race([
  Promise.resolve(3),
  new Promise((resolve, reject) => reject()),
])
console.log(p1) // Promise <resolved>: 3

// Сначала происходит отклонение, разрешение по тайм-ауту игнорируется
let p2 = Promise.race([
  Promise.reject(4),
  new Promise((resolve, reject) => resolve()),
])
console.log(p2) // Promise <rejected>: 4

// Порядок обхода — значение для расчета
let p3 = Promise.race([
  Promise.resolve(5),
  Promise.resolve(6),
  Promise.resolve(7),
])
console.log(p3) // Promise <resolved>: 5

Если один из промисов отклоняется, то тот, что будет отклонен первым, устанавливает причину отклонения составного промиса. Последующие отклонения не влияют на причину отклонения; однако на нормальное поведение отклонения этих содержащихся в нем экземпляров промисов это не влияет. Как и в случае с Promise.all(), составной промис будет просто обрабатывать отклонение всех содержащихся промисов.
// Хотя только первая причина отклонения будет указана в
// обработчике отклонений, вторая причина отклонения будет просто
// обработана без ошибок
let p = Promise.race([
  Promise.reject(3),
  new Promise((resolve, reject) => setImmediate(reject)),
])

p.catch(reason => setImmediate(() => console.log(reason))) // 3
// Нет необработанных ошибок


5.3.3 Серийная композиция промисов


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

Цепочка промисов вместе с намерением каждого последующего промиса, использующего значение своего предшественника, является фундаментальной особенностью промисов. Это во многом аналогично композиции функций, где несколько функций объединены в новую функцию.
function addTwo (x) { return x + 2 }
function addThree (x) { return x + 3 }
function addFive (x) { return x + 5 }
function addTen (x) {
  return addFive(addTwo(addThree(x)))
}

console.log(addTen(7)) // 17

В этом примере композиция функций используется для объединения этих трех функций в одну для работы с одним значением. Точно так же промисы могут быть составлены вместе, чтобы постепенно потреблять значение и производить один промис, содержащий результат.
function addTwo (x) { return x + 2 }
function addThree (x) { return x + 3 }
function addFive (x) { return x + 5 }
function addTen (x) {
  return Promise.resolve(x)
    .then(addTwo)
    .then(addThree)
    .then(addFive)
}

addTen(8).then(console.log) // 18

Это можно преобразовать в более лаконичную форму с помощью Array.prototype.reduce()
function addTwo (x) { return x + 2 }
function addThree (x) { return x + 3 }
function addFive (x) { return x + 5 }
function addTen (x) {
  return [addTwo, addThree, addFive]
    .reduce((promise, fn) => promise.then(fn), Promise.resolve(x))
}

addTen(8).then(console.log) // 18

Такая стратегия композиции промисов может быть обобщена в функцию, которая объединяет любое количество функций в цепочку промисов с передачей значений.
function addTwo (x) { return x + 2 }
function addThree (x) { return x + 3 }
function addFive (x) { return x + 5 }
let addTen = compose(addTwo, addThree, addFive)

addTen(8).then(console.log) // 18

function compose (...fns) {
  return x => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x))
}


6. Асинхронные функции


Асинхронные функции, также называемые парой оперативных ключевых слов async/await, являются применением парадигмы ES6 Promise к функциям ECMAScript. Поддержка async/await была представлена в спецификации ES7. Это и поведенческое, и синтаксическое усовершенствование спецификации, которое допускает код JavaScript. Он написан синхронно, но на самом деле способен вести себя асинхронно. Простейший пример этого начинается с простого промиса, который разрешается значением после тайм-аута:
new Promise((resolve, reject) => setTimeout(() => resolve(10), 1000))

Этот промис будет разрешен со значением 10 спустя 1000 мс. Чтобы другие части программы могли получить доступ к этому значению, как только оно будет готово, оно должно существовать внутри обработчика разрешения:
let p = new Promise((resolve, reject) => setTimeout(() => resolve(10), 1000))

p.then(console.info) // 10

Это довольно неудобно, так как теперь остальная часть программы должна быть добавлена в обработчик промиса. Можно переместить обработчик в определение функции:
function handler (x) {
  console.log(x)
}

let p = new Promise((resolve, reject) => setTimeout(() => resolve(10), 1000))

p.then(handler) // 10

Это ненамного исправило ситуацию. Но факт остается фактом: любой последующий код, который хочет получить доступ к значению, созданному промисом, должен быть передан этому значению через обработчик. Это означает, что его помещают в функцию-обработчик. ES7 предлагает async/await в качестве элегантного решения этой проблемы.

Асинхронную функцию можно объявить, добавив ключевое слово async. Это ключевое слово может использоваться в объявлениях функций, функциях-выражениях, стрелочных функциях и методах:
async function foo () {}

let bar = async function () {}

let baz = async () => {}

class Qux {
  async qux () {}
}

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

Рассмотрим этот простой пример, показывающий, что функция foo() все еще вычисляется, прежде чем перейти к последующим инструкциям:
async function foo () {
  console.log(1)
}
foo()

console.log(2)
// 1
// 2

В асинхронной функции любое значение, явно возвращаемое с ключевым словом return, будет эффективно преобразовано в объект промиса с помощью Promise.resolve(). Асинхронная функция всегда будет возвращать объект промиса. Вне функции вычисляемая функция будет объектом промиса:
async function foo () {
  console.log(1)

  return 3
}

// Добавление обработчика разрешения к возвращаемому промису
foo().then(console.log)

console.log(2)
// 1
// 2
// 3

Конечно, это означает, что возвращение объекта промиса покажет идентичное поведение:
async function foo () {
  console.log(1)

  return Promise.resolve(3)
}

// Добавление обработчика разрешения к возвращаемому промису
foo().then(console.log)

console.log(2)
// 1
// 2
// 3

Возвращаемое значение асинхронной функции ожидает, но на самом деле не требует доступного объекта: оно также будет работать с обычными значениями. Объект с возможностью последующего использования будет развернут через первый аргумент, предоставленный обратному вызову then(). Объект, недоступный для последующего просмотра, будет пропущен, как если бы это был уже разрешенный промис.
// Возврат примитива
async function foo () {
  return 'foo'
}
foo().then(console.log) // foo

// Возврат объекта, недоступного для последующего просмотра
async function bar () {
  return ['bar']
}
bar().then(console.log) // ['bar']

// Возврат доступного для последующего просмотра объекта (не промиса)
async function baz () {
  const thenable = {
    then (callback) { callback('baz') },
  }
  return thenable
}
baz().then(console.log) // baz

// Возврат промиса
async function qux () {
  return Promise.resolve('qux')
}
qux().then(console.log) // qux

Как и в случае с функциями обработчика промисов, выдача значения ошибки будет возвращать отклоненный промис.
async function foo () {
  console.log(1)
  throw new Error(3)
}

// Прикрепление обработчика отклонения к возвращаемому промису
foo().catch(console.log)
console.log(2)
// 1
// 2
// Error: 3

Однако ошибки отклонения промисов не будут регистрироваться асинхронной функцией:
async function foo () {
  console.log(1)
  Promise.reject(3)
}

// Прикрепление обработчика отклонения к возвращаемому промису
foo().catch(console.log)
console.log(2)
// 1
// 2
// Uncaught (in promise): 3

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

Рассмотрим пример из начала:
function handler (x) {
  console.log(x)
}

let p = new Promise((resolve, reject) => setTimeout(() => resolve(10), 1000))

p.then(handler) // 10

Его можно переписать с помощью async/await следующим образом:
async function foo () {
  let p = new Promise((resolve, reject) => setTimeout(() => resolve(10), 1000))

  console.log(await p)
}
foo() // 3

Ключевое слово await приостанавливает выполнение асинхронной функции, освобождая поток выполнения во время выполнения JavaScript. Это поведение мало чем отличается от ключевого слова yield в функции генератора. Ключевое слово await попытается «развернуть» значение объекта, передать значение в выражение и асинхронно возобновить выполнение асинхронной функции.

Ключевое слово await используется так же, как унарный оператор JavaScript. Его можно использовать отдельно или внутри выражения.
// Асинхронно выводит "foo"
async function foo () {
  console.log(await Promise.resolve('foo'))
}
foo() // foo

// Асинхронно выводит "bar"
async function bar () {
  return await Promise.resolve('bar')
}
bar().then(console.log) // bar

// Асинхронно выводит "baz" спустя 1000 мс
async function baz () {
  await new Promise((resolve, reject) => setTimeout(resolve, 1000))
  console.log('baz')
}
baz() // baz <спустся 1000 мс

Ключевое слово await предвосхищает, но на самом деле не требует наличия доступного объекта: оно также будет работать с обычными значениями. Объект с возможностью последующего использования будет «развернут» через первый аргумент, предоставленный обратному вызову then(). Объект, недоступный для последующего просмотра, будет пропущен, как если бы это был уже разрешенный промис.
async function foo () {
  console.log(await 'foo')
}
foo() // foo

// Ожидание объекта, недоступного для просмотра
async function bar () {
  console.log(await ['bar'])
}
bar() // ['bar']

// Ожидание доступного для просмотра объекта (не промиса)
async function baz () {
  const thenable = {
    then (callback) { callback('baz') },
  }
  console.log(await thenable)
}
baz() // baz

// Ожидание промиса
async function qux () {
  console.log(await Promise.resolve('qux'))
}
qux() // qux

Как и в случае с функциями обработчика промисов, выдача ошибки вместо этого возвратит отклоненный промис:
async function foo () {
  console.log(1)
  await (() => { throw new Error(3) })()
}

// Прикрепление обработчика отклонения к возвращаемому промису
foo().catch(console.log)
console.log(2)
// 1
// 2
// Error: 3

Как было показано ранее, автономный Promise.reject() не будет захвачен асинхронной функцией и будет выдаваться как необработанная ошибка. Однако использование await для отклоненного промиса развернет значение ошибки:
async function foo () {
  console.log(1)
  await Promise.reject(3)
  console.log(4) // этот код никогда не выполнится
}

// Прикрепление обработчика отклонения к возвращаемому промису
foo().catch(console.log)
console.log(2)
// 1
// 2
// 3

Ключевое слово await должно использоваться внутри асинхронной функции; его нельзя использовать в контексте верхнего уровня, таком как тег сценария или модуль. Однако ничто не мешает вам немедленно вызывать асинхронную функцию.
async function foo () {
  console.log(await Promise.resolve(1))
}
foo() // 1

// Немедленный вызов асинхронной функции-выражения
void async function () {
  console.log(await Promise.resolve(2))
}() // 2

Кроме того, асинхронная природа функции не распространяется на вложенные функции. Поэтому ключевое слово await также может появляться только внутри определения асинхронной функции; попытка использовать await внутри синхронной функции вызовет ошибку SyntaxError.
// Недопустимо: 'await' внутри стрелочной функции
function foo () {
  const syncFn = () => {
    return await Promise.resolve('foo')
  }
  console.log(syncFn())
}

// Недопустимо: 'await' внутри объявления функции
function bar () {
  function syncFn () {
    return await Promise.resolve('bar')
  }
  console.log(syncFn())

  // Недопустимо: 'await' внутри функции-выражения
  function baz () {
    const syncFn = function () {
      return await Promise.resolve('baz')
    }
    console.log(syncFn())
  }
}

  // Недопустимо: использование функции-выражения или стрелочной функции с IIFE
function qux () {
  (function () { console.log(await Promise.resolve('qux')) })();
  (() => console.log(await Promise.resolve('qux')))()
}


6.1 Остановка и возобновление выполнения


Истинная природа использования ключевого слова await более тонка, чем может показаться на первый взгляд. Рассмотрим следующий пример, где три функции вызываются по порядку, но их выходные данные печатаются в обратном порядке:
async function foo () {
  console.log(await Promise.resolve('foo'))
}

async function bar () {
  console.log(await 'bar')
}

async function baz () {
  console.log('baz')
}

foo()
bar()
baz()
// baz
// foo
// bar

В парадигме async/await ключевое слово await выполняет всю тяжелую работу. Ключевое слово async во многих отношениях является просто специальным индикатором для интерпретатора JavaScript. В конце концов, асинхронная функция, которая не содержит ключевое слово await, выполняется так же, как обычная функция.
async function foo () {
  console.log(2)
}

console.log(1)
foo()
console.log(3)
// 1
// 2
// 3

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

Следовательно, даже когда await сопряжено с немедленно доступным значением, остальная часть функции все равно будет выполняться асинхронно. Это продемонстрировано в следующем примере:
async function foo () {
  console.log(2)
  await null
  console.log(4)
}

console.log(1)
foo()
console.log(3)
// 1
// 2
// 3
// 4

Порядок вывода на консоль лучше всего объяснить с точки зрения того, как среда выполнения обрабатывает этот пример:
  1. Вывод 1.
  2. Вызов асинхронной функции foo.
  3. (внутри foo) Вывод 2.
  4. (внутри foo) Ключевое слово await приостанавливает выполнение и добавляет задачу в очередь сообщений для немедленно доступного значения null.
  5. foo завершается.
  6. Вывод 3.
  7. Синхронный поток выполнения заканчивается.
  8. Среда выполнения JavaScript удаляет задачу из очереди сообщений, чтобы возобновить выполнение.
  9. 9. (внутри foo) Выполнение возобновляется; await предоставляется со значением null (которое здесь не используется).
  10. 10. (внутри foo) Вывод 4.
  11. 11. Возвращение foo.

Еще один пример, демонстрирующий обработку асинхронного кода (в этот раз с использованием промиса):
async function foo () {
  console.log(2)
  console.log(await Promise.resolve(6))
  console.log(7)
}

async function bar () {
  console.log(4)
  console.log(await 8)
  console.log(9)
}

console.log(1)
foo()
console.log(3)
bar()
console.log(5)
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9


6.2 Стратегии для асинхронных функций


Из-за их удобства и полезности асинхронные функции все чаще повсеместно используются в кодовых базах JavaScript. Тем не менее при использовании асинхронных функций помните о некоторых особенностях.

6.2.1 Реализация Sleep


При первом изучении JavaScript многие разработчики используют конструкцию, аналогичную Thread.sleep() в Java, пытаясь ввести неблокирующую задержку в программу. Раньше это был краеугольный педагогический способ представления, как setTimeout вписывается в поведение JavaScript во время выполнения.

С асинхронными функциями это уже не так! Построить утилиту, которая позволяет функции sleep() — «заснуть» — на миллисекунды, очень просто:
async function sleep(delay) {
  return new Promise((resolve) => setTimeout(resolve, delay))
}

void async function foo() {
  const t0 = Date.now()
  await sleep(1500) // sleep for ~1500ms
  console.log(Date.now() - t0)
}()
// 1502


6.2.2 Максимизация распараллеливания


Если ключевое слово await не используется с осторожностью, программа может упустить возможные ускорения распараллеливания. Рассмотрим следующий пример, который ожидает пять случайных тайм-аутов последовательно:
async function randomDelay(id) {
  // Задержка между 0 и 1000 мс
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    console.log(`${id} finished`)
    resolve()
  }, delay))
}

void async function foo() {
  const t0 = Date.now()
  await randomDelay(0)
  await randomDelay(1)
  await randomDelay(2)
  await randomDelay(3)
  await randomDelay(4)
  console.log(`${Date.now() - t0}ms elapsed`)
}()
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 3341ms elapsed

После свертывания в цикл for получаем следующее:
async function randomDelay(id) {
  // Задержка между 0 и 1000 мс
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    console.log(`${id} finished`)
    resolve()
  }, delay))
}

void async function foo() {
  const t0 = Date.now()
  for (let i = 0; i < 5; ++i) {
    await randomDelay(i)
  }
  console.log(`${Date.now() - t0}ms elapsed`)
}()
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
//  2219ms elapsed

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

Если сохранение порядка не требуется, лучше инициализировать промисы сразу и ожидать результатов по мере их появления.
async function randomDelay(id) {
  // Задержка между 0 и 1000 мс
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    setImmediate(() => console.log(`${id} finished`))
    resolve()
  }, delay))
}

void async function foo() {
  const t0 = Date.now()
  const p0 = randomDelay(0)
  const p1 = randomDelay(1)
  const p2 = randomDelay(2)
  const p3 = randomDelay(3)
  const p4 = randomDelay(4)
  await p0
  await p1
  await p2
  await p3
  await p4

  setImmediate(() => console.log(`${Date.now() - t0}ms elapsed`))
}()
// 1 finished
// 4 finished
// 3 finished
// 0 finished
// 2 finished
// 672ms elapsed

После свертывания в массив и цикла for получаем следующее:
async function randomDelay(id) {
  // Задержка между 0 и 1000 мс
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    console.log(`${id} finished`)
    resolve()
  }, delay))
}

void async function foo() {
  const t0 = Date.now()
  const promises = Array(5).fill(null).map((_, i) => randomDelay(i))
  for (const p of promises)
    await p

  console.log(`${Date.now() - t0}ms elapsed`)
}()
// 4 finished
// 2 finished
// 1 finished
// 0 finished
// 3 finished
// 906ms elapsed

Обратите внимание, что, хотя выполнение промисов нарушило порядок, операторы промисов предоставляются разрешенным значениям в порядке:
async function randomDelay(id) {
  // Задержка между 0 и 1000 мс
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    console.log(`${id} finished`)
    resolve(id)
  }, delay))
}

void async function foo() {
  const t0 = Date.now()

  const promises = Array(5).fill(null).map((_, i) => randomDelay(i))
  for (const p of promises)
    console.log(`awaited ${await p}`)

  console.log(`${Date.now() - t0}ms elapsed`)
}()
// 1 finished
// 2 finished
// 4 finished
// 3 finished
// 0 finished
// awaited 0
// awaited 1
// awaited 2
// awaited 3
// awaited 4
// 906ms elapsed


6.2.3 Серийное выполнение промисов


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

С async/await цепочка промисов становится очень простой
function addTwo(x) {return x + 2}
function addThree(x) {return x + 3}
function addFive(x) {return x + 5}
async function addTen(x) {
  for (const fn of [addTwo, addThree, addFive])
    x = await fn(x)

  return x
}
addTen(9).then(console.log) // 19

Здесь await напрямую передает возвращаемое значение каждой функции и результат выводится итеративно. Предыдущий пример не имеет дела с промисами, но его можно перенастроить на использование асинхронных функций — и, следовательно, промисов — вместо этого:
async function addTwo(x) {return x + 2}
async function addThree(x) {return x + 3}
async function addFive(x) {return x + 5}
async function addTen(x) {
  for (const fn of [addTwo, addThree, addFive])
    x = await fn(x)

  return x
}
addTen(9).then(console.log) // 19


6.2.4 Трассировка стека и управление памятью


Промисы и асинхронные функции имеют значительную степень совпадения с точки зрения функциональности, которую они предоставляют, но они значительно расходятся, когда дело доходит до того, как они представлены в памяти. Рассмотрим следующий пример, который показывает чтение трассировки стека для отклоненного промиса:
function fooPromiseExecutor(resolve, reject) {
  setTimeout(reject, 1000, 'bar')
}

void function foo() {
  new Promise(fooPromiseExecutor)
}()
// Uncaught (in promise) bar
// setTimeout
// setTimeout (async)
// fooPromiseExecutor
// foo

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

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

Рассмотрим предыдущий пример, как если он был переработан с помощью асинхронных функций:
function fooPromiseExecutor(resolve, reject) {
  setTimeout(reject, 1000, 'bar')
}

void async function foo() {
  await new Promise(fooPromiseExecutor)
}()
// Uncaught (in promise) bar
// foo
// async function (async)
// foo

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

Комментарии (11)


  1. hardtop
    27.02.2022 18:25

    Фундаментальная статья. Спасибо за развёрнутые примеры!


  1. AlyProgrammer
    27.02.2022 21:15
    -1

    Не могу поставить лайк, но могу написать БОЛЬШОЕ СПАСИБО за Ваш труд!!!


    1. Elliot_001 Автор
      27.02.2022 21:15

      А почему лайк то поставить не можешь?


      1. AlyProgrammer
        27.02.2022 21:37
        -1

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


      1. StillR007
        28.02.2022 10:45
        -1

        Та же ситуация, спасибо за статью!


  1. rock
    28.02.2022 00:03
    +2

    Неплохо. Несколько дополнений:

    Не рассмотрены такие относительно свежие вещи, как:

    queueMicrotask
    Promise.allSettled
    Promise.any
    Асинхронные итераторы / генераторы / for-await-of

    Про await верхнего уровня небольшая ошибка - с недавних пор его можно использовать в контексте модуля.

    Можно было бы расширить вещами, всё ещё не добавленными в стандарт языка, но активно применяемые сообществом, вроде Observable.


    1. Elliot_001 Автор
      28.02.2022 11:18

      Согласен, статья получилась не совсем полная и исчерпывающая. Про некоторые вещи (queueMicrotask await top level и Observable) я не знал и мне не хотелось тратить еще больше времени на более глубокое изучение вопроса, а как можно быстрее выпустить статью и двигается дальше.


  1. bruian
    28.02.2022 04:31
    -2

    Такое ощущение, что на хабре открыт конкурс авторефератов по давно пережеванным тематикам.


    1. bruian
      01.03.2022 08:01
      -3

      https://habr.com/ru/post/452974/
      https://habr.com/ru/post/462355/
      https://habr.com/ru/post/319350/
      https://habr.com/ru/company/wrike/blog/302896/
      https://habr.com/ru/company/ruvds/blog/340508/
      https://habr.com/ru/post/651037/
      https://habr.com/ru/post/111634/
      https://habr.com/ru/post/241161/
      https://habr.com/ru/post/491012/
      https://habr.com/ru/post/128772/
      https://habr.com/ru/company/ruvds/blog/337662/
      https://habr.com/ru/company/piter/blog/434360/
      Этот список можно продолжать ещё и ещё очень долго... это только на хабре, не считая медиум, где подобных "перепечаток" целый легион.

      Но правда глаза режет, проще минус влепить, чем воспользоваться самостоятельно поиском


      1. bruian
        02.03.2022 06:42
        -2

        Давай ещё. Минус без аргументов, жалкое зрелище беспомощности.