Error Boundaries в React: препарируем лягушку
Представим, что у нас есть приложение на React, в котором можно читать и писать отзывы. Пользователь открыл список отзывов, пролистал его, нажал кнопку «Написать отзыв». Форма написания отзыва открывается в попапе, над списком. Пользователь начинает вводить текст, свой e-mail. Вдруг валидация почты срабатывает с ошибкой, которую разработчики забыли обработать. Результат — белый экран. React просто не смог ничего отрендерить из-за этой ошибки в каком-то попапе.
Первая же мысль — не надо было всё уничтожать, список же был не при делах. Чтобы обработать ошибку в render-фазе в React, необходимо использовать Error Boundaries. Почему именно так нужно обрабатывать ошибки — расскажу под катом.
try/catch спешит на помощь
Итак, начнём с простого. Если попросить вас обработать ошибки в JavaScript-коде, вы без сомнений обернете код в конструкцию try/catch:
try {
throw new Error('Привет, Мир! Я ошибка!');
} catch (error) {
console.error(error);
}
Запустим его и, как ни удивительно, в консоли мы увидим текст ошибки и стек вызовов. Всем известная конструкция, на рынке JavaScript с 1995 года. Код достаточно прост в понимании. Всё работает идеально.
Теперь обратим свой взор на React. Разработчики данной библиотеки позиционируют её как простую функцию, которая принимает на вход любые данные и возвращает визуальное представление этих данных:
function React(data) {
return UI;
}
const UI = React({ name: 'John' });
Выглядит несколько абстрактно, но пока нам этого хватит. Кажется, тут можно применить паттерн обработки ошибок в JavaScript, к которому мы так уже привыкли:
try {
const UI = React({ name: 'John' });
} catch (error) {
console.error(error);
}
Пока всё выглядит достаточно логично и просто. Попробуем реализовать подобный подход в реальном коде.
Обернём все в try/catch
Любое React-приложение начинается с того, что мы рендерим самый верхнеуровневый компонент — точку входа в приложение — в DOM-ноду:
ReactDOM.render(
,
document.getElementById("root")
);
Старый добрый синхронный рендер
try {
ReactDOM.render(
,
document.getElementById("root")
);
} catch (error) {
console.error("React render error: ", error);
}
Ошибки, которые будут брошены во время первого рендера, будут пойманы этим try/catch.
Но если ошибка будет происходить в результате, например, смены стейта какого-либо компонента внутри, то этот try/catch уже не сработает, так как свою функцию ReactDOM.render уже выполнит к тому моменту — то есть выполнит первоначальный рендер
Вот есть демо, где можно поиграться с таким try/catch. В AppWithImmediateError.js находится компонент, который бросает ошибку при первом же рендере. В AppWithDeferredError.js — после изменения внутреннего стейта. Как видно, catch ловит ошибку только из AppWithImmediateError.js (см. консоль).
Что-то мы не видели, что так обрабатывают ошибки в компонентах в React. Этот пример я привёл просто для иллюстрации того, как работает первый рендер в браузере, когда мы только выполняем рендер приложения в реальный DOM. Дальше ещё будет несколько странных примеров, но они раскроют некоторые особенности в работе React.
Кстати, в новых методах первого рендера в React 18 не будет синхронной версии рендера всего приложения сразу. Поэтому такой подход с оборачиванием в try/catch не будет работать даже для первого рендера.
try/catch внутри компонента
Просто сделать глобальный try/catch — интересная затея. Только вот она не работает. Может тогда просто внутри рендера в
// Можно и классовый компонент взять
// и внутри render() try/catch написать.
// Разницы нет
const App = () => {
try {
return (
);
} catch (error) {
console.error('App error handler: ', error);
return ;
}
}
Сделал демку для такого варианта. Открываем, тыкаем в кнопку Increase value. Когда value достигнет значения 4,
в результате транспиляции (babel«ем, typescript«ом, кем-то ещё, кого вы выбрали) превращается в
React.createElement(
'div',
null,
React.createElement(ChildWithError, null)
)
Вот тут можно поиграться с babel«ем, например.
То есть весь наш JSX превращается в вызовы функций. Таким образом, try/catch должен был отловить ошибку. В чём тут подвох? Неужели React умеет останавливать вызов функции?
С чем на самом деле работает React
Если приглядеться, то мы видим, что в React.createElement (ChildWithError, null) нет вызова рендера ChildWithError. Погодите, а что вообще возвращает вызов React.createElement? Если кому-то интересно прям source потыкать, то вот ссылка на то место, где создаётся элемент. В общем виде возвращается вот такой объект:
// Исходник: https://github.com/facebook/react/blob/main/packages/react/src/ReactElement.js#L148
const element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE, // Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props, // Record the component responsible for creating this element.
_owner: owner,
};
На выходе render-функции мы получаем объекты, которые вложены в другие объекты. Для нашего примера мы получим объект, который описывает
Прямого вызова render-функции ChildWithError мы не видим внутри
Render выполняется от родителей к детям. После рендера
Кстати, в этом и заключается декларативность React«а. А не в том, что мы цикл for написать в теле render-функции не можем.
И тут вы можете воскликнуть — чтобы объект такой собрать, нужно же функцию ChildWithError вызвать? Всё верно, только вызов функции ChildWithError происходит не внутри
Приведу аналогию такую: componentDidUpdate не происходит же в контексте рендера, он просто запускается React«ом после того, как компонент перерендерился. Либо вот такая аналогия (которая даже ближе к истине):
try {
Promise.resolve().then(() => {
throw new Error('wow!');
});
} catch (error) {
console.log('Error from catch: ', error);
}
Ошибка из промиса никогда не будет поймана в catch, так как происходит в другом месте Event-loop«а. Catch — синхронный callstack задач, промис — микротаска.
В том, что React сам вызывает render-функции компонентов, легко убедиться. Достаточно в демке поменять
И почему бы везде так не писать? Просто делать вызов функции-компонента. Должно же и работать быстрее, не придётся ждать, когда там и где React запустит рендер детей.
Тут я сразу отправлю читать замечательную статью Дэна Абрамова React as a UI Runtime. Конкретно про вызов компонента внутри рендера другого компонента можно прочитать в разделе Inversion of Control и далее Lazy Evaluation.
Забавно, но какое-то время назад ручной вызов рендера компонентов предлагался как паттерн по ускорению работы React-приложений. Вот пример, когда такой вызов может даже в ногу выстрелить:
function Counter() {
const [count, setCount] = React.useState(0)
const increment = () => setCount(c => c + 1)
return
}
function App() {
const [items, setItems] = React.useState([])
const addItem = () => setItems(i => [...i, {id: i.length}])
return (
{items.map(Counter)}
)
}
Попробуйте поиграться с этим примером в codesandbox. Уже после первого нажатия на AddItem мы получим ошибку, что в разных рендерах были вызваны разные хуки. А это нарушает правило использования хуков в React. Оставлю ссылочку на статью Kent C. Dodds про этот пример.
Хорошо, что ребята из Facebook занимаются просветительской деятельностью. И тут не только про Дэна речь. у них есть замечательный канал на YouTube — Facebook Open Source. Особенно нравятся их ролики в формате Explain Like I’m 5. Крайне рекомендую, чтобы самому научиться просто объяснять сложные штуки. Вот один из таких примеров:
Но у нас тут не такой формат — чуть более многословный.
Вернёмся к обработке ошибок. Простого try/catch внутри render () {} будет мало! А как же componentDidUpdateи другие lifecycle-методы? Да-да, классовые компоненты ещё поддерживаются в React. Если в функциональном компоненте мы просто вообще всё обернули бы в try/catch (опустим вопрос здравого смысла такого подхода), то в классовом компоненте придётся в каждый lifecycle-метод пихать try/catch. Не очень изящно, да… Какой вывод? Правильно, переходим на функциональные компоненты! Там try/catch юзать проще =)
Ладно, закончим играться с try/catch. Кажется, мы поняли, что в React мы не достигнем успеха с ним. Но, прежде чем переходить к Error Boundaries, я покажу ещё одну демку, которая точно отобьёт любое желание использовать try/catch для отлова ошибок рендера.
Сферический пример в вакууме
Что тут у нас есть: функциональный компонент
Внутри
В
Зачем всё так сложно? Да потому что!
Берём и тыкаем в кнопку. Как видим, после первого клика перендерился только
Продолжая увеличивать счётчик в
Эта демка — простая модель, которая отражает, что React сам решает, когда и что отрендерить и в каком контексте.
Вот мы и увидели, как работает Error Boundaries. Как пользоваться Error Boundaries и какие у него есть ограничения, я описывать не буду. Есть ссылка на доку на Reactjs.org, там всё достаточно подробно описали. Кстати, там указано, а когда всё же try/catch можно использовать. Да, мы от него полностью не отказываемся :)
Но куда интереснее понять, как именно это работает в React. Это что, какой-то особый try/catch?
try/catch по-React’овски
В игру вступает магический React Fiber. Это и название архитектуры, и название сущности из внутренностей React. Информация об этой сущности открывается для широкой общественности с приходом React 16. Вот, даже в официальной доке засветился.
Кто смотрел результат вызова React.createElement в консоль, тот видел, что там выводится гораздо больше информации, нежели чем ожидается. На скрине можно увидеть только часть из того, что React туда кладёт:
Суть вот в чём: помимо дерева React-элементов/компонентов, существует ещё и набор неких Fiber-нод, которые к этим элементам/компонентам привязаны. В этих нодах содержится внутреннее состояние (полезное только для React) React-элемента/компонента: какие были пропсы ранее, какой следующий effect запустить, нужно ли рендерить компонент сейчас и т. д.
Подробно про Fiber-архитектуру можно почитать в блоге inDepth.dev или в статье от одного из ключевых разработчиков из React-core — acdlite.
Имея это внутреннее представление, React уже знает, что делать с ошибкой, которая случилась во время фазы рендера конкретного компонента. То есть React может остановить рендер текущего дерева компонентов, отыскать ближайший сверху компонент, в котором есть или getDerivedStateFromError, или componentDidCatch — хотя бы кто-то один. Имея ссылки на родителей в FiberNode (там в связном списке всё лежит), сделать это проще простого. Вот есть source-код, в котором можно посмотреть, как это примерно работает.
Например, вот функция, в которой определяется, имеет ли компонент методы Error Boundaries. А вот исходник того, как организован внутренний так называемый workLoop в React. Тут же можно понять, что никакой магии в React нет — внутри всё же используется старый добрый try/catch для отлова ошибок. Почти как в нашем абстрактном примере, который я привел в начале статьи.
Для классического React с синхронным рендером используется тот же подход. Просто функция для workLoop используется другая. Впрочем, конкурентый React (18 версия и более новые) — это совсем другая история. Рекомендую открыть ссылки и поизучать их отдельно после прочтения этой статьи.
В общем виде это выглядит так:
Запускаем рендер компонента.
Если во время workLoop была ошибка, она будет поймана в try/catch и обработана.
В списке FiberNode ищем компонент-родитель с необходимыми методами (getDerivedStateFromError или componentDidCatch).
Нашли — React считает ошибку обработанной.
Все ветки отрендеренного дерева можно не выбрасывать. Отбросим только ту ветку, где была ошибка — ровно до того места, где мы определили Error Boundaries, границы распространения этой ошибки.
Если можно было бы представить работу с React, как с живым человеком, то общение с ним выглядело бы как-то так (своего рода объяснение в стиле Explain Like I’m 5):
Привет, я — React.
Спасибо за инструкции в JSX о том, что куда рендерить.
Дальше я сам все буду делать. Можешь расслабиться)
try {
*React изображает бурную деятельность*
} catch (error) {
Ну вот, опять ошибка.
Пойду искать родителей этого негодяя, который ошибку бросил.
Может они хоть что-то с ошибкой этой сделают.
Ну а то, что я уже сделал у других родителей — выбрасывать не буду.
Зря работал что ли?
}
The message
Такое копание в реализации какой-либо фичи порой дает интересные результаты. Можно иначе взглянуть на давно уже знакомую библиотеку/фреймворк. Или просто лучше понять, как использовать свой инструмент по-максимуму. Рекомендую всем иногда погружаться в какой-либо аспект в реализации вашего любимого JS-решения. Я точно уверен, что это путешествие окупится.
Список литературы
Да-да, прям как в рефератах) Ссылок много, хочется, чтобы к ним легко можно было вернуться: