Метод catch

В этой статье поговорим про метод catch и его особенности, о которых многие не думают. Разберём, как catch «ловит ошибку в цепочке промисов», чем отличается вызов then с двумя аргументами от связки then + catch, и поговорим ещё немного про микротаски.

Как catch «ловит ошибку в цепочке»

Начнём с примера, который уже разбирался в статье про метод then. (Если вы её не читали — начните с неё.)

new Promise((_, reject) => reject(1)) .then(x => x * 2) .then(x => x * 5) .catch(console.log);

Где-то в начале «цепочки промисов» происходит реджект, а потом, каким-то неожиданным образом, в console.log приходит значение, которое зареджектилось в самом верхнем промисе. Что здесь на самом деле происходит?

Мы уже разбирали: на самом деле создаётся четыре промиса. Если у методов then нет второго аргумента (или вместо него передана не функция), он заменяется на x => { throw x; }.

const p1 = new Promise((_, reject) => reject(1)); const p2 = p1.then(x => x * 2, x => { throw x; }); const p3 = p2.then(x => x * 5, x => { throw x; }); const p4 = p3.catch(console.log);

Соответственно:

  • p1 режектится → p2 режектится тем же значением → p3 режектится тем же значением.
  • Метод catch обрабатывает реджект не первого промиса, а p3.

Ещё раз: метод catch — это просто метод конкретного промиса. Он подписан на p3 и реагирует на изменение состояния p3. То, что p3 оказался rejected со значением 1 (которое изначально было в p1) — следствие устройства цепочки. Сам catch про это ничего не знает: он отреагировал исключительно на состояние p3.

Аргумент catch обрабатывает реджект промиса p3.

Как работает then (повторение)

Метод then позволяет передать два аргумента (можно и пять, но последние три проигнорируются). Зачем два?

p.then(fn1, fn2); // обрабатывает fulfill и reject
  • Если произошёл fulfilled — выполняется первый колбэк.
  • Если произошёл rejected — выполняется второй колбэк.

Если хотим обрабатывать только fulfilled — передаём только первый аргумент:

p.then(fn1); // обрабатывает только fulfill

А если хотим обрабатывать только реджект? Можно передать первым аргументом не-функцию (например, null):

p.then(null, fn2); // обрабатывает только reject

И вот метод catch делает буквально то же самое:

p.catch(fn2); // делает то же самое

Реализация catch

Спойлер: дальше — решение задачи «реализуйте catch». Если хотите сделать сами, остановитесь здесь.

Возьмём промис p (неважно, в каком он состоянии) и подпишемся через catch. Чтобы понять, что происходит, подменим метод then своей функцией, которая выводит все переданные аргументы:

const p = new Promise(resolve => resolve("maxcode")); p.then = (...args) => console.log("args:", args); p.catch(function foo() {}); // args: [undefined, foo]

В консоли видим [undefined, foo]. Никакого другого кода здесь нет — только переприсваивание then и обычный синхронный вызов catch. Это доказывает, что:

  1. catch внутри себя вызывает then.
  2. Функция, переданная в catch, — это второй аргумент then.
  3. Первым аргументом then передаётся undefined.

То есть catch — это просто обёртка над then, вызванным с undefined и колбэком. Реализация:

Promise.prototype.catch = function(fn) { return this.then(undefined, fn); };

Если открыть спецификацию, там тоже буквально два шага: записать в переменную промис и вызвать на нём then(undefined, onRejected). catch — самый простой метод в теме промисов.

Что возвращает catch

const p = new Promise((_, reject) => reject("Ooops")); p.catch(reason => { ... });

Все знают, что при реджекте выполнится колбэк, в reason попадёт "Ooops". А что будет, если зарезолвить промис (сделать fulfilled) и всё равно вызвать catch?

const p = new Promise(resolve => resolve("maxcode")); p.catch(reason => { ... });

Колбэк не выполнится (он только для rejected). Но что возвращает этот вызов? Мы знаем, что catch — обёртка над then. Значит, такой вызов эквивалентен:

