JavaScript: Полное руководство по обработке ошибок
Опытные разработчики ожидают неожиданного. Если что-то может пойти не так, так оно и будет — обычно в тот момент, когда первый пользователь получает доступ к вашему новому приложению.
Некоторых ошибок веб-приложений можно избежать, например:
- Хороший редактор или линтер может отлавливать синтаксические ошибки.
- Хорошая валидация может выявить ошибки пользовательского ввода.
- Надёжные процессы тестирования могут обнаруживать логические ошибки.
И всё же ошибки остаются. Браузеры могут сбоить или не поддерживать API, который мы используем. Серверы могут дать сбой или слишком долго отвечать. Сетевое соединение может выйти из строя или стать ненадёжным. Проблемы могут быть временными, но мы не может программировать такие проблемы. Однако мы можем предвидеть проблемы, принимать меры по их устранению и повышать отказоустойчивость нашего приложения.
Отображение сообщения об ошибке — крайняя мера
В идеале пользователи никогда не должны видеть сообщения об ошибке.
Мы можем игнорировать незначительные проблемы, такие как невозможность загрузки декоративного изображения. Мы могли бы решить более серьёзные проблемы, такие как сбои сохранения данных Ajax локально и загружая их позже. Ошибка становиться необходимой только тогда, когда пользователь рискует потерять данные, предполагая, что он может что-то с этим сделать.
Поэтому необходимо выявлять ошибки по мере их возникновения и определять наилучшие действия. Вызов и перехват ошибок в приложении JavaScript поначалу может быть пугающим, но, возможно, это проще, чем вы ожидаете.
Как JavaScript обрабатывает ошибки
Когда оператор JavaScript приводит к ошибке, говорят, что он генерирует (выбрасывает) исключение. JavaScript создаёт и выбрасывает объект Error
, описывающий ошибку. Мы можем увидеть это в действии на CodePen. Если установить в десятичные разряды (decimal places) отрицательное число, мы увидим сообщение об ошибке в консоли внизу. (Обратите внимание, что мы не встраиваем CodePen в это руководство, потому что нужно иметь возможно видеть вывод консоли, чтобы этот пример имел смысл)
Результат не обновиться, и мы увидим сообщение RangeError
в консоли. Следующая функция выдаёт ошибку, когда dp
имеет отрицательно значение:
// division calculation
function divide(v1, v2, dp) {
return (v1 / v2).toFixed(dp);
}
После выдачи ошибки интерпретатор JavaScript проверяет наличие кода обработки исключений. В функции Division()
ничего нет, поэтому она проверяет вызывающую функцию:
// show result of division
function showResult() {
result.value = divide(
parseFloat(num1.value),
parseFloat(num2.value),
parseFloat(dp.value)
);
}
Интерпретатор повторяет процесс для каждой функции в стеке вызовов, пока не произойдёт одно из следующих событий:
- Он находит обработчик исключений.
- Он достигает верхнего уровня кода (что приводит к завершению программы и отображению ошибки в консоли, как показано в примере на CodePen выше).
Перехват исключений
Мы можем добавить обработчик исключений в функцию divide()
с помощью блока try...catch
:
// division calculation
function divide(v1, v2, dp) {
try {
return (v1 / v2).toFixed(dp);
}
catch(e) {
console.log(`
error name : ${ e.name }
error message: ${ e.message }
`);
return 'ERROR';
}
}
Функция выполняет код в блоке try {}
, но при возникновении исключения выполняется блок catch {}
и получает выброшенный объект ошибки. Как и прежде, попробуйте в decimal places
установить отрицательное число в этой демонстрации CodePen.
Теперь result
показывает ERROR
. Консоль показывает имя ошибки и сообщение, но это выводится оператором console.log
и не завершает работу программы.
Примечание: эта демонстрация блока
try...catch
излишняя для базовой функции, такой какdivide()
. Как мы увидим ниже, проще убедиться, чтоdp
равен нулю или больше.
Можно определить не обязательный блок finally {}
, если требуется, чтобы код запускался при выполнении кода try
или catch
:
function divide(v1, v2, dp) {
try {
return (v1 / v2).toFixed(dp);
}
catch(e) {
return 'ERROR';
}
finally {
console.log('done');
}
}
В консоль выведется done
, независимо от того, успешно ли выполнено вычисление или возникла ошибка. Блок finally
обычно выполняет действия, которые в противном случае нам пришлось бы повторять как в блоке try
, так и в блоке catch
. Например, отмену вызова API или закрытие соединения с базой данных.
Для блока try
требуется либо блок catch
, либо блок finally
, либо и то и другое. Обратите внимание, что когда блок finally
содержит оператор return
, это значение становится возвращаемым значением для всей функции; другие операторы в блоках try
или catch
игнорируются.
Вложенные обработчики исключений
Что произойдёт, если мы добавим обработчик исключений к вызывающей функции showResult()
?
// show result of division
function showResult() {
try {
result.value = divide(
parseFloat(num1.value),
parseFloat(num2.value),
parseFloat(dp.value)
);
}
catch(e) {
result.value = 'FAIL!';
}
}
Ответ… ничего! Блок catch
никогда не выполняется, потому что в функции divide()
блок catch
обрабатывает ошибку.
Тем не менее мы могли бы программно генерировать новый объект Error
в divide()
и при желании передать исходную ошибку в свойстве cause
второго аргумента:
function divide(v1, v2, dp) {
try {
return (v1 / v2).toFixed(dp);
}
catch(e) {
throw new Error('ERROR', { cause: e });
}
}
Это вызовет блок catch
в вызывающей функции:
// show result of division
function showResult() {
try {
//...
}
catch(e) {
console.log( e.message ); // ERROR
console.log( e.cause.name ); // RangeError
result.value = 'FAIL!';
}
}
Стандартные типы ошибок JavaScript
Когда возникает исключение, JavaScript создаёт и выдаёт объект, описывающий ошибку, используя один из следующих типов.
SyntaxError
Ошибка, возникающая из-за синтаксически недопустимого кода, такого как отсутствующая скобка:
if condition) { // SyntaxError
console.log('condition is true');
}
Примечание: такие языки, как C++ и Java, сообщают об ошибках синтаксиса во время компиляции. JavaScript — интерпретируемый язык, поэтому синтаксические ошибки не выявляются до тех пор, пока код не запустится. Любой хороший редактор кода или линтер могут обнаружить синтаксические ошибки до того, как мы попытаемся запустить код.
ReferenceError
Ошибка при доступе к несуществующей переменной:
function inc() {
value++; // ReferenceError
}
Опять, хороший редактор кода или линтер могут обнаружить эту проблему.
TypeError
Ошибка возникает, когда значение не соответствует ожидаемому типу, например, при вызове несуществующего метода объекта:
const obj = {};
obj.missingMethod(); // TypeError
RangeError
Ошибка возникает, когда значение не входит в набор или диапазон допустимых значений. Используемый выше метод toFixed()
генерирует эту ошибку, потому что он ожидает значение от 0 до 100:
const n = 123.456;
console.log( n.toFixed(-1) ); // RangeError
URIError
Ошибка выдаваемая функциями обработки URI, такими как encodeURI()
и decodeURI()
, при обнаружении неправильных URI:
const u = decodeURIComponent('%'); // URIError
EvalError
Ошибка возникающая при передаче строки, содержащей не валидный JavaScript код, в функцию eval()
:
eval('console.logg x;'); // EvalError
Примечание: пожалуйста, не используйте
eval()
! Выполнение произвольного кода, содержащегося в строке, возможно, созданной на основе пользовательского ввода, слишком опасно!
AggregateError
Ошибка возникает, когда несколько ошибок объединены в одну ошибку. Обычно возникает при вызове такой операции, как Promise.all()
, которая возвращает результаты нескольких промисов.
InternalError
Нестандартная ошибка (только в Firefox) возникает при возникновении внутренней ошибки движка JavaScript. Обычно это результат того, что что-то занимает слишком много памяти, например, большой массив или слишком много рекурсии
.
Error
Наконец, есть общий объект Error
, чаще всего используемый при реализации собственных исключений... о котором мы поговорим дальше.
Генерация/выбрасывание собственных исключений
Мы можем использовать throw
для генерации/выбрасывания собственных исключений, когда возникает ошибка — или должна произойти. Например:
- нашей функции не передаются валидные параметры
- ajax-запрос не возвращает ожидаемые данные
- обновление DOM завершается ошибкой, поскольку узел не существует
Оператор throw
фактически принимает любое значение или объект. Например:
throw 'A simple error string';
throw 42;
throw true;
throw { message: 'An error', name: 'MyError' };
Исключения генерируются для каждой функции в стеке вызовов до тех пор, пока они не будут перехвачены обработчиком исключений (catch
). Однако на практике мы хотим создать и сгенерировать объект Error
, чтобы он действовал идентично стандартным ошибкам, выдаваемым JavaScript.
Можно создать общий объект Error
, передав необязательное сообщение конструктору:
throw new Error('An error has occurred');
Так же Error
можно использовать как функцию, без new
. Она возвращает объект Error
, идентичный приведённому выше:
throw Error('An error has occurred');
При желании можно передать имя файла и номер строки в качестве второго и третьего параметров:
throw new Error('An error has occurred', 'script.js', 99);
В этом редко возникает необходимость, так как по умолчанию они относятся к файлу и строке, где мы вызвали объект Error
. (Также их сложно поддерживать, поскольку наши файлы меняются!)
Мы можем определить общие объекты Error
, но по возможности следует использовать стандартный тип Error
. Например:
throw new RangeError('Decimal places must be 0 or greater');
Все объекты Error
имеют следующие свойства, которые можно проверить в блоке catch
:
.name
: имя типа ошибки, напримерError
илиRangeError
..message
: сообщение об ошибке.
В Firefox поддерживаются следующие нестандартные свойства:
.fileName
: файл, в котором произошла ошибка..lineNumber
: номер строки, в которой произошла ошибка..columnNumber
: номер столбца, в котором произошла ошибка..stack
: трассировка стека со списком вызовов функций, сделанных до возникновения ошибки.
Мы можем изменить функцию divide()
так, чтобы она вызывала ошибку RangeError
, когда количество знаков после запятой не является числом, меньше нуля и больше восьми:
// division calculation
function divide(v1, v2, dp) {
if (isNaN(dp) || dp < 0 || dp > 8) {
throw new RangeError('Decimal places must be between 0 and 8');
}
return (v1 / v2).toFixed(dp);
}
Точно так же мы могли бы выдать Error
или TypeError
, когда значения делимого не является числом, чтобы предотвратить результат NaN
:
if (isNaN(v1)) {
throw new TypeError('Dividend must be a number');
}
Также можно обрабатывать делитель, который не является числом или равен нулю. JavaScript возвращает Infinity
при делении на ноль, но это может запутать пользователя. Вместо того чтобы вызывать общую ошибку, мы могли бы создать собственный тип ошибки DivByZeroError
:
// new DivByZeroError Error type
class DivByZeroError extends Error {
constructor(message) {
super(message);
this.name = 'DivByZeroError';
}
}
Затем вызывать/выбрасывать его подобным образом:
if (isNaN(v2) || !v2) {
throw new DivByZeroError('Divisor must be a non-zero number');
}
Теперь добавьте блок try...catch
к вызывающей функции showResult()
. Он сможет получить тип любой ошибки и отреагировать соответствующим образом — в данном случае, выводя сообщение об ошибке:
// show result of division
function showResult() {
try {
result.value = divide(
parseFloat(num1.value),
parseFloat(num2.value),
parseFloat(dp.value)
);
errmsg.textContent = '';
}
catch (e) {
result.value = 'ERROR';
errmsg.textContent = e.message;
console.log( e.name );
}
}
Попробуйте ввести недопустимые нечисловые, нулевые и отрицательные значения в демонстрации на CodePen.
Окончательная версия функции divide()
проверяет все входящие значения и при необходимости выдаёт соответствующую ошибку:
// division calculation
function divide(v1, v2, dp) {
if (isNaN(v1)) {
throw new TypeError('Dividend must be a number');
}
if (isNaN(v2) || !v2) {
throw new DivByZeroError('Divisor must be a non-zero number');
}
if (isNaN(dp) || dp < 0 || dp > 8) {
throw new RangeError('Decimal places must be between 0 and 8');
}
return (v1 / v2).toFixed(dp);
}
Больше нет необходимости размещать блок try...catch
вокруг финального return
, так как он никогда не должен генерировать ошибку. Если бы это произошло, JavaScript сгенерировал бы свою собственную ошибку и обработал бы её блоком catch
в showResult()
/
Ошибки асинхронной функции
Мы не можем перехватывать исключения, генерируемые асинхронными функциями на основе обратного вызова, потому что после завершения выполнения блока try...catch
выдаётся ошибка. Этот код выглядит правильно, но блок catch
никогда не выполнится, и через секунду консоль отобразит сообщение Uncaught Error
:
function asyncError(delay = 1000) {
setTimeout(() => {
throw new Error('I am never caught!');
}, delay);
}
try {
asyncError();
}
catch(e) {
console.error('This will never run');
}
Соглашение, принятое в большинстве фреймворков и серверных сред выполнения, таких как Node.js, заключается в том, чтобы возвращать ошибку в качестве первого параметра функции обратного вызова. Это не приведёт к возникновению исключения, хотя при необходимости мы можем вручную сгенерировать ошибку:
function asyncError(delay = 1000, callback) {
setTimeout(() => {
callback('This is an error message');
}, delay);
}
asyncError(1000, e => {
if (e) {
throw new Error(`error: ${ e }`);
}
});
Ошибки на основе промисов
Обратные вызовы могут стать громоздкими, поэтому при написании асинхронного кода предпочтительнее использовать промисы. При возникновении ошибки метод reject()
промиса может вернуть новый объект Error
или любое другое значение:
function wait(delay = 1000) {
return new Promise((resolve, reject) => {
if (isNaN(delay) || delay < 0) {
reject( new TypeError('Invalid delay') );
}
else {
setTimeout(() => {
resolve(`waited ${ delay } ms`);
}, delay);
}
})
}
Примечание: функции должны быть либо 100% синхронными, либо 100% асинхронными. Вот почему необходимо проверять значение
delay
внутри возвращаемого промиса. Если бы мы проверили значениеdelay
и выдали ошибку перед возвратом промиса, функция стала бы синхронной при возникновении ошибки.
Метод Promise.catch()
выполняется при передаче недопустимого параметра delay
и получает возвращённый объект Error
:
// invalid delay value passed
wait('INVALID')
.then( res => console.log( res ))
.catch( e => console.error( e.message ) )
.finally( () => console.log('complete') );
Я считаю цепочки промисов немного сложными для чтения. К счастью, мы можем использовать await
для вызова любой функции, возвращающей промис. Это должно происходить внутри асинхронной функции, но мы можем перехватывать ошибки с помощью стандартного блока try...catch
.
Следующая (вызываемая немедленно) асинхронная функция функционально идентична цепочке промисов выше:
(async () => {
try {
console.log( await wait('INVALID') );
}
catch (e) {
console.error( e.message );
}
finally {
console.log('complete');
}
})();
Исключительная обработка исключения
Выбрасывать объекты Error
и обрабатывать исключения в JavaScript легко:
try {
throw new Error('I am an error!');
}
catch (e) {
console.log(`error ${ e.message }`)
}
Создание отказоустойчивого приложения, адекватно реагирующего на ошибки и облегчающее жизнь пользователя, является сложным испытанием. Всегда ожидайте неожиданного.
Дополнительная информация: