Меню

Then js обработка ошибок

Ошибки — это хорошо. Автор материала, перевод которого мы сегодня публикуем, говорит, что уверен в том, что эта идея известна всем. На первый взгляд ошибки кажутся чем-то страшным. Им могут сопутствовать какие-то потери. Ошибка, сделанная на публике, вредит авторитету того, кто её совершил. Но, совершая ошибки, мы на них учимся, а значит, попадая в следующий раз в ситуацию, в которой раньше вели себя неправильно, делаем всё как нужно.

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

Этот материал, посвящённый обработке ошибок в JavaScript, разбит на три части. Сначала мы сделаем общий обзор системы обработки ошибок в JavaScript и поговорим об объектах ошибок. После этого мы поищем ответ на вопрос о том, что делать с ошибками, возникающими в серверном коде (в частности, при использовании связки Node.js + Express.js). Далее — обсудим обработку ошибок в React.js. Фреймворки, которые будут здесь рассматриваться, выбраны по причине их огромной популярности. Однако рассматриваемые здесь принципы работы с ошибками универсальны, поэтому вы, даже если не пользуетесь Express и React, без труда сможете применить то, что узнали, к тем инструментам, с которыми работаете.

Код демонстрационного проекта, используемого в данном материале, можно найти в этом репозитории.

1. Ошибки в JavaScript и универсальные способы работы с ними

Если в вашем коде что-то пошло не так, вы можете воспользоваться следующей конструкцией.

throw new Error('something went wrong')

В ходе выполнения этой команды будет создан экземпляр объекта Error и будет сгенерировано (или, как говорят, «выброшено») исключение с этим объектом. Инструкция throw может генерировать исключения, содержащие произвольные выражения. При этом выполнение скрипта остановится в том случае, если не были предприняты меры по обработке ошибки.

Начинающие JS-программисты обычно не используют инструкцию throw. Они, как правило, сталкиваются с исключениями, выдаваемыми либо средой выполнения языка, либо сторонними библиотеками. Когда это происходит — в консоль попадает нечто вроде ReferenceError: fs is not defined и выполнение программы останавливается.

▍Объект Error

У экземпляров объекта Error есть несколько свойств, которыми мы можем пользоваться. Первое интересующее нас свойство — message. Именно сюда попадает та строка, которую можно передать конструктору ошибки в качестве аргумента. Например, ниже показано создание экземпляра объекта Error и вывод в консоль переданной конструктором строки через обращение к его свойству message.

const myError = new Error('please improve your code')
console.log(myError.message) // please improve your code

Второе свойство объекта, очень важное, представляет собой трассировку стека ошибки. Это — свойство stack. Обратившись к нему можно просмотреть стек вызовов (историю ошибки), который показывает последовательность операций, приведшую к неправильной работе программы. В частности, это позволяет понять — в каком именно файле содержится сбойный код, и увидеть, какая последовательность вызовов функций привела к ошибке. Вот пример того, что можно увидеть, обратившись к свойству stack.

Error: please improve your code
 at Object.<anonymous> (/Users/gisderdube/Documents/_projects/hacking.nosync/error-handling/src/general.js:1:79)
 at Module._compile (internal/modules/cjs/loader.js:689:30)
 at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
 at Module.load (internal/modules/cjs/loader.js:599:32)
 at tryModuleLoad (internal/modules/cjs/loader.js:538:12)
 at Function.Module._load (internal/modules/cjs/loader.js:530:3)
 at Function.Module.runMain (internal/modules/cjs/loader.js:742:12)
 at startup (internal/bootstrap/node.js:266:19)
 at bootstrapNodeJSCore (internal/bootstrap/node.js:596:3)

Здесь, в верхней части, находится сообщение об ошибке, затем следует указание на тот участок кода, выполнение которого вызвало ошибку, потом описывается то место, откуда был вызван этот сбойный участок. Это продолжается до самого «дальнего» по отношению к ошибке фрагмента кода.

▍Генерирование и обработка ошибок

Создание экземпляра объекта Error, то есть, выполнение команды вида new Error(), ни к каким особым последствиям не приводит. Интересные вещи начинают происходить после применения оператора throw, который генерирует ошибку. Как уже было сказано, если такую ошибку не обработать, выполнение скрипта остановится. При этом нет никакой разницы — был ли оператор throw использован самим программистом, произошла ли ошибка в некоей библиотеке или в среде выполнения языка (в браузере или в Node.js). Поговорим о различных сценариях обработки ошибок.

▍Конструкция try…catch

Блок try...catch представляет собой самый простой способ обработки ошибок, о котором часто забывают. В наши дни, правда, он используется гораздо интенсивнее чем раньше, благодаря тому, что его можно применять для обработки ошибок в конструкциях async/await.

Этот блок можно использовать для обработки любых ошибок, происходящих в синхронном коде. Рассмотрим пример.

const a = 5

try {
    console.log(b) // переменная b не объявлена - возникает ошибка
} catch (err) {
    console.error(err) // в консоль попадает сообщение об ошибке и стек ошибки
}

console.log(a) // выполнение скрипта не останавливается, данная команда выполняется

Если бы в этом примере мы не заключили бы сбойную команду console.log(b) в блок try...catch, то выполнение скрипта было бы остановлено.

▍Блок finally

Иногда случается так, что некий код нужно выполнить независимо от того, произошла ошибка или нет. Для этого можно, в конструкции try...catch, использовать третий, необязательный, блок — finally. Часто его использование эквивалентно некоему коду, который идёт сразу после try...catch, но в некоторых ситуациях он может пригодиться. Вот пример его использования.

const a = 5

try {
    console.log(b) // переменная b не объявлена - возникает ошибка
} catch (err) {
    console.error(err) // в консоль попадает сообщение об ошибке и стек ошибки
} finally {
    console.log(a) // этот код будет выполнен в любом случае
}

▍Асинхронные механизмы — коллбэки

Программируя на JavaScript всегда стоит обращать внимание на участки кода, выполняющиеся асинхронно. Если у вас имеется асинхронная функция и в ней возникает ошибка, скрипт продолжит выполняться. Когда асинхронные механизмы в JS реализуются с использованием коллбэков (кстати, делать так не рекомендуется), соответствующий коллбэк (функция обратного вызова) обычно получает два параметра. Это нечто вроде параметра err, который может содержать ошибку, и result — с результатами выполнения асинхронной операции. Выглядит это примерно так:

myAsyncFunc(someInput, (err, result) => {
    if(err) return console.error(err) // порядок работы с объектом ошибки мы рассмотрим позже
    console.log(result)
})

Если в коллбэк попадает ошибка, она видна там в виде параметра err. В противном случае в этот параметр попадёт значение undefined или null. Если оказалось, что в err что-то есть, важно отреагировать на это, либо так как в нашем примере, воспользовавшись командой return, либо воспользовавшись конструкцией if...else и поместив в блок else команды для работы с результатом выполнения асинхронной операции. Речь идёт о том, чтобы, в том случае, если произошла ошибка, исключить возможность работы с результатом, параметром result, который в таком случае может иметь значение undefined. Работа с таким значением, если предполагается, например, что оно содержит объект, сама может вызвать ошибку. Скажем, это произойдёт при попытке использовать конструкцию result.data или подобную ей.

▍Асинхронные механизмы — промисы

