В разработке приложений на React часто возникают ситуации, когда компоненты отрисовываются без необходимости. Чаще всего это происходит при отрисовке родительского компонента или изменении пропсов.
Лишняя перерисовка компонентов может привести к непредвиденным событиям, когда при отрисовке компонента срабатывают побочные эффекты, либо перерисовка может повлиять на производительность.
Самый простой способ отследить перерисовку компонента — это расставить точки остановки выполнения кода в дебагере, либо добавить вывод лога.
Ниже простое приложение из двух компонентов:
import { useState, useEffect } from 'react';
const Greeting = () => {
useEffect(() => {
console.log(`Компонент Greeting отрисован в ${new Date().toLocaleTimeString()}`);
});
return <h3>Hello, world!</h3>;
};
const App = () => {
const [name, setName] = useState('');
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<Greeting />
</>
);
};
В компонент Greeting
мы добавили вывод в console.log()
с меткой о времени для наглядности. Так мы сможем отследить время отрисовки компонента. Компонент App
использует в представлении Greeting
и содержит поле ввода, в котором меняется состояние компонента.
Если ввести значение в input
, то в консоли браузера мы увидим вывод сообщения об отрисовке компонента Greeting
. Компонент будет отрисовываться каждый раз, когда мы вводим значение в поле. Добавляем или удаляем один символ — и компонент перерисовывается. Если мы напечатаем слово hello
, компонент перерисуется пять раз.
Расширение Profiler
Есть более удобный способ отслеживать отрисовку компонентов. Для этого нужно установить расширения для браузера React Developer Tools. Расширение добавляет несколько инструментов для работы с React-приложением. Один из них — это Profiler, позволяющий отслеживать отрисовку компонентов.
Нужно открыть вкладку Profiler
в devtools, нажать кнопку записи слева и после этого взаимодействовать с приложением — например, ввести в поле любое значение, после этого остановить запись
Так будет выглядеть результат, если ввести в поле строку "hello"
:
В правой верхней части можно переключаться между шагами рендера, Profiler сохраняет информацию о каждом рендере и отображает информацию сколько времени отрисовывался каждый компонент.
Пока может показаться, что это немного. Но в сложных приложениях с большим количеством компонентов такой инструмент может быть очень полезным.
Обычно при сборке React-приложение оптимизируется и поддержка профайлера из него убирается. Но вы можете использовать флаг --profile
в команде сборки, если используете create-react-app
, чтобы оставить поддержку инструмента на сервере:
npm run build -- --profile
Инструмент memo
В нашем приложении изменяется состояние корневого компонента. Это влечет за собой его перерисовку, что, в свою очередь, влечет за собой перерисовку всех дочерних компонентов. Компонент Greeting
каждый раз перерисовывается. Это не всегда нужно, ведь в компоненте каждый раз рендерится одно и то же сообщение.
Такое поведение можно изменить, в React для этого используется инструмент мемоизации memo
. Достаточно в него передать наш компонент:
import { memo } from 'react';
const Memoized = memo(MyComponent);
Вот как будет выглядеть наше приложение:
import { memo, useState, useEffect } from 'react';
const Greeting = memo(() => {
useEffect(() => {
console.log(`Компонент Greeting отрисован в ${new Date().toLocaleTimeString()}`);
});
return <h3>Hello, world!</h3>;
});
const App = () => {
const [name, setName] = useState('');
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<Greeting />
</>
);
};
Теперь, если ввести в поле значение, то по профайлеру или выводу логов мы увидим, что компонент Greeting
не отрисовывается.
Это не значит, что компонент теперь всегда будет отрисовываться один раз. Если мы будем передавать в компонент измененный пропс, то перерисовка произойдет все равно:
import { memo, useState, useEffect } from 'react';
const Greeting = memo(({ name }) => {
useEffect(() => {
console.log(`Компонент Greeting отрисован в ${new Date().toLocaleTimeString()}`);
});
return <h3>{`Hello, ${name}!`}</h3>;
});
const App = () => {
const [name, setName] = useState('');
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<Greeting name={name} />
</>
);
};
Мемоизация и useMemo
Изменение пропсов может привести к ситуации, когда сами данные не меняются, но меняется ссылка на объект. В этом случае перерисовка произойдет, и memo
не поможет. Немного модифицируем наше приложение для демонстрации такой ситуации:
import { memo, useState } from 'react';
const Button = memo(({ onClick }) => {
console.log(`Компонент Button отрисован в ${new Date().toLocaleTimeString()}`);
return <button onClick={onClick}>Нажми меня</button>;
});
const Greeting = memo(({ name }) => {
console.log(`Компонент Greeting отрисован в ${new Date().toLocaleTimeString()}`);
return <h3>{`Hello, ${name}!`}</h3>;
});
const App = () => {
const [name, setName] = useState('');
const buttonHandler = () => setName('world');
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<Greeting name={name} />
<Button onClick={buttonHandler} />
</>
);
};
В приложение добавлен компонент Button
, который принимает функцию для события onClick
. Несмотря на то, что компонент мемоизирован, он все равно будет перерисовываться при каждой отрисовке App
. Это происходит, потому что в компонент передается функция buttonHandler()
. При каждой отрисовке App
создается новая функция. React видит изменение ссылки на функцию и вызывает перерисовку компонента Button
.
Чтобы избежать ненужных перерисовок компонента Button
, нужно использовать хук useMemo
. Он принимает функцию, которая возвращает какой-то результат. Хук запоминает этот результат и возвращает его каждый раз, не вызывая повторно переданную функцию.
Первым параметром в хук передается функция, возвращаемое значение которой нужно запомнить, а вторым параметром — массив зависимостей. При изменении любой из указанных зависимостей будет вызываться переданная функция и вычисляться новый результат. С этой точки зрения useMemo
похож на useEffect
.
Доработаем наше приложение так, чтобы в качестве результата возвращалась функция:
import { memo, useState, useMemo } from 'react';
const Button = memo(({ onClick }) => {
console.log(`Компонент Button отрисован в ${new Date().toLocaleTimeString()}`);
return <button onClick={onClick}>Нажми меня</button>;
});
const Greeting = memo(({ name }) => {
console.log(`Компонент Greeting отрисован в ${new Date().toLocaleTimeString()}`);
return <h3>{`Hello, ${name}!`}</h3>;
});
const App = () => {
const [name, setName] = useState('');
const buttonHandler = useMemo(() => () => setName('world'), []);
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<Greeting name={name} />
<Button onClick={buttonHandler} />
</>
);
};
В useMemo
передается функция () => () => setName('world')
. Эта функция возвращает функцию () => setName('world')
. В итоге эта возвращенная функция будет мемоизирована, и при отрисовке компонента App
ссылка на функцию в переменной buttonHandler
будет оставаться той же. Компонент Button
перерисовываться не будет.
Обычно useMemo
используют для каких-то сложных вычислений, чтобы не пересчитывать результат при одних и тех же параметрах. А для сохранения ссылки на функцию, как в нашем случае, используется похожий хук useCallback
.
Важно знать, что useMemo()
не гарантирует, что не будет нового вычисления. В какой-то момент React может вызвать мемоизированную функцию — обычно это происходит для освобождения новых ресурсов. Это значит, что вы не должны строить логику приложения полагаясь на useMemo()
, эта функция предназначена лишь для оптимизации работы компонентов.
useCallback
Хук useCallback()
работает похожим образом как useMemo
, только уже мемоизирует не результат вызова переданной функции, а саму функцию. Это позволяет немного сократить код и избавиться от лишних объявлений функций:
const buttonHandler = useCallback(() => setName('world'), []);
Как и useMemo
, useCallback
принимает вторым параметром массив зависимостей.
Выводы
Мы изучили способы, как избежать лишних перерисовок компонентов. Знать эти инструменты важно, но бездумно использовать везде не стоит. Любая оптимизация тянет за собой усложнение кода.
В этом уроке мы использовали простое приложение, но в обычной практике подобный проект не нуждается в такой оптимизации. Несколько раз подумайте, прежде чем что-то оптимизировать, изучите узкие места. Если приложение плохо работает, то чаще всего проблема находится в самой логике приложения. Поиск и исправление проблемных мест — отдельная тема, о которой мы поговорим в следующем уроке.
Дополнительные материалы
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
- Статья «Как учиться и справляться с негативными мыслями»
- Статья «Ловушки обучения»
- Статья «Сложные простые задачи по программированию»
- Вебинар «Как самостоятельно учиться»
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.