Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Представим, что у нас есть приложение на 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(
<App />,
document.getElementById("root")
);
Старый добрый синхронный рендер <App /> и всех компонентов внутри. Отличное место, чтобы обернуть приложение в try/catch:
try {
ReactDOM.render(
<App />,
document.getElementById("root")
);
} catch (error) {
console.error("React render error: ", error);
}
Ошибки, которые будут брошены во время первого рендера, будут пойманы этим try/catch.
Но если ошибка будет происходить в результате, например, смены стейта какого-либо компонента внутри, то этот try/catch уже не сработает, так как свою функцию ReactDOM.render уже выполнит к тому моменту — то есть выполнит первоначальный рендер <App /> в DOM. Всё, что будет происходить дальше, его не касается.
Вот есть демо, где можно поиграться с таким try/catch. В AppWithImmediateError.js находится компонент, который бросает ошибку при первом же рендере. В AppWithDeferredError.js — после изменения внутреннего стейта. Как видно, catch ловит ошибку только из AppWithImmediateError.js (см. консоль).
Что-то мы не видели, что так обрабатывают ошибки в компонентах в React. Этот пример я привёл просто для иллюстрации того, как работает первый рендер в браузере, когда мы только выполняем рендер приложения в реальный DOM. Дальше ещё будет несколько странных примеров, но они раскроют некоторые особенности в работе React.
Кстати, в новых методах первого рендера в React 18 не будет синхронной версии рендера всего приложения сразу. Поэтому такой подход с оборачиванием в try/catch не будет работать даже для первого рендера.
try/catch внутри компонента
Просто сделать глобальный try/catch — интересная затея. Только вот она не работает. Может тогда просто внутри рендера в <App /> сделать try/catch? Прям в рендер запихать. И ведь нет никаких запретов на это. Опустим тут вопрос про декларативность, чистоту функций. Не будем разбрасываться терминами — всё же синтаксис позволяет такой пируэт:
// Можно и классовый компонент взять
// и внутри render() try/catch написать.
// Разницы нет
const App = () => {
try {
return (
<div>
<ChildWithError />
</div>
);
} catch (error) {
console.error('App error handler: ', error);
return <FallbackUI/>;
}
}
Сделал демку для такого варианта. Открываем, тыкаем в кнопку Increase value. Когда value достигнет значения 4, <ChildWithError/> кинет ошибку в render-функции. Но ни сообщения в консоли, ни FallbackUI нет. Как же так? Мы же знаем, что
<div>
<ChildWithError />
</div>
в результате транспиляции (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-функции мы получаем объекты, которые вложены в другие объекты. Для нашего примера мы получим объект, который описывает <div>, у которого в props.children будет лежать объект, который описывает <ChildWithError />. Попробуйте сами вывести в консоль результат вызова render-функции.
Прямого вызова render-функции ChildWithError мы не видим внутри <App />. Мы лишь создали схему, по которой в дальнейшем будет рендерится <App />.
Render выполняется от родителей к детям. После рендера <App /> внутри <ChildWithError /> тоже создаётся объект, который описывает все элементы, возвращаемые render-функцией компонента <ChildWithError />. Мы как бы говорим React’у: если отренедрился <App />, то внутри него потом надо отрендерить <ChildWithError />.
Кстати, в этом и заключается декларативность React’а. А не в том, что мы цикл for написать в теле render-функции не можем.
И тут вы можете воскликнуть — чтобы объект такой собрать, нужно же функцию ChildWithError вызвать? Всё верно, только вызов функции ChildWithError происходит не внутри <App />. Он происходит совсем в другом месте. Пока что можно ограничиться таким объяснением — React сам вызывает render-функции компонентов в каком-то своём контексте. Позже я раскрою эту мысль глубже. В конкурентом режиме (он ждёт нас во всей красе в React 18) React ещё и во времени может эти вызовы раскидать так, как сам посчитает нужным.
Приведу аналогию такую: 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-функции компонентов, легко убедиться. Достаточно в демке поменять <ChildWithError /> на {ChildWithError()} внутри <App />. Мы прям руками сами вызовем render-функцию компонента ChildWithError внутри рендера <App />. Ошибка начнёт обрабатываться с помощью try/catch. Увидим сообщение в консоли и даже fallback UI отрендерится!
И почему бы везде так не писать? Просто делать вызов функции-компонента. Должно же и работать быстрее, не придётся ждать, когда там и где 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 <button onClick={increment}>{count}</button>
}
function App() {
const [items, setItems] = React.useState([])
const addItem = () => setItems(i => [...i, {id: i.length}])
return (
<div>
<button onClick={addItem}>Add Item</button>
<div>{items.map(Counter)}</div>
</div>
)
}
Попробуйте поиграться с этим примером в 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 для отлова ошибок рендера.
Сферический пример в вакууме
Что тут у нас есть: функциональный компонент <App />, у которого определён внутренний стейт. Значение из этого стейта шарится по всему дочернему дереву через React.context. <App /> рендерит внутри себя компонент <Child />. <Child /> обернут в HOC memo, внутри себя рендерит компонент <GrandChild />.
Внутри <Child /> я использовал классический try/catch, чтобы поймать все ошибки в рендере ниже по дереву. Внутри <GrandChild /> есть логика, что если значение из контекста будет больше 3, то бросается ошибка в рендере. Схематично это всё выглядит примерно так:
В <App /> используются getDerivedStateFromError, чтобы обновить стейт компонента <App /> в случае ошибки в дочернем дереве. Также есть componentDidCatch, в котором можно выполнить любой side effect в случае ошибки в дочернем дереве. То есть <App /> выступает в этом приложении как Error Boundary — именно он является той самой границей, за которую ошибка из дочернего дерева не пролезет дальше, вверх по дереву.
Зачем всё так сложно? Да потому что!
Берём и тыкаем в кнопку. Как видим, после первого клика перендерился только <App /> и <GrandChild />. <App /> — потому что у него стейт поменялся, <GrandChild /> — потому что поменялось значение в контексте. <Child /> же вообще никак не участвовал в этом процессе, так как он обернут в HOC memo. Как будто его и нет, хотя он находится, если так можно сказать, между <App /> и <GrandChild />. Подсветим зелёным тех ребят, кто перерендерился в этой ситуации.
Продолжая увеличивать счётчик в <App>, мы дойдём до ошибки в <GrandChild />. Как и в прошлые разы, при увеличении счётчика <Child /> вновь не будет участвовать, а значит 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-решения. Я точно уверен, что это путешествие окупится.
Список литературы
Да-да, прям как в рефератах) Ссылок много, хочется, чтобы к ним легко можно было вернуться:
Error Boundaries, документация React. Описание того, как обработать ошибки у вас в приложении
Статья Дэна Абрамова React as a UI Runtime. Дэн достаточно глубоко описывает то, как работает React.
Статья Kent C. Dodds Don't call a React function component. Наглядное объяснение, почему не стоит самому вызывать render-компоненты.
Facebook Open Source. Youtube-канал разработчиков из Facebook.
Inside Fiber: in-depth overview of the new reconciliation algorithm in React. Достаточно хардкорная статья про новую архитектуру React. На этом же ресурсе есть еще одна замечательная статья: The how and why on React’s usage of linked list in Fiber to walk the component’s tree. В общем, это для тех, кто хочет прям глубже погрузиться в то, как устроен React изнутри.
React Fiber Architecture. Чуть менее хардкорное описание Fiber-архитектуры.