Правильная обработка ошибок в JavaScript

в 15:04, , рубрики: javascript, Блог компании Mail.Ru Group, Разработка веб-сайтов

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

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

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

Демокод

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

Правильная обработка ошибок в JavaScript - 1

При нажатии на каждую из кнопок взрывается «бомба», симулирующая исключение 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.

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

Правильная обработка ошибок в JavaScript - 2

Это скриншот сообщения от 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.

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

Правильная обработка ошибок в JavaScript - 3

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

Заключение

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

Автор: Mail.Ru Group

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js