- Распространенные ошибки при работе с промисами в JavaScript, о которых должен знать каждый
- Ошибка № 1. Использование блока try/catch внутри промиса
- Ошибка № 2. Использование асинхронной функции внутри промиса
- Ошибка № 3. Забывать про .catch()
- Ошибка № 4. Не использовать Promise.all()
- Ошибка № 5. Неправильное использование Promise.race()
- Ошибка № 6. Злоупотребление промисами
- Промисы: обработка ошибок
- Неявный try…catch
- Пробрасывание ошибок
- Необработанные ошибки
- Итого
Распространенные ошибки при работе с промисами в JavaScript, о которых должен знать каждый
Хотел бы я знать об этих ошибках, когда изучал JavaScript и промисы.
Всякий раз, когда ко мне обращается какой-нибудь разработчик и жалуется на то, что его код не работает или медленно выполняется, я прежде всего обращаю внимание на эти ошибки. Когда я начал программировать 4 года назад, я не знал о них и привык их игнорировать. Однако после назначения в проект, который обрабатывает около миллиона запросов в течение нескольких минут, у меня не было другого выбора, кроме как оптимизировать свой код (поскольку мы достигли уровня, когда дальнейшее вертикальное масштабирование стало невозможным).
Поэтому в данной статье я бы хотел поговорить о самых распространенных ошибках при работе с промисами в JS, на которые многие не обращают внимания.
Ошибка № 1. Использование блока try/catch внутри промиса
Использовать блок try/catch внутри промиса нецелесообразно, поскольку если Ваш код выдаст ошибку (внутри промиса), она будет перехвачена обработчиком ошибок самого промиса.
new Promise((resolve, reject) => < try < const data = someFunction() // ваш код resolve() >catch(e) < reject(e) >>) .then(data => console.log(data)) .catch(error => console.log(error))
new Promise((resolve, reject) => < const data = someFunction() // ваш код resolve(data) >) .then(data => console.log(data)) .catch(error => console.log(error))
Это будет работать всегда, за исключением случая, описанного ниже.
Ошибка № 2. Использование асинхронной функции внутри промиса
При использовании асинхронной функции внутри промиса возникают некоторые неприятные побочные эффекты.
Допустим, Вы решили выполнить некоторую асинхронную задачу, добавили в промис ключевое слово «async», и Ваш код выдает ошибку. Однако теперь Вы не можете обработать эту ошибку ни с помощью .catch(), ни с помощью await:
// этот код не сможет перехватить ошибку new Promise(async() => < throw new Error('message') >).catch(e => console.log(e.message)) // этот код также не сможет перехватить ошибку (async() => < try < await new Promise(async() =>< throw new Error('message') >) > catch(e) < console.log(e.message) >>)();
Каждый раз, когда я встречаю асинхронную функцию внутри промиса, я пытаюсь их разделить. И у меня это получается в 9 из 10 случаев. Тем не менее, это не всегда возможно. В таком случае у Вас нет другого выбора, кроме как использовать блок try/catch внутри промиса (да, это противоречит первой ошибке, но это единственный выход):
new Promise(async(resolve, reject) => < try < throw new Error('message') >catch(error) < reject(error) >>).catch(e => console.log(e.message)) // или используя async/await (async() => < try < await new Promise(async(resolve, reject) => < try < throw new Error('message') >catch(error) < reject(error) >>) > catch(e) < console.log(e.message) >>)();
Ошибка № 3. Забывать про .catch()
Эта одна из тех ошибок, о существовании которой даже не подозреваешь, пока не начнется тестирование. Либо, если Вы какой-нибудь атеист, который не верит в тесты, Ваш код обязательно рухнет в продакшне. Потому что продакшн строго следует закону Мерфи, который гласит: «Anything that can go wrong will go wrong» (можно перевести так: «Если что-то может пойти не так, это обязательно произойдет»; аналогией в русском языке является «закон подлости» — прим. пер.).
Для того, чтобы сделать код элегантнее, можно обернуть промис в try/catch вместо использования .then().catch().
Ошибка № 4. Не использовать Promise.all()
Если Вы профессиональный разработчик, Вы наверняка понимаете, что я хочу сказать. Если у Вас есть несколько не зависящих друг от друга промисов, Вы можете выполнить их одновременно. По умолчанию, промисы выполняются параллельно, однако если Вам необходимо выполнить их последовательно (с помощью await), это займет много времени. Promise.all() позволяет сильно сократить время ожидания:
const = require('util') const sleep = promisify(setTimeout) async function f1() < await sleep(1000) >async function f2() < await sleep(2000) >async function f3() < await sleep(3000) >// выполняем последовательно (async() => < console.time('sequential') await f1() await f2() await f3() console.timeEnd('sequential') // около 6 секунд >)();
Ошибка № 5. Неправильное использование Promise.race()
Promise.race() не всегда делает Ваш код быстрее.
Это может показаться странным, но это действительно так. Я не утверждаю, что Promise.race() — бесполезный метод, но Вы должны четко понимать, зачем его используете.
Вы, например, можете использовать Promise.race() для запуска кода после разрешения любого из промисов. Но это не означает, что выполнение кода, следующего за промисами, начнется сразу же после разрешения одного из них. Promise.race() будет ждать разрешения всех промисов и только после этого освободит поток:
const = require('util') const sleep = promisify(setTimeout) async function f1() < await sleep(1000) >async function f2() < await sleep(2000) >async function f3() < await sleep(3000) >(async() => < console.time('race') await Promise.race([f1(), f2(), f3()]) >)(); process.on('exit', () => < console.timeEnd('race') // около 3 секунд, код не стал быстрее! >)
Ошибка № 6. Злоупотребление промисами
Промисы делают код медленнее, так что не злоупотребляйте ими.
Часто приходится видеть разработчиков, использующих длинную цепочку .then(), чтобы их код выглядел лучше. Вы и глазом не успеете моргнуть, как эта цепочка станет слишком длинной. Для того, чтобы наглядно убедиться в негативных последствиях такой ситуации, необходимо (далее я немного отступлю от оригинального текста для того, чтобы описать процесс подробнее, нежели в статье — прим. пер.):
1) создать файл script.js следующего содержания (с лишними промисами):
new Promise((resolve) => < // некий код, возвращающий данные пользователя const user = < name: 'John Doe', age: 50, >resolve(user) >).then(userObj => < const = userObj return age >).then(age => < if(age >25) < return true >throw new Error('Age is less than 25') >).then(() => < console.log('Age is greater than 25') >).catch(e => < console.log(e.message) >)
2) открыть командную строку (для пользователей Windows: чтобы открыть командную строку в папке с нужным файлом, зажимаем Shift, кликаем правой кнопкой мыши, выбираем «Открыть окно команд»), запустить script.js с помощью следующей команды (должен быть установлен Node.js):
node --trace-events-enabled script.js
3) Node.js создает файл журнала (в моем случае node_trace.1.txt) в папке со скриптом;
4) открываем Chrome (потому что это работает только в нем), вводим в адресной строке «chrome://tracing»;
5) нажимаем Load, загружаем файл журнала, созданного Node.js;
6) открываем вкладку Promise.
Видим примерно следующее:
Зеленые блоки — промисы, выполнение каждого из которых занимает несколько миллисекунд. Следовательно, чем больше будет промисов, тем дольше они будут выполняться.
new Promise((resolve, reject) => < const user = < name: 'John Doe', age: 50, >if(user.age > 25) < resolve() >else < reject('Age is less than 25') >>).then(() => < console.log('Age is greater than 25') >).catch(e => < console.log(e.message) >)
Видим следующее:
Зеленых блоков (промисов) стало меньше, а значит время выполнения кода сократилось.
Таким образом, использовать несколько промисов следует только в том случае, если Вам необходимо выполнить некоторый асинхронный код.
Промисы: обработка ошибок
Цепочки промисов отлично подходят для перехвата ошибок. Если промис завершается с ошибкой, то управление переходит в ближайший обработчик ошибок. На практике это очень удобно.
Например, в представленном ниже примере для fetch указана неправильная ссылка (сайт не существует), и .catch перехватывает ошибку:
fetch('https://no-such-server.blabla') // ошибка .then(response => response.json()) .catch(err => alert(err)) // TypeError: failed to fetch (текст может отличаться)
Как видно, .catch не обязательно должен быть сразу после ошибки, он может быть далее, после одного или даже нескольких .then
Или, может быть, с сервером всё в порядке, но в ответе мы получим некорректный JSON. Самый лёгкий путь перехватить все ошибки – это добавить .catch в конец цепочки:
fetch('/article/promise-chaining/user.json') .then(response => response.json()) .then(user => fetch(`https://api.github.com/users/$`)) .then(response => response.json()) .then(githubUser => new Promise((resolve, reject) => < let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = "promise-avatar-example"; document.body.append(img); setTimeout(() =>< img.remove(); resolve(githubUser); >, 3000); >)) .catch(error => alert(error.message));
Если все в порядке, то такой .catch вообще не выполнится. Но если любой из промисов будет отклонён (проблемы с сетью или некорректная json-строка, или что угодно другое), то ошибка будет перехвачена.
Неявный try…catch
Вокруг функции промиса и обработчиков находится «невидимый try..catch «. Если происходит исключение, то оно перехватывается, и промис считается отклонённым с этой ошибкой.
new Promise((resolve, reject) => < throw new Error("Ошибка!"); >).catch(alert); // Error: Ошибка!
…Работает так же, как и этот:
new Promise((resolve, reject) => < reject(new Error("Ошибка!")); >).catch(alert); // Error: Ошибка!
«Невидимый try..catch » вокруг промиса автоматически перехватывает ошибку и превращает её в отклонённый промис.
Это работает не только в функции промиса, но и в обработчиках. Если мы бросим ошибку ( throw ) из обработчика ( .then ), то промис будет считаться отклонённым, и управление перейдёт к ближайшему обработчику ошибок.
new Promise((resolve, reject) => < resolve("ок"); >).then((result) => < throw new Error("Ошибка!"); // генерируем ошибку >).catch(alert); // Error: Ошибка!
Это происходит для всех ошибок, не только для тех, которые вызваны оператором throw . Например, программная ошибка:
new Promise((resolve, reject) => < resolve("ок"); >).then((result) => < blabla(); // нет такой функции >).catch(alert); // ReferenceError: blabla is not defined
Финальный .catch перехватывает как промисы, в которых вызван reject , так и случайные ошибки в обработчиках.
Пробрасывание ошибок
Как мы уже заметили, .catch ведёт себя как try..catch . Мы можем иметь столько обработчиков .then , сколько мы хотим, и затем использовать один .catch в конце, чтобы перехватить ошибки из всех обработчиков.
В обычном try..catch мы можем проанализировать ошибку и повторно пробросить дальше, если не можем её обработать. То же самое возможно для промисов.
Если мы пробросим ( throw ) ошибку внутри блока .catch , то управление перейдёт к следующему ближайшему обработчику ошибок. А если мы обработаем ошибку и завершим работу обработчика нормально, то продолжит работу ближайший успешный обработчик .then .
В примере ниже .catch успешно обрабатывает ошибку:
// the execution: catch -> then new Promise((resolve, reject) => < throw new Error("Ошибка!"); >).catch(function(error) < alert("Ошибка обработана, продолжить работу"); >).then(() => alert("Управление перейдёт в следующий then"));
Здесь блок .catch завершается нормально. Поэтому вызывается следующий успешный обработчик .then .
В примере ниже мы видим другую ситуацию с блоком .catch . Обработчик (*) перехватывает ошибку и не может обработать её (например, он знает как обработать только URIError ), поэтому ошибка пробрасывается далее:
// the execution: catch -> catch -> then new Promise((resolve, reject) => < throw new Error("Ошибка!"); >).catch(function(error) < // (*) if (error instanceof URIError) < // обрабатываем ошибку >else < alert("Не могу обработать ошибку"); throw error; // пробрасывает эту или другую ошибку в следующий catch >>).then(function() < /* не выполнится */ >).catch(error => < // (**) alert(`Неизвестная ошибка: $`); // ничего не возвращаем => выполнение продолжается в нормальном режиме >);
Управление переходит от первого блока .catch (*) к следующему (**) , вниз по цепочке.
Необработанные ошибки
Что произойдёт, если ошибка не будет обработана? Например, мы просто забыли добавить .catch в конец цепочки, как здесь:
new Promise(function() < noSuchFunction(); // Ошибка (нет такой функции) >) .then(() => < // обработчики .then, один или более >); // без .catch в самом конце!
В случае ошибки выполнение должно перейти к ближайшему обработчику ошибок. Но в примере выше нет никакого обработчика. Поэтому ошибка как бы «застревает», её некому обработать.
На практике, как и при обычных необработанных ошибках в коде, это означает, что что-то пошло сильно не так.
Что происходит, когда обычная ошибка не перехвачена try..catch ? Скрипт умирает с сообщением в консоли. Похожее происходит и в случае необработанной ошибки промиса.
JavaScript-движок отслеживает такие ситуации и генерирует в этом случае глобальную ошибку. Вы можете увидеть её в консоли, если запустите пример выше.
В браузере мы можем поймать такие ошибки, используя событие unhandledrejection :
window.addEventListener('unhandledrejection', function(event) < // объект события имеет два специальных свойства: alert(event.promise); // [object Promise] - промис, который сгенерировал ошибку alert(event.reason); // Error: Ошибка! - объект ошибки, которая не была обработана >); new Promise(function() < throw new Error("Ошибка!"); >); // нет обработчика ошибок
Это событие является частью стандарта HTML.
Если происходит ошибка, и отсутствует её обработчик, то генерируется событие unhandledrejection , и соответствующий объект event содержит информацию об ошибке.
Обычно такие ошибки неустранимы, поэтому лучше всего – информировать пользователя о проблеме и, возможно, отправить информацию об ошибке на сервер.
В не-браузерных средах, таких как Node.js, есть другие способы отслеживания необработанных ошибок.
Итого
- .catch перехватывает все виды ошибок в промисах: будь то вызов reject() или ошибка, брошенная в обработчике при помощи throw .
- .then также перехватывает ошибки таким же образом, если задан второй аргумент (который является обработчиком ошибок).
- Необходимо размещать .catch там, где мы хотим обработать ошибки и знаем, как это сделать. Обработчик может проанализировать ошибку (могут быть полезны пользовательские классы ошибок) и пробросить её, если ничего не знает о ней (возможно, это программная ошибка).
- Можно и совсем не использовать .catch , если нет нормального способа восстановиться после ошибки.
- В любом случае нам следует использовать обработчик события unhandledrejection (для браузеров и аналог для других окружений), чтобы отслеживать необработанные ошибки и информировать о них пользователя (и, возможно, наш сервер), благодаря чему наше приложение никогда не будет «просто умирать».