Я считаю, что событийная парадигма JS добавляет языку определённое богатство. Мне нравится представлять браузер в виде машины, управляемой событиями, в том числе и ошибками. По сути, ошибка — это невозникновение какого-то события, хотя кто-то с этим и не согласится. Если такое утверждение кажется вам странным, то пристегните ремни, эта поездка будет для вас необычной.
Все примеры будут рассмотрены применительно к клиентскому JavaScript. В основу повествования легли идеи, озвученные в статье «Исключительная обработка событий в JavaScript». Название можно перефразировать так: «При возникновении исключения JS проверяет наличие обработчика в стеке вызовов». Если вы незнакомы с базовыми понятиями, то рекомендую сначала прочитать ту статью. Здесь же мы будем рассматривать вопрос глубже, не ограничиваясь простыми потребностями в обработке исключений. Так что когда в следующий раз вам опять попадётся блок
try...catch
, то вы уже подойдёте к нему с оглядкой.Демокод
Использованный для примеров код вы можете скачать с GitHub, он представляет собой вот такую страницу:
![](https://habrastorage.org/getpro/habr/post_images/ba5/3ee/f31/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.
Мне нравится, как эти сообщения вылавливаются на сервере.
![](https://habrastorage.org/files/e70/70b/623/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.
Ниже представлен пример отчёта, отправляемого на сервер обработчиком исключений. Если вы будете прогонять демокод, то у вас этот отчёт может быть немного другим, в зависимости от используемого браузера.
![](https://habrastorage.org/getpro/habr/post_images/a9a/5f5/854/a9a5f585459087731f28f445d448c629.jpg)
Этот обработчик даже сообщает о том, что ошибка связана с асинхронным кодом, точнее с обработчиком
setTimeout()
. Прямо сказка!Заключение
Есть как минимум два основных подхода к обработке ошибок. Первый — когда вы игнорируете ошибки, останавливая исполнение без уведомления. Второй — когда вы сразу получаете информацию об ошибке и отматываете до момента её возникновения. Думаю, всем очевидно, какой из этих подходов лучше и почему. Говоря кратко: не скрывайте проблемы. Никто не будет винить вас за возможные сбои в программе. Вполне допустимо останавливать исполнение, откатывать состояние и давать пользователю новую попытку. Мир несовершенен, поэтому важно давать второй шанс. Ошибки неизбежны, и значение имеет только то, как вы с ними справляетесь.
Комментарии (10)
NikitaTratorov
21.04.2016 20:15Я правильно понимаю, что это хороший принцип почти для любого языка
slonopotamus
21.04.2016 20:59Нет. В некоторых хороших языках рассматриваемая ошибка (вызов несуществующего метода) отлавливается на этапе компиляции. Еще в некоторых хороших языках не существует исключений.
vanxant
21.04.2016 21:51+10… или не отлавливается, если модули системы грузятся динамически, и в каком-нибудь интерфейсе какого-нибудь компонента не реализован какой-нибудь метод, который ожидается другим динамическим компонентам. Ну там из-за dll hell, несоответствия версий интерфейсов или чего-нибудь такого.
И поскольку в таких хороших языках нет простых штатных методов работы с такими ситуациями, то исключение кидает уже ОС.
vitalets
22.04.2016 12:42Я думаю, можно добавить в статью примечание про промисы:
- внутри промисов нет смысла использовать try...catch, потому что промис автоматически отлавливает их и переходит в rejected
- если в цепочке промисов вы не напишите .catch, но никакой window.onerror вашу ошибку не поймает. Ждем https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onunhandledrejection
- в ноде непойманные ошибки в промисах вообще тихо проглатываются https://github.com/nodejs/node/issues/830
ChALkeRx
22.04.2016 13:17> в ноде непойманные ошибки в промисах вообще тихо проглатываются https://github.com/nodejs/node/issues/830
Вы ищете https://nodejs.org/api/process.html#process_event_unhandledrejection
ChALkeRx
22.04.2016 13:11Я боюсь, что в 2016 году «правильная обработка ошибок в асинхронном js» — это всё-таки уже Promises и async/await, про что в этой статье не написано почти ничего.
gearbox
>window.addEventListener('error',
Вот бы вы поделились опытом как аналогичное сделать в расширении Firefox? Как раз сейчас решаю такую задачу, никак не пойму к кому привязаться. window нет, можно создать, но к исполняющемуся коду оно никакого отношения иметь не будет.
Semmaz
ЕМНИП, вот релевантная документация (вам нужно сконвертировать
BrowserWindow
в chrome window)gearbox
Ммм.
Возможно я ошибаюсь и чего то не знаю, но там описан способ создать window и загрузить туда как то контент. А вот как привязать background.js к этому window? Что бы при ошибке в background.js срабатывал error listener в этом window?Понял, надо попробовать. У меня правда bootstraped расширение, но думаю разберусь. Спасибо!