Для выполнения асинхронных операций в JavaScript лучше использовать не коллбэки а промисы. Тут, в дополнение к улучшенной читабельности кода, имеются и более совершенные механизмы обработки ошибок. А именно, возиться с объектом ошибки, который может попасть в функцию обратного вызова, при использовании промисов не нужно. Здесь для этой цели предусмотрен специальный блок catch. Он перехватывает все ошибки, произошедшие в промисах, которые находятся до него, или все ошибки, которые произошли в коде после предыдущего блока catch. Обратите внимание на то, что если в промисе произошла ошибка, для обработки которой нет блока catch, это не остановит выполнение скрипта, но сообщение об ошибке будет не особенно удобочитаемым.

(node:7741) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: something went wrong
(node:7741) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code. */

В результате можно порекомендовать всегда, при работе с промисами, использовать блок catch. Взглянем на пример.

Promise.resolve(1)
    .then(res => {
        console.log(res) // 1

        throw new Error('something went wrong')

        return Promise.resolve(2)
    })
    .then(res => {
        console.log(res) // этот блок выполнен не будет
    })
    .catch(err => {
        console.error(err) // о том, что делать с этой ошибкой, поговорим позже
        return Promise.resolve(3)
    })
    .then(res => {
        console.log(res) // 3
    })
    .catch(err => {
        // этот блок тут на тот случай, если в предыдущем блоке возникнет какая-нибудь ошибка
        console.error(err)
    })

▍Асинхронные механизмы и try…catch

После того, как в JavaScript появилась конструкция async/await, мы вернулись к классическому способу обработки ошибок — к try...catch...finally. Обрабатывать ошибки при таком подходе оказывается очень легко и удобно. Рассмотрим пример.

;(async function() {
    try {
        await someFuncThatThrowsAnError()
    } catch (err) {
        console.error(err) // об этом поговорим позже
    }

    console.log('Easy!') // будет выполнено
})()

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

2. Генерирование и обработка ошибок в серверном коде

Теперь, когда у нас есть инструменты для работы с ошибками, посмотрим на то, что мы можем с ними делать в реальных ситуациях. Генерирование и правильная обработка ошибок — это важнейший аспект серверного программирования. Существуют разные подходы к работе с ошибками. Здесь будет продемонстрирован подход с использованием собственного конструктора для экземпляров объекта Error и кодов ошибок, которые удобно передавать во фронтенд или любым механизмам, использующим серверные API. Как структурирован бэкенд конкретного проекта — особого значения не имеет, так как при любом подходе можно использовать одни и те же идеи, касающиеся работы с ошибками.

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

  1. Универсальная обработка ошибок — некий базовый механизм, подходящий для обработки любых ошибок, в ходе работы которого просто выдаётся сообщение наподобие Something went wrong, please try again or contact us, предлагающее пользователю попробовать выполнить операцию, давшую сбой, ещё раз или связаться с владельцем сервера. Эта система не отличается особой интеллектуальностью, но она, по крайней мере, способна сообщить пользователю о том, что что-то пошло не так. Подобное сообщение гораздо лучше, чем «бесконечная загрузка» или нечто подобное.
  2. Обработка конкретных ошибок — механизм, позволяющий сообщить пользователю подробные сведения о причинах неправильного поведения системы и дать ему конкретные советы по борьбе с неполадкой. Например, это может касаться отсутствия неких важных данных в запросе, который пользователь отправляет на сервер, или в том, что в базе данных уже существует некая запись, которую он пытается добавить ещё раз, и так далее.

▍Разработка собственного конструктора объектов ошибок

Здесь мы воспользуемся стандартным классом Error и расширим его. Пользоваться механизмами наследования в JavaScript — дело рискованное, но в данном случае эти механизмы оказываются весьма полезными. Зачем нам наследование? Дело в том, что нам, для того, чтобы код удобно было бы отлаживать, нужны сведения о трассировке стека ошибки. Расширяя стандартный класс Error, мы, без дополнительных усилий, получаем возможности по трассировке стека. Мы добавляем в наш собственный объект ошибки два свойства. Первое — это свойство code, доступ к которому можно будет получить с помощью конструкции вида err.code. Второе — свойство status. В него будет записываться код состояния HTTP, который планируется передавать клиентской части приложения.

Вот как выглядит класс CustomError, код которого оформлен в виде модуля.

class CustomError extends Error {
    constructor(code = 'GENERIC', status = 500, ...params) {
        super(...params)

        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, CustomError)
        }

        this.code = code
        this.status = status
    }
}

module.exports = CustomError

▍Маршрутизация

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

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

{
    error: 'SOME_ERROR_CODE',
    description: 'Something bad happened. Please try again or contact support.'
}

Если на данном этапе происходящие кажется вам непонятным — не беспокойтесь — просто продолжайте читать, пробуйте работать с тем, о чём идёт речь, и постепенно вы во всём разберётесь. На самом деле, если говорить о компьютерном обучении, здесь применяется подход «сверху-вниз», когда сначала обсуждаются общие идеи, а потом осуществляется переход к частностям.

Вот как выглядит код обработчика маршрутов.

const express = require('express')
const router = express.Router()
const CustomError = require('../CustomError')

router.use(async (req, res) => {
    try {
        const route = require(`.${req.path}`)[req.method]

        try {
            const result = route(req) // Передаём запрос функции route
            res.send(result) // Передаём клиенту то, что получено от функции route
        } catch (err) {
            /*
            Сюда мы попадаем в том случае, если в функции route произойдёт ошибка
            */
            if (err instanceof CustomError) {
                /* 
                Если ошибка уже обработана - трансформируем её в 
                возвращаемый объект
                */

                return res.status(err.status).send({
                    error: err.code,
                    description: err.message,
                })
            } else {
                console.error(err) // Для отладочных целей

                // Общая ошибка - вернём универсальный объект ошибки
                return res.status(500).send({
                    error: 'GENERIC',
                    description: 'Something went wrong. Please try again or contact support.',
                })
            }
        }
    } catch (err) {
        /* 
         Сюда мы попадём, если запрос окажется неудачным, то есть,
         либо не будет найдено файла, соответствующего пути, переданному
         в запросе, либо не будет экспортированной функции с заданным
         методом запроса
        */
        res.status(404).send({
            error: 'NOT_FOUND',
            description: 'The resource you tried to access does not exist.',
        })
    }
})

module.exports = router

Полагаем, комментарии в коде достаточно хорошо его поясняют. Надеемся, читать их удобнее, чем объяснения подобного кода, данные после него.

Теперь взглянем на файл маршрутов.

const CustomError = require('../CustomError')

const GET = req => {
    // пример успешного выполнения запроса
    return { name: 'Rio de Janeiro' }
}

const POST = req => {
    // пример ошибки общего характера
    throw new Error('Some unexpected error, may also be thrown by a library or the runtime.')
}

const DELETE = req => {
    // пример ошибки, обрабатываемой особым образом
    throw new CustomError('CITY_NOT_FOUND', 404, 'The city you are trying to delete could not be found.')
}

const PATCH = req => {
    // пример перехвата ошибок и использования CustomError
    try {
        // тут случилось что-то нехорошее
        throw new Error('Some internal error')
    } catch (err) {
        console.error(err) // принимаем решение о том, что нам тут делать

        throw new CustomError(
            'CITY_NOT_EDITABLE',
            400,
            'The city you are trying to edit is not editable.'
        )
    }
}

module.exports = {
    GET,
    POST,
    DELETE,
    PATCH,
}

В этих примерах с самими запросами ничего не делается. Тут просто рассматриваются разные сценарии возникновения ошибок. Итак, например, запрос GET /city попадёт в функцию const GET = req =>..., запрос POST /city попадёт в функцию const POST = req =>... и так далее. Эта схема работает и при использовании параметров запросов. Например — для запроса вида GET /city?startsWith=R. В целом, здесь продемонстрировано, что при обработке ошибок, во фронтенд может попасть либо общая ошибка, содержащая лишь предложение попробовать снова или связаться с владельцем сервера, либо ошибка, сформированная с использованием конструктора CustomError, которая содержит подробные сведения о проблеме.
Данные общей ошибки придут в клиентскую часть приложения в таком виде:

