Skip to main content

Как React 18 улучшает производительность приложения

· 13 min read

Источник.

React 18 представил конкурентные (concurrent) возможности, которые радикально меняют способ рендеринга приложений. В этой статье мы рассмотрим, как эти возможности улучшают производительность приложения.

Начнем с повторения основ "долгих (долго выполняющихся) задач" (long tasks) и соответствующих метрик производительности.

Основной поток (main thread) и долгие задачи

Когда мы запускаем JavaScript в браузере, движок JS выполняет код в однопоточной среде, которую часто называют "основным (главным) потоком" (main thread). Кроме выполнения кода, главный поток отвечает за реакции на действия пользователя, такие как клики и ввод, обработку сетевых событий, таймеров, обновление анимаций, а также за перекомпоновку и перерисовку макета страницы.

Основной поток отвечает за последовательную обработку задач.

Следующая задача ждет завершения обработки предыдущей. Маленькие задачи выполняются браузером плавно, обеспечивая бесшовный пользовательский опыт (UX), долгие задачи могут блокировать обработку других задач.

Любая задача, обработка которой занимает больше 50 мс, считается долгой.

Этот порог обусловлен тем, что устройство должно создавать новый кадр (frame) каждые 16 мс (60 кадров в секунду, fps) для обеспечения плавного визуального опыта. Однако, устройство также должно обрабатывать другие задачи, такие как ответы на ввод пользователя и выполнение JS.

Порог в 50 мс позволяет устройству выделять ресурсы как для рендеринга кадров, так и для выполнения других задач. Дополнительные ~33.33 мс предназначены для выполнения других задач и обеспечения плавного визуального опыта. Подробнее об отметке в 50 мс можно почитать в этой статье.


Для поддержания оптимальной производительности важно минимизировать количество долгих задач. Существует 2 метрики, позволяющие измерить влияние таких задач на производительность приложения: общее время блокировки (TBT) и интерактивность до следующей отрисовки (TTI).

TBT - это важная метрика, показывающая время между первой отрисовской контента (FCP) и временем до интерактивности (TTI). TBT - это количество времени, превышающее 50 мс, что может существенно повлиять на UX.

TBT составляет 45 мс, поскольку у нас имеется 2 задачи, выполнение которых занимает больше 50 мс на 30 и 15 мс, соответственно. TBT - это сумма этих значений: 30 мс + 15 мс = 45 мс.

INP, новая метрика, показывает время от первого взаимодействия пользователя со страницей (например, нажатие кнопки) до рендеринга результатов этого взаимодействия на экране (следующей отрисовки). Эта метрика особенно важна для страниц с большим количеством взаимодействий пользователей, например, для электронных магазинов или социальных сетей. Она представляет собой сумму всех метрик INP в течение текущей сессии пользователя и возвращает худший показатель.

INP составляет 250 мс, поскольку это самое большое время задержки.

Обычный рендеринг React

Визуальное обновление в React делится на 2 фазы: фаза рендеринга (render phase) и фаза фиксации (commit phase). Фаза рендеринга в React - это чисто вычислительная стадия, когда элементы React сравниваются с существующей DOM (объектная модель документа). Эта стадия включает создание нового дерева элементов React, известное под названием "виртуальная DOM", которое представляет собой легковесное представление реальной DOM в памяти.

В течение фазы рендеринга React вычисляет отличия между текущей DOM и новым деревом компонентов React и готовит необходимые обновления.

После этого начинается фаза фиксации. На этом этапе React применяет обновления к реальной DOM. Это включает создание, обновление и удаление узлов DOM для отражения нового дерева компонентов.


При обычном синхронном рендеринге все элементы дерева компонентов имеют одинаковый приоритет. Рендеринг дерева компонентов (первоначальная отрисовка или обновление состояния) выполняется React как одна задача, после чего результаты рендеринга сразу применяются к DOM.

Синхронный рендеринг является операцией "Все или ничего", когда гарантируется завершение начатого рендеринга. В зависимости от сложности компонента, фаза рендеринга может занимать много времени. Основной поток на это время блокируется. Это означает, что если пользователь попытается взаимодействовать с приложением в этот период, он будет иметь дело с неотзывчивым пользовательским интерфейсом (UI) до тех пор, пока React не завершит рендеринг и не зафиксирует его результаты в DOM.


