Promise и результат отложенной операции
В предыдущем уроке мы разделили каталог на модули. Данные экспортируются из js/data.js, операции отбора находятся в js/model.js, а создание карточек — в js/view.js. До сих пор массив был доступен сразу. При загрузке с сервера между началом операции и получением массива появится ожидание. Прежде чем добавлять сеть, рассмотрим, как JavaScript представляет такой результат.
Продолжим работу с четырьмя курсами catalog-lab. Их идентификаторы, названия и число уроков сохраняются. В этом уроке источник всё ещё локальный: Promise.resolve поможет изучить передачу результата без HTTP-запроса. Мы будем читать ожидаемый порядок сообщений, а затем менять один этап цепочки. Учебные программы при подготовке текста не запускались; приведённые результаты предназначены для ручной сверки читателем.
Значение и обещание результата
Когда функция возвращает массив, вызывающий код может сразу обратиться к его элементам. Когда она возвращает Promise, доступен объект, с которым связывают дальнейшие действия. Сам массив будет передан обработчику успешного завершения. Поэтому число записей нужно читать у полученного значения, а не у объекта обещания.
У Promise есть состояния ожидания, успешного завершения и отклонения. Для успеха сохраняется значение; для отклонения — причина. После завершения конкретное обещание не начинает заново другую операцию. Повторная загрузка каталога позднее будет создавать новый Promise. Это помогает отделить отдельную попытку от общего состояния страницы. Описание состояний приведено в справочнике Promise.
Временно замените содержимое js/app.js следующим полным примером. HTML сохраняет подключение <script type="module" src="./js/app.js"></script>, а локальный сервер из прошлого урока продолжает показывать папку проекта:
import { courses } from './data.js';
console.log('Начало');
const operation = Promise.resolve(courses);
operation.then(value => {
console.log(`Получено курсов: ${value.length}`);
});
console.log('Продолжаем программу');
Для нашего обычного массива Promise.resolve создаёт уже успешно завершённое обещание. Однако обработчик then не вызывается внутри той же последовательности инструкций немедленно. Сначала завершается текущая синхронная работа, затем выполняется реакция обещания. В этом отдельном примере ожидаем сообщения:
Начало
Продолжаем программу
Получено курсов: 4
Здесь не измеряется длительность ожидания: секундомера нет, сеть не используется. Мы наблюдаем порядок зависимых действий. Не стоит заменять эту модель фразой «then выполняется через одну секунду»: фиксированной задержки программа не задаёт. Такой порядок позволяет одинаково присоединять обработчик к ещё ожидающему и уже завершённому обещанию.
Заметьте также, что value — имя параметра обработчика. Оно не становится доступно в следующей строке внешней программы. Чтобы использовать полученные записи, продолжите работу внутри обработчика или передайте результат дальше через цепочку. Попытка записать массив в переменную и сразу прочитать её снаружи вернёт к прежней ошибке: чтение может произойти раньше присваивания.
Цепочка преобразований каталога
Подключим знакомые функции модели. Замените весь js/app.js; изменяются только способ передачи данных и наблюдение результата, сами модули модели сохраняются:
import { courses } from './data.js';
import { selectCourses, countLessons } from './model.js';
Promise.resolve(courses)
.then(value => selectCourses(value, {
topic: 'frontend',
sort: 'lessons',
}))
.then(visible => {
console.log(visible.map(course => course.id).join(', '));
return countLessons(visible);
})
.then(total => {
console.log(`Уроков в отборе: ${total}`);
});
В исходных данных три курса имеют тему frontend: JavaScript, HTML/CSS и производительность. После сортировки по числу уроков порядок идентификаторов должен быть performance, html-css, javascript. Их значения lessons равны 12, 16 и 20, поэтому ожидаемая сумма составляет 48. Четвёртый курс Markdown относится к publishing и в этот отбор не попадает.
Первый обработчик возвращает выбранный массив. Второй получает его как visible, выводит идентификаторы и возвращает число. Третий уже работает с числом. Значение на каждом этапе определяется возвращаемым результатом предыдущего этапа, а не именем переменной. Правила реакций и создания следующего обещания формально задаются в спецификации ECMAScript.
Особенно важен return во втором обработчике. Если оставить вычисление countLessons(visible), но не вернуть его, следующий обработчик получит undefined. Внутри стрелочной функции с фигурными скобками неявного возврата выражения нет. В первом обработчике использована короткая форма без фигурных скобок, поэтому результат выражения возвращается автоматически.
Каждый вызов then возвращает новый Promise. Когда обработчик возвращает обычное значение, оно становится значением успеха следующего звена. Когда возвращает другой Promise, цепочка следует его завершению. Это понадобится при чтении тела HTTP-ответа: получение заголовков и получение разобранного JSON — два связанных асинхронных этапа. Общее руководство по построению цепочек есть в Using promises.
Ошибка внутри этапа
Теперь изменим условие: перед подсчётом убедимся, что отбор не пуст. Этот листинг снова заменяет весь js/app.js:
import { courses } from './data.js';
import { selectCourses, countLessons } from './model.js';
Promise.resolve(courses)
.then(value => selectCourses(value, { topic: 'unknown' }))
.then(visible => {
if (visible.length === 0) {
throw new Error('В учебном отборе нет курсов');
}
return countLessons(visible);
})
.then(total => console.log(`Уроков: ${total}`))
.catch(error => {
console.error('Не удалось получить итог:', error.message);
});
Модель допускает такое сравнение темы и возвращает пустой массив: ни одна запись не соответствует unknown. Мы специально считаем это ошибкой данной операции. После throw следующий обработчик успеха с подсчётом результата пропускается; причина поступает в catch. В обычном интерфейсе отсутствие подходящих курсов вполне может быть успешным пустым результатом. Здесь ошибку вводит наше правило, а не JavaScript автоматически.
Обработчик catch в этом примере только сообщает о проблеме. Если продолжить цепочку ещё одним then, он будет выполняться после нормально завершившегося catch. Поэтому нельзя незаметно возвращать [] из обработки сбоя и считать, что ошибка всё ещё передаётся дальше. Возврат запасных данных означает осознанное восстановление операции; повторный throw сохраняет её отклонённое состояние.
В конце цепочки полезно отделять завершение от успеха. finally подходит для освобождения занятого элемента управления, но не получает массив курсов как аргумент. Нормально завершившийся обработчик сохраняет прежний исход; исключение внутри него может изменить его. Поэтому очищающий код тоже должен оставаться простым и предсказуемым. Эти условия описаны в справочнике finally.
Два обработчика и одна операция
К одному operation можно присоединить два обработчика then. Оба получат результат той же операции; присоединение второго обработчика само по себе не начинает повторное получение курсов. Это удобно, когда одно завершение нужно показать в интерфейсе и отдельно записать в журнал. Однако два независимых обработчика не образуют последовательность: второй не получает значение, возвращённое первым. Для зависимости используйте цепочку, в которой следующий then вызван у обещания, возвращённого предыдущим.
Есть ещё одно отличие от обычного вызова функции. Если внутри обработчика выполняется тяжёлый синхронный цикл, Promise не переносит этот цикл в отдельный поток. Пока цикл работает, JavaScript продолжает занимать основной поток страницы. В нашей цепочке всего четыре записи, поэтому операции малы. При переходе к тысячам записей нужно будет анализировать стоимость обработки отдельно от способа получения результата. Слово «асинхронный» описывает порядок взаимодействия, но не обещает ускорение каждого вычисления.
У нас появился способ передавать результат между этапами и направлять сбой в отдельный обработчик. Сохраните модули каталога: в следующем уроке заменим локальный источник JSON-файлом и применим эту же цепочку к настоящему HTTP-обмену.