{
    error: 'GENERIC',
    description: 'Something went wrong. Please try again or contact support.'
}

Конструктор CustomError используется так:

throw new CustomError('MY_CODE', 400, 'Error description')

Это даёт следующий JSON-код, передаваемый во фронтенд:

{
    error: 'MY_CODE',
    description: 'Error description'
}

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

Не забудьте о том, что здесь лежит репозиторий с рассматриваемым здесь кодом. Можете его загрузить, поэкспериментировать с ним, и, если надо, адаптировать под нужды вашего проекта.

3. Работа с ошибками на клиенте

Теперь пришла пора описать третью часть нашей системы обработки ошибок, касающуюся фронтенда. Тут нужно будет, во-первых, обрабатывать ошибки, возникающие в клиентской части приложения, а во-вторых, понадобится оповещать пользователя об ошибках, возникающих на сервере. Разберёмся сначала с показом сведений о серверных ошибках. Как уже было сказано, в этом примере будет использована библиотека React.

▍Сохранение сведений об ошибках в состоянии приложения

Как и любые другие данные, ошибки и сообщения об ошибках могут меняться, поэтому их имеет смысл помещать в состояние компонентов. При монтировании компонента данные об ошибке сбрасываются, поэтому, когда пользователь впервые видит страницу, там сообщений об ошибках не будет.

Следующее, с чем надо разобраться, заключается в том, что ошибки одного типа нужно показывать в одном стиле. По аналогии с сервером, здесь можно выделить 3 типа ошибок.

  1. Глобальные ошибки — в эту категорию попадают сообщения об ошибках общего характера, приходящие с сервера, или ошибки, которые, например, возникают в том случае, если пользователь не вошёл в систему и в других подобных ситуациях.
  2. Специфические ошибки, выдаваемые серверной частью приложения — сюда относятся ошибки, сведения о которых приходят с сервера. Например, подобная ошибка возникает, если пользователь попытался войти в систему и отправил на сервер имя и пароль, а сервер сообщил ему о том, что пароль неправильный. Подобные вещи в клиентской части приложения не проверяются, поэтому сообщения о таких ошибках должны приходить с сервера.
  3. Специфические ошибки, выдаваемые клиентской частью приложения. Пример такой ошибки — сообщение о некорректном адресе электронной почты, введённом в соответствующее поле.

Ошибки второго и третьего типов очень похожи, работать с ними можно, используя хранилище состояния компонентов одного уровня. Их главное различие заключается в том, что они исходят из разных источников. Ниже, анализируя код, мы посмотрим на работу с ними.

Здесь будет использоваться встроенная в React система управления состоянием приложения, но, при необходимости, вы можете воспользоваться и специализированными решениями для управления состоянием — такими, как MobX или Redux.

▍Глобальные ошибки

Обычно сообщения о таких ошибках сохраняются в компоненте наиболее высокого уровня, имеющем состояние. Они выводятся в статическом элементе пользовательского интерфейса. Это может быть красное поле в верхней части экрана, модальное окно или что угодно другое. Реализация зависит от конкретного проекта. Вот как выглядит сообщение о такой ошибке.

Сообщение о глобальной ошибке

Теперь взглянем на код, который хранится в файле Application.js.

import React, { Component } from 'react'

import GlobalError from './GlobalError'

class Application extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
        }

        this._resetError = this._resetError.bind(this)
        this._setError = this._setError.bind(this)
    }

    render() {
        return (
            <div className="container">
                <GlobalError error={this.state.error} resetError={this._resetError} />
                <h1>Handling Errors</h1>
            </div>
        )
    }

    _resetError() {
        this.setState({ error: '' })
    }

    _setError(newError) {
        this.setState({ error: newError })
    }
}

export default Application

Как видно, в состоянии, в Application.js, имеется место для хранения данных ошибки. Кроме того, тут предусмотрены методы для сброса этих данных и для их изменения.

Ошибка и метод для сброса ошибки передаётся компоненту GlobalError, который отвечает за вывод сообщения об ошибке на экран и за сброс ошибки после нажатия на значок x в поле, где выводится сообщение. Вот код компонента GlobalError (файл GlobalError.js).

import React, { Component } from 'react'

class GlobalError extends Component {
    render() {
        if (!this.props.error) return null

        return (
            <div
                style={{
                    position: 'fixed',
                    top: 0,
                    left: '50%',
                    transform: 'translateX(-50%)',
                    padding: 10,
                    backgroundColor: '#ffcccc',
                    boxShadow: '0 3px 25px -10px rgba(0,0,0,0.5)',
                    display: 'flex',
                    alignItems: 'center',
                }}
            >
                {this.props.error}
                 
                <i
                    className="material-icons"
                    style={{ cursor: 'pointer' }}
                    onClick={this.props.resetError}
                >
                    close
                </font></i>
            </div>
        )
    }
}

export default GlobalError

Обратите внимание на строку if (!this.props.error) return null. Она указывает на то, что при отсутствии ошибки компонент ничего не выводит. Это предотвращает постоянный показ красного прямоугольника на странице. Конечно, вы, при желании, можете поменять внешний вид и поведение этого компонента. Например, вместо того, чтобы сбрасывать ошибку по нажатию на x, можно задать тайм-аут в пару секунд, по истечении которого состояние ошибки сбрасывается автоматически.

Теперь, когда всё готово для работы с глобальными ошибками, для задания глобальной ошибки достаточно воспользоваться _setError из Application.js. Например, это можно сделать в том случае, если сервер, после обращения к нему, вернул сообщение об общей ошибке (error: 'GENERIC'). Рассмотрим пример (файл GenericErrorReq.js).

import React, { Component } from 'react'
import axios from 'axios'

class GenericErrorReq extends Component {
    constructor(props) {
        super(props)

        this._callBackend = this._callBackend.bind(this)
    }

    render() {
        return (
            <div>
                <button onClick={this._callBackend}>Click me to call the backend</button>
            </div>
        )
    }

    _callBackend() {
        axios
            .post('/api/city')
            .then(result => {
                // сделать что-нибудь с результатом в том случае, если запрос оказался успешным
            })
            .catch(err => {
                if (err.response.data.error === 'GENERIC') {
                    this.props.setError(err.response.data.description)
                }
            })
    }
}

export default GenericErrorReq

На самом деле, на этом наш разговор об обработке ошибок можно было бы и закончить. Даже если в проекте нужно оповещать пользователя о специфических ошибках, никто не мешает просто поменять глобальное состояние, хранящее ошибку и вывести соответствующее сообщение поверх страницы. Однако тут мы не остановимся и поговорим о специфических ошибках. Во-первых, это руководство по обработке ошибок иначе было бы неполным, а во-вторых, с точки зрения UX-специалистов, неправильно будет показывать сообщения обо всех ошибках так, будто все они — глобальные.

▍Обработка специфических ошибок, возникающих при выполнении запросов

Вот пример специфического сообщения об ошибке, выводимого в том случае, если пользователь пытается удалить из базы данных город, которого там нет.

Сообщение о специфической ошибке

Тут используется тот же принцип, который мы применяли при работе с глобальными ошибками. Только сведения о таких ошибках хранятся в локальном состоянии соответствующих компонентов. Работа с ними очень похожа на работу с глобальными ошибками. Вот код файла SpecificErrorReq.js.

