Асинхронность: 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,
а не значение "готово". Для ожидания нескольких исходов не проверяйте точные
миллисекунды: проверяйте порядок событий и конечные значения.
Объясните, чем отличается отказ источника от пустого результата и почему прекращение ожидания не гарантирует отмену серверного эффекта.