Concurrent Mode в React: адаптируем веб-приложения под устройства и скорость интернета
В этой статье я расскажу о конкурентном режиме в React. Разберёмся, что это: какие есть особенности, какие новые инструменты появились и как с их помощью оптимизировать работу веб-приложений, чтобы у пользователей всё летало. Конкурентный режим — новая фишка в React. Его задача — адаптировать приложение к разным устройствам и скорости сети. Пока что Concurrent Mode — эксперимент, который может быть изменён разработчиками библиотеки, а значит, новых инструментов нет в стейбле. Я вас предупредил, а теперь — поехали.
Сейчас для отрисовки компонентов есть два ограничения: мощность процессора и скорость передачи данных по сети. Когда требуется что-то показать пользователю, текущая версия React пытается отрисовать каждый компонент от начала и до конца. Неважно, что интерфейс может зависнуть на несколько секунд. Такая же история с передачей данных. React будет ждать абсолютно все необходимые компоненту данные, вместо того чтобы рисовать его по частям.
Конкурентный режим решает перечисленные проблемы. С ним React может приостанавливать, приоритизировать и даже отменять операции, которые раньше были блокирующими, поэтому в конкурентном режиме можно начинать отрисовывать компоненты независимо от того, были ли получены все данные или только часть.
Concurrent Mode — это Fiber-архитектура
Конкурентный режим — это не новая штука, которую разработчики внезапно решили добавить, и всё тут же заработало. К его выходу готовились заранее. В 16-й версии движок React переключили на Fiber-архитектуру, которая по принципу работы напоминает планировщик задач в операционной системе. Планировщик распределяет вычислительные ресурсы между процессами. Он способен переключаться в любой момент времени, поэтому у пользователя возникает иллюзия, что процессы работают параллельно.
Fiber-архитектура делает то же самое, но с компонентами. Несмотря на то, что она уже есть в React, Fiber-архитектура будто находится в анабиозе и не использует свои возможности по максимуму. Конкурентный режим включит её на полную мощность.
При обновлении компонента в обычном режиме приходится целиком рисовать новый кадр на экране. Пока обновление не завершится, пользователь ничего не увидит. В этом случае React работает синхронно. Fiber использует другую концепцию. Каждые 16 мс происходит прерывание и проверка: изменилось ли виртуальное дерево, появились ли новые данные? Если да, пользователь увидит их сразу.
Почему 16 мс? Разработчики React стремятся, чтобы перерисовка экрана происходила на скорости, близкой к 60 кадрам в секунду. Чтобы уложить 60 обновлений в 1000 мс, нужно осуществлять их примерно каждые 16 мс. Отсюда и цифра. Конкурентный режим включается «из коробки» и добавляет новые инструменты, которые делают жизнь фронтендера лучше. Расскажу о каждом подробно.
Suspense
Suspense появился в React 16.6 в качестве механизма динамической загрузки компонентов. В конкурентном режиме эта логика сохраняется, но появляются дополнительные возможности. Suspense превращается в механизм, который работает в связке с библиотекой загрузки данных. Мы запрашиваем специальный ресурс через библиотеку и читаем из него данные.
Suspense в конкурентном режиме читает данные, которые ещё не готовы. Как? Запрашиваем данные, а пока они не пришли целиком, уже начинаем читать их по небольшим кусочкам. Самая крутая фишка для разработчиков — это управление порядком отображения загруженных данных. Suspense позволяет отображать компоненты страницы как одновременно, так и независимо друг от друга. Он делает код понятным: достаточно взглянуть на структуру Suspense, чтобы узнать, в каком порядке запрашиваются данные.
Типичное решение для загрузки страниц в «старом» React — Fetch-On-Render. В этом случае мы запрашиваем данные после render внутри useEffect или componentDidMount. Это стандартная логика в случае, когда нет Redux или другого слоя, работающего с данными. Например, мы хотим нарисовать 2 компонента, каждому из которых нужны данные:
- Запрос компонента 1
- Ожидание…
- Получение данных → отрисовка компонента 1
- Запрос компонента 2
- Ожидание…
- Получение данных → отрисовка компонента 2
В таком подходе запрос следующего компонента происходит только после отрисовки первого. Это долго и неудобно.
Рассмотрим другой способ, Fetch-Then-Render: сначала запрашиваем все данные, потом отрисовываем страничку.
- Запрос компонента 1
- Запрос компонента 2
- Ожидание…
- Получение компонента 1
- Получение компонента 2
- Отрисовка компонентов
В этом случае мы выносим состояние запроса куда-то наверх — делегируем библиотеке для работы с данными. Способ работает здорово, но есть нюанс. Если один из компонентов грузится гораздо дольше, чем другой, пользователь ничего не увидит, хотя мы могли бы ему уже что-то показать. Рассмотрим пример кода из демки с 2 компонентами: User и Posts. Оборачиваем компоненты в Suspense:
const resource = fetchData() // где-то выше в дереве React
function Page({ resource }) {
return (
Loading user...}>
Loading posts...}>
)
}
Может показаться, что такой подход близок к Fetch-On-Render, когда мы запрашивали данные после отрисовки первого компонента. Но на самом деле с использованием Suspense данные придут гораздо быстрее. Всё благодаря тому, что оба запроса отправляются параллельно.
В Suspense можно указать fallback, компонент, который хотим отобразить, и внутрь компонента передать ресурс, реализуемый библиотекой получения данных. Мы используем его as is. Внутри компонентов запрашиваем данные с ресурса и вызываем метод чтения. Это promise, который делает за нас библиотека. Suspense поймёт, загрузились ли данные, и если да — покажет их.
Обратите внимание, что компоненты пробуют читать данные, которые ещё находятся в процессе получения:
function User() {
const user = resource.user.read()
return {user.name}
}
function Posts() {
const posts = resource.posts.read()
return // список постов
}
В текущих демках Дэна Абрамова в качестве заглушки для ресурса используется такая штука.
read() {
if (status === 'pending') {
throw suspender
} else if (status === 'error') {
throw result
} else if (status === 'success') {
return result
}
}
Если ресурс ещё загружается, мы выбрасываем объект Promise в качестве исключения. Suspense ловит это исключение, понимает, что это Promise, и продолжает грузиться. Если же вместо Promise прилетит исключение с любым другим объектом, станет понятно, что запрос завершился ошибкой. Когда же вернётся готовый результат, Suspense его отобразит. Нам важно получить ресурс и вызвать у него метод. Как это реализовано внутри — решение разработчиков библиотеки, главное, чтобы их реализацию понимал Suspense.
Когда запрашивать данные? Запрашивать наверху дерева — не лучшая идея, потому что они могут никогда не потребоваться. Более подходящий вариант — делать это сразу при переходе внутри обработчиков событий. Например, получить начальное состояние через хук, а дальше делать запрос за ресурсами, как только пользователь нажмёт на кнопку.
Вот как это будет выглядеть в коде:
function App() {
const [resource, setResource] = useState(initialResource)
return (
<>
Suspense — невероятно гибкая штука. С его помощью можно отображать компоненты друг за другом.
return (
Loading user...}>
Loading posts...}>
)
Или одновременно, тогда оба компонента нужно обернуть в один Suspense.
return (
Loading user and posts...}>
)
Или же грузить компоненты отдельно друг от друга, обернув их в независимые Suspense. Ресурс будет загружаться через библиотеку. Это очень здорово и удобно.
return (
<>
Loading user...}>
Loading posts...}>
>
)
Кроме того, компоненты Error Boundary поймают ошибки внутри Suspense. Если что-то пошло не так, мы сможем показать, что пользователь загрузился, а посты нет, и выдать ошибку.
return (
Loading user...}>
Could not fetch posts}>
Loading posts...}>
)
А теперь давайте взглянем на другие инструменты, благодаря которым преимущества конкурентного режима раскрываются полностью.
SuspenseList
SuspenseList в конкурентном режиме помогает управлять порядком загрузки Suspense. Если бы нам потребовалось загрузить несколько Suspense строго друг за другом без него, их бы пришлось вложить друг в друга:
return (
Loading user...}>
Loading posts...}>
Loading facts...}>
)
SuspenseList позволяет сделать это гораздо проще:
return (
Loading posts...}>
Loading facts...}>
)
Гибкость SuspenseList поражает. Можно как угодно вкладывать SuspenseList друг в друга и настраивать порядок загрузки внутри так, как будет удобно для отображения виджетов и любых других компонентов.
useTransition
Специальный хук, который откладывает обновление компонента до полной готовности и убирает промежуточное состояние загрузки. Для чего это нужно? React при изменении состояния стремится сделать переход как можно быстрее. Но иногда важно не торопиться. Если на действие пользователя подгружается часть данных, то обычно в момент загрузки мы показываем лоадер или скелетон. Если данные придут очень быстро, то лоадер не успеет совершить даже пол-оборота. Он моргнёт, затем исчезнет, и мы нарисуем обновлённый компонент. В таких случаях разумнее вообще не показывать лоадер.
Здесь на помощь придёт useTransition. Как он работает в коде? Вызываем хук useTransition и указываем тайм-аут в миллисекундах. Если данные не придут за указанное время, то мы всё равно покажем лоадер. Но если получим их быстрее, произойдёт моментальный переход.
function App() {
const [resource, setResource] = useState(initialResource)
const [startTransition, isPending] = useTransition({ timeoutMs: 2000 })
return <>
Иногда при переходе на страницу мы не хотим показывать лоадер, но всё равно нужно что-то изменить в интерфейсе. Например, на время перехода заблокировать кнопку. Тогда придётся кстати свойство isPending — оно сообщит, что мы находимся в стадии перехода. Для пользователя обновление будет моментальным, но здесь важно отметить, что магия useTransition действует только на компоненты, обёрнутые в Suspense. Сам по себе useTransition не заработает.
Переходы часто встречаются в интерфейсах. Логику, отвечающую за переход, было бы здорово зашить в кнопку и интегрировать в библиотеку. Если есть компонент, ответственный переходы между страницами, можно обернуть в handleClick тот onClick, который передаётся через пропсы в кнопку, и показать состояние isDisabled.
function Button({ text, onClick }) {
const [startTransition, isPending] = useTransition({ timeoutMs: 2000 })
function handleClick() {
startTransition(() => {
onClick()
})
}
return
}
useDeferredValue
Итак, есть компонент, с помощью которого мы делаем переходы. Иногда возникает такая ситуация: пользователь хочет уйти на другую страницу, часть данных мы получили и готовы показать. При этом страницы между собой отличаются незначительно. В этом случае было бы логично показывать пользователю устаревшие данные, пока не подгрузится всё остальное.
Сейчас React так не умеет: в текущей версии на экране пользователя могут отображаться только данные из актуального состояния. Но useDeferredValue в конкурентном режиме умеет возвращать отложенную версию значения, показывать устаревшие данные вместо мигающего лоадера или fallback во время загрузки. Этот хук принимает значение, для которого мы хотим получить отложенную версию, и задержку в миллисекундах.
Интерфейс становится супер текучим. Производить обновления можно с минимальным количеством данных, а всё остальное грузится постепенно. У пользователя создаётся впечатление, что приложение работает быстро и плавно. В действии useDeferredValue выглядит так:
function Page({ resource }) {
const deferredResource = useDeferredValue(resource, { timeoutMs: 1000 })
const isDeferred = resource !== deferredResource;
return (
Loading user...}>
Loading posts...}>
)
}
Можно сравнить значение из пропсов с тем, что получено через useDeferredValue. Если они отличаются, значит страница ещё находится в состоянии загрузки.
Интересно, что useDeferredValue позволит повторить фокус с отложенной загрузкой не только для данных, которые передаются по сети, но и для того, чтобы убрать фриз интерфейса из-за больших вычислений.
Почему это здорово? Разные устройства работают по-разному. Если запустить приложение, использующее useDeferredValue, на новом iPhone, переход со страницы на страницу будет моментальным, даже если страницы тяжёлые. Но при использовании debounced задержка появится даже на мощном устройстве. UseDeferredValue и конкурентный режим адаптируются к железу: если оно работает медленно, инпут всё равно будет летать, а сама страничка — обновляться так, как позволяет устройство.
Как переключить проект в Concurrent Mode?
Конкурентный режим — это именно режим, поэтому его потребуется включить. Как тумблер, который заставляет Fiber работать на полную мощность. С чего же начать?
Убираем легаси. Избавляемся от всех устаревших методов в коде и убеждаемся, что их нет в библиотеках. Если приложение без проблем работает в React.StrictMode, то всё в порядке — переезд будет простым. Потенциальная сложность — проблемы внутри библиотек. В этом случае нужно либо перейти на новую версию, либо сменить библиотеку. Или же отказаться от конкурентного режима. После избавления от легаси останется только переключить root.
С приходом Concurrent Mode будет доступно три режима подключения root:
- Старый режим
ReactDOM.render(
, rootNode)
Рендер после выхода конкурентного режима устареет. - Блокирующий режим
ReactDOM.createBlockingRoot(rootNode).render(
)
В качестве промежуточного этапа будет добавлен блокирующий режим, который даёт доступ к части возможностей конкурентного режима на проектах, где есть легаси или другие трудности с переездом. - Конкурентный режим
ReactDOM.createRoot(rootNode).render(
)
Если всё хорошо, нет легаси, и проект можно сразу переключить, замените в проекте рендер на createRoot — и вперёд в светлое будущее.
Выводы
Блокирующие операции внутри React превращаются в асинхронные за счёт переключения на Fiber. Появляются новые инструменты, с которыми легко адаптировать приложение и к возможностям устройства, и к скорости сети:
- Suspense, благодаря которому можно указывать порядок загрузки данных.
- SuspenseList, с которым это ещё удобнее.
- useTransition для создания плавных переходов между компонентами, обёрнутыми в Suspense.
- useDeferredValue — чтобы показывать устаревшие данные во время операций ввода-вывода и обновления компонентов
Попробуйте поэкспериментировать с конкурентным режимом, пока он ещё не вышел. Concurrent Mode позволяет добиться впечатляющих результатов: быстрой и плавной загрузки компонентов в любом удобном порядке, сверхтекучести интерфейса. Подробности описаны в документации, там же лежат демки с примерами, которые стоит поизучать самостоятельно. А если вам любопытно, как работает Fiber-архитектура, вот ссылка на интересный доклад.
Оцените свои проекты — что можно улучшить, используя новые инструменты? А когда конкурентный режим выйдет, смело переезжайте. Всё будет супер!