import React, { Component } from 'react'
import axios from 'axios'

import InlineError from './InlineError'

class SpecificErrorRequest extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
        }

        this._callBackend = this._callBackend.bind(this)
    }

    render() {
        return (
            <div>
                <button onClick={this._callBackend}>Delete your city</button>
                <InlineError error={this.state.error} />
            </div>
        )
    }

    _callBackend() {
        this.setState({
            error: '',
        })

        axios
            .delete('/api/city')
            .then(result => {
                // сделать что-нибудь с результатом в том случае, если запрос оказался успешным
            })
            .catch(err => {
                if (err.response.data.error === 'GENERIC') {
                    this.props.setError(err.response.data.description)
                } else {
                    this.setState({
                        error: err.response.data.description,
                    })
                }
            })
    }
}

export default SpecificErrorRequest

Тут стоит отметить, что для сброса специфических ошибок недостаточно, например, просто нажать на некую кнопку x. То, что пользователь прочёл сообщение об ошибке и закрыл его, не помогает такую ошибку исправить. Исправить её можно, правильно сформировав запрос к серверу, например — введя в ситуации, показанной на предыдущем рисунке, имя города, который есть в базе. В результате очищать сообщение об ошибке имеет смысл, например, после выполнения нового запроса. Сбросить ошибку можно и в том случае, если пользователь внёс изменения в то, что будет использоваться при формировании нового запроса, то есть — при изменении содержимого поля ввода.

▍Ошибки, возникающие в клиентской части приложения

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

В поле ничего нет, мы сообщаем об этом пользователю

Вот код файла SpecificErrorFrontend.js, реализующий вышеописанный функционал.

import React, { Component } from 'react'
import axios from 'axios'

import InlineError from './InlineError'

class SpecificErrorRequest extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
            city: '',
        }

        this._callBackend = this._callBackend.bind(this)
        this._changeCity = this._changeCity.bind(this)
    }

    render() {
        return (
            <div>
                <input
                    type="text"
                    value={this.state.city}
                    style={{ marginRight: 15 }}
                    onChange={this._changeCity}
                />
                <button onClick={this._callBackend}>Delete your city</button>
                <InlineError error={this.state.error} />
            </div>
        )
    }

    _changeCity(e) {
        this.setState({
            error: '',
            city: e.target.value,
        })
    }

    _validate() {
        if (!this.state.city.length) throw new Error('Please provide a city name.')
    }

    _callBackend() {
        this.setState({
            error: '',
        })

        try {
            this._validate()
        } catch (err) {
            return this.setState({ error: err.message })
        }

        axios
            .delete('/api/city')
            .then(result => {
                // сделать что-нибудь с результатом в том случае, если запрос оказался успешным
            })
            .catch(err => {
                if (err.response.data.error === 'GENERIC') {
                    this.props.setError(err.response.data.description)
                } else {
                    this.setState({
                        error: err.response.data.description,
                    })
                }
            })
    }
}

export default SpecificErrorRequest

▍Интернационализация сообщений об ошибках с использованием кодов ошибок

Возможно, сейчас вы задаётесь вопросом о том, зачем нам нужны коды ошибок (наподобие GENERIC), если мы показываем пользователю только сообщения об ошибках, полученных с сервера. Дело в том, что, по мере роста и развития приложения, оно, вполне возможно, выйдет на мировой рынок, а это означает, что настанет время, когда создателям приложения нужно будет задуматься о поддержке им нескольких языков. Коды ошибок позволяют отличать их друг от друга и выводить сообщения о них на языке пользователя сайта.

Итоги

Надеемся, теперь у вас сформировалось понимание того, как можно работать с ошибками в веб-приложениях. Нечто вроде console.error(err) следует использовать только в отладочных целях, в продакшн подобные вещи, забытые программистом, проникать не должны. Упрощает решение задачи логирования использование какой-нибудь подходящей библиотеки наподобие loglevel.

Уважаемые читатели! Как вы обрабатываете ошибки в своих проектах?

Последнее обновление: 30.08.2021

Одним из преимуществ промисов является более простая обработка ошибок. Для получения и обработки ошибки мы можем использовать
функцию catch() объекта Promise, которая в качестве параметра принимает функцию обработчика ошибки:

const myPromise = new Promise(function(resolve, reject){
	console.log("Выполнение асинхронной операции");
	reject("Переданы некорректные данные");
});
myPromise.catch( function(error){
	console.log(error);
});

Функция catch() в качестве параметра принимает обработчик ошибки. Параметром этой функции-обработчика является то значение,
которое передается в reject().

Консольный вывод:

Выполнение асинхронной операции
Переданы некорректные данные

Генерация ошибки

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

const myPromise = new Promise(function(resolve){
	console.log("Выполнение асинхронной операции");
	getSomeWork();		// вызов не существующей функции
	resolve("Hello world!");
});
myPromise.catch( function(error){
	console.log(error);
});

Поскольку функция getSomeWork() нигде не объявлена, то выполнение асинхронной задачи завершится ошибкой и не дойдет до вызова resolve("Hello world!").
Поэтому сработает функция обработки ошибок из catch(), которая через параметр error получит информацию о возникшей ошибке, и
в консоли браузера мы увидим сообщение об ошибке:

Выполнение асинхронной операции
ReferenceError: getSomeWork is not defined
    at index.html:39
    at new Promise (<anonymous>)
    at index.html:37

throw

Также ошибка может быть результатом вызова оператора throw, который генерирует ошибку:

cconst myPromise = new Promise(function(resolve, reject){
	console.log("Выполнение асинхронной операции");
	const parsed = parseInt("Hello");
	if (isNaN(parsed)) { 
		throw "Not a number";			// Генерируем ошибку
	}
	resolve(parsed);
});
myPromise.catch( function(error){
	console.log(error);
});

Здесь парсится в число случайная строка. И если результат парсинга не представляет число, то с помощью оператора throw генерируем ошибку.
Это придет к завершению всей функции с ошибкой. И в итоге результат будет обработан функцией catch:

Выполнение асинхронной операции
Not a number

В этом случае функция обработчика получает сообщение об оошибке, который указывается после оператора throw.

Данная ситуация может показаться искуственной, так как нам нет смысла генерировать в коде выше ошибку с помощью throw, поскольку в этом случае мы также
можем передать сообщение об ошибке в функцию reject:

if (isNaN(parsed)) { 
	reject("Not a number");
}

Однако данный оператор может применяться во внешней функции, которую мы вызываем в коде:

function getNumber(str){
	const parsed = parseInt(str);
	if (isNaN(parsed)) throw "Not a number";			// Генерируем ошибку
	else return parsed;
}
const myPromise = new Promise(function(resolve){
	console.log("Выполнение асинхронной операции");
	const result = getNumber("hello");
	resolve(result);
});
myPromise.catch( function(error){
	console.log(error);
});

Здесь парсинг строки в число вынесен во внешнюю функцию — getNumber, однако при вызове этой функции в промисе, также из оператора throw возникнет ошибка.
И соответственно будет выполняться функция catch(), где роизойдет обработка ошибки.

try..catch

Как и в общем случае, операции, которые могут генерировать ошибку, можно помещать в конструкцию try..catch, а при возникновении исключения в блоке catch вызывать функцию reject():

const myPromise = new Promise(function(resolve, reject){
	try{
		console.log("Выполнение асинхронной операции");
		getSomeWork();		// вызов не существующей функции
		resolve("Hello world!");
	}
	catch(err){
		reject(`Произошла ошибка: ${err.message}`);
	}
});
myPromise.catch( function(error){
	console.log(error);
});

