Оптимизация производительности приложения.
Данная статья рассказывает о принципах и приемах оптимизации производительности React-приложений. Все примеры кода приводятся на эффекторе, однако большую часть описанных приемов легко применить к связке React c любым стейт-менеджером.
Сама возможность осуществления некоторых из описанных ниже оптимизаций — это одна из ключевых причин (помимо отделения бизнес-логики от представления), по которой стейт-менеджеры вообще существуют.
Основные принципы
В большинстве случаев оптимизация производительности сводится к оптимизации процесса рендеринга приложения. Безусловно, React весьма экономично ререндерит DOM (или нативные представления в случае React Native), обновляя только то, что требуется.
Однако пересчет виртуального DOM (выполнение вложенных render-функций, построение дерева React-элементов, мутация fiber-нод) порой может быть весьма затратным (везде ниже под ререндерингом мы подразумеваем именно этот процесс, исключая непосредственно коммит в DOM).
Чем масштабнее и чаще происходит ререндеринг — тем хуже. Если одно действие пользователя инициирует целый ворох ререндеров, затрагивающих большое количество элементов, приложение ощущается менее отзывчивым, падает частота кадров, пользовательский ввод становится медленным и неприятным, UX ухудшается.
Эта проблема острее всего стоит в React Native, где все обновления представления должны сериализовываться и проходить через bridge, а ререндеринг нагружает JS-тред (который у нас, к сожалению, только один), снижая частоту кадров. Но и для веба все это тоже весьма актуально в ряде случаев.
Оптимизируя React-приложение, нужно держать в голове следующие вещи:
- При возникновении ререндеринга в любой из нод, React пересчитывает все поддерево.
- Остановить нисходящий ререндер можно только посредством pure-компонентов (
React.memo
для функциональных компонентов). Для компонентов, обернутых вReact.memo
, React производит сравнение каждогоprop
с точностью до ссылок (===), и исполняет render-функцию только в том случае, если хотя бы один пропов изменился. - Каждый стор, подключенный через
useStore
, потенциально МОЖЕТ вызывать ОТДЕЛЬНЫЙ ререндер (зависит от батчинга обновлений реактом). - Обновление эффектор-состояния в большинстве случаев очень "дешевое". Ререндеринг — на порядок дороже.
Больше контейнеров богу контейнеров
Создавайте больше небольших компонентов-контейнеров со своим кусочками состояния. Опускайте состояние вниз по дереву элементов.
Рассмотрим компонент TODO-листа, в который можно добавлять задачи (стайледы и модель упущены для краткости).
import { useStore } from 'effector-react'
import { Task, Input, Button } from '@/ui'
import { $tasks, $newTaskInput, onInput, createTask } from './model'
const Todo = () => {
const tasks = useStore($tasks)
const newTaskInput = useStore($newTaskInput)
return (
<Wrap>
<TasksWrap>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</TasksWrap>
<InputWrap>
<Input value={newTaskInput} onChange={onInput} />
<Button label="Create task" onClick={createTask} />
</InputWrap>
</Wrap>
)
}
При любом изменении как $tasks
, так и $newTaskInput
будет происходить ререндеринг всего компонента. При такой реализации на каждый ввод символа (!) происходит ререндеринг всего списка задач. Чем больше список задач, и чем тяжелее сами компоненты задач, тем больше будет инпут лаг. Давайте оптимизируем этот кейс, разбив на компоненты-контейнеры, по которым раскидаем состояние (в реальном проекте контейнеры скорее всего будут больше и будут разнесены по разным файлам):
const TasksList = () => {
const tasks = useStore($tasks)
return (
<TasksWrap>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</TasksWrap>
)
}
const CreateTask = () => {
const newTaskInput = useStore($newTaskInput)
return (
<InputWrap>
<Input value={newTaskInput} onChange={onInput} />
<Button label="Create task" onClick={createTask} />
</InputWrap>
)
}
const Todo = () => (
<Wrap>
<TasksList />
<CreateTask />
</Wrap>
)
Теперь при изменении $newTaskInput
происходит ререндер только небольшого текстового инпута. Аналогично при изменении $tasks
ререндерится только список задач (синим обозначены компоненты, которые будут ререндериться):
Используйте React.memo
Оборачивайте containers
и entries
в React.memo
:
import * as React from 'react'
const TasksList = React.memo(() => {
const tasks = useStore($tasks)
return (
<TasksWrap>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</TasksWrap>
)
})
Замечательная особенность контейнеров в том, что они полностью определяют состояние всего поддерева элементов. Оборачивая их в React.memo
, мы блокируем нисходящий поток ререндеринга.
На рисунке ниже это отображено визуально (ререндеринг инициируется изменением состояния в "Some Component"):
Удобная аналогия представляйте ререндеринг как нисходящий по дереву компонентов поток воды. Ваша задача — минимизировать количество нод, которые заполнены водой.
React.memo
— это "заслонка", блокирующая поток. Когда вы опускаете состояние, вода начинает разливаться ниже.
Объединяйте связанное состояние
Предположим, у нас имеется три стора:
const $products = createStore<Item[]>([])
const $totalPrice = $products.map((products) =>
products.reduce((acc, product) => acc + product.price, 0)
)
const $count = $products.map((products) => products.length)
И в некотором компоненте нам по какой-то причине требуются все три. Если подключить сторы вот так:
const Comp = React.memo(() => {
const products = useStore($products)
const totalPrice = useStore($totalPrice)
const count = useStore($count)
return (
// ...
)
})
... то при изменении $products
произойдет от 1 до 3 (!) ререндеров (useStore
обновляет состояние посредством setState
, используя локальное состояние компонентов). Реальное количество ререндеров зависит от батчинга обновлений реактом (реакт может как сбатчить, так и не сбатчить последовательные вызовы setState
).
Чтобы контролируемо получать один ререндер, нужно объединить связанное состояние посредством combine
:
const $productsInfo = combine(
$products,
$totalPrice,
$count,
(products, totalPrice, count) => ({
products,
totalPrice,
count,
})
)
const Comp = React.memo(() => {
const { products, totalPrice, count } = useStore($productsInfo)
return (
// ...
)
})
Поступив таким образом, мы сведем количество ререндеров до одного.
Обратите внимание, что здесь речь идет о связанном состоянии, в более широком смысле — о состоянии, которое изменяется "одновременно". Для независимого состояния эта оптимизация не имеет смысла и его можно подключать через отдельные
useStore
.
useStoreMap
Замечательный хук useStoreMap
позволяет многократно понизить количество ререндеров при рендеринге списков.
Рассмотрим пример:
export const $items = createStore<Item[]>([])
export const $selectedItem = createStore<Item | null>(null)
export const selectItem = createEvent<Item>()
$selectItem.on(selectItem, (_, item) => item)
import { $items, $selectedItem, selectItem } from './model'
import { Item } from '@/ui'
const ItemsList = React.memo(() => {
const items = useStore($items)
const selectedItem = useStore($selectedItem)
return (
<>
{items.map((item) => (
<Item
item={item}
onSelect={selectItem}
selected={selectedItem === item}
/>
))}
</>
)
})
При такой организации компонентов при выделении элемента происходит ререндеринг ВСЕГО списка, что совершенно не рационально. Давайте сделаем контейнер для элемента списка и используем useStoreMap
:
import { useStoreMap } from 'effector-react'
import { $items, $selectedItem, selectItem } from './model'
import { Item } from '@/ui'
type ItemContainerProps = {
item: Item
onSelect: (item: Item) => void
}
const ItemContainer = React.memo(({ item, onSelect }) => {
const currentItemSelected = useStoreMap({
store: $selectedItem,
keys: [item],
fn: (selectedItem, [item]) => selectedItem === item,
})
return (
<Item item={item} onSelect={selectItem} selected={currentItemSelected} />
)
})
const ItemsList = React.memo(() => {
const items = useStore($items)
return (
<>
{items.map((item) => (
<ItemContainer key={item.id} item={item} onSelect={selectItem} />
))}
</>
)
})
useStoreMap
получает в качестве аргумента стор, массив зависимостей (зависимости могут быть пропсами или локальным состоянием) и функцию-маппер. Результат маппера превращается в локальное состояние. Замечательно то, что ререндеринг произойдет только тогда, когда полученное состояние изменится. И вместо ререндеринга всего списка со всеми его элементами при выделении элемента будет ререндериться только сам элемент.
Предупреждение. В отдельных случаях использование useStoreMap
может напротив ухудшить перфоманс. Это произойдет в том случае, если изменение состояния затрагивает множество элементов списка одновременно. В этом случае вы легко можете словить 100 ререндеров на элементах вместо 1 большого на родителе. Общее правило достаточно простое: обдумывая, как подключить состояние, задайте себе вопрос "как много элементов ЗАЧАСТУЮ должно быть перерисовано при изменении этого состояния". Если ответ "немного" (как в случае с выделением элементов списка) — используйте useStoreMap
. Если ответ "перерисуются все или почти все" — лучше подключите состояние в родительском компоненте, вычислите состояние отдельного элемента при рендеринге и передайте через проп.
Рекомендуем также использовать effector-react от 21.3.3 и старше. В этой версии была реализована важная оптимизация: useStoreMap
больше не создает отдельный юнит на каждое использование, что безусловно позитивно скажется на производительности.
Мемоизация тяжелых вычислений, мемоизация коллбэков
WARNING! описанные в этом подразделе оптимизации намного менее актуальны, чем все предыдущее. Не применяйте
useMemo
иuseCallback
везде подряд. В большинстве случаев это экономия на спичках, которая только снизит читаемость кода.
Данный совет не относится к теме стейт-менеджеров, он актуален для любого React-приложения.
До оптимизации
const MyComp = ({ data, anotherData }: Props) => {
// computeExpensiveValue — какие-то дорогие вычисления
const someComputedState = computeExpensiveValue(data, anotherData)
return (
// верстка
)
}
После
const MyComp = ({ data, anotherData }: Props) => {
// computeExpensiveValue — какие-то дорогие вычисления
const someComputedState = React.useMemo(
() => computeExpensiveValue(data, anotherData),
[data, anotherData]
)
return (
// верстка
)
}
React.useMemo
мемоизирует вычисления, выполняя их тогда и только тогда, когда изменилась одна из зависимостей (которые передаются массивом во втором аргументе). Без использования useMemo
вычисления будут производиться при каждом рендеринге.
В некоторых (!) случаях хорошей оптимизацией будет также обернуть в useCallback
передаваемый в pure-компонент коллбэк:
const MyComp = () => {
const onSomething = useCallback((a) => doSomething(a, b), [b])
return <MyPureComponent onSomething={onSomething} />
}
Если ваш коллбэк не зависит от состояния компонента, того же эффекта можно добиться, вынося его в обычную переменную вне компонента:
const onSomething = (a) => doSomething(a)
const MyComp = () => {
return <MyPureComponent onSomething={onSomething} />
}
Данная оптимизация имеет смысл ТОЛЬКО если коллбэк передается в pure-компонент. Если коллбэк передается в обычный компонент, она полностью лишена смысла, так как такой компонент в любом случае будет перерендерен при ререндеринге родителя.