Представьте, что вы известный певец, которого фанаты постоянно донимают расспросами о предстоящем сингле.
Чтобы получить передышку, вы обещаете разослать им сингл, когда он будет выпущен. Вы даёте фанатам список, в который они могут записаться. Они могут оставить там свой e-mail, чтобы получить песню, как только она выйдет. И даже больше: если что-то пойдёт не так, например, в студии будет пожар и песню выпустить не выйдет, они также получат уведомление об этом.
Все счастливы! Вы счастливы, потому что вас больше не донимают фанаты, а фанаты могут больше не беспокоиться, что пропустят новый сингл.
Это аналогия из реальной жизни для ситуаций, с которыми мы часто сталкиваемся в программировании:
- Есть «создающий» код, который делает что-то, что занимает время. Например, загружает данные по сети. В нашей аналогии это – «певец».
- Есть «потребляющий» код, который хочет получить результат «создающего» кода, когда он будет готов. Он может быть необходим более чем одной функции. Это – «фанаты».
Promise(по англ.promise, будем называть такой объект «промис») – это специальный объект в JavaScript, который связывает «создающий» и «потребляющий» коды вместе. В терминах нашей аналогии – это «список для подписки». «Создающий» код может выполняться сколько потребуется, чтобы получить результат, а промис делает результат доступным для кода, который подписан на него, когда результат готов.
Аналогия не совсем точна, потому что объект Promise в JavaScript гораздо сложнее простого списка подписок: он обладает дополнительными возможностями и ограничениями. Но для начала и такая аналогия хороша.
Синтаксис создания Promise:
let promise = new Promise(function(resolve, reject) {
// функция-исполнитель (executor)
// "певец"
});
Функция, переданная в конструкцию new Promise, называется исполнитель (executor). Когда Promise создаётся, она запускается автоматически. Она должна содержать «создающий» код, который когда-нибудь создаст результат. В терминах нашей аналогии: исполнитель – это «певец».
Её аргументы resolve и reject – это колбэки, которые предоставляет сам JavaScript. Наш код – только внутри исполнителя.
Когда он получает результат, сейчас или позже – не важно, он должен вызвать один из этих колбэков:
resolve(value)— если работа завершилась успешно, с результатомvalue.reject(error)— если произошла ошибка,error– объект ошибки.
Итак, исполнитель запускается автоматически, он должен выполнить работу, а затем вызвать resolve или reject.
У объекта promise, возвращаемого конструктором new Promise, есть внутренние свойства:
state(«состояние») — вначале"pending"(«ожидание»), потом меняется на"fulfilled"(«выполнено успешно») при вызовеresolveили на"rejected"(«выполнено с ошибкой») при вызовеreject.result(«результат») — вначалеundefined, далее изменяется наvalueпри вызовеresolve(value)или наerrorпри вызовеreject(error).
Так что исполнитель по итогу переводит promise в одно из двух состояний:
Позже мы рассмотрим, как «фанаты» узнают об этих изменениях.
Ниже пример конструктора Promise и простого исполнителя с кодом, дающим результат с задержкой (через setTimeout):
let promise = new Promise(function(resolve, reject) {
// эта функция выполнится автоматически, при вызове new Promise
// через 1 секунду сигнализировать, что задача выполнена с результатом "done"
setTimeout(() => resolve("done"), 1000);
});
Мы можем наблюдать две вещи, запустив код выше:
- Функция-исполнитель запускается сразу же при вызове
new Promise. - Исполнитель получает два аргумента:
resolveиreject— это функции, встроенные в JavaScript, поэтому нам не нужно их писать. Нам нужно лишь позаботиться, чтобы исполнитель вызвал одну из них по готовности.
Спустя одну секунду «обработки» исполнитель вызовет resolve("done"), чтобы передать результат:
Это был пример успешно выполненной задачи, в результате мы получили «успешно выполненный» промис.
А теперь пример, в котором исполнитель сообщит, что задача выполнена с ошибкой:
let promise = new Promise(function(resolve, reject) {
// спустя одну секунду будет сообщено, что задача выполнена с ошибкой
setTimeout(() => reject(new Error("Whoops!")), 1000);
});
Подведём промежуточные итоги: исполнитель выполняет задачу (что-то, что обычно требует времени), затем вызывает resolve или reject, чтобы изменить состояние соответствующего Promise.
Промис – и успешный, и отклонённый будем называть «завершённым», в отличие от изначального промиса «в ожидании».
Исполнитель должен вызвать что-то одно: resolve или reject. Состояние промиса может быть изменено только один раз.
Все последующие вызовы resolve и reject будут проигнорированы:
let promise = new Promise(function(resolve, reject) {
resolve("done");
reject(new Error("…")); // игнорируется
setTimeout(() => resolve("…")); // игнорируется
});
Идея в том, что задача, выполняемая исполнителем, может иметь только один итог: результат или ошибку.
Также заметим, что функция resolve/reject ожидает только один аргумент (или ни одного). Все дополнительные аргументы будут проигнорированы.
reject с объектом ErrorВ случае, если что-то пошло не так, мы должны вызвать reject. Это можно сделать с аргументом любого типа (как и resolve), но рекомендуется использовать объект Error (или унаследованный от него). Почему так? Скоро нам станет понятно.
resolve/reject сразуОбычно исполнитель делает что-то асинхронное и после этого вызывает resolve/reject, то есть через какое-то время. Но это не обязательно, resolve или reject могут быть вызваны сразу:
let promise = new Promise(function(resolve, reject) {
// задача, не требующая времени
resolve(123); // мгновенно выдаст результат: 123
});
Это может случиться, например, когда мы начали выполнять какую-то задачу, но тут же увидели, что ранее её уже выполняли, и результат закеширован.
Такая ситуация нормальна. Мы сразу получим успешно завершённый Promise.
state и result – внутренниеСвойства state и result – это внутренние свойства объекта Promise и мы не имеем к ним прямого доступа. Для обработки результата следует использовать методы .then/.catch/.finally, про них речь пойдёт дальше.
Потребители: then, catch, finally
Объект Promise служит связующим звеном между исполнителем («создающим» кодом или «певцом») и функциями-потребителями («фанатами»), которые получат либо результат, либо ошибку. Функции-потребители могут быть зарегистрированы (подписаны) с помощью методов .then, .catch и .finally.
then
Наиболее важный и фундаментальный метод – .then.
Синтаксис:
promise.then(
function(result) { /* обработает успешное выполнение */ },
function(error) { /* обработает ошибку */ }
);
Первый аргумент метода .then – функция, которая выполняется, когда промис переходит в состояние «выполнен успешно», и получает результат.
Второй аргумент .then – функция, которая выполняется, когда промис переходит в состояние «выполнен с ошибкой», и получает ошибку.
Например, вот реакция на успешно выполненный промис:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve("done!"), 1000);
});
// resolve запустит первую функцию, переданную в .then
promise.then(
result => alert(result), // выведет "done!" через одну секунду
error => alert(error) // не будет запущена
);
Выполнилась первая функция.
А в случае ошибки в промисе – выполнится вторая:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => reject(new Error("Whoops!")), 1000);
});
// reject запустит вторую функцию, переданную в .then
promise.then(
result => alert(result), // не будет запущена
error => alert(error) // выведет "Error: Whoops!" спустя одну секунду
);
Если мы заинтересованы только в результате успешного выполнения задачи, то в then можно передать только одну функцию:
let promise = new Promise(resolve => {
setTimeout(() => resolve("done!"), 1000);
});
promise.then(alert); // выведет "done!" спустя одну секунду
catch
Если мы хотели бы только обработать ошибку, то можно использовать null в качестве первого аргумента: .then(null, errorHandlingFunction). Или можно воспользоваться методом .catch(errorHandlingFunction), который сделает тоже самое:
let promise = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("Ошибка!")), 1000);
});
// .catch(f) это тоже самое, что promise.then(null, f)
promise.catch(alert); // выведет "Error: Ошибка!" спустя одну секунду
Вызов .catch(f) – это сокращённый, «укороченный» вариант .then(null, f).
finally
По аналогии с блоком finally из обычного try {...} catch {...}, у промисов также есть метод finally.
Вызов .finally(f) похож на .then(f, f), в том смысле, что f выполнится в любом случае, когда промис завершится: успешно или с ошибкой.
finally хорошо подходит для очистки, например остановки индикатора загрузки, его ведь нужно остановить вне зависимости от результата.
Например:
new Promise((resolve, reject) => {
/* сделать что-то, что займёт время, и после вызвать resolve/reject */
})
// выполнится, когда промис завершится, независимо от того, успешно или нет
.finally(() => остановить индикатор загрузки)
.then(result => показать результат, err => показать ошибку)
Но это не совсем псевдоним then(f,f), как можно было подумать. Существует несколько важных отличий:
-
Обработчик, вызываемый из
finally, не имеет аргументов. Вfinallyмы не знаем, как был завершён промис. И это нормально, потому что обычно наша задача – выполнить «общие» завершающие процедуры. -
Обработчик
finally«пропускает» результат или ошибку дальше, к последующим обработчикам.Например, здесь результат проходит через
finallyкthen:new Promise((resolve, reject) => { setTimeout(() => resolve("result"), 2000) }) .finally(() => alert("Промис завершён")) .then(result => alert(result)); // <-- .then обработает результатА здесь ошибка из промиса проходит через
finallyкcatch:new Promise((resolve, reject) => { throw new Error("error"); }) .finally(() => alert("Промис завершён")) .catch(err => alert(err)); // <-- .catch обработает объект ошибкиЭто очень удобно, потому что
finallyне предназначен для обработки результата промиса. Так что он просто пропускает его через себя дальше.Мы более подробно поговорим о создании цепочек промисов и передаче результатов между обработчиками в следующей главе.
-
Последнее, но не менее значимое: вызов
.finally(f)удобнее, чем.then(f, f)– не надо дублировать функции f.
Если промис в состоянии ожидания, обработчики в .then/catch/finally будут ждать его. Однако, если промис уже завершён, то обработчики выполнятся сразу:
// при создании промиса он сразу переводится в состояние "успешно завершён"
let promise = new Promise(resolve => resolve("готово!"));
promise.then(alert); // готово! (выведется сразу)
Теперь рассмотрим несколько практических примеров того, как промисы могут облегчить нам написание асинхронного кода.
Пример: loadScript
У нас есть функция loadScript для загрузки скрипта из предыдущей главы.
Давайте вспомним, как выглядел вариант с колбэками:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error(`Ошибка загрузки скрипта ${src}`));
document.head.append(script);
}
Теперь перепишем её, используя Promise.
Новой функции loadScript более не нужен аргумент callback. Вместо этого она будет создавать и возвращать объект Promise, который перейдет в состояние «успешно завершён», когда загрузка закончится. Внешний код может добавлять обработчики («подписчиков»), используя .then:
function loadScript(src) {
return new Promise(function(resolve, reject) {
let script = document.createElement('script');
script.src = src;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Ошибка загрузки скрипта ${src}`));
document.head.append(script);
});
}
Применение:
let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");
promise.then(
script => alert(`${script.src} загружен!`),
error => alert(`Ошибка: ${error.message}`)
);
promise.then(script => alert('Ещё один обработчик...'));
Сразу заметно несколько преимуществ перед подходом с использованием колбэков:
| Промисы | Колбэки |
|---|---|
Промисы позволяют делать вещи в естественном порядке. Сперва мы запускаем loadScript(script), и затем (.then) мы пишем, что делать с результатом. |
У нас должна быть функцияcallback на момент вызова loadScript(script, callback). Другими словами, нам нужно знать что делать с результатом до того, как вызовется loadScript. |
Мы можем вызывать .then у Promise столько раз, сколько захотим. Каждый раз мы добавляем нового «фаната», новую функцию-подписчика в «список подписок». Больше об этом в следующей главе: Цепочка промисов. |
Колбэк может быть только один. |
Таким образом, промисы позволяют улучшить порядок кода и дают нам гибкость. Но это далеко не всё. Мы узнаем ещё много полезного в последующих главах.
Комментарии
<code>, для нескольких строк кода — тег<pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)