Консольный вывод:

Выполнение асинхронной операции
Произошла ошибка: getSomeWork is not defined

Обработка ошибки с помощью функции then

Кроме функции catch для получения информации об ошибке и ее обработки также можно использовать функцию
then() — ее второй параметр представляет обработчик ошибки, который в качестве параметра получает переданное из функции
reject значение:

promise
  .then(function(value){
    // получение значения
  },
  function(error){
    // обработка ошибки
  });
 

Второй параметр функции then() представляет функцию обработчика ошибок. С помощью параметра error в функции-обработчика мы можем получить переданное в reject() значение, либо информацию о возникшей ошибке.

Рассмотрим следуюший пример:

function generateNumber(str){ 
	return new Promise(function(resolve, reject){
		const parsed = parseInt(str);
		if (isNaN(parsed))	reject("значение не является числом")
		else resolve(parsed);
	})
	.then(function(value){ console.log("Результат операции:", value);}, 
		function(error){ console.log("Возникла ошибка:", error);});
}

generateNumber("23");
generateNumber("hello");

В данном случае для того, чтобы в промис можно было передать разные данные, он определен как возващаемый результат функции generateNumber(). То есть в данном случае консольный вывод будет следующим:

Результат операции: 23
Возникла ошибка: значение не является числом

JavaScript Promise

Определение и применение

JavaScript метод .then()ECMAScript 2015 объекта Promise добавляет обработчик, или обработчики, вызываемые когда объект Promise имеет состояние fulfilled (успешное выполнение), или rejected (выполнение отклонено).

Поддержка браузерами

JavaScript синтаксис:

p.then(onFulfilled);
p.then(onFulfilled, onRejected);

onFulfilled - Function
onRejected - Function

После выполнения или отклонения обещания соответствующая функция обработчика (onFulfilled, или onRejected) будет вызвана асинхронно (запланирована в текущем цикле потока). Поведение функции обработчика соответствует определенному набору правил. Если функция обработчика:

  • возвращает значение, то обещание, возвращенное методом .then()ECMAScript 2015 разрешается с этим значением;
  • ничего не возвращает, то обещание, возвращенное методом .then()ECMAScript 2015 разрешается с неопределенным значением (undefined);
  • вызывает ошибку, то обещание, возвращенное методом .then()ECMAScript 2015 отклоняется с вызванной ошибкой в качестве его значения;
  • возвращает уже разрешенное обещание, то обещание, возвращенное методом .then()ECMAScript 2015 разрешается со значением этого обещания в качестве его значения;
  • возвращает уже отклоненное обещание, то обещание, возвращенное методом .then()ECMAScript 2015 отклоняется со значением этого обещания в качестве его значения;
  • возвращает еще один объект Promise находящийся в состоянии ожидания, то разрешение, или отклонение обещания, возвращенного методом .then()ECMAScript 2015 будет следовать за разрешением, или отклонением обещания, возвращенного обработчиком. Кроме того, значение обещания, возвращаемого методом .then()ECMAScript 2015 будет таким же, как значение обещания, возвращаемого обработчиком.

Спецификация

ECMAScript 2015 (6th Edition, ECMA-262)

Значения параметров

Параметр Описание
onFulfilled Функция, которая будет вызвана, когда обещание выполнено. Эта функция имеет только один аргумент — значение с которым обещание было выполнено. Если это не функция, она внутренне заменяется функцией «Identity» (возвращает полученный аргумент).
onRejected Функция, которая будет вызвана, когда обещание отклонено. Эта функция имеет только один аргумент — значение с которым обещание было отклонено. Если это не функция, она внутренне заменяется функцией «Thrower» (она выдает ошибку, полученную в качестве аргумента). Необязательный аргумент.

Пример использования

Базовое использование обрабочиков

const promise = Promise.resolve("Успешно"); // обещание успешно выполнено с заданным значением
const promise2 = Promise.reject(new Error("Отклонено")); // обещание отклонено с заданным значением

promise.then(function(value) { // Если успешно выполнен (fulfilled)
  console.log(value); 
}, function (reason) { // Если завершился ошибкой (rejected)
  console.log(reason); 
});

// Успешно

// использование стрелочной функции
promise2.then( val => console.log(elem), // обработчик для успешного выполнения
                            reason => console.log(reason.message)); // обработчик при отклоненном выполнении

// Отклонено

В этом примере мы инициализировали две переменные, которые содержат объект Promise, в первом случае с использованием метода resolve()ECMAScript 2015 обещание было успешно выполнено с заданным значением, а во втором с использованием метода reject()ECMAScript 2015 отклонено.

С использованием метода then()ECMAScript 2015 мы добавили обработчики, вызываемые когда объект Promise имеет состояние fulfilled (успешное выполнение), или rejected (выполнение отклонено). В первом случае будет срабатывать обработчик для успешного выполнения, а во втором обработчик при отклоненном выполнении.

Составление цепных вызовов

В следующем примере мы рассмотрим с Вами как составлять цепочки вызовов с использованием метода then()ECMAScript 2015:

const promise = Promise.resolve(1); // обещание успешно выполнено с заданным значением

promise.then(val => {val = val + 1; console.log(val); return val}) // обработчик для успешного выполнения
              .then(val => {val = val + 1; console.log(val); return val}) // обработчик для успешного выполнения
              .then(val => {val = val + 1; console.log(val); return val}) // обработчик для успешного выполнения

// 2
// 3
// 4

В этом примере мы инициализировали переменную, которая содержит объект Promise, который мы вернули с использованием метода resolve()ECMAScript 2015 (обещание было успешно выполнено с заданным значением).

С использованием метода then()ECMAScript 2015 мы добавили обработчики, вызываемые когда объект Promise имеет состояние fulfilled (успешное выполнение). Обратите внимание, что при каждом последующем вызове метода then()ECMAScript 2015 передается новое значение (увеличиваем переданное значение из предыдущего вызова на единицу), в результате чего в консоль будут выведены три значения — 2, 3 и 4.

Обработка ошибок в обработчиках

В следудующем примере мы с Вами рассмотрим ситуацию при которой в вызове обработчика, предназначенного для обработки состояния fulfilled (успешное выполнение) объекта Promise происходит ошибка:

const promise = Promise.resolve(); // обещание успешно выполнено с заданным значением
promise.then(() => {throw new Error("Ошибочка вышла")}) // генерируем исключение и возвращаем отклоненное обещание
               .then( val => console.log( val ), // обработчик не будет вызван
                           err => console.log( err.message )); // обработчик при отклоненном выполнении

// Ошибочка вышла

В этом примере мы инициализировали переменную, которая содержит объект Promise, который мы вернули с использованием метода resolve()ECMAScript 2015 (обещание было успешно выполнено с заданным значением). С использованием методов then()ECMAScript 2015 мы добавили цепочку вызовов.

Обратите внимание, что внутри первого вызова метода then()ECMAScript 2015 мы генерируем исключение с определенном текстом ошибки. Что приводит к тому, что мы возвращаем объект Promise выполнение которого было отклонено. В результате следующий вызов метода then()ECMAScript 2015 будет обрабатывать ошибку, то есть сработает функция, которая предназначена для случая, когда обещание отклонено (второй аргумент).

На практике, как правило, для удобства и лучшей читаемости кода при обработке ошибок используют метод catch()ECMAScript 2015, а не используют синтаксис метода then()ECMAScript 2015 с двумя аргументами. Рассмотрим следующий пример:

const promise = Promise.resolve(); // обещание успешно выполнено с заданным значением