const p = new Promise(resolve => resolve("maxcode")); const p2 = p.catch(reason => { ... }); const p2 = p.then(undefined, reason => { ... }); const p2 = p.then(x => x, reason => { ... }); // undefined → x => x

Так как первый аргумент then не функция, он заменяется на x => x. Тогда:

  • Если p fulfilled, попадаем в x => x, возвращаем xp2 тоже fulfilled с тем же значением.

    Если p fulfilled, то p2 тоже fulfilled с тем же значением.

  • Если p rejected, состояние p2 зависит от колбэка, переданного в catch.

    Если p rejected, то состояние p2 зависит от колбэка.

Алгоритм определения статуса p2

(Подробнее — в статье про then.) Кратко:

  1. Вернули что-то хорошее (синхронно значение или fulfilled-промис) — статус fulfilled.
  2. Бросили что-то плохое (или вернули rejected-промис) — статус rejected.
  3. Вернули «зависший» промис — статус pending.

Когда использовать catch

Когда нам нужно обработать случай reject, но не нужно обрабатывать случай fulfill. Это единственный случай!

  • Нужно обработать и fulfill, и reject → используем then с двумя колбэками.
  • Нужно обработать только fulfill → передаём один аргумент в then.
  • Нужно обработать только reject → используем catch.

Других причин использовать catch нет.

Пример из жизни

Разберём, к чему приводит плохое понимание catch. Немного терминов:

  • API (application programming interface) описывает, как клиент может общаться с сервером.
  • UI (user interface) описывает, как человек может общаться с компьютером.
  • fetch — функция, выполняющая запросы к серверу. Её нет в самом языке JavaScript, но она есть в браузере и в Node.js.

Как правило, приложение разделяют на модули, чтобы код не превращался в кашу. Пусть есть файл API.js с функцией, делающей запрос:

// API.js function fetchProblems() { return fetch("https://maxcode.com/api/problems") // p1 .then(response => response.json()); // p2 }

И файл UI.js, где мы хотим загрузить задачи и вставить их в HTML:

// UI.js fetchProblems().then( problems => { body.innerHTML += problems.join(", "); }, reason => { body.innerHTML += `<h1>${reason}</h1>`; }, );

Обозначим промисы внутри fetchProblems как p1 и p2:

  1. Если всё ок — сначала p1 fulfilled, потом p2 fulfilled.
  2. Если нет интернетаp1 rejected, и p2 тоже rejected, потому что у then нет второго обработчика.
  3. Если интернет есть, но сервер не работает — не получится вызвать json, метод json вернёт rejected-промис, и p2 станет rejected.

Соблазн добавить catch

Возникает соблазн прицепить catch, чтобы логировать реджект (например, отправлять информацию об ошибках на отдельный, независимый сервер логирования):

// API.js function fetchProblems() { return fetch("https://maxcode.com/api/problems") // p1 .then(response => response.json()); // p2 .catch(reason => { logError(reason); }); // p3 }

Появляется новый промис p3. Что происходит?

Если p2 fulfilled:

  1. catch — это then со скрытым первым колбэком x => x.
  2. p3 становится fulfilled со значением из p2.
  3. Попадаем в первый callback в UI.js.
// UI.js fetchProblems().then( problems => { body.innerHTML += problems.join(", "); }, reason => { body.innerHTML += `<h1>${reason}</h1>`; }, // problems — массив из p2 );

Никакой проблемы нет.

Если p2 rejected — вот здесь баг:

  1. p2 rejected → вызывается колбэк catch.
  2. В нём возвращается undefined (мы ничего не вернули, только залогировали).
  3. Раз вернули значение — промис p3 становится fulfilled со значением undefined.
  4. Попадаем в первый callback в UI.js!
// UI.js fetchProblems().then( problems => { body.innerHTML += problems.join(", "); }, reason => { body.innerHTML += `<h1>${reason}</h1>`; }, // problems — undefined );

В переменную problems попадает undefined, и всё ломается на problems.join(", ") — хотя мы хотели как лучше и просто логировали ошибку.

Как правильно

Нужно в колбэке catch пробросить причину дальше через throw:

