Учебник веб-разработки
Разделы учебника
На этой странице

II–III. JS, TypeScript и модели данных

Асинхронность: Promise, ошибки и отмена

Оглавление · Типы · Вычисления и эффекты

Задача: получить результат операции, которая завершится позже, обработать отказ и не принять устаревший ответ за новый. Нужны функции и граница эффектов. Примеры выполняются локально, без живой CMS. Блоки JavaScript можно сохранять в отдельные .mjs и запускать через node имя-файла.mjs.

Promise — один будущий исход

Promise находится в pending, пока операция не завершена; затем становится fulfilled со значением или rejected с причиной. Переход в конечное состояние не повторяется. Promise не является потоком событий и сам не хранит прогресс.

Исходник схемы
stateDiagram-v2
  [*] --> Pending
  Pending --> Fulfilled: Получено значение
  Pending --> Rejected: Получен отказ

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

async-функция возвращает Promise. await получает значение успешного исхода либо выбрасывает причину отказа в текущую функцию. async function.

async function readTitle() {
  return "Открытие";
}
const pendingTitle = readTitle();
console.log(pendingTitle instanceof Promise); // true
console.log(await pendingTitle); // "Открытие"

На верхнем уровне await разрешён в ESM-модуле. В обычной функции без async такой синтаксис использовать нельзя.

Ожидание отдаёт управление

const order = [];
async function record() {
  order.push("до await");
  await Promise.resolve();
  order.push("после await");
}
const task = record();
order.push("вызывающий код");
await task;
console.log(order); // ["до await", "вызывающий код", "после await"]

До первого await функция выполняется синхронно. Продолжение после ожидания планируется отдельно, даже для уже выполненного Promise. await не блокирует весь процесс и не создаёт отдельный поток для JS-кода. Долгое синхронное вычисление всё ещё удерживает выполнение других JS-обработчиков. Поведение await.

Исходник схемы
sequenceDiagram
  participant Caller as Вызывающий код
  participant Fn as async-функция
  Caller->>Fn: Вызов
  Fn->>Fn: Синхронная часть до await
  Fn-->>Caller: Promise, функция приостановлена
  Caller->>Caller: Продолжить свою работу
  Fn->>Fn: Возобновиться после ожидания
  Fn-->>Caller: Завершить возвращённый Promise

Стрелки здесь показывают порядок управления. Возвращение Promise из вызова не означает завершения операции.

Кто отвечает за ошибку

async function readFixture(mode) {
  if (mode === "failure") throw new Error("Учебный источник недоступен");
  return ["Открытие"];
}
try {
  const titles = await readFixture("failure");
  console.log(titles);
} catch (error) {
  console.log(error instanceof Error ? error.message : "Неизвестная ошибка");
}

Ошибка внутри async становится отказом возвращённого Promise. Чтобы поймать её обычным try/catch, надо ожидать этот Promise внутри try. try { readFixture("failure"); } не обработает будущий отказ. Для цепочки можно использовать .catch(...); обязанность обработать отказ не исчезает, если результат вызова никому не нужен.

Ошибка источника и пустой список — разные исходы. catch { return []; } скрывает отказ под видом корректных пустых данных. Выбирайте такую политику только когда это действительно смысл модели. Здесь отказ показываем отдельно.

Для fetch статус HTTP 404/500 сам по себе не означает rejected Promise: нужно проверить response.ok. Затем может отдельно не удаться чтение JSON или его runtime-валидация. Эти границы не стоит объединять в «запрос сработал». Проверка HTTP-статуса fetch.

Зависимые и независимые операции

async function readPart(name) {
  return `${name}: учебные данные`;
}
const [news, events] = await Promise.all([readPart("Новости"), readPart("События")]);
console.log(news, events);

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

Для зависимых действий нужен порядок: получить идентификатор → загрузить запись по нему. Для независимых результатов можно ждать группу. Если нужен отчёт по каждому исходу, используйте Promise.allSettled и разберите варианты fulfilled/rejected. Promise.race выбирает первый завершившийся исход, но тоже не останавливает проигравшие операции.

array.forEach(async ...) не собирает Promise callback-функций в группу. Для последовательной работы используйте for...of с await; для независимой — Promise.all(array.map(...)). Большому набору запросов нужна ограниченная конкурентность: общий Promise.all не ограничивает нагрузку.

Отмена — договор с операцией

AbortController передаёт сигнал отмены API, который умеет его учитывать. Вызов abort() не отменяет произвольный Promise и не откатывает уже выполненную запись на сервере. AbortController.

Локальный пример использует таймер Node, поддерживающий AbortSignal:

import { setTimeout as delay } from "node:timers/promises";

const controller = new AbortController();
const waiting = delay(1000, "готово", { signal: controller.signal });
controller.abort();
try {
  console.log(await waiting);
} catch (error) {
  console.log(error instanceof Error ? error.name : "Неизвестная ошибка"); // AbortError
}

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

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

Таймаут также ограничивает ожидание на стороне клиента. Если ответ записи потерян, таймаут не доказывает, что сервер не выполнил запись. Безопасность повтора определяется смыслом операции и её контрактом, а не Promise.

Как работает CMS-клиент Atmanki

Клиент использует AbortSignal.timeout(10000), проверяет response.ok и ожидает JSON внутри try. Отказ оборачивается в CmsUnavailableError с исходной причиной cause. Встроенного повтора запроса здесь нет. Десятисекундный сигнал относится к отдельному запросу, а не всему обходу страниц CMS.

list запрашивает страницы последовательно: номер следующей страницы зависит от текущей, форма ответа проверяется Zod. Нельзя попутно заменить этот обход на тысячу параллельных запросов. Кеширование Next и режим no-store — отдельные свойства клиента; async сам по себе не задаёт свежесть данных.

Практика и самопроверка

Сначала предскажите порядок вывода примера record, затем выполните его. В readFixture проверьте успех и отказ; не отправляйте запросы в production. Для проверки конкурентных исходов создайте два Promise с вручную сохранёнными resolve/reject: подключите обработку общей группы, завершите второй раньше первого и проверьте порядок результата. В отдельном случае отвергните один, затем завершите другой и убедитесь, что тот не был автоматически отменён.

Повторите пример таймера с уже отменённым сигналом. Ожидается AbortError, а не значение "готово". Для ожидания нескольких исходов не проверяйте точные миллисекунды: проверяйте порядок событий и конечные значения.

Объясните, чем отличается отказ источника от пустого результата и почему прекращение ожидания не гарантирует отмену серверного эффекта.