promise.then(() => {throw new Error("Ошибочка вышла")}) // генерируем исключение и возвращаем отклоненное обещание
              .catch(error => console.log(error.message)) // добавляем обработчик для отклоненного обещания
              .then(() => console.log("Обработчик будет вызван" )); // обработчик для успешного выполнения
  
// Ошибочка вышла
// Обработчик будет вызван

В этом примере мы инициализировали переменную, которая содержит объект Promise, который мы вернули с использованием метода resolve()ECMAScript 2015 (обещание было успешно выполнено).

С использованием метода then()ECMAScript 2015 мы добавили обработчик, вызываемый когда объект Promise имеет состояние fulfilled (успешное выполнение). Внутри него мы генерируем исключение с определенным текстом ошибки, что приводит к тому, что мы возвращаем объект Promise выполнение которого было отклонено.

Обратите внимание, что далее для обработки ошибки мы используем метод catch()ECMAScript 2015, а не используем синтаксис метода then()ECMAScript 2015 с двумя аргументами. Метод catch()ECMAScript 2015 является сокращенным вариантом метода then(undefined, onRejected).

Обратите внимание, что не смотря на то что мы обработали отклоненное обещание внутри метода catch()ECMAScript 2015, следующий за ним метод then()ECMAScript 2015 будет срабатывать как для обещания, которое было успешно выполнено (сработает функция из первого аргумента), так как функция обработчик метода catch()ECMAScript 2015 не выдает ошибку, или возвращает обещание, которое само по себе отвергается.

В следующем примере мы рассмотрим ситуацию в которой метод catch()ECMAScript 2015 будет генерировать исключение:

const promise = Promise.reject(new Error("Ошибочка")); // обещание отклонено

promise.catch(error => { // добавляем обработчик для отклоненного обещания
  console.log(error.message);  // выводит в консоль текст ошибки
  throw new Error("Ошибочка 2"); // генерируем исключение и возвращаем отклоненное обещание
}).catch(error => { // добавляем обработчик для отклоненного обещания
  console.log(error.message); // выводит в консоль текст ошибки
  throw new Error("Ошибочка 3");// генерируем исключение и возвращаем отклоненное обещание
}).catch(error => console.log(error.message)) // добавляем обработчик для отклоненного обещания и выводим в консоль текст ошибки
  .then(() => console.log("Наконец без ошибок" )); // обработчик для успешного выполнения
  
// Ошибочка
// Ошибочка 2
// Ошибочка 3
// Наконец без ошибок

В этом примере мы инициализировали переменную, которая содержит объект Promise, который мы вернули с использованием метода reject()ECMAScript 2015 (обещание было отклонено).

Для обработки ошибок мы используем цепочку вызовов, которую мы составили с использованием метода catch()ECMAScript 2015. Обратите внимание, что первые два вызова метода catch()ECMAScript 2015 помимо того, что выводят в консоль текст ошибки, дополнительно еще генерируют исключение.

Третий метод catch()ECMAScript 2015 просто выводит в консоль текст ошибки и возвращает обещание, которое имеет состояние fulfilled (успешное выполнение), которое мы и обрабатываем в методе then()ECMAScript 2015, который находится в конце цепочки.

Нюансы асинхронного выполнения

В следующих примерах мы рассмотрим нюансы асинхронного выполнения при использовании цепных методов then()ECMAScript 2015:

const promise = Promise.resolve("hello"); // обещание успешно выполнено с заданным значением

promise.then(function(val) { // обработчик для успешного выполнения
  return new Promise(function(resolve, reject) { // возвращаем новое обещание
      setTimeout(function() { // вызываем функцию через указанное число миллисекунд
        val += " world"; // изменяем полученное значение
        console.log(1, val); // выводим значение в консоль
        resolve(val); // изменяем состояние обещания на fulfilled (успешное выполнение)
      }, 1000);
    });
  })
  .then(function(val) { // обработчик для успешного выполнения
    setTimeout(function() { // вызываем функцию через указанное число миллисекунд
      val += " !!!"; // изменяем полученное значение
      console.log(2, val); // выводим значение в консоль
    }, 1000)
    return val; // возвращаем значение
  })
  .then(function(val) { // обработчик для успешного выполнения
    console.log(3, val); // выводим значение в консоль
  });

// 1 "hello world"
// 3 "hello world"
// 2 "hello world !!!"

В этом примере мы инициализировали переменную, которая содержит объект Promise, который мы вернули с использованием метода resolve()ECMAScript 2015 (обещание было успешно выполнено). С использованием метода then()ECMAScript 2015 мы добавили обработчики, вызываемые когда объект Promise имеет состояние fulfilled (успешное выполнение).

Внутри первого обработчика then()ECMAScript 2015 мы возвращаем новое обещание, которое с использованием функции setTimeout() через одну секунду вызовет метод resolve()ECMAScript 2015, который вернет обещание, которое было успешно выполнено.

Следующий метод then()ECMAScript 2015 принимает переданное значение, с использованием функции setTimeout() через одну секунду изменяет это значение, выведет это значение в консоль, и возвратит это значение. Здесь и кроется главный нюанс асинхронного выполнения, полученное значение будет передано сразу в следующий обработчик не дожидаясь завершения выполнения функции setTimeout(), которая изменяет впоследствии это значение.

Последний обработчик then()ECMAScript 2015 просто выводит в консоль полученное значение. В следующем примере мы рассмотрим как вывести значение по порядку:

const promise = Promise.resolve("hello"); // обещание успешно выполнено с заданным значением

promise.then(function(val) { // обработчик для успешного выполнения
  return new Promise(function(resolve, reject) { // возвращаем новое обещание
      setTimeout(function() { // вызываем функцию через указанное число миллисекунд
        val += " world"; // изменяем полученное значение
        console.log(1, val); // выводим значение в консоль
        resolve(val); // изменяем состояние обещания на fulfilled (успешное выполнение)
      }, 1000);
    });
  })
  .then(function(val) { // обработчик для успешного выполнения
    return new Promise(function(resolve, reject) { // возвращаем новое обещание
      setTimeout(function() { // вызываем функцию через указанное число миллисекунд
        val += " !!!"; // изменяем полученное значение
        console.log(2, val); // выводим значение в консоль
        resolve(val); // изменяем состояние обещания на fulfilled (успешное выполнение)
      }, 1000);
    });
  })
  .then(function(val) { // обработчик для успешного выполнения
    console.log(3, val); // выводим значение в консоль
  });

// 1 "hello world"
// 2 "hello world !!!"
// 3 "hello world !!!"

По аналогии с предыдущим примером мы инициализировали переменную, которая содержит объект Promise, который мы вернули с использованием метода resolve()ECMAScript 2015 (обещание было успешно выполнено), и с использованием метода then()ECMAScript 2015 добавили обработчики.

Как вы могли догадаться, в отличии от предыдущего примера нам достаточно во втором методе then()ECMAScript 2015 вернуть обещание по аналогии с первым вызовом этого метода. В этом случае следующий обработчик не будет вызван до тех пор пока предыдущее обещание не будет выполнено, или отклонено. Зная эти нюансы вы сможете эффективно выполнять свою работу исходя из поставленной задачи.

JavaScript Promise

  • « Previous
  • Next »

A Promise is an object representing the eventual completion or failure of an asynchronous operation. Since most people are consumers of already-created promises, this guide will explain consumption of returned promises before explaining how to create them.

Essentially, a promise is a returned object to which you attach callbacks, instead of passing callbacks into a function. Imagine a function, createAudioFileAsync(), which asynchronously generates a sound file given a configuration record and two callback functions: one called if the audio file is successfully created, and the other called if an error occurs.

