Обещания JavaScript: введение
Обещания упрощают отложенные и асинхронные вычисления. Обещание представляет
операцию, которая еще не завершена.
Разработчики, приготовьтесь к поворотному моменту в истории веб-разработки.
[Начинается барабанная дробь]
В JavaScript появились обещания!
[Взрывается фейерверк, блестки осыпаются дождем с небес, толпа сходит с ума]
Здесь вы попадаете в одну из следующих категорий:
- Люди вокруг вас аплодируют, но вы не понимаете, о чем идет речь. Может быть, вы даже не знаете, что такое «обещание». Вы бы пожали плечами, но придавлены грузом блесток. Если я прав, расслабьтесь — лично мне потребовались годы, чтобы понять, зачем мне вообще это надо. Пожалуй, вам стоит начать с самого начала.
- Вы воздеваете кулаки к небу, торжествуя! Вовремя, правда? Вам уже приходилось пользоваться функцией обещаний, но вас беспокоит, что API для их реализации несколько отличаются друг от друга. Какой API используется в официальной версии JavaScript? Наверное, для начала вам нужно обратиться к терминологии.
- Вы уже об этом знаете и посмеиваетесь над теми, кто скачет туда-сюда, будто услышав что-то новое. Насладитесь своим превосходством минутку-другую, а затем смело переходите к справочнику по API.
Откуда такая суета? #
JavaScript является однопоточным, то есть два фрагмента сценария не могут выполняться одновременно; они должны запускаться последовательно. В браузерах JavaScript разделяет поток со множеством других вещей, которые различаются от браузера к браузеру. Но обычно JavaScript находится в той же очереди, что и отрисовка, обновление стилей и обработка действий пользователя (таких как выделение текста и взаимодействие с элементами управления на формах). Действия, выполняемые с чем-то из перечисленного, задерживают все остальное.
Человек многопоточен. Вы можете печатать несколькими пальцами или одновременно вести машину и разговаривать. Единственная блокирующая функция, с которой нам приходится сталкиваться, — это чихание. Во время чихания все текущие действия вынужденно приостанавливаются. Приятного в этом мало, особенно когда вы ведете машину и пытаетесь поддержать разговор. Вам наверняка не захочется писать чихающий код.
Вероятно, вы использовали события и обратные вызовы, чтобы это обойти. Вот, например, события:
var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {
// ура-ура, изображение загрузилось
});
img1.addEventListener('error', function() {
// блин, все сломалось
});
На чихание не похоже. Мы получаем изображение, добавляем пару прослушивателей, затем JavaScript может прекратить выполнение, пока не будет вызван один из этих прослушивателей.
К сожалению, в приведенном выше примере возможно, что события произошли до того, как мы начали их слушать, поэтому нам нужно это обойти, используя свойство изображений «complete»:
var img1 = document.querySelector('.img-1');
function loaded() {
// ура-ура, изображение загрузилось
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// блин, все сломалось
});
Такой подход не позволяет отловить изображения с ошибками до того, как мы получим возможность их прослушать; к сожалению, DOM не дает нам такой возможности. Кроме того, здесь описана загрузка одного изображения. Все становится куда сложнее, если нам нужно отследить загрузку набора изображений.
События — не всегда лучший подход #
События отлично справляются с тем, что может произойти несколько раз с одним и тем же объектом — keyup
, touchstart
и т.д. Для таких событий вам не особенно важно знать, что произошло до того, как вы прикрепили прослушиватель. Но когда дойдет до асинхронной обработки успеха/неудачи, в идеале вам понадобится что-то такое:
img1.callThisIfLoadedOrWhenLoaded(function() {
// загружено
}).orIfFailedCallThis(function() {
// неудача
});
// и…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// все загружено
}).orIfSomeFailedCallThis(function() {
// не удалось загрузить 1 или более
});
Собственно, это и делают обещания, только с более удобным наименованием. Если бы у элементов изображений HTML был «готовый» метод, возвращающий обещание, можно было бы сделать так:
img1.ready()
.then(function() {
// загружено
}, function() {
// неудача
});
// и…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
// все загружено
}, function() {
// не удалось загрузить 1 или более
});
По сути, обещания немного похожи на прослушиватели событий, за исключением следующих нюансов:
- Обещание может быть успешным или неудачным только один раз. Оно не может завершиться успехом или неудачей дважды, а также переключиться с успеха на неудачу или наоборот.
- Если обещание выполнено или не выполнено, и после вы добавите обратный вызов успеха или неудачи, будет запрошен верный обратный вызов, несмотря на то, что событие произошло раньше.
Это чрезвычайно полезно для асинхронной обработки успеха/неудачи, потому что вас интересует не столько точное время, когда что-то становится доступным, сколько реакция на результат.
Терминология обещаний #
Доменик Деникола вычитал черновик этой статьи и поставил мне двойку за терминологию. Он оставил меня после уроков, заставил 100 раз написать «States and Fates» и написал обеспокоенное письмо родителям. Несмотря на это, я все еще путаюсь в терминологии, но основы таковы:
Обещание может быть:
- выполнено (fulfilled) — действие, связанное с обещанием, выполнено успешно
- отклонено (rejected) — действие, связанное с обещанием, не выполнено
- на рассмотрении (pending) — еще не выполнено и не отклонено
- урегулировано (settled) — выполнено или отклонено
В спецификации также используется термин thenable для описания объекта, похожего на обещание, в том смысле, что у него есть метод then
. Этот термин напоминает мне бывшего тренера сборной Англии по футболу Терри Венейблса, так что я буду использовать его как можно реже.
Обещания теперь в JavaScript! #
Обещания существуют уже некоторое время в виде библиотек, таких как:
Вышеупомянутые обещания и обещания JavaScript имеют общее стандартизированное поведение, называемое Promises/A+. Если вы пользуетесь jQuery, там есть нечто подобное, называемое Deferreds. Однако Deferreds не совместимы с Promise/A+, что создает некоторые отличия и делает их менее полезными, так что будьте осторожны. В jQuery также есть тип Promise, но это всего лишь подмножество Deferred с теми же проблемами.
Хотя реализации обещаний следуют стандартизированному поведению, их общие API различаются. Обещания JavaScript похожи по API на RSVP.js. Вот как создается обещание:
var promise = new Promise(function(resolve, reject) {
// выполняем действие, скорее всего, асинхронно, затем…
if (/* все прошло как должно было */) {
resolve("Сработало!");
}
else {
reject(Error("Не вышло"));
}
});
Конструктор обещания принимает один аргумент — обратный вызов с двумя параметрами, resolve (разрешение) и reject (отклонение). Выполните какое-нибудь действие в рамках обратного вызова, возможно, асинхронно, затем вызовите resolve, если все работает, в противном случае вызовите reject.
Аналогично throw
в старом добром JavaScript, принято, но не обязательно, вызывать reject с объектом Error. Преимущество объектов Error в том, что они захватывают трассировку стека, делая инструменты отладки более удобными.
Вот как используется такое обещание:
promise.then(function(result) {
console.log(result); // "Сработало!"
}, function(err) {
console.log(err); // Error: "Не вышло"
});
then()
принимает два аргумента: обратный вызов для успеха и еще один для отказа. Оба являются необязательными, так что можно добавить обратный вызов только для успеха или неудачи.
Обещания JavaScript изначально появились в DOM под названием «Futures», затем были переименованы в «Promises» и, наконец, перешли в JavaScript. Их наличие в JavaScript вместо DOM — это здорово, поскольку они будут доступны в небраузерных JS-контекстах, таких как Node.js (другой вопрос, используют ли они их в своих основных API).
Хоть это и функция JavaScript, DOM вполне себе ее использует. Фактически, все новые API-интерфейсы DOM с асинхронными методами успеха/отказа будут использовать обещания. Это уже происходит с Quota Management, Font Load Events, ServiceWorker, Web MIDI, Streams и т. д.
Поддержка браузеров и полизаполнение #
Реализации обещаний в браузерах уже существуют.
Начиная с Chrome 32, Opera 19, Firefox 29, Safari 8 и Microsoft Edge, обещания включены по умолчанию.
Чтобы довести браузеры, в которых отсутствует полная реализация обещаний, до соответствия спецификации или добавить обещания для других браузеров и Node.js, ознакомьтесь с полизаполнением (2k архив gzip).
Совместимость с другими библиотеками #
API обещаний JavaScript будет рассматривать что угодно с методом then()
как объекты, подобные обещаниям, или, в терминах обещаний, thenable
(вздох). Так что если вы используете библиотеку, которая возвращает обещание Q, все в порядке, они прекрасно поладят с обещаниями JavaScript.
Хотя, как я уже говорил, Deferreds в jQuery немного… бесполезны. К счастью, их можно преобразовать в стандартные обещания, и сделать это стоит как можно скорее:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
Здесь метод jQuery $.ajax
возвращает Deferred. Так как у объекта есть метод then()
, Promise.resolve()
может превратить его в обещание JavaScript. Однако иногда deferred-объекты передают своим обратным вызовам несколько аргументов, например:
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
В то время как JS-обещания игнорируют все, кроме первого:
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
К счастью, обычно именно это вам и нужно, или, по крайней мере, это дает вам доступ к тому, что вам нужно. Также имейте в виду, что jQuery не следует соглашению о передаче объектов Error в reject.
Сложный асинхронный код стал проще #
Хорошо, давайте кое-что запрограммируем. Скажем, мы хотим:
- Запустить счетчик, чтобы зарегистрировать загрузку
- Получить JSON для истории, который даст нам название и URL-адреса для каждой главы
- Добавить заголовок на страницу
- Получить каждую главу
- Добавить историю на страницу
- Остановить счетчик
…при этом нам нужно сообщить пользователю, если что-то пойдет не так. Также на этом этапе нам понадобится остановить счетчик, иначе он продолжит накручиваться, слетит с катушек и упадет, зацепив какой-нибудь еще элемент интерфейса.
Само собой, вы не станете использовать JavaScript для передачи истории, поскольку HTML работает быстрее, но этот шаблон довольно распространен при работе с API-интерфейсами: несколько выборок данных, затем, когда все готово, какие-то действия.
Для начала разберемся с получением данных из сети:
Использование обещаний в XMLHttpRequest #
Старые API-интерфейсы будут обновлены для использования обещаний, если будет возможно реализовать обратную совместимость. XMLHttpRequest
— главный кандидат, но пока давайте напишем простую функцию для выполнения запроса GET:
function get(url) {
// Возвращаем новое обещание.
return new Promise(function(resolve, reject) {
// Выполняем обычные действия XHR
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function() {
// этот метод вызывается даже для 404 и т.п.
// поэтому проверяем статус
if (req.status == 200) {
// Разрешаем обещание с текстом ответа
resolve(req.response);
}
else {
// В остальных случаях отклоняем с текстом статуса
// в надежде, что там будет осмысленный текст ошибки
reject(Error(req.statusText));
}
};
// Обрабатываем ошибки сети
req.onerror = function() {
reject(Error("Network Error"));
};
// Выполняем запрос
req.send();
});
}
А теперь воспользуемся этой функцией:
get('story.json').then(function(response) {
console.log("Успешно!", response);
}, function(error) {
console.error("Ошибка!", error);
})
Теперь мы можем делать HTTP-запросы, не набирая вручную XMLHttpRequest
, и это здорово, потому что чем меньше мне придется видеть отвратительный горбатый регистр XMLHttpRequest
, тем легче мне будет жить.
Формирование цепочки #
Одним then()
все не кончается, методы then
можно связывать друг с другом, чтобы преобразовать значения или последовательно выполнить дополнительные асинхронные действия.
Преобразование значений #
Можно преобразовывать значения, просто возвращая новое значение:
var promise = new Promise(function(resolve, reject) {
resolve(1);
});
promise.then(function(val) {
console.log(val); // 1
return val + 2;
}).then(function(val) {
console.log(val); // 3
})
В качестве практического примера вернемся к:
get('story.json').then(function(response) {
console.log("Успешно!", response);
})
Ответ в формате JSON, но сейчас мы получаем его как обычный текст. Можно изменить нашу функцию get, чтобы использовать свойство JSON responseType
, но также можно решить эту проблему при помощи обещаний:
get('story.json').then(function(response) {
return JSON.parse(response);
}).then(function(response) {
console.log("Ура, JSON!", response);
})
Поскольку JSON.parse()
принимает единственный аргумент и возвращает преобразованное значение, можно сократить путь:
get('story.json').then(JSON.parse).then(function(response) {
console.log("Ура, JSON!", response);
})
Собственно, мы можем с легкостью написать функцию getJSON()
:
function getJSON(url) {
return get(url).then(JSON.parse);
}
getJSON()
по-прежнему возвращает обещание, которое извлекает URL-адрес, а затем анализирует ответ как JSON.
Организация очереди асинхронных действий #
Также можно связать несколько методов then
для последовательного выполнения асинхронных действий.
Когда вы возвращаете что-то из обратного вызова then()
, в этом есть немного волшебства. Если вы возвращаете значение, следующий then()
вызывается с этим значением. Однако, если вы возвращаете что-то похожее на обещание, следующий then()
ожидает и вызывается только тогда, когда это обещание будет урегулировано (завершится успехом или неудачей). Например:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
console.log("Получена глава 1!", chapter1);
})
Здесь мы делаем асинхронный запрос к story.json
, который дает нам набор URL-адресов для запроса, а затем запрашиваем первый из них. Именно на этом этапе обещания начинают существенно отличаться от простых паттернов обратного вызова.
Можно даже написать метод быстрого вызова для получения глав:
var storyPromise;
function getChapter(i) {
storyPromise = storyPromise || getJSON('story.json');
return storyPromise.then(function(story) {
return getJSON(story.chapterUrls[i]);
})
}
// пользоваться им просто:
getChapter(0).then(function(chapter) {
console.log(chapter);
return getChapter(1);
}).then(function(chapter) {
console.log(chapter);
})
Мы не загружаем story.json
до вызова getChapter
, но при следующем вызове getChapter
мы повторно используем обещание истории, так что story.json
будет получен всего один раз. Обещания рулят!
Обработка ошибок #
Как мы видели ранее, then()
принимает два аргумента, один для успеха, другой для неудачи (или, выражаясь языком обещаний, выполнения и отклонения):
get('story.json').then(function(response) {
console.log("Успешно!", response);
}, function(error) {
console.log("Ошибка!", error);
})
Также можно использовать catch()
:
get('story.json').then(function(response) {
console.log("Успешно!", response);
}).catch(function(error) {
console.log("Ошибка!", error);
})
В catch()
нет ничего особенного, это не более чем обертка для then(undefined, func)
, но более читабельная. Обратите внимание, что два приведенных выше примера кода не ведут себя одинаково — последний эквивалентен:
get('story.json').then(function(response) {
console.log("Успешно!", response);
}).then(undefined, function(error) {
console.log("Ошибка!", error);
})
Разница небольшая, но чрезвычайно полезная. При отклонении обещаний выполняется переход к следующему then()
с обратным вызовом отклонения (или catch()
, поскольку они эквивалентны). С then(func1, func2)
будет вызван метод func1
или func2
, но не оба. Однако с then(func1).catch(func2)
оба будут вызваны при отклонении func1
, поскольку они являются отдельными шагами в цепочке. Взгляните на следующий пример:
asyncThing1().then(function() {
return asyncThing2();
}).then(function() {
return asyncThing3();
}).catch(function(err) {
return asyncRecovery1();
}).then(function() {
return asyncThing4();
}, function(err) {
return asyncRecovery2();
}).catch(function(err) {
console.log("Не о чем тут переживать");
}).then(function() {
console.log("Все готово!");
})
Приведенная выше схема очень похожа на обычную конструкцию try/catch в JavaScript — ошибки, возникающие в «try», немедленно переходят в блок catch()
. Так это выглядит в виде блок-схемы (я просто люблю блок-схемы):
Вдоль синих линий обещания выполняются, вдоль красных — отклоняются.
Исключения и обещания JavaScript #
Отклонения случаются, когда обещание отклоняется явно, а также неявно, если в обратном вызове конструктора возникает ошибка:
var jsonPromise = new Promise(function(resolve, reject) {
// JSON.parse выдает ошибку, если передать ему некорректный
// JSON, так что здесь будет неявное отклонение:
resolve(JSON.parse("Это не JSON"));
});
jsonPromise.then(function(data) {
// Этого не произойдет:
console.log("Сработало!", data);
}).catch(function(err) {
// Вот что получится на самом деле:
console.log("Не вышло!", err);
})
Это означает, что нужно выполнять всю работу, связанную с обещаниями, внутри обратного вызова конструктора обещаний, чтобы ошибки автоматически перехватывались и становились отклонениями.
То же самое касается ошибок, возникающих в обратных вызовах then()
.
get('/').then(JSON.parse).then(function() {
// Это не будет работать, '/' - страница HTML, а не JSON
// поэтому JSON.parse выдаст ошибку
console.log("Сработало!", data);
}).catch(function(err) {
// Вот что получится на самом деле:
console.log("Не вышло!", err);
})
Обработка ошибок на практике #
В нашей истории и главах мы можем использовать catch для отображения ошибки пользователю:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
addHtmlToPage(chapter1.html);
}).catch(function() {
addTextToPage("Не удалось показать главу");
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
Если получить story.chapterUrls[0]
не удалось (например, http 500 или пользователь не в сети), метод пропустит все последующие успешные обратные вызовы, включая обратный вызов в getJSON()
, который пытается проанализировать ответ как JSON, а также обратный вызов, который добавляет на страницу chapter1.html. Вместо этого будет выполнен переход к обратному вызову catch. В результате на страницу будет добавлено сообщение «Не удалось показать главу», если какое-либо из предыдущих действий не удалось.
Как и в случае с try/catch в JavaScript, ошибка перехватывается, и последующий код продолжает работу, поэтому счетчик всегда скрыт, что нам и нужно. Вышеупомянутое становится неблокирующей асинхронной версией:
try {
var story = getJSONSync('story.json');
var chapter1 = getJSONSync(story.chapterUrls[0]);
addHtmlToPage(chapter1.html);
}
catch (e) {
addTextToPage("Не удалось показать главу");
}
document.querySelector('.spinner').style.display = 'none'
Возможно, вы решите применить catch()
только для регистрации, без восстановления после ошибки. Для этого достаточно заново выдать ошибку. Это можно сделать в нашем методе getJSON()
:
function getJSON(url) {
return get(url).then(JSON.parse).catch(function(err) {
console.log("не удалось выполнить getJSON для", url, err);
throw err;
});
}
Итак, нам удалось получить одну главу, но нам нужны все. Сейчас мы все сделаем.
Параллелизм и последовательность: лучшее от обоих #
Не так-то просто мыслить асинхронно. Если вы буксуете на старте, попробуйте написать код так, как будто он синхронный. В этом случае:
try {
var story = getJSONSync('story.json');
addHtmlToPage(story.heading);
story.chapterUrls.forEach(function(chapterUrl) {
var chapter = getJSONSync(chapterUrl);
addHtmlToPage(chapter.html);
});
addTextToPage("Все готово");
}
catch (err) {
addTextToPage("Блин, сломалось: " + err.message);
}
document.querySelector('.spinner').style.display = 'none'
Это работает! Но этот код синхронный и блокирует браузер во время загрузки. Чтобы сделать его асинхронным, используем then()
и будем выполнять все действия последовательно.
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Необходимо: для всех url в story.chapterUrls получить и отобразить
}).then(function() {
// И все готово!
addTextToPage("Все готово");
}).catch(function(err) {
// Отлавливаем все ошибки, полученные в процессе
addTextToPage("Блин, сломалось: " + err.message);
}).then(function() {
// Всегда скрываем счетчик
document.querySelector('.spinner').style.display = 'none';
})
Но как мы можем просмотреть URL-адреса глав и получить их по порядку? Это не работает:
story.chapterUrls.forEach(function(chapterUrl) {
// Получаем главу
getJSON(chapterUrl).then(function(chapter) {
// и добавляем ее на страницу
addHtmlToPage(chapter.html);
});
})
forEach
не поддерживает асинхронность, поэтому наши главы будут появляться в том порядке, в каком они загружаются, как, в общем-то, было написано «Криминальное чтиво». Впрочем, здесь не криминальное чтиво, так что давайте исправим.
Создание последовательности #
Нам нужно превратить массив chapterUrls
в последовательность обещаний. Можно это сделать с помощью then()
:
// Начинаем с обещания, которое всегда выполняется
var sequence = Promise.resolve();
// Просматриваем url-адреса наших глав
story.chapterUrls.forEach(function(chapterUrl) {
// Добавляем эти действия в конец последовательности
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
})
Здесь мы впервые сталкиваемся с методом Promise.resolve()
, который создает обещание, выполняемое при любом значении, которое вы ему передадите. Если передать ему экземпляр Promise
, он вернет его как есть (примечание: это изменение спецификации применяется еще не во всех реализациях). Если вы передадите ему что-то похожее на обещание (объект с методом then()
), он создаст подлинное обещание Promise
, которое будет выполнено или отклонено таким же образом. При передаче любого другого значения, например, Promise.resolve('Hello')
, метод создаст обещание, которое будет выполнено с этим значением. Если вызвать его с пустым значением, как показано выше, обещание будет выполнено с «undefined».
Также есть метод Promise.reject(val)
, который создает обещание, отклоняемое с указанным или неопределенным значением.
Мы можем привести в порядок приведенный выше код с помощью array.reduce
:
// Просматриваем url-адреса наших глав
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Добавляем эти действия в конец последовательности
return sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve())
Этот код делает то же самое, что и в предыдущем примере, но не требует отдельной переменной sequence. Наш обратный вызов reduce выполняется для каждого элемента в массиве. Для первого вызова sequence — это Promise.resolve()
, но для остальных вызовов это то, что мы вернули из предыдущего вызова. Метод array.reduce
чрезвычайно полезен для сжатия массива до одного значения, которое в данном случае является обещанием.
Соберем все вместе:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Как только мы разобрались с обещанием последней главы…
return sequence.then(function() {
// …получаем следующую главу
return getJSON(chapterUrl);
}).then(function(chapter) {
// и добавляем ее на страницу
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// И все готово!
addTextToPage("Все готово");
}).catch(function(err) {
// Отлавливаем все ошибки, полученные в процессе
addTextToPage("Блин, сломалось: " + err.message);
}).then(function() {
// Всегда скрываем счетчик
document.querySelector('.spinner').style.display = 'none';
})
И вот она, полностью асинхронная версия синхронного кода. Но мы можем добиться еще большего. На данный момент наша страница скачивается так:
Браузеры неплохо справляются с параллельной загрузкой нескольких файлов, так что, загружая главы одну за другой, мы теряем производительность. Что нам нужно, так это загрузить их одновременно, а затем обработать их, когда все они будут получены. К счастью, для этого есть API:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all
принимает массив обещаний и создает обещание, которое выполняется, когда все они успешно выполнены. Вы получаете массив результатов (значений, с которыми были выполнены обещания) в том же порядке, что и переданные вами обещания.
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Принимаем массив обещаний и ожидаем
return Promise.all(
// Соотносим наш массив url-адресов глав
// с массивом обещаний json глав
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Теперь json-ы глав упорядочены! Проходим по ним…
chapters.forEach(function(chapter) {
// …и добавляем на страницу
addHtmlToPage(chapter.html);
});
addTextToPage("Все готово");
}).catch(function(err) {
// отлавливаем все ошибки, полученные в процессе
addTextToPage("Блин, сломалось: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
В зависимости от соединения такой код может отработать на несколько секунд быстрее, чем загрузка по очереди, к тому же его объем меньше, чем в нашем первом примере. Главы могут загружаться в любом порядке, но на экране они появятся в нужном.
Однако воспринимаемую производительность все еще можно улучшить. Когда будет получена первая глава, ее нужно добавить на страницу. Это позволит пользователю начать чтение до того, как будут получены остальные главы. Когда придет третья глава, мы не будем добавлять ее на страницу, потому что пользователь может не понять, что вторая глава отсутствует. Когда придет вторая глава, мы сможем добавить вторую и третью главы и т. д.
Для этого мы получаем JSON для всех наших глав одновременно, а затем создаем последовательность, чтобы добавить их в документ:
getJSON('story.json')
.then(function(story) {
addHtmlToPage(story.heading);
// Соотносим наш массив url-адресов глав
// с массивом обещаний json глав.
// Таким образом мы гарантируем параллельную загрузку.
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// Используем reduce, чтобы выстроить цепочку обещаний,
// добавляя на страницу содержимое каждой главы
return sequence
.then(function() {
// Ожидаем, пока завершится вся поледовательность,
// затем ожидаем получения этой главы.
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("Все готово");
}).catch(function(err) {
// отлавливаем все ошибки, полученные в процессе
addTextToPage("Блин, сломалось: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
Вот и оно — лучшее от обоих подходов! Для доставки всего контента требуется такое же количество времени, но первую часть контента пользователь получает раньше.
В этом простом примере все главы появляются примерно в одно и то же время, но преимущество отображения по одной за раз будет хорошо заметно, когда глав станет больше и они будут объемнее.
Чтобы выполнить описанное выше при помощи обратных вызовов или событий в стиле Node.js, потребуется примерно вдвое больше кода, но, что более важно, написать его будет вовсе не так просто. Однако и это еще не все: в сочетании с другими функциями ES6 обещания становятся еще проще.
Бонусный раунд: расширенные возможности #
С тех пор, как я написал эту статью, возможности использования обещаний значительно расширились. Начиная с Chrome 55, асинхронные функции позволяют писать код на основе обещаний, как если бы он был синхронным, но без блокировки основного потока. Об этом можно прочесть в моей статье об асинхронных функциях. В основных браузерах широко поддерживаются как обещания, так и асинхронные функции. Вы можете найти подробности в справочнике MDN по обещаниям и асинхронным функциям.
Большое спасибо Анне ван Кестерен, Доменику Дениколе, Тому Эшворту, Реми Шарпу, Адди Османи, Артуру Эвансу и Ютаке Хирано, которые вычитали мою работу и внесли исправления и рекомендации.
Также спасибо Матиасу Биненсу за обновление различных частей статьи.