Много статей написано об оптимизации производительности React. В основном, если где-то обновление state происходит медленно, то вам нужно:
Убедиться, что у вас запущен сборка под production. (Сборка dev умышленно медленнее, в крайнем случае - даже на порядок)
Убедиться, что вы не подняли state выше по дереву, чем это необходимо. (Например, размещение state в централизованном хранилище может быть не лучшей идеей)
Запустите React DevTools Profiler, чтобы увидеть, что будет заново отрисовано, оберните самые дорогие [по ресурсам] суб-деревья с помощью memo. (При необходимости добавьте
useMemo
)
Этот последний шаг, он раздражает, особенно для промежуточных компонентов, в идеале - компилятор сделает это за вас. В будущем. Возможно.
В этом посте, я хочу поделиться 2 другими подходами. Они так просты, что люди редко понимают, что это улучшает производительность рендера.
Эти подходы дополняют то, что вы уже знаете! Они не заменяют memo
или useMemo
, но будет хорошее идеей начать с них.
Медленный компонент (искусственно)
Вот компонент с серьёзной проблемой производительности:
import { useState } from 'react';
export default function App() {
let [color, setColor] = useState('red');
return (
<div>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p style={{ color }}>Привет, мир!</p>
<ExpensiveTree />
</div>
);
}
function ExpensiveTree() {
let now = performance.now();
while (performance.now() - now < 100) {
// искусственная задержка -- ничего не делаем 100мс
}
return <p>Я - очень медленное дерево компонентов.</p>;
}
Проблема в том, что каждый раз, когда цвет меняется внутри App
, мы повторно рендерим <ExpensiveTree />
, в которой мы искусственно добавили задержку, чтобы сделать его медленным.
Я мог бы поместить его в memo()
и остановиться на этом, но уже есть много статей, поэтому я не буду тратить на это время. Я хочу показать 2 других решения.
Решение 1: перенесите state вниз
Если вы внимательно посмотрите на код рендеринга, то заметите, что только часть возвращенного дерева компонентов на самом деле заботится о цвете:
export default function App() {
let [color, setColor] = useState('red');
return (
<div>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p style={{ color }}>Привет, мир!</p>
<ExpensiveTree />
</div>
);
}
Так что давайте извлечём эту часть в компонент Form
и спустим state в этот компонент:
export default function App() {
return (
<>
<Form />
<ExpensiveTree />
</>
);
}
function Form() {
let [color, setColor] = useState('red');
return (
<>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p style={{ color }}>Hello, world!</p>
</>
);
}
Теперь, если цвет изменится, то заново рендерится только Form
. Задача решена.
Решение 2: поднимите контент вверх
Вышеупомянутой решение не работает, если часть state используется где-то над дорогим деревом. Для примера, давайте предположим, что мы поместили цвет в родительский <div>
:
export default function App() {
let [color, setColor] = useState('red');
return (
<div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p>Hello, world!</p>
<ExpensiveTree />
</div>
);
}
Теперь кажется, что мы не можем просто извлечь нужные части, которые не используют цвет, в другой компонент, поскольку он будет включать родительский <div>
, который включает <ExpensiveTree />
. Теперь без memo
не обойтись, не так ли?
Или все же сможем?
Поиграйте в этой песочнице и посмотрите, сможете ли вы разобраться.
Ответ на удивление прост
Ответ
export default function App() {
return (
<ColorPicker>
<p>Hello, world!</p>
<ExpensiveTree />
</ColorPicker>
);
}
function ColorPicker({ children }) {
let [color, setColor] = useState("red");
return (
<div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
{children}
</div>
);
}
Мы разделили компонент App
на 2 части. Части, зависящие от цвета, вместе с самим цветом мы по поместили в ColorPicker
.
Части, которые не работают с цветом, остались в компоненте App
и передаются в ColorPicker
как JSX-контент (children prop).
Когда цвет меняется, ColorPicker
выполняет повторный рендер. Но у него всё ещё есть children prop, который получен из App
в прошлый раз, поэтому React не заходит в это суб-дерево.
В результате <ExpensiveTree />
не рендерится заново.
Какова же мораль?
Прежде, чем применять оптимизацию, такую как memo
или useMemo
, имеет смысл посмотреть, а может вы сможете отделить части, которые меняются, от частей, которые не меняются.
Эти подходы интересны тем, что сами по себе они не имеют ничего общего с производительностью. Использование children prop для разделения компонентов обычно упрощает отслеживание потока данных в вашем приложении и сокращает количество prop, которые идут вниз. Повышение производительности в таких случаях - это как вишенка на торте, а не конечная цель.
Любопытно, что данный паттерн также откроет больше преимуществ в будущем.
Например, когда серверные компоненты готовы к внедрению, наш компонент ColorPicker может получать свои children с сервера. На сервере может работать либо весь компонент <ExpensiveTree />
, либо его части, и даже изменение state верхнего уровня React "пропускает" на клиент.
Этого не сможет сделать даже memo
! Но опять же, оба подхода дополняют друг друга. Не забывайте перемещать состояние вниз (а контент вверх).
Затем, если этого не хватило, то используйте Profiler и добавьте эти memo
.
Я мог об этом раньше прочитать где-то ещё?
Это не новая идея. Это естественная реакция на композиционную модель React. Это достаточно просто, но эти подходы недооценивают, а они заслуживают немного больше любви.