Доброго времени суток. Представляю вашему вниманию еще один фрагмент перевода статьи "JavaScript Promises. There and back again." В нем речь пойдет о параллельном и последовательном выполнении Ajax-запросов, и том, как промисы позволяют упростить решение этой задачи. А так же, о том, как правильное распараллеливание запросов улучшает скорость работы клиентской части веб-приложения.
Интересно? Добро пожаловать под кат.



Параллельность и последовательность — берем лучшее из двух подходов


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

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);
// TODO: для ссылки каждого раздела из story.chapterUrls выполнить загрузку и отображение
}).then(function() {
// И все готово!
addTextToPage("Все готово");
}).catch(function(err) {
// Обработаем произошедшие в процессе ошибки
addTextToPage("Черт сломалось: " + err.message);
}).then(function() {
// В любом случаи спрячем индикатор загрузки
document.querySelector('.spinner').style.display = 'none';
});

Но как нам обойти все ссылки разделов и скачать их все в верном порядке? Этот вариант не подойдет:

story.chapterUrls.forEach(function(chapterUrl) {
// Скачать раздел
getJSON(chapterUrl).then(function(chapter) {
// Отобразить его на странице
addHtmlToPage(chapter.html);
});
});

увы, "forEach" не рассчитан на работу с асинхронным кодом, таким образом наши разделы появятся в порядке скачивания(прим. Пер.: он не обязательно соответствует порядку отправки запросов), что соответствует написанию "Криминального чтива". Но это не "Криминальное чтиво", так что давайте исправим…

Создание последовательности



Мы хотим превратить наши chapterUrls в массив промисов. Для этого мы используем "then":

// Начнем с промиса, который всегда выполняется
var sequence = Promise.resolve();

// Обходим ссылки разделов
story.chapterUrls.forEach(function(chapterUrl) {
// Добавляем эти действия в конец последовательности
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
});

Здесь мы впервые увидели Promise.resolve в действии. Этот метод создает промис, который успешно завершается вне зависимости, от переданного значения. Если Вы передаете что-то промисоподобное (что-то с методом 'then'), он создает новый промис, который ведет себя так же, в плане выполнения/отказа, фактически, клон. Если же передать любое другое значение, к примеру, Promise.resolve('Hello'), то он создаст промис, который будет выполнен с этим значением. Если вызвать его без значения, как в предыдущем примере, то промис выполнится со значением "undefined".
Так же существует метод Promise.reject(val), он создает промис, который отказывает с переданным значением(или undefined, в случаи отсутствия значения).
Мы можем привести в порядок предыдущий код, используя array.reduce:

// Обходим ссылки раздело
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(
// Проведем преобразование массива ссылок на разделы
// в массив 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);

// Установим соответствие между массивом ссылок на разделы
// и массивом промисов 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 их применение становится еще проще…
0

Комментарии

Для того, чтоб оставлять комментарии или зарегистрируйтесь.