Here’s some code that uses createAudioFileAsync():

function successCallback(result) {
  console.log(`Audio file ready at URL: ${result}`);
}

function failureCallback(error) {
  console.error(`Error generating audio file: ${error}`);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

If createAudioFileAsync() were rewritten to return a promise, you would attach your callbacks to it instead:

createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

This convention has several advantages. We will explore each one.

Chaining

A common need is to execute two or more asynchronous operations back to back, where each subsequent operation starts when the previous operation succeeds, with the result from the previous step. In the old days, doing several asynchronous operations in a row would lead to the classic callback pyramid of doom:

doSomething(function (result) {
  doSomethingElse(result, function (newResult) {
    doThirdThing(newResult, function (finalResult) {
      console.log(`Got the final result: ${finalResult}`);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

With promises, we accomplish this by creating a promise chain. The API design of promises makes this great, because callbacks are attached to the returned promise object, instead of being passed into a function.

Here’s the magic: the then() function returns a new promise, different from the original:

const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

This second promise (promise2) represents the completion not just of doSomething(), but also of the successCallback or failureCallback you passed in — which can be other asynchronous functions returning a promise. When that’s the case, any callbacks added to promise2 get queued behind the promise returned by either successCallback or failureCallback.

With this pattern, you can create longer chains of processing, where each promise represents the completion of one asynchronous step in the chain. In addition, the arguments to then are optional, and catch(failureCallback) is short for then(null, failureCallback) — so if your error handling code is the same for all steps, you can attach it to the end of the chain:

doSomething()
  .then(function (result) {
    return doSomethingElse(result);
  })
  .then(function (newResult) {
    return doThirdThing(newResult);
  })
  .then(function (finalResult) {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

You might see this expressed with arrow functions instead:

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

Important: Always return results, otherwise callbacks won’t catch the result of a previous promise (with arrow functions, () => x is short for () => { return x; }). If the previous handler started a promise but did not return it, there’s no way to track its settlement anymore, and the promise is said to be «floating».

doSomething()
  .then((url) => {
    // I forgot to return this
    fetch(url);
  })
  .then((result) => {
    // result is undefined, because nothing is returned from
    // the previous handler.
    // There's no way to know the return value of the fetch()
    // call anymore, or whether it succeeded at all.
  });

This may be worse if you have race conditions — if the promise from the last handler is not returned, the next then handler will be called early, and any value it reads may be incomplete.

const listOfIngredients = [];

doSomething()
  .then((url) => {
    // I forgot to return this
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // Always [], because the fetch request hasn't completed yet.
  });

Therefore, as a rule of thumb, whenever your operation encounters a promise, return it and defer its handling to the next then handler.

const listOfIngredients = [];

doSomething()
  .then((url) =>
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      }),
  )
  .then(() => {
    console.log(listOfIngredients);
  });

// OR

doSomething()
  .then((url) => fetch(url))
  .then((res) => res.json())
  .then((data) => {
    listOfIngredients.push(data);
  })
  .then(() => {
    console.log(listOfIngredients);
  });

Nesting

In the two examples above, the first one has one promise chain nested in the return value of another then() handler, while the second one uses an entirely flat chain. Simple promise chains are best kept flat without nesting, as nesting can be a result of careless composition. See common mistakes.

Nesting is a control structure to limit the scope of catch statements. Specifically, a nested catch only catches failures in its scope and below, not errors higher up in the chain outside the nested scope. When used correctly, this gives greater precision in error recovery:

doSomethingCritical()
  .then((result) =>
    doSomethingOptional(result)
      .then((optionalResult) => doSomethingExtraNice(optionalResult))
      .catch((e) => {}),
  ) // Ignore if optional stuff fails; proceed.
  .then(() => moreCriticalStuff())
  .catch((e) => console.error(`Critical failure: ${e.message}`));

Note that the optional steps here are nested — with the nesting caused not by the indentation, but by the placement of the outer ( and ) parentheses around the steps.

The inner error-silencing catch handler only catches failures from doSomethingOptional() and doSomethingExtraNice(), after which the code resumes with moreCriticalStuff(). Importantly, if doSomethingCritical() fails, its error is caught by the final (outer) catch only, and does not get swallowed by the inner catch handler.

Chaining after a catch

It’s possible to chain after a failure, i.e. a catch, which is useful to accomplish new actions even after an action failed in the chain. Read the following example:

new Promise((resolve, reject) => {
  console.log("Initial");

  resolve();
})
  .then(() => {
    throw new Error("Something failed");

    console.log("Do this");
  })
  .catch(() => {
    console.error("Do that");
  })
  .then(() => {
    console.log("Do this, no matter what happened before");
  });

This will output the following text:

Initial
Do that
Do this, no matter what happened before

Note: The text «Do this» is not displayed because the «Something failed» error caused a rejection.

Common mistakes

Here are some common mistakes to watch out for when composing promise chains. Several of these mistakes manifest in the following example:

// Bad example! Spot 3 mistakes!

doSomething()
  .then(function (result) {
    // Forgot to return promise from inner chain + unnecessary nesting
    doSomethingElse(result).then((newResult) => doThirdThing(newResult));
  })
  .then(() => doFourthThing());
// Forgot to terminate chain with a catch!

The first mistake is to not chain things together properly. This happens when we create a new promise but forget to return it. As a consequence, the chain is broken — or rather, we have two independent chains racing. This means doFourthThing() won’t wait for doSomethingElse() or doThirdThing() to finish, and will run concurrently with them — which is likely unintended. Separate chains also have separate error handling, leading to uncaught errors.

The second mistake is to nest unnecessarily, enabling the first mistake. Nesting also limits the scope of inner error handlers, which—if unintended—can lead to uncaught errors. A variant of this is the promise constructor anti-pattern, which combines nesting with redundant use of the promise constructor to wrap code that already uses promises.

The third mistake is forgetting to terminate chains with catch. Unterminated promise chains lead to uncaught promise rejections in most browsers. See error handling below.

A good rule of thumb is to always either return or terminate promise chains, and as soon as you get a new promise, return it immediately, to flatten things:

doSomething()
  .then(function (result) {
    // If using a full function expression: return the promise
    return doSomethingElse(result);
  })
  // If using arrow functions: omit the braces and implicitly return the result
  .then((newResult) => doThirdThing(newResult))
  // Even if the previous chained promise returns a result, the next one
  // doesn't necessarily have to use it. You can pass a handler that doesn't
  // consume any result.
  .then((/* result ignored */) => doFourthThing())
  // Always end the promise chain with a catch handler to avoid any
  // unhandled rejections!
  .catch((error) => console.error(error));

Note that () => x is short for () => { return x; }.

Now we have a single deterministic chain with proper error handling.

Using async/await addresses most, if not all of these problems — the tradeoff being that it may be easy to forget the await keyword.

Error handling

You might recall seeing failureCallback three times in the pyramid of doom earlier, compared to only once at the end of the promise chain:

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => console.log(`Got the final result: ${finalResult}`))
  .catch(failureCallback);

If there’s an exception, the browser will look down the chain for .catch() handlers or onRejected. This is very much modeled after how synchronous code works:

try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch (error) {
  failureCallback(error);
}

This symmetry with asynchronous code culminates in the async/await syntax:

async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Got the final result: ${finalResult}`);
  } catch (error) {
    failureCallback(error);
  }
}

It builds on promises — for example, doSomething() is the same function as before, so there’s minimal refactoring needed to change from promises to async/await. You can read more about the async/await syntax in the async functions and await references.

Promises solve a fundamental flaw with the callback pyramid of doom, by catching all errors, even thrown exceptions and programming errors. This is essential for functional composition of asynchronous operations.

Promise rejection events

If a promise rejection event is not handled by any handler, it bubbles to the top of the call stack, and the host needs to surface it. On the web, whenever a promise is rejected, one of two events is sent to the global scope (generally, this is either the window or, if being used in a web worker, it’s the Worker or other worker-based interface). The two events are:

unhandledrejection

Sent when a promise is rejected but there is no rejection handler available.

rejectionhandled

Sent when a handler is attached to a rejected promise that has already caused an unhandledrejection event.

In both cases, the event (of type PromiseRejectionEvent) has as members a promise property indicating the promise that was rejected, and a reason property that provides the reason given for the promise to be rejected.

These make it possible to offer fallback error handling for promises, as well as to help debug issues with your promise management. These handlers are global per context, so all errors will go to the same event handlers, regardless of source.

In Node.js, handling promise rejection is slightly different. You capture unhandled rejections by adding a handler for the Node.js unhandledRejection event (notice the difference in capitalization of the name), like this:

process.on("unhandledRejection", (reason, promise) => {
  // Add code here to examine the "promise" and "reason" values
});

For Node.js, to prevent the error from being logged to the console (the default action that would otherwise occur), adding that process.on() listener is all that’s necessary; there’s no need for an equivalent of the browser runtime’s preventDefault() method.

However, if you add that process.on listener but don’t also have code within it to handle rejected promises, they will just be dropped on the floor and silently ignored. So ideally, you should add code within that listener to examine each rejected promise and make sure it was not caused by an actual code bug.

Composition

There are four composition tools for running asynchronous operations concurrently: Promise.all(), Promise.allSettled(), Promise.any(), and Promise.race().

We can start operations at the same time and wait for them all to finish like this:

Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
  // use result1, result2 and result3
});

If one of the promises in the array rejects, Promise.all() immediately rejects the returned promise and aborts the other operations. This may cause unexpected state or behavior. Promise.allSettled() is another composition tool that ensures all operations are complete before resolving.

These methods all run promises concurrently — a sequence of promises are started simultaneously and do not wait for each other. Sequential composition is possible using some clever JavaScript:

[func1, func2, func3]
  .reduce((p, f) => p.then(f), Promise.resolve())
  .then((result3) => {
    /* use result3 */
  });

In this example, we reduce an array of asynchronous functions down to a promise chain. The code above is equivalent to:

Promise.resolve()
  .then(func1)
  .then(func2)
  .then(func3)
  .then((result3) => {
    /* use result3 */
  });

This can be made into a reusable compose function, which is common in functional programming:

const applyAsync = (acc, val) => acc.then(val);
const composeAsync =
  (...funcs) =>
  (x) =>
    funcs.reduce(applyAsync, Promise.resolve(x));

The composeAsync() function accepts any number of functions as arguments and returns a new function that accepts an initial value to be passed through the composition pipeline:

const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

Sequential composition can also be done more succinctly with async/await:

let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* use last result (i.e. result3) */

However, before you compose promises sequentially, consider if it’s really necessary — it’s always better to run promises concurrently so that they don’t unnecessarily block each other unless one promise’s execution depends on another’s result.

Creating a Promise around an old callback API

A Promise can be created from scratch using its constructor. This should be needed only to wrap old APIs.

In an ideal world, all asynchronous functions would already return promises. Unfortunately, some APIs still expect success and/or failure callbacks to be passed in the old way. The most obvious example is the setTimeout() function:

setTimeout(() => saySomething("10 seconds passed"), 10 * 1000);

Mixing old-style callbacks and promises is problematic. If saySomething() fails or contains a programming error, nothing catches it. This is intrinsic to the design of setTimeout.

Luckily we can wrap setTimeout in a promise. The best practice is to wrap the callback-accepting functions at the lowest possible level, and then never call them directly again:

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(10 * 1000)
  .then(() => saySomething("10 seconds"))
  .catch(failureCallback);

The promise constructor takes an executor function that lets us resolve or reject a promise manually. Since setTimeout() doesn’t really fail, we left out reject in this case. For more information on how the executor function works, see the Promise() reference.

Timing

Lastly, we will look into the more technical details, about when the registered callbacks get called.

Guarantees

In the callback-based API, when and how the callback gets called depends on the API implementor. For example, the callback may be called synchronously or asynchronously:

function doSomething(callback) {
  if (Math.random() > 0.5) {
    callback();
  } else {
    setTimeout(() => callback(), 1000);
  }
}

This leads to the state of Zalgo, because it makes side effects hard to analyze:

let value = 1;
doSomething(() => {
  value = 2;
});
console.log(value); // 1 or 2?

On the other hand, promises are a form of inversion of control — the API implementor does not control when the callback gets called. Instead, the job of maintaining the callback queue and deciding when to call the callbacks is delegated to the promise implementation, and both the API user and API developer automatically gets strong semantic guarantees, including:

  • Callbacks added with then() will never be invoked before the completion of the current run of the JavaScript event loop.
  • These callbacks will be invoked even if they were added after the success or failure of the asynchronous operation that the promise represents.
  • Multiple callbacks may be added by calling then() several times. They will be invoked one after another, in the order in which they were inserted.

To avoid surprises, functions passed to then() will never be called synchronously, even with an already-resolved promise:

Promise.resolve().then(() => console.log(2));
console.log(1);
// Logs: 1, 2

Instead of running immediately, the passed-in function is put on a microtask queue, which means it runs later (only after the function which created it exits, and when the JavaScript execution stack is empty), just before control is returned to the event loop; i.e. pretty soon:

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(0).then(() => console.log(4));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

Task queues vs. microtasks

Promise callbacks are handled as a microtask whereas setTimeout() callbacks are handled as task queues.

const promise = new Promise((resolve, reject) => {
  console.log("Promise callback");
  resolve();
}).then((result) => {
  console.log("Promise callback (.then)");
});

setTimeout(() => {
  console.log("event-loop cycle: Promise (fulfilled)", promise);
}, 0);

console.log("Promise (pending)", promise);

The code above will output:

Promise callback
Promise (pending) Promise {<pending>}
Promise callback (.then)
event-loop cycle: Promise (fulfilled) Promise {<fulfilled>}

For more details, refer to Tasks vs. microtasks.

When promises and tasks collide

If you run into situations in which you have promises and tasks (such as events or callbacks) which are firing in unpredictable orders, it’s possible you may benefit from using a microtask to check status or balance out your promises when promises are created conditionally.

If you think microtasks may help solve this problem, see the microtask guide to learn more about how to use queueMicrotask() to enqueue a function as a microtask.

See also

Промисы: обработка ошибок

Цепочки промисов отлично подходят для перехвата ошибок. Если промис завершается с ошибкой, то управление переходит в ближайший обработчик ошибок. На практике это очень удобно.

Например, в представленном ниже примере для 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/${user.name}`))
  .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(`Неизвестная ошибка: ${error}`);
  // ничего не возвращаем => выполнение продолжается в нормальном режиме

});

Управление переходит от первого блока .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 (для браузеров и аналог для других окружений), чтобы отслеживать необработанные ошибки и информировать о них пользователя (и, возможно, наш сервер), благодаря чему наше приложение никогда не будет «просто умирать».

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии

А вот еще интересные материалы:

  • Яшка сломя голову остановился исправьте ошибки
  • Ясность цели позволяет целеустремленно добиваться намеченного исправьте ошибки
  • Ясность цели позволяет целеустремленно добиваться намеченного где ошибка
  • Thehunter call of the wild ошибка
  • The sims 4 wickedwhims ошибка