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

Мы можем избежать некоторых ошибок веб-приложений, например:

  • Хороший редактор или линтер может отлавливать синтаксические ошибки.
  • Хорошая проверка может выявить ошибки пользовательского ввода.
  • Надежные процессы тестирования могут обнаруживать логические ошибки.

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

Отображение сообщения об ошибке — крайняя мера

В идеале пользователи никогда не должны видеть сообщения об ошибках.

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

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

Как JavaScript обрабатывает ошибки

Когда оператор JavaScript приводит к ошибке, говорят, что он выдает исключение. JavaScript создает и выдает объект Error, описывающий ошибку. Мы можем увидеть это в действии в этой демонстрации CodePen. Если мы установим для десятичных знаков отрицательное число, мы увидим сообщение об ошибке в консоли внизу. (Обратите внимание, что мы не встраиваем CodePen в это руководство, потому что вам нужно иметь возможность видеть вывод консоли, чтобы они имели смысл.)

Результат не обновится, и мы увидим сообщение RangeError в консоли. Следующая функция выдает ошибку, когда dp имеет отрицательное значение:

function divide(v1, v2, dp) {
  return (v1 / v2).toFixed(dp);
}

После выдачи ошибки интерпретатор JavaScript проверяет наличие кода обработки исключений. В функции divide() ничего нет, поэтому она проверяет вызывающую функцию:

function showResult() {
  result.value = divide(
    parseFloat(num1.value),
    parseFloat(num2.value),
    parseFloat(dp.value)
  );
}

Интерпретатор повторяет процесс для каждой функции в стеке вызовов, пока не произойдет одно из следующих событий:

  • он находит обработчик исключений
  • он достигает верхнего уровня кода (что приводит к завершению программы и отображению ошибки в консоли, как показано в примере CodePen выше)

Перехват исключений

Мы можем добавить обработчик исключений в функцию divide() с помощью блока try…catch:

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 {} выполняется и получает выброшенный объект ошибки. Как и прежде, попробуйте установить отрицательное число для десятичных знаков в этой демонстрации CodePen.

Результат теперь показывает ОШИБКА. Консоль показывает имя ошибки и сообщение, но это выводится оператором 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, это значение становится возвращаемым значением для всей функции; другие операторы return в блоках try или catch игнорируются.

Вложенные обработчики исключений

Что произойдет, если мы добавим обработчик исключений к вызывающей функции showResult()?

function showResult() {
  try {
    result.value = divide(
      parseFloat(num1.value),
      parseFloat(num2.value),
      parseFloat(dp.value)
    );
  }
  catch(e) {
    result.value = 'FAIL!';
  }
}

Ответ: ничего! Этот блок catch никогда не достигается, потому что блок catch в функции divide() обрабатывает ошибку.

Однако мы могли бы программно добавить новый объект Error в divide() и при желании передать исходную ошибку в свойстве cause второго аргумента:

function divide(v1, v2, dp) {
  try {
    return (v1 / v2).toFixed(dp);
  }
  catch(e) {
    throw new Error('ERROR', { cause: e });
  }
}

Это вызовет блок catch в вызывающей функции:

function showResult() {
  try {
    
  }
  catch(e) {
    console.log( e.message ); 
    console.log( e.cause.name ); 
    result.value = 'FAIL!';
  }
}

Стандартные типы ошибок JavaScript

Когда возникает исключение, JavaScript создает и выдает объект, описывающий ошибку, используя один из следующих типов.

Ошибка синтаксиса

Ошибка, возникающая из-за синтаксически недопустимого кода, такого как отсутствующая скобка:

if condition) { 
  console.log('condition is true');
}

Примечание. Такие языки, как C++ и Java, сообщают об ошибках синтаксиса во время компиляции. JavaScript — это интерпретируемый язык, поэтому синтаксические ошибки не выявляются до тех пор, пока код не запустится. Любой хороший редактор кода или линтер может обнаружить синтаксические ошибки до того, как мы попытаемся запустить код.

ReferenceError

Ошибка при доступе к несуществующей переменной:

function inc() {
  value++; 
}

Опять же, хорошие редакторы кода и линтеры могут обнаружить эти проблемы.

Ошибка типа

Ошибка возникает, когда значение не соответствует ожидаемому типу, например, при вызове метода несуществующего объекта:

const obj = {};
obj.missingMethod();

RangeError

Возникает ошибка, когда значение не входит в набор или диапазон допустимых значений. Используемый выше метод toFixed() генерирует эту ошибку, потому что он обычно ожидает значение от 0 до 100:

const n = 123.456;
console.log( n.toFixed(-1) );

URIError

Ошибка, выдаваемая функциями обработки URI, такими как encodeURI() и decodeURI(), когда они сталкиваются с неправильным форматом URI:

const u = decodeURIComponent('%');

EvalError

Возникла ошибка при передаче строки, содержащей неверный код JavaScript, в функцию eval():

eval('console.logg x;');

Примечание: не используйте eval()! Выполнение произвольного кода, содержащегося в строке, возможно, созданной на основе пользовательского ввода, слишком опасно!

Агрегатеррор

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

Внутренняя ошибка

Нестандартная (только для Firefox) ошибка возникает при возникновении внутренней ошибки в движке JavaScript. Обычно это результат того, что что-то занимает слишком много памяти, например, большой массив или «слишком много рекурсии».

Ошибка

Наконец, есть общий объект 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, когда количество знаков после запятой не является числом, меньше нуля или больше восьми:

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 возвращает Бесконечность при делении на ноль, но это может запутать пользователей. Вместо создания общего Error мы могли бы создать собственный тип ошибки DivByZeroError:

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(). Он может получить любой тип Error и отреагировать соответствующим образом — в данном случае, показывая сообщение об ошибке:

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() проверяет все входные значения и при необходимости выдает соответствующий Error:

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, заключается в том, чтобы возвращать ошибку в качестве первого параметра функции обратного вызова. Это не приведет к возникновению исключения, хотя при необходимости мы можем вручную сгенерировать Error:

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:

wait('INVALID')
  .then( res => console.log( res ))
  .catch( e => console.error( e.message ) )
  .finally( () => console.log('complete') );

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

Следующая (вызываемая сразу) функция async функционально идентична цепочке промисов выше:

(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 }`)
}

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