В этой статье поговорим про метод catch и его особенности, о которых многие не думают. Разберём, как catch «ловит ошибку в цепочке промисов», чем отличается вызов then с двумя аргументами от связки then + 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 позволяет передать два аргумента (можно и пять, но последние три проигнорируются). Зачем два?
p.then(fn1, fn2); // обрабатывает fulfill и reject
fulfilled — выполняется первый колбэк.rejected — выполняется второй колбэк.Если хотим обрабатывать только fulfilled — передаём только первый аргумент:
p.then(fn1); // обрабатывает только fulfill
А если хотим обрабатывать только реджект? Можно передать первым аргументом не-функцию (например, null):
p.then(null, fn2); // обрабатывает только reject
И вот метод catch делает буквально то же самое:
p.catch(fn2); // делает то же самое
Спойлер: дальше — решение задачи «реализуйте
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. Это доказывает, что:
catch внутри себя вызывает then.catch, — это второй аргумент then.then передаётся undefined.То есть catch — это просто обёртка над then, вызванным с undefined и колбэком. Реализация:
Promise.prototype.catch = function(fn) { return this.then(undefined, fn); };
Если открыть спецификацию, там тоже буквально два шага: записать в переменную промис и вызвать на нём then(undefined, onRejected). 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, возвращаем x → p2 тоже fulfilled с тем же значением.
Если
pfulfilled, тоp2тоже fulfilled с тем же значением.
Если p rejected, состояние p2 зависит от колбэка, переданного в catch.
Если
prejected, то состояниеp2зависит от колбэка.
(Подробнее — в статье про then.) Кратко:
fulfilled-промис) — статус fulfilled.rejected-промис) — статус rejected.pending.Когда нам нужно обработать случай
reject, но не нужно обрабатывать случайfulfill. Это единственный случай!
fulfill, и reject → используем then с двумя колбэками.fulfill → передаём один аргумент в then.reject → используем catch.Других причин использовать catch нет.
Разберём, к чему приводит плохое понимание catch. Немного терминов:
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:
p1 fulfilled, потом p2 fulfilled.p1 rejected, и p2 тоже rejected, потому что у then нет второго обработчика.json, метод json вернёт rejected-промис, и p2 станет rejected.Возникает соблазн прицепить 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:
catch — это then со скрытым первым колбэком x => x.p3 становится fulfilled со значением из p2.UI.js.// UI.js fetchProblems().then( problems => { body.innerHTML += problems.join(", "); }, reason => { body.innerHTML += `<h1>${reason}</h1>`; }, // problems — массив из p2 );
Никакой проблемы нет.
Если p2 rejected — вот здесь баг:
p2 rejected → вызывается колбэк catch.undefined (мы ничего не вернули, только залогировали).p3 становится fulfilled со значением undefined.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:
catch.p2.p3 становится rejected по причине реджекта p2.UI.js.// UI.js fetchProblems().then( problems => { body.innerHTML += problems.join(", "); }, reason => { // reason — причина реджекта p2 body.innerHTML += `<h1>${reason}</h1>`; }, );
Очень важный момент: если мы используем
catchгде-то в середине цепочки, нужно помнить, что результатом этой цепочки скорее всего кто-то дальше пользуется. Если вcatchне пробросить ошибку дальше, цепочка станет завершаться новым промисом с другим статусом — и это приведёт к проблемам.
Классический пример из статей про промисы — два способа подписаться:
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. Учитывая всю предыдущую теорию, разобрать это можно самостоятельно.
promise .then(x => console.log("A"), x => console.log("B")); queueMicrotask(() => console.log("C"));
queueMicrotaskотправляет функцию в очередь микротасков без промисов — то есть колбэк можно отправить в очередь и обычной стандартной функцией, не только черезthen/catch/finally.
Если promise fulfilled:
A уходит в очередь, C уходит в очередь.A выводится.C выводится.Если promise rejected:
B уходит в очередь, C уходит в очередь.B выводится.C выводится.В обоих случаях — один тик работы с очередью: по одному разу что-то отправили, по одному разу забрали в том же порядке.
promise.then(x => console.log("A")) .catch(x => console.log("B")); queueMicrotask(() => console.log("C"));
Если promise fulfilled:
A уходит в очередь, C уходит в очередь.A выводится; промис, возвращённый then, становится fulfilled, и в очередь уходит скрытая функция x => x (на которую заменился undefined первый аргумент catch).C выводится.x => x выполняется.Если promise rejected:
x => { throw x; } уходит в очередь (скрытая функция вместо отсутствующего второго аргумента then), C уходит в очередь.x => { throw x; } выполняется; промис, возвращённый then, становится rejected, и в очередь уходит колбэк catch → B уходит в очередь.C выводится.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.