В следующем демо у нас имеется поле для ввода текста и большой список городов, который фильтруется на основе значения инпута. В обычном рендеринге React будет повторно рендерить компонент CitiesList при вводе каждого символа. Это является дорогим вычислением, поскольку список состоит из десятков тысяч городов, из-за чего между вводом символа и его отображением в инпуте возникает заметная задержка.

На вкладке "Производительность" (Performance) инструментов разработчика в браузере можно увидеть, что обработка ввода каждого символа является долгой задачей.

Задачи, помеченные с помощью красного треугольника в верхнем левом углу, считаются долгими. Обратите внимание, что TBT составляет 4425,40 мс.

Разработчики React часто прибегают к помощи debounce для реализации отложенного рендеринга.


React 18 представляет новый конкурентный рендерер, действующий за сценой. Этот рендерер позволяет помечать определенные рендеринги как несрочные.

При рендеринге компонентов с низким приоритетом (розовые) React периодически обращается к основному потоку для определения наличия более важных задач.

В данном случае React "обращается" к основному потоку каждые 5 мс для определения наличия более важных задач для обработки, таких как пользовательский ввод или даже рендеринг другого компонента, который в данный момент является более важным для пользовательского опыта. Это позволяет сделать рендеринг неблокирующим и приоритизировать более важные задачи.

Вместо одной непрерывной задачи для каждого рендеринга, конкурентный рендерер периодически возвращает управление основному потоку с интервалом в 5 мс в процессе (повторного) рендеринга низкоприоритетных компонентов.

Конкурентный рендерер также способен одновременно рендерить несколько версий дерева компонентов в фоновом режиме без фиксации результатов.

В то время как синхронный рендеринг - это вычисление "Все или ничего", конкурентный рендерер позволяет React приостанавливать и возобновлять рендеринг одного или нескольких компонентов для обеспечения лучшего UX.

React приостанавливает текущий рендеринг после действия пользователя, которое привело к приоритизации рендеринга другого обновления.

Конкурентные возможности позволяют React приостанавливать и возобновлять рендеринг компонентов на основе внешних событий, таких как действия пользователя. Когда пользователь начинает взаимодействовать с ComponentOne, React приостанавливает текущий рендеринг, приоритизирует и рендерит ComponentTwo, после чего продолжает рендеринг компонента ComponentOne. Более подробно об этом мы поговорим в разделе, посвященном Suspense.

Переходы (transitions)

Мы можем пометить обновление как несрочное с помощью функции startTransition, возвращаемой хуком useTransition. Эта новая мощная возможность позволяет помечать определенные обновления состояния как "переходы", что служит индикатором того, что их синхронный рендеринг может привести к визуальным изменениям, потенциально ухудшающим UX.

Оборачивая обновление состояния в startTransition, мы сообщаем React о возможности приостановки или прерывания рендеринга для выполнения более важных задач в целях сохранения интерактивности текущего UI.

import { useTransition } from "react";

