Оптимизация рендеринга React-компонентов: как не навредить
Всем привет! Если вы используете React для создания UI, то уверена, что вы слышали о таких понятиях, как PureComponent
, memo
, useCallback
и прочих возможностях, которые нам предоставляют создатели библиотеки для оптимизации наших приложений. Разработчики React уже позаботились о том, чтобы обновление DOM было предсказуемым и производительным: преобразования деревьев React-элементов выполняются максимально эффективно с помощью алгоритма согласования (reconciliation). Однако при большом количестве компонентов, глубокой вложенности или неправильной архитектуре количество отрисовок или вызовов функций может заметно увеличиться. Для оптимизации использования ресурсов мы применяем различные приёмы, позволяющие нам, к примеру, избавиться от лишних отрисовок с одинаковыми входными значениями props
.
Я рассмотрела частые ошибки при оптимизациях и возможные способы улучшения, сделав акцент на функциональных компонентах. Давайте разберёмся, как не навредить нашему приложению при попытках его улучшить.
Примечание. Возможно, для кого-то перечисленное покажется очевидным. Надеюсь, будут и те, кто найдёт для себя что-то полезное.
Хуки как чёрный ящик: неправильное использование useCallback
Зачастую разработчики представляют себе хуки компонентов как ящик, который принимает на вход определённые параметры и выполняет некую магию.
Одно из убеждений, с которым я зачастую сталкивалась при собеседованиях разработчиков, заключалось в том, что необходимо использовать useCallback
для всех колбэков без разбора, в ожидании, что оборачивание функции в хук создаст функцию только один раз, по аналогии с методами класса.
Рассмотрим пример обычного компонента с колбэком, чтобы убедиться, что это не так. Колбэк создаётся и записывается в переменную при каждой отрисовке компонента:
const Component = () => {
const onClick = () => console.log('Clicked');
return Компонент
Обернём функцию в useCallback
:
const Component = () => {
const onClick = useCallback(() => {
console.log('Clicked');
}, []);
return Компонент;
);
Многие могут подумать, что в таком случае мы оптимизируем компонент, ведь один раз создали и передали функцию в хук useCallback
, который принимает обычную функцию и возвращает мемоизированную. На самом же деле useCallback
, как и любая другая функция и любой хук, вызывается при каждой отрисовке компонента. В данном случае вызов useCallback()
аналогичен отдельному созданию нашей функции и передаче её как аргумента:
const Component = () => {
const handleClick = () => console.log('Clicked');
const onClick = useCallback(handleClick, []);
return Компонент;
);
Помимо создания функции при каждом вызове компонента теперь дополнительно создаётся массив зависимостей, а также выполняются внутренние вычисления хука useCallback
. Попытка оптимизации при неправильном понимании работы библиотеки привела к обратному эффекту.
Ошибка в передаче мемоизированного колбэка
Рассмотрим ещё один пример с использованием хука useCallback
.
const ChildComponent = ({ id, onClick }) => {
return ( onClick(id) }>Some nested component..);
};
const MainComponent = () => {
const [selectedId, setSelectedId] = React.useState('1');
const onClick = useCallback((id) => {
setSelected(id);
}, []);
return (
);
};
Мы создали компонент, принимающий колбэк onClick
. В MainComponent
используется хук useCallback
, благодаря которому мы всегда получаем одну и ту же мемоизированную функцию onClick
, которую передаём дочерним компонентам. В данном случае использование хука тоже никак не оптимизирует работу приложения, так как рендер дочерних компонентов всё также будет выполняться после каждого обновления состояния MainComponent
(установки selectedId
).
Чтобы исправить это, необходимо обернуть ChildComponent
в функцию React.memo()
:
const ChildComponent = ({ id, onClick }) => {
return ( onClick(id) }>Some nested component..);
};
const MemoizedChildComponent = React.memo(ChildComponent);
const MainComponent = () => {
const [selectedId, setSelectedId] = React.useState('1');
const onClick = useCallback((id) => {
setSelected(id);
}, []);
return (
);
};
Использование memo()
по-умолчанию без параметров позволяет поверхностно сравнить предыдущие и новые props на наличие изменений, а использование useCallback
гарантирует передачу одной и той же функции в мемоизированный дочерний компонент, и лишних отрисовок удастся избежать.Этот подход особенно актуален, если ChildComponent
— компонент со сложной структурой и другими вложенными компонентами, отрисовка которого ощутимо влияет на производительность.
Приёмы оптимизации без использования memo ()
Правильная композиция компонентов также позволит избежать лишних отрисовок дочерних компонентов без дополнительных манипуляций. Рассмотрим несколько примеров:
- Приём «Спуск состояния вниз». Если изменение состояния родительского компонента вызывает лишнюю отрисовку дочерних компонентов, то стоит вынести состояние и соответствующую вёрстку в ещё один отдельный дочерний компонент. В таком случае изменение состояния в одном компоненте не вызовет лишней отрисовки в соседнем.
- Приём «Поднятие состояния вверх». В случае, когда состояние не получится извлечь в отдельный компонент, возможно изменение структуры компонентов с использованием Children. В таком случае будет отрисован только компонента, который действительно изменяется, а компоненты, прокидываемые в Children, будут оставаться без изменений.
Код примеров и подробное описание обоих приёмов можно изучить в статье Дэна Абрамова «Before you memo ()».
Передача лишних свойств в мемоизированные компоненты
При невнимательной передаче props возможны ситуации, когда вы передаете, на самом деле, необязательные свойства. При отрисовке целого списка сложных компонентов это может вызвать множественную перерисовку всего списка вместо одного или нескольких компонентов.
Рассмотрим на примере небольшого приложения с несколькими карточками тарифов и возможностью выбрать один из них:
const TariffCard = ({ id, selectedTariff, onClick }) => {
const selected = selectedTariff === id;
return (
onClick(id) }
className={ selected ? 'selected' : 'not-selected' }
>
Tariff { id }
);
};
const MemoizedTariffCard = React.memo(TariffCard);
const tariffs = ['tariff_1', 'tariff_2', 'tariff_3'];
const App = () => {
const [selectedTariff, setSelectedTariff] = React.useState('tariff_1');
const onClick = React.useCallback((id) => {
setSelectedTariff(id);
}, []);
return (
{ tariffs.map((tariffId) => (
))}
);
};
При клике на один из тарифов и изменении выбранного тарифа в состоянии компонента будут перерисованы все карточки:
В таком случае лучше вычислять состояния дочерних компонентов в родительском, чтобы избежать лишних отрисовок. Отрефакторим компонент, передав уже вычисленное значение флага selected
в карточку тарифа:
const TariffCard = ({ id, selected, onClick }) => {
return (
onClick(id) }
className={ selected ? 'selected' : 'not-selected' }
>
Tariff { id }
);
};
const MemoizedTariffCard = React.memo(TariffCard);
const tariffs = ['1', '2', '3'];
const App = () => {
const [selectedTariff, setSelectedTariff] = React.useState('1');
const onClick = React.useCallback((id) => {
setSelectedTariff(id);
}, []);
return (
{ tariffs.map((tariffId) => (
))}
);
};
После небольших изменений при нажатии на карточку, отрисовка происходит только в тех компонентах, у которых props действительно изменились:
Использовать методики оптимизации React-приложений необходимо аккуратно, так как при невнимательности или непонимании принципа работы можно добиться обратного эффекта и вместо улучшения производительности получить просадку. Оптимизируйте компоненты после профилирования, чтобы убедиться, что оптимизация стоит затрат и действительно работает, как вы это запланировали :)
Спасибо за внимание, делитесь своими предложениями в комментариях!