[Перевод] Правильная обработка ошибок в JavaScript

Обработка ошибок в JavaScript — дело рискованное. Если вы верите в закон Мёрфи, то прекрасно знаете: если что-то может пойти не так, именно это и случится! В этой статье мы рассмотрим подводные камни и правильные подходы в сфере обработки ошибок в JS. А напоследок поговорим об асинхронном коде и Ajax.

Я считаю, что событийная парадигма JS добавляет языку определённое богатство. Мне нравится представлять браузер в виде машины, управляемой событиями, в том числе и ошибками. По сути, ошибка — это невозникновение какого-то события, хотя кто-то с этим и не согласится. Если такое утверждение кажется вам странным, то пристегните ремни, эта поездка будет для вас необычной.

Все примеры будут рассмотрены применительно к клиентскому JavaScript. В основу повествования легли идеи, озвученные в статье «Исключительная обработка событий в JavaScript». Название можно перефразировать так: «При возникновении исключения JS проверяет наличие обработчика в стеке вызовов». Если вы незнакомы с базовыми понятиями, то рекомендую сначала прочитать ту статью. Здесь же мы будем рассматривать вопрос глубже, не ограничиваясь простыми потребностями в обработке исключений. Так что когда в следующий раз вам опять попадётся блок try...catch, то вы уже подойдёте к нему с оглядкой.


Использованный для примеров код вы можете скачать с GitHub, он представляет собой вот такую страницу:

ba53eef3166be73578d3b861371985ac.jpg

При нажатии на каждую из кнопок взрывается «бомба», симулирующая исключение TypeError. Ниже приведено определение этого модуля из модульного теста.

function error() {
    var foo = {};
    return foo.bar();
}


Сначала функция объявляет пустой объект foo. Обратите внимание, что нигде нет определения bar(). Давайте теперь посмотрим, как взорвётся наша бомба при запуске модульного теста.

it('throws a TypeError', function () {
    should.throws(target, TypeError);
});


Тест написан в Mocha с помощью тестовых утверждений из should.js. Mocha выступает в качестве исполнителя тестов, а should.js — в качестве библиотеки утверждений. Если вы пока не сталкивались с этими тестовыми API, то можете спокойно их изучить. Исполнение теста начинается с it('description'), а заканчивается успешным или неуспешным завершением в should. Прогон можно делать прямо на сервере, без использования браузера. Я рекомендую не пренебрегать тестированием, поскольку оно позволяет доказать ключевые идеи чистого JavaScript.

Итак, error() сначала определяет пустой объект, а затем пытается обратиться к методу. Но поскольку bar() внутри объекта не существует, то возникает исключение. И такое может произойти с каждым, если вы используете динамический язык вроде JavaScript!


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

function badHandler(fn) {
    try {
        return fn();
    } catch (e) { }
    return null;
}


В качестве зависимости обработчик получает коллбэк fn. Затем эта зависимость вызывается изнутри функции-обработчика. Модульные тесты демонстрируют её использование:

it('returns a value without errors', function() {
    var fn = function() {
        return 1;
    };
    var result = target(fn);
    result.should.equal(1);
});

it('returns a null with errors', function() {
    var fn = function() {
        throw Error('random error');
    };
    var result = target(fn);
    should(result).equal(null);
});


Как видите, в случае возникновения проблемы этот странный обработчик возвращает null. Коллбэк fn() при этом может указывать либо на нормальный метод, либо на «бомбу». Продолжение истории:

 (function (handler, bomb) {
    var badButton = document.getElementById('bad');

    if (badButton) {
        badButton.addEventListener('click', function () {
            handler(bomb);
            console.log('Imagine, getting promoted for hiding mistakes');
        });
    }
}(badHandler, error));


Что плохого в получении просто null? Это оставляет нас в неведении относительно причины ошибки, не даёт никакой полезной информации. Подобный подход — остановка выполнения без внятного уведомления — может быть причиной неверных решений с точки зрения UX, способных приводить к повреждению данных. Можно убить несколько часов на отладку, при этом упустив из виду блок try...catch. Приведённый выше обработчик просто глотает ошибки в коде и притворяется, что всё в порядке. Такое прокатывает в компаниях, не слишком заботящихся о высоком качестве кода. Но помните, что скрытие ошибок в будущем чревато большими временны́ми потерями на отладку. В многослойном продукте с глубокими стеками вызовов практически невозможно будет найти корень проблемы. Есть ряд ситуаций, когда имеет смысл использовать скрытый блок try...catch, но в обработке ошибок этого лучше избегать.

Если вы будете применять остановку выполнения без внятного уведомления, то в конце концов вам захочется подойти к обработке ошибок более разумно. И JavaScript позволяет использовать более элегантный подход.


Идём дальше. Теперь пришла пора рассмотреть кривой обработчик ошибок. Здесь мы не будем касаться использования DOM, суть та же, что и в предыдущей части. Кривой обработчик отличается от плохого только способом обработки исключений:

function uglyHandler(fn) {
    try {
        return fn();
    } catch (e) {
        throw Error('a new error');
    }
}

it('returns a new error with errors', function () {
    var fn = function () {
        throw new TypeError('type error');
    };
    should.throws(function () {
        target(fn);
    }, Error);
});


Если сравнить с плохим обработчиком — стало определённо лучше. Исключение заставляет «всплыть» стек вызовов. Здесь мне нравится то, что ошибки будут отматывать (unwind) стек, а это крайне полезно для отладки. При возникновении исключения интерпретатор отправится вверх по стеку в поисках другого обработчика. Это даёт нам немало возможностей для работы с ошибками на самом верху стека вызовов. Но поскольку речь идёт о кривом обработчике, то изначальная ошибка просто теряется. Приходится возвращаться вниз по стеку, пытаясь найти исходное исключение. Хорошо хоть, что мы знаем о существовании проблемы, выбросившей исключение.

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