// API.js function fetchProblems() { return fetch("https://maxcode.com/api/problems") // p1 .then(response => response.json()); // p2 .catch(reason => { logError(reason); throw reason; }); // p3 }

Теперь, если p2 rejected:

  1. Срабатывает колбэк catch.
  2. В нём бросается причина реджекта p2.
  3. Промис p3 становится rejected по причине реджекта p2.
  4. Попадаем во второй callback в UI.js.
// UI.js fetchProblems().then( problems => { body.innerHTML += problems.join(", "); }, reason => { // reason — причина реджекта p2 body.innerHTML += `<h1>${reason}</h1>`; }, );

Очень важный момент: если мы используем catch где-то в середине цепочки, нужно помнить, что результатом этой цепочки скорее всего кто-то дальше пользуется. Если в catch не пробросить ошибку дальше, цепочка станет завершаться новым промисом с другим статусом — и это приведёт к проблемам.

В чём разница: then + catch vs then с двумя колбэками

Классический пример из статей про промисы — два способа подписаться:

promise .then(onFulfilled) .catch(onRejected); promise .then(onFulfilled, onRejected);

Основное отличие: в первом случае три промиса, во втором — два.

const p1 = promise; const p2 = p1.then(onFulfilled); const p3 = p2.catch(onRejected); const p4 = promise; const p5 = p4.then(onFulfilled, onRejected);

В первом примере catch вызывается не на исходном promise, а на новом промисе p2, вернувшемся из then. Поэтому есть p1, p2, p3. Во втором случае — только p4 и p5.

Конкретное поведение зависит от статуса promise, содержимого onFulfilled и содержимого onRejected. Учитывая всю предыдущую теорию, разобрать это можно самостоятельно.

Разница с точки зрения очереди микротасков

then с двумя колбэками

promise .then(x => console.log("A"), x => console.log("B")); queueMicrotask(() => console.log("C"));

queueMicrotask отправляет функцию в очередь микротасков без промисов — то есть колбэк можно отправить в очередь и обычной стандартной функцией, не только через then/catch/finally.

Если promise fulfilled:

  1. A уходит в очередь, C уходит в очередь.
  2. A выводится.
  3. C выводится.

Если promise rejected:

  1. B уходит в очередь, C уходит в очередь.
  2. B выводится.
  3. C выводится.

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

then + catch

promise.then(x => console.log("A")) .catch(x => console.log("B")); queueMicrotask(() => console.log("C"));

Если promise fulfilled:

  1. A уходит в очередь, C уходит в очередь.
  2. A выводится; промис, возвращённый then, становится fulfilled, и в очередь уходит скрытая функция x => x (на которую заменился undefined первый аргумент catch).
  3. C выводится.
  4. x => x выполняется.

Если promise rejected:

  1. x => { throw x; } уходит в очередь (скрытая функция вместо отсутствующего второго аргумента then), C уходит в очередь.
  2. x => { throw x; } выполняется; промис, возвращённый then, становится rejected, и в очередь уходит колбэк catchB уходит в очередь.
  3. C выводится.
  4. B выводится.

Итоговое сравнение

// then с двумя колбэками promise .then(x => console.log("A"), x => console.log("B")); queueMicrotask(() => console.log("C")); // Вывод: B, C
// then + catch promise.then(x => console.log("A")) .catch(x => console.log("B")); queueMicrotask(() => console.log("C")); // Вывод: C, B

Практически одинаковый по смыслу код даёт разный порядок вывода. Помимо очевидных различий (где-то может пойматься ошибка), есть разница в количестве тиков работы с очередью микротасков:

  • В случае then с двумя колбэками всё прозрачно: один раз отправили в очередь, один раз забрали.
  • В случае then + catch происходит больше событий. Связка then + catch порождает на один промис больше, а значит — на один вызов микротаски и на один шаг работы с очередью больше. От этого меняется порядок вывода.

Вывод: будьте аккуратны. Иногда бывают задачи, где даже один лишний отложенный вызов через очередь микротасков может повлиять на результат. Поэтому нужно знать, как работает then и что на самом деле делает catch.