В этом уроке будем разбираться с разделением кода и с ленивой подгрузкой для улучшения производительности приложения, а также поймем как в React управляться с ошибками.
GitHub-репозиторий с исходниками: http://bit.ly/gh_course_react_beginner
Lazy-подгрузка
Как вы наверное знаете, React-приложение обычно пишется в удобной форме в виде множества разных файлов, которые подключают друг друга, а затем это собирается в так называемый бандл, то есть единый JS-файл, в котором находится весь код приложения.
Однако это не всегда подходит, потому что эта сборка может быть очень большой, а пользователь будет использовать далеко не все функции нашего приложения. Например, он при каждом сеансе работы вряд ли будет переходить в профиль и настройки, или читать содержимое какой-нибудь модалки с пользовательским соглашением. Однако этот код все равно будет скачиваться при первоначальной загрузке приложения. И будет даже, скорее всего как-то обрабатываться интерпретатором, хотя реально этим кодом мы еще не воспользовались.
То есть это и лишний объем занятой памяти и лишняя трата мощности процессора на парсинг (особенно, когда дело касается мобильных устройств). А хочется чтобы было так — нажал юзер на какую-то кнопочку и тут же подгрузился JS-файл с кодом, реализующим соответствующий функционал.
Соответственно, хорошо бы иметь возможность разбить наше приложение на части или чанки. Webpack, который используется внутри Create React App, умеет это делать для любого JS-кода. Но хотелось бы чтобы это было прямо внутри API React’а. Что ж, это возможно.
Давайте взглянем вот на такой код:
App.js:
import React from 'react'
function App() {
return (
<div>
<span
onClick={_ => {
import('./math')
.then(math => console.log(math.sqr(5)))
}}
>
Load module dynamically and run function
</span>
</div>
)
}
export default App
sqr.js:
function sqr (num) {
return num * num
}
export { sqr }
Есть файл с нашим приложением, где при клике на кнопку должен динамически подгрузиться файл sqr.js. В нем находится функция, которой мы пользуемся после загрузки. Я думаю вы уже заметили что вот эта конструкция очень похожа на… Правильно, Promise. так что если вы знаете как это работает, то вам понятно и зачем тут then. А он получает результат успешной загрузки запрошенного модуля.
Далее мы уже можем внутри запустить любую функцию или компонент, который экспортируется. Если это простой экспорт, то обращение к sqr производится как я показал, а если же экспорт по дефолту, то результат его будет лежать в поле default.
В принципе, такой динамический импорт скоро должен стать частью стандарта ECMAScript, так что можно к нему уже привыкать.
Однако если вы импортируете компоненты, то удобнее воспользоваться ленивой функцией React.lazy(). Вот пример её работы в связке со специальным компонентом Suspense (переводится как «подвешенный»):
App.js:
import React, { Suspense, lazy } from 'react'
const LicenseAgreement = lazy(() => import('./LicenseAgreement'))
function App() {
return (
<div>
Welcome to my App!
<Suspense fallback={'Loading...'}>
<LicenseAgreement />
</Suspense>
</div>
)
}
export default App
LicenseAgreement.js:
import React from 'react'
function LicenseAgreement() {
return (
<div>
License here
</div>
)
}
export default LicenseAgreement
Происходит тут следующее. Из React мы импортируем компонент Suspense и функцию lazy. Затем с помощью lazy подгружаем компонент LicenseAgreement. Точнее фактически мы готовы его теперь подгрузить, когда понадобится. Дальше отрабатывает render и встречается нужный компонент. Он обернут в Suspense, который имеет пропс fallback, содержимое которого отрисовывается, когда чанк находится в процессе подгрузки. Здесь обычная JSX-разметка. Если не хотите ничего рисовать, просто напишите тут null, но что-то внутри все равно должно быть, иначе получите ошибку. А еще обратим внимание, что подгружается еще и отдельный от общего бандла файл-чанк с нашим компонентом LicenseAgreement.
Ну вот, работает. Однако из этого примера непонятно зачем эта ленивая подгрузка нужна. Так что давайте переделаем его так:
App.js:
import React, { Suspense, lazy } from 'react'
const LicenseAgreement = lazy(() => import('./LicenseAgreement'))
class App extends React.Component {
state = {
showLicense: false
}
showLicense = _ => this.setState(_ => ({ showLicense: true }))
render () {
return (
<div>
Welcome to my App!
<button onClick={this.showLicense}>Show License</button>
{this.state.showLicense ? (
<Suspense fallback={null}>
<LicenseAgreement />
</Suspense>
) : null}
</div>
)
}
}
export default App
Тут уже показывать или не показывать LicenseAgreement, решается в зависимости от состояния поля showLicense в стейте. Изначально оно false, но как только после клика по кнопке туда записывается true, рисуется LicenseAgreement. Давайте я наглядно продемонстрирую, что LicenseAgreement это именно отдельный чанк. Загружаю приложение. Очищаю логи на вкладке Network и жму на Show License. Как видно — подгрузился отдельный файл. Вот так это работает.
Кстати, внутри Suspense можно расположить более одного lazy-компонента.
Сделать lazy-компонентами, как я уже говорил, вполне резонно стОит разные страницы приложения. И они также будут выделены в отдельные компоненты с подгрузкой по требованию.
Вот так может выглядеть подобный ход. Мы не будем углубляться в дебри пакета под названием react-router, просто коротко взглянем на пример из доков (https://reactjs.org/docs/code-splitting.html#route-based-code-splitting).
Home и About подгружаются лениво. В рендере срабатывает Router и Switch выбирает, какой конкретный Route и связанный с ним компонент будет рендерится. Если в адресной строке просто слэш, то отрендерится Home, если ‘/about’, то About. Так вот, если мы зашли на ‘/’, About не будет грузится. Он дернется в виде отдельного чанка и отрисуется только если мы перейдем на ‘/about’. Ну и те приемущества, которых я говорил в начале видео — производительность и более быстрая первоначальная загрузка, тут налицо.
Ах да, у lazy пока что нет поддержки именованных экспортов, есть только дефолтные. Поэтому нужно использовать промежуточный компонент для этих целей. Вот тут показано, что в ManyComponents есть несколько компонентов на экспорт и мы хотим, допустим по дефолту экспортировать MyComponent. Тогда создаем промежуточный компонент MyComponent.js, где по дефолту экспортируем наш MyComponent. Ну и потом уже его импортируем где надо.
Отлично, с разделением кода на части разобрались. Посмотрим на обработку ошибок.
Обработка ошибок
Ошибки в JS вещь пренеприятная, потому что ведут к падению всего приложения, как правило. Для отлова ошибок у нас есть конструкция try…catch, но мы же в среде React пишем, значит нужно что-то вроде специального компонента для этого. Чтобы он мог ловить ошибки в нужных пределах.
Я взял исходный код предыдущего примера с лицензией и создал отдельный минималистичный Webpack-конфиг для сборки проекта. Вот он, если интересно. В будущих выпусках мы по шагам такой конфиг напишем, а так же подробно разбрем внутренности create-react-app и, возможно, других сборок для создания React-приложений.
В общем, сейчас мы не получаем красивых ошибок, реализованных в create-react-app и можем потестить так называемые Error Boundaries. Это специальный компонент, по поведению похожий на джаваскриптовый try…catch. Он не дает ошибке уронить все приложение и позволяет вывести красивое сообщение или залогировать её куда-то.
Представьте, это ведь полезно если какой-то пользователь вашего приложения при определенных условиях словил баг, а вы получили информацию благодаря которой можно этот баг найти и исправить.
Все что я сделал в App.js, так это обернул имеющийся код в импортированный компонент ErrorBoundary. Вот его содержимое:
import React from 'react'
class ErrorBoundary extends React.Component {
state = { hasError: false }
static getDerivedStateFromError(error) {
console.log(error)
return { hasError: true }
}
componentDidCatch(error, info) {
console.log('Logging error to extenal log service ...', error, info)
}
render() {
return this.state.hasError
? <h1>Something went wrong.</h1>
: this.props.children
}
}
export default ErrorBoundary
Здесь у нас есть стейт с полем наличия ошибки, изначально ее, естественно, нет. И в рендере рисуется this.props.children. То есть вот это внутреннее содержимое нашей обертки ErrorBoundary в App.js. А вот если кто-то внутри ErrorBoundary произведет ошибку, то это дернет метод жизненного цикла getDerivedStateFromError. Его назначение, поменять стейт так, чтобы вместо ошибочного кода нарисовать некое сообщение об ошибке. Этот метод должен возвратить объект для модификации стейта. В нашем случае ровно это и происходит.
При возникновении ошибки возвращается hasError: true и рисуется не глючный this.props.children, а заголовок с сообщением об ошибке. В LicenseAgreement, кстати, я обратился к несуществующему полю this.props.profile.name, что и вызвало ошибку.
И да, чтобы было легче запомнить назначение метода getDerivedStateFromError (он кстати статический — вот это ключевое слово, что означает, что он доступен для вызова и без создания экземпляра класса).. так вот, чтобы было легче запомнить, разберемся в буквальном перевода — «получить произодный стейт из ошибки». То есть при получении ошибки нужно переключить стейт так, чтобы отрисовалось что-то что, не производит ошибки, а, скажем сообщает о ней, раз уж это про обработку ошибок.
А можно даже нарисовать форму отправки сообщения с подробным описанием того, как ошибка была получена, что предшествовало её получению. Главное чтобы и этот компонент не глючил, а то придется и его оборачивать в Error Boundary =) Ну так конечно в реальных приложениях не делают. Но можно нафантазировать подобную систему для этапа открытого бета-тестирования продукта, когда тестеров много и они не являются частью команды разработки.
Здесь мы еще выводим сообщение об ошибке, которое прилетает аргументом.
Ну и у нас еще остался componentDidCatch, который вызывается сразу после getDerivedStateFromError. В нем как раз можно отправить информацию об ошибке в сервис логирования или сделать что-то еще, что вы считаете необходимым. Он принимает error и info в аргументы.
Посмотрим на итоговый вывод в консоли: вывод джаваскриптового стека с ошибкой, затем это же, но просто текстом — вывод из getDerivedStateFromError, далее дерево компонентов реакта до места ошибки, следующим идет мой console.log() с текстом той же ошибки и с объектом, содержащим вот это дерево компонентов Реакт, но уже в текстовом виде — можно как-то вывести или куда-то записать.
Кстати говоря, удобно было бы в стек-трейсе реакт-компонентов выводить строки, в которых происходили вызовы, чтобы не шерстить весь код, а смотреть прямо в нужные места по указанным номерам строк. В этом помогает данный Babel-плагин (https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-source). Смотрите, теперь при возникновении ошибок у нас становятся видны номера строк, что чрезвычайно удобно!
Однако же, давайте сделаем пример более показательным и обернем в ErrorBoundary еще и наш LicenseAgreement, в котором и содержится, как мы знаем ошибка. В реальности конечно мы не знаем заранее где ошибка.
И вот теперь, надпись Something went wrong появилась вместо предполагаемого LicenseAgreement, а все остальное не упало и продолжило работать. Теперь понятно, надеюсь преимущество? Это по сути фильтр проникновения ошибок в остальные части нашего приложения. Как фильтр для воды. Вы же не пьете воду из-под крана, верно? =)) Я недеюсь, ведь так и подхватить бациллы недолго. Вот так и приложение болеет и падает от вездесущих ошибок. Беспокойтесь об этом, особенно когда ваш продукт в продакшене. Там перебои могут очень печально сказаться. И сказываются.
В общем, потенциально глючные компоненты оборачивайте в такие обработчики ошибок и не давайте вашему творению умирать полностью.
Начиная с 16й версии React, ошибки не перехваченные ни одним Error Boundary, ведут к анмаунтингу всего приложения. Например, в нашем случае получится так. Уберем все обработчики и получим пустоту. Явно видно что в DOM-дереве только пустой div#root Так сразу явно понятно что фатальная ошибка имеет место быть. Раньше же ошибка выводилась в консоли и не вела к полному анмантингу.
В сообществе разработчиков Реакта велось активное обсуждение этого вопроса, но в итоге пришли к поведению, которое вы наблюдаете в 16-й версии. Это боле наглядно и безопасно. Представим что у вас есть чатик на Реакте и что-то рушится, но человек все еще видит чат. Он может отправить сообщение, которое возможно попадет не к тому человеку, кому надо.
Понимаете чем это может грозить, да?)) Всяким разным неприятным. А еще хуже, если это финансовое приложение и вы не тому перевели денег или не ту сумму, с лишним ноликом скажем. Это вообще крах. Такого нельзя допускать. Поэтому, если ошибка нигде не обрабатывается, приложение полностью извлекается из DOM-дерева.
Почему бы не использовать try/catch? Ну просто потому, что это неудобно. try/catch хорош для императивного кода типа такого (https://reactjs.org/docs/error-boundaries.html#how-about-trycatch), когда мы явно пишем все действия для достижения результата, однако Реакт штука декларативная. Мы говорим — рисуй такой-то компонент и больше ничего не хочу знать. Вся императивность спрятана внутри. Разумеется, там не магия происходит, а тоже конкретные действия, но от нас это скрыто слоями абстракции. И их там много. Так вот, если уж мы оперируем компонентами, то и try/catch вполне закономерно превращается в компонент типа ErrorBoundary, который мы видели ранее.
Работает это аналогично try/catch. Try/catch’и можно вкладывать друг в друга и возникшая ошибка задержится ближайшим catch (это слово, кстати переводится как «ловить», что четко отражает назначение). Error Boundary (опять же говорящее название — граница ошибки) работает так же. Ошибка нашего LicenseAgreement задержалась ближайшим компонентом ErrorBoundary.
Теперь насчет ошибок в обработчиках событий (event handler’ах). Error Boundary их не ловит. И это не «к сожалению». Дело в том, что такие ошибки не ведут к тому, что Реакт не знает что рисовать в render, ведь эти события будут происходить до или после рендера и там уже можно ловить ошибки с помощью try/catch. Если же в результате работы какого-нибудь обработчика клика по кнопке что-то в рендере сломается, то ближайший Error Boundary это обработает.
Давайте еще разберем вот такой пример кода от Дена Абрамова, видного деятеля в мире Реакта (https://codepen.io/gaearon/pen/wqvxGa?editors=0010).
Приложение рисует несколько счетчиков, которые по достижению значения 5, выдают ошибку — вот тут, «throw new Error». Сам код счетчика очень простой и если вы внимательно изучили материал предыдущих уроков, он вам уже понятен без лишних объяснений. Счетчики обернуты в ErrorBoundary. В котором объявлен уже знакомый нам componentDidCatch, который при отлове ошибки записывает в стейт JS и реактовский стек трейс. Это я уже вам показывал. И в render, если есть ошибка, выводится сообщение с подробностями ошибки, откуда видно, какой компонент все порушил. Причем, что интересно, Есть три ErrorBoundary и четыре счетчика. Потому что в одном из ErrorBoundary сразу два счетчика. Это значит, что если один из них валится, то тянет за собой и второй. И это видно.
Ну и вот, мы закончили основы обработки ошибок и разделения кода. Конечно там бывает куча самых разных конкретных ситуаций, но это уже другая история.
А пока что я с вами прощаюсь и в следующем уроке будет еще интереснее. Подписывайтесь на канал, делитесь нашими роликами и поддержите финансово, чтобы помочь новым выпускам появляться чаще. Удачи.