Отмотать исключения можно одним способом — поместив try...catch наверху стека вызовов. Например:

function main(bomb) {
    try {
        bomb();
    } catch (e) {
        // Handle all the error things
    }
}


Но у нас же браузер управляется событиями, помните? А исключения в JavaScript — такие же полноправные события. Поэтому в данном случае интерпретатор прерывает исполнение текущего контекста и производит отмотку. Мы можем использовать глобальный обработчик событий onerror, и выглядеть это будет примерно так:

window.addEventListener('error', function (e) {
    var error = e.error;
    console.log(error);
});


Этот обработчик может выловить ошибку в любом исполняемом контексте. То есть любая ошибка может стать причиной события-ошибки (Error event). Нюанс здесь в том, что вся обработка ошибок локализуется в одном месте в коде — в обработчике событий. Как и в случае с любыми другими событиями, вы можете создавать цепочки обработчиков для работы со специфическими ошибками. И если вы придерживаетесь принципов SOLID, то сможете задавать каждому обработчику ошибок свою специализацию. Регистрировать обработчики можно в любое время, интерпретатор будет прогонять столько обработчиков в цикле, сколько нужно. При этом вы сможете избавить свою кодовую базу от блоков try...catch, что только пойдёт на пользу при отладке. То есть суть в том, чтобы подходить к обработке ошибок в JS так же, как к обработке событий.

Теперь, когда мы можем отматывать стек с помощью глобальных обработчиков, что мы будем делать с этим сокровищем?


Стек вызовов — невероятно полезный инструмент для решения проблем. Не в последнюю очередь потому, что браузер предоставляет информацию как есть, «из коробки». Конечно, свойство стека в объекте ошибки не является стандартным, но зато консистентно доступно в самых свежих версиях браузеров.

Это позволяет нам делать такие классные вещи, как логгирование на сервер:

window.addEventListener('error', function (e) {
    var stack = e.error.stack;
    var message = e.error.toString();
    if (stack) {
        message += '\n' + stack;
    }
    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/log', true);
    xhr.send(message);
});


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

Мне нравится, как эти сообщения вылавливаются на сервере.

e7070b623e6c4a67b46526d741c2038a.jpg

Это скриншот сообщения от Firefox Developer Edition 46. Обратите внимание, что благодаря правильной обработке ошибок здесь нет ничего лишнего, всё кратко и по существу. И не нужно прятать ошибки! Достаточно взглянуть на сообщение, и сразу становится понятно, кто и где кинул исключение. Такая прозрачность крайне полезна при отладке кода фронтенда. Подобные сообщения можно складировать в персистентном хранилище для будущего анализа, чтобы лучше понять, в каких ситуациях возникают ошибки. В общем, не нужно недооценивать возможности стека вызовов, в том числе для нужд отладки.


JavaScript извлекает асинхронный код из текущего исполняемого контекста. Это означает, что с выражениями try...catch, наподобие приведённого ниже, возникает проблема.

function asyncHandler(fn) {
    try {
        setTimeout(function () {
            fn();
        }, 1);
    } catch (e) { }
}


Развитие событий по версии модульного теста:

it('does not catch exceptions with errors', function () {
    var fn = function () {
        throw new TypeError('type error');
    };
    failedPromise(function() {
        target(fn);
    }).should.be.rejectedWith(TypeError);
});

function failedPromise(fn) {
    return new Promise(function(resolve, reject) {
        reject(fn);
    });
}


Пришлось завернуть в обработчик промис проверки исключения. Обратите внимание, что здесь имеет место необработанное исключение, несмотря на наличие блока кода вокруг замечательного try...catch. К сожалению, выражения try...catch работают только с одиночным исполняемым контекстом. И к моменту выброса исключения интерпретатор уже перешёл к другой части кода, оставил try...catch. Точно такая же ситуация возникает и с Ajax-вызовами.

Здесь у нас есть два пути. Первый — поймать исключение внутри асинхронного коллбэка:

setTimeout(function () {
    try {
       fn();
    } catch (e) {
        // Handle this async error
    }
}, 1);


Это вполне рабочий вариант, но тут много чего можно улучшить. Во-первых, везде раскиданы блоки try...catch — дань программированию 1970-х. Во-вторых, движок V8 не слишком удачно использует эти блоки внутри функций, поэтому разработчики рекомендуют размещать try...catch сверху стека вызовов.

Так что нам делать? Я не просто так упоминал о том, что глобальные обработчики ошибок работают с любым исполняемым контекстом. Если такой обработчик подписать на событие window.onerror, то больше ничего не нужно! У вас сразу начинают соблюдаться принципы DRY и SOLID.

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

a9a5f585459087731f28f445d448c629.jpg

Этот обработчик даже сообщает о том, что ошибка связана с асинхронным кодом, точнее с обработчиком setTimeout(). Прямо сказка!


Есть как минимум два основных подхода к обработке ошибок. Первый — когда вы игнорируете ошибки, останавливая исполнение без уведомления. Второй — когда вы сразу получаете информацию об ошибке и отматываете до момента её возникновения. Думаю, всем очевидно, какой из этих подходов лучше и почему. Говоря кратко: не скрывайте проблемы. Никто не будет винить вас за возможные сбои в программе. Вполне допустимо останавливать исполнение, откатывать состояние и давать пользователю новую попытку. Мир несовершенен, поэтому важно давать второй шанс. Ошибки неизбежны, и значение имеет только то, как вы с ними справляетесь.

© Habrahabr.ru