Как всегда, нас не очень интересует вопрос, как работать с ошибками в джаваскрипте. Потому что горазо важнее вопрос зачем. К тому моменту, когда вы встречаетесь в инструкциями try-catch
и throw
, вы уже решили больше сотни задач и как-то нормально обходились бросания ошибок.
В основном ошибки происходили, когда вы делали что-то не так. Я приведу несколько примеров.
Иногда мы случайно обращались к несуществующему ключу объекта или несуществующему индексу массива и получали Cannot read properties of undefined (reading 'toUpperCase').
const fruits = ["apple", "peach", "orange"]; fruits[3].toUpperCase();
Иногда мы пытались переприсвоить значение константы и получали Assignment to constant variable.
const x = 5; x++;
Иногда мы вызывали reduce без начального значения на пустом массиве и получали Reduce of empty array with no initial value.
[].reduce((a, b) => a + b);
Что объединяет все эти примеры? Их можно было избежать. Можно проверять, что индекс существует, сравнивая его с длиной массива. У редьюса нужно всегда указывать начальное значение. А с const нужно просто быть внимательнее :)
Бросаем. В любой функции вы можете написать throw
и какое-то значение после него. В джаваскрипте бросить можно все что угодно. Но лучше всего бросать объект ошибки (Error или его классы-наследники). Если мы бросаем объект ошибки, то в нем автоматически создается поле stack, по которому мы можем определить, где именно произошла ошибка.
После вызова инструкции throw
выполнение кода останавливается, мы досрочно завершаем все функции, которые были в данный момент запущены и попадаем в ближайший catch
.
Ловим. Также в любом месте вы можете обернуть произвольный кусок кода в инструкцию try-catch. Тогда если внутри какой-то функции, которая вызывалась в результате выполнения этого кода, была брошена ошибка мы поймаем ее в блоке catch.
function foo() { console.log(1); throw new Error("oops"); console.log(2); } function bar() { console.log(3); foo(); console.log(4); } function main() { try { console.log(5); bar(); console.log(6); } catch (e) { console.log(e.message); } } main(); // В консоль выведутся 5 3 1 oops // e.stack будет содержать примерно такую строку // Error: oops // at foo (<anonymous>:2:9) // at bar (<anonymous>:6:3) // at main (<anonymous>:12:5)
После того, как мы бросили ошибку в foo
не смысла не только продолжать выполнять foo
, но и продолжать выполнять bar
. Соотвественно, мы сразу оказываемся в блоке catch
.
В разных языках программирования используются термины exception (исключение) и error (ошибка), которые значат примерно одно и то же. Мне ближе термин exception, потому что он лучше объясняет, что происходит. А происходит исключительная ситуация.
Как я писал выше, если вы попытались вызвать метод у undefined
, потому что не проверили, что ключ существует, то это не исключение, а баг. Баги нужно исправлять и отлавливать написанием тестов.
Например, если у объекта потенциально может не быть какого-то ключа, можно добавить проверку через if
или использовать oprional chaining (?.
).
В других языках программирования эксепшены встречаются даже в тех местах, где джаваскрипт вас прощает.
Java бросает ошибку, если вы обратились по неправильному индексу. В джаваскрипте вы бы получили undefined
.
int[] arr = {2, 3, 5, 7, 11}; int last = arr[5]; | Exception java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5 | at (#2:1)
Python бросает ошибку, если вы поделили на ноль. В джаваскрипте вы бы получили Infinity
.
x = 10 y = 0 div = x / y Traceback (most recent call last): File "<python-input-3>", line 3, in <module> div = x / y ~~^~~ ZeroDivisionError: division by zero
Кстати, наличие в языке JavaScript двух значаений, означающих отсутствие значения (null
и undefined
) обязано тем, что в момент создания языка автору хотелось одновременно две вещи: предусмотреть совместимость с джавой (нужен null
) и не реализовывать механизм ловли ошибок (так появился undefined
).
Эксперты расскажут вам, что null
и undefined
обладают разной семантикой. Мол, undefined
возникает, когда мы обращаемся к чему-то несуществующему (переменной, аргументу, индексу, ключу), и не должен использоваться программистом. А null
мы должны присваивать явно, когда хотим показать, что чего-то нет, и мы его устанавливаем осознанно.
В целом, это хорошее правило, но сам язык очень часто уравнивает эти значения. Например, при сравнении через двойное равно (==
) null
и undefined
равны друг другу и никому больше. А optional chaining и nullish coalescing operator опять же работают одинаково для null
и undefined
.
Когда мы пишем на реакте, вполне осознанно можно реализовать компонент, где мы явно должны передать undefined
, потому что onClick
ожидает функцию или ничего. Передать null
нельзя, это будет ошибкой.
function Link({ children, url, withMetrics }) { function sendMetrics() { // do something } return ( <a href={url} onClick={withMetrics ? sendMetrics : undefined}> {children} </a> ); }
Исключения возникают в тех случаях, когда вы предусмотрели все, что могли, но проблема возникла не по вашей вине. Например, вы хотите прочитать файл, а его нет. Вы хотите отправить сетевой запрос, а интернет отвалился. Вы ожидали, что данные в файле будут в определенном формате, а там какая-то фигня.
Особенность джаваскрипта в том, что практически все такие операции возникают в результате асинхронных взаимодействий, которые в джаваскрипте исторически реализовывались через колбэки. С появлением синтаксиса async-await (ES2017) try-catch получил вторую жизнь и широкое распространение.
До появления синтаксиса async-await самым популярным примером использования try-catch являлся кейс с парсингом джейсона.
JSON — это аббревиатура, в названии которой три буквы из четырех являются ложью. Расшифровывающийся как JavaScript Object Notation этот формат — в общем случае — не имеет отношения ни к джаваскрипту, ни к объектам (точнее имеет отношение не только к объектам и не только к джавскрипту).
Notation — по-русски, видимо, нотация — это система записи. Приведу несколько примеров, когда используется слово натация:
Джейсон позволяет в виде строки описать какое-то значение. В первую очередь для того, чтобы передавать его между приложениями. И хотя приложения могут быть написаны не на джаваскрипте, JSON используется повсеместно.
Преобразование данных в строку является частным случаем сериализации — преобразования данных в какой-то формат для передачи куда-либо с возможностью обратного преобразования в дальнейшем.
В джаваскрипте получить эту строчку можно, вызвав фунцию JSON.stringify
.
JSON.stringify(["1", "2", "3"]) === '["1","2","3"]' JSON.stringify({x: 1, y: 2}) === '{"x":1,"y":2}' JSON.stringify(123) === '123' JSON.stringify("123") === '"123"' // можно получить красивую запись, кстати JSON.stringify({ firstName: "Max", lastName: "Sinyakov" }, null, 2) === `{ "firstName": "Max", "lastName": "Sinyakov" }`
Первый раз вы можете получить исключение, попытавшись преобразовать в джейсон значение типа bigint
(длинные числа). Основная проблема в том, как его закодировать. Если записать как число, то принимающая сторона может сломаться, если число окажется слишком длинным. Если записать в виде строки, то принимающая сторона никогда не догадается, что это был bigint
.
JSON.stringify(239n); // TypeError: Do not know how to serialize a BigInt
Но это опять пример того, когда вы сами создаете себе проблемы. Просто не передавайте bigint в эту функцию. И все будет окей.
Главная проблема возникает тогда, когда мы пытаемся доверять другим людям. Нам говорят, вот строка, в ней JSON, ты можешь распарсить его и получить джавасриптовое значение.
const json1 = `{"x":1,"y":2}`; const json2 = `{'x':1,'y':2}`; JSON.parse(json1); // все окей JSON.parse(json2); // подстава
При попытке распарсить json2
мы получаем SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2). Вот так и доверяй людям. Что с этим можно сделать? Ничего. Только обернуть в try catch.
В браузере есть возможность привязать к сайту данные, которые будут доступны даже после перезапуска браузера. Один из способов — использование Web Storage API (также известный как localStorage).
Если я считываю данные из localStorage (где мы можем хранить только строки), я верю в то, что там лежит то, что я туда положил. Но доступ к localStorage имеет любой скрипт на странице. Кто угодно может перезаписать мое значение.
function restoreFavoriteIds() { const favoriteIdsJson = localStorage.getItem("favoriteIds"); if (favoriteIdsJson === null) { return []; // 🤷🏻♂️ } try { // 🤞🏼 Пожалуйста, пусть там будет валидный джейсон, как я ожидаю const favoriteIds = JSON.parse(favoriteIdsJson); if (Array.isArray(favoriteIds) && favoriteIds.every(id => typeof id === "number") ) { return favoriteIds } return []; // 🤷🏻♂️ } catch { return []; // 🤷🏻♂️ } }
В трех случаях мы вынуждены вернуть пустой массив, потому что не смогли получить нужные данные. И только в одном из них мы используем try catch.
null
JSON.parse
бросит ошибку и в блоке catch
мы сможем ее обработать (вернуть пустой массив).На самом деле хорошей идеей было бы обернуть вообще весь этот код в try catch, поскольку сам объект localStorage
тоже может отсутствовать. Например, в Safari в приватном режиме (incognito mode), localStorage
не работает.
На этом примеры ловли ошибок с помощью try catch в пользовательском коде обычно заканчиваются, потому что из-за однопоточности и работы через Event Loop, все остальные операции, где мы взаимодействуем с внешним миром, работают через колбэки.
Метод fetch возвращает объект класа Promise. Если запрос ушел успешно, мы попадем в первый колбэк. Если запрос не ушел (например, нет интрнета), мы попадем во второй колбэк.
fetch("https://maxcode.dev/").then( () => { /* браузер смог отправить запрос */ }, () => { /* например, интернет не работает */ } );
Если бы fetch работал синхронно, мы могли бы использовать try catch. На самом деле мы можем использовать и сейчас, но это особый try catch, который работает внутри async-функций, и тогда от then
надо будет избавиться. А мы сейчас как раз обсуждаем, почему до синтаксиса async-await try-catch нам бы не помог.
Более древние API работают на колбэках без промисов. Например, в браузере можно прочитать содержимое файла:
const reader = new FileReader(); reader.onload = function(event) { console.log(event.target.result); }; reader.onerror = function(event) { console.log("файл оказался не текстовым, а картинкой, например"); }; reader.readAsText(file);
В этом случае мы сначала как бы подписываемся на событие ошибки, присваивая в поле onerror
функцию, которая дожна вы полниться в случае, если файл не прочитался. А затем вызываем метод readAsText
, который запускает процесс чтения. Этот вызов ничего не возвращает. Но когда файл прочитается, сработает колбэк из поля onload
.
В конце блока про асинхронные ошибки хотел бы отметить, что ошибки, разобранные в начале статьи (редьюс на пустом массиве, изменение константы, обращение к полю андефайнда), все синхронные. Но при этом они тоже являются исключениями. То есть исключительными ситуациями, вызванными внешней системой.
Вот только внешней системой в этом случае выступает программист, использующий язык. А сам джаваскрипт со своей стороны делает все, чтобы просигнализировать: внешний мир опять делает что-то не так. И бросает эксепшен.