function Button() {
const [isPending, startTransition] = useTransition();

return (
<button
onClick={() => {
urgentUpdate();
// !
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}

При запуске перехода конкурентный рендерер в фоновом режиме готовит новое дерево. По завершении рендеринга результат сохраняется в памяти до тех пор, пока планировщик React в подходящий момент не обновит DOM для отражения нового состояния. Подходящим моментом может быть период простоя (idle) браузера и отсутствие высокоприоритетной задачи в очереди на выполнение.

Использование перехода будет идеальным для демо CitiesList. Вместо прямого обновления значения, передаваемого в качестве параметра searchQuery при вводе каждого символа, мы можем разделить состояние на 2 значения и обернуть обновление состояния searchQuery в startTransition.

Это сообщит React, что обновление состояния может привести в визуальным изменениям, ухудшающим UI, поэтому React должен сохранять интерактивность UI при подготовке нового состояния в фоновом режиме без незамедлительной фиксации обновлений.

Теперь ввод остается плавным без задержек в отображении символов. Это происходит благодаря тому, что состояние text, которое используется в качестве value инпута, по-прежнему обновляется синхронно.

В фоне React запускает рендеринг нового дерева при вводе каждого символа. Но теперь это не синхронная задача "Все или ничего", React держит новую версию дерева компонентов в памяти, а текущий UI ("старое" состояние) остается отзывчивым на действия пользователя.

На вкладке "Производительность" видно, что оборачивание обновления состояния в startTransition существенно уменьшает количество долгих задач и TBT.

Вкладка "Производительность" показывает существенное уменьшение количества долгих задач и TBT.

Переходы являются частью фундаментального сдвига в модели рендеринга, позволяя React конкурентно рендерить несколько версий UI и управлять приоритетами разных задач. Это обеспечивает более плавный и отзывчивый UX, особенно в случае частных обновлений или интенсивно использующих ЦП задач.


Серверные компоненты React

Серверные компоненты React - это экспериментальная возможность, появившаяся в React 18, но готовая для использования во фреймворках.

Раньше React предлагал 2 способа рендеринга приложения. Мы могли рендерить все на клиенте (рендеринг на стороне клиента, CSR) или рендерить дерево компонентов в HTML на сервере и отправлять эту статику клиенту вместе со сборкой JS, необходимой для гидратации (hydration) компонентов на стороне клиента (рендеринг на стороне сервера, SSR).

Оба подхода исходят из предположения, что синхронный рендерер React должен перестраивать дерево компонентов на стороне клиента с помощью JS, даже если дерево компонентов доступно на сервере.


Серверные компоненты React позволяют отправлять клиенту сериализованное (serialized) дерево компонентов. Клиентский React понимает этот формат и использует его для эффективной реконструкции дерева компонентов без необходимости отправки файла HTML или сборки JS.

Мы можем использовать этот новый паттерн рендеринга с помощью метода renderToPipeableStream из пакета react-server-dom-webpack/server и метода createRoot из пакета react-dom/client.

// server/index.js
import React from 'react';
import { renderToPipeableStream } from 'react-server-dom-webpack/server';
import App from '../src/App.js'

app.get('/rsc', async function(req, res) {
const { pipe } = renderToPipeableStream(React.createElement(App));

return pipe(res);
});

---
// src/index.js
import { createRoot } from 'react-dom/client';
import { createFromFetch } from 'react-server-dom-webpack/client';

export function Index() {
...
return createFromFetch(fetch('/rsc'));
}

const root = createRoot(document.getElementById('root'));

root.render(<Index />);

Смотрите полное демо.


По умолчанию React не гидратирует серверные компоненты. Компоненты не ожидают использования клиентской интерактивности, такой как доступ к объекту window или применение хуков useState или useEffect.

Для добавления компонента и его импортов в сборку JS, доставляемую клиенту для обеспечения интерактивности компонента, можно использовать директиву сборщика "use client" в начале файла. Это сообщит сборщику о необходимости добавления компонента и его импортов в сборку для клиента, а React - о необходимости гидратировать дерево на стороне клиента для добавления интерактивности. Такие компоненты называются клиентскими.

Обратите внимание: реализации фреймворков могут отличаться. Например, Next.js предварительно рендерит клиентские компоненты в HTML на сервере по аналогии с SSR. Однако, по умолчанию клиентские компоненты рендерятся по аналогии с CSR.

За оптимизацию размера сборки при работе с клиентскими компонентами отвечает разработчик. Это можно сделать следующими способами:

  • указывать директиву use client только в самых листовых (leaf) узлах интерактивных компонентов. Это может потребовать некоторой декомпозиции компонентов;
  • передавать дерево компонентов в качестве пропов, а не импортировать их напрямую. Это позволит React рендерить children как серверные компоненты без их добавления в сборку для клиента.

Suspense

Другой важной новой возможностью является компонент Suspense. Несмотря на то, что Suspense был представлен в React 16 для разделения кода с помощью React.lazy, React 18 расширил Suspense, позволяя использовать его для получения данных.

С помощью Suspense мы можем отложить рендеринг компонента до определенного момента, например, до загрузки данных из удаленного источника. Вместо основного компонента мы можем рендерить резервный (fallback) компонент - индикатор загрузки основного компонента.

Декларативное определение состояний загрузки избавляет от необходимости применения логики условного рендеринга. Suspense в сочетании с серверными компонентами React позволяет напрямую обращаться к серверным источникам данных (база данных, файловая система и др.) без обращения к отдельной конечной точке API.

async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}

export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}

Между Suspense и серверными компонентами React существует глубокая интеграция. Когда компонент приостанавливается (suspended), например, для загрузки данных, React в это время не простаивает. Он приостанавливает рендеринг такого компонента и занимается выполнением других задач.

После того, как ожидаемые данные стали доступны, React продолжает рендеринг приостановленного компонента прерываемым способом, как в случае с переходами.

React также может менять приоритеты компонентов в ответ на действия пользователя. Например, когда пользователь взаимодействует с приостановленным компонентом, который еще не был отрендерен, React прерывает текущий рендеринг и приоритизирует компонент, с которым взаимодействует пользователь.

Как только такой компонент готов, React фиксирует его в DOM и возвращается к предыдущему рендерингу. Это обеспечивает приоритизацию действий пользователя, а также отзывчивость и актуальность UI.

Сочетание Suspense и потокового (streamable) формата серверных компонентов React позволяет отправлять клиенту высокоприоритетные обновления по мере готовности, без ожидания завершения рендеринга задач с низким приоритетом. Это позволяет клиенту быстрее начать обработку данных и обеспечить более плавный UX за счет постепенного предоставления контента по мере его доступности неблокирующим способом.

Этот механизм прерываемого рендеринга в сочетании с возможностью Suspense по обработке асинхронных операций предоставляет более плавный и сосредоточенный на пользователе опыт, особенно в сложных приложениях с большим количеством сетевых запросов.

Получение данных

Кроме обновлений в части, касающейся рендеринга, React 18 представил новый интерфейс для эффективного получения данных и их мемоизации (кэширования).

React 18 предоставляет функцию кэширования, запоминающую результат вызова переданной функции. При вызове той же функции с теми же аргументами в рамках одного цикла рендеринга, вместо повторного вызова функции будет использовано мемоизированное значение.

import { cache } from 'react'

export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})

getUser(1)
getUser(1) // Вызывается в том же цикле рендеринга, возвращается кэшированный результат

В React 18 вызовы fetch кэшируются по умолчанию. Это помогает уменьшить количество сетевых запросов в рамках одного рендеринга, что улучшает производительность приложения и снижает стоимость API.

export const fetchPost = (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}

fetchPost(1)
fetchPost(1) // Вызывается в том же цикле рендеринга, возвращается кэшированный результат

Эти возможности являются полезными при работе с серверными компонентами React, поскольку они не имеют доступа к API контекста. Автоматическое кэширование позволяет экспортировать одну функцию из глобального модуля и "переиспользовать" ее во всем приложении.

async function fetchBlogPost(id) {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}

async function BlogPostLayout() {
const post = await fetchBlogPost('123');
return '...'
}

async function BlogPostContent() {
const post = await fetchBlogPost('123'); // Возвращается мемоизированное значение
return '...'
}

export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
)
}

Заключение

Таким образом, последние возможности React 18 улучшают производительность приложения несколькими способами:

  • благодаря конкурентности процесс рендеринга может приостанавливаться, возобновляться и даже прерываться. Это означает, что UI может незамедлительно отвечать на действия пользователя даже при обработке большой задачи рендеринга;
  • интерфейс переходов позволяет плавно обновлять слой отображения неблокирующим способом;
  • серверные компоненты React позволяют разработчикам создавать компоненты, работающие как на сервере, так и на клиенте, сочетая интерактивность клиентских приложений с производительностью серверного рендеринга без расходов на гидратацию;
  • расширенный функционал Suspense улучшает производительность загрузки, позволяя рендерить приложение по частям.

Такие возможности, как автоматическое кэширование сетевых запросов и серверные компоненты, автоматически оборачиваемые в SuspenseErrorBoundary), уже сейчас доступны в Next.js 13 (при использовании роутера приложения - App Router).

Прим. пер.: для тех, кто хочет погрузиться в тему серверных компонентов React и их реализации в Next.js 13 более глубоко, советую взглянуть на эту статью.