Компоненты React должны быть чистыми: суть функции StrictMode и компилятора
Почему React ожидает чистые компоненты, как двойная отрисовка в StrictMode выявляет нарушение принципа чистоты, где должны находиться побочные эффекты, и почему компилятор React опирается на принцип чистоты.
Источники: React Components Must Be Pure — the Secret Behind StrictMode and the Compiler
До сих пор подход в React заключался в том, что «нужно написать код именно так, и на экране появится содержимое». Однако существует одно правило, которого React тихо ожидает от каждого компонента, и оно объясняет два момента, сбивающих с толку большинство новичков: почему StrictMode вызывает ваш компонент дважды и как компилятор React может настолько эффективно оптимизировать код. Это правило — чистота.
Что такое чистая функция
Вспомним математику: y = f(x) = 2x при входных данных 3 возвращает 6. Она делает это вчера, сегодня и при ста вызовах, при этом никогда не вмешивается в изменение чего-либо за пределами самой себя.
Такая функция считается чистой, что означает соблюдение двух условий.
- Одни и те же входные данные всегда дают один и тот же результат.
- У неё нет побочных эффектов, поэтому ничто за пределами её собственного диапазона не меняется.
Код делает ситуацию более понятной. double ниже является «чистым», тогда как addToTotal — нет.
// ✅ pure function — same input, same output, and it touches nothing outside
function double(x: number): number {
return x * 2;
}
double(3); // 6
double(3); // 6 — six no matter how many times you call it
// 🔴 impure function — it changes the outer variable `total` (a side effect)
let total = 0;
function addToTotal(x: number): number {
total += x; // side effect!
return total; // the result changes on every call
}
addToTotal(3); // 3
addToTotal(3); // 6 — same input, different result
Компоненты также должны быть чистыми
React предполагает, что каждый компонент — это чистая функция. При одинаковых параметрах и состоянии он должен возвращать один и тот же JSX каждый раз.
type Props = { name: string };
// ✅ pure — same name, always the same result
function Greeting({ name }: Props) {
return <h1>Hello, {name}!</h1>;
}
Если присвоить этому компоненту имя «Jane», он всегда будет возвращать <h1>Привет, Jane!</h1>. React опирается на такую предсказуемость для эффективной обновления экрана.
Нарушение правила: изменение элементов снаружи во время отрисовки
Проблемы возникают, когда компонент изменяет что-то за пределами себя во время отрисовки.
let count = 0; // a variable outside the component
// 🔴 not pure — changes an external variable while rendering
function Counter() {
count = count + 1; // side effect!
return <p>{count}</p>;
}
Counter увеличивает значение внешней переменной count при каждой отрисовке, поэтому результат зависит от того, как часто он вызывается: 1 — в первый раз, 2 — во второй. Оба условия чистоты нарушаются. React может отрисовывать компонент несколько раз или в другом порядке, поэтому подобные компоненты приводят к ошибкам, трудным для прогнозирования.
Попробуйте сами и узнайте, почему появляется 2
Запустите Counter — первым, что появится на экране, будет 2, а не 1, и значение дальше не увеличивается. Сначала это кажется загадочным, но понимание этого явления становится отличным примером для изучения принципа чистоты.
Почему 2? В режиме разработки StrictMode вызывает функцию компонента дважды при монтировании, поэтому при одном монтировании Counter выполняется дважды.
- Первый вызов:
countувеличивается с 0 до 1, и результатом становится<p>1</p>. - Второй вызов:
countувеличивается с 1 до 2, и результатом становится<p>2</p>.
На экране отображается второй результат. При выключенном режиме StrictMode функция выполняется один раз, и вы увидите значение 1.
Почему она останавливается на 2, а не продолжает увеличиваться до 3, 4 и т. д.? Компонент выполняется только тогда, когда React его перерисовывает. У этого компонента нет состояния, а его родительский компонент не перерисовывается, поэтому он выполняется при первом загрузке (дважды из-за StrictMode), а затем просто «отдыхает», в результате чего значение count остается прежним.
Чтобы наблюдать за изменением значения, необходимо заставить произойти перерисовку. В следующем примере это делается с помощью нажатия на кнопку. (useState будет рассмотрен более подробно позже, поэтому пока можно рассматривать его как инструмент, который перерисовывает экран при нажатии на кнопку.)
import { useState } from 'react';
let count = 0;
function Counter() {
const [, setTick] = useState(0); // a device to trigger re-renders
count = count + 1; // 🔴 changes an external variable on every render
return (
<div>
<p>{count}</p>
<button onClick={() => setTick((n) => n + 1)}>Re-render</button>
</div>
);
}
Каждое нажатие на кнопку перерисовывает компонент Counter с учетом его собственных изменений состояния, а функция StrictMode вызывает его дважды за раз, поэтому число увеличивается на два: 2, 4, 6 и так далее. Без StrictMode оно увеличивалось бы на один за раз.
Одно замечание к этой демонстрации. Зачем вообще хранить состояние внутри Counter? Если переместить его в родительский компонент и оставить Counter в качестве дочернего без параметров, нажатие кнопки не изменит отображаемое значение 2. Компонент, который не принимает никаких входных данных, не может иметь разных результатов, поэтому компилятор React кэширует его и пропускает перерисовку. Чтобы увидеть изменения, необходимо вмешаться во внешнюю переменную внутри компонента, который действительно осуществляет перерисовку. Как ни странно, это поведение само по себе является хорошим примером того, что компилятор исходит из принципа чистоты кода. Вопрос о том, как компилятор кэширует компоненты, заслуживает отдельного обсуждения.
Вот суть проблемы. Чистый компонент должен давать одинаковый результат независимо от того, сколько раз его вызывают, но этот компонент зависит от количества вызовов. StrictMode намеренно вызывает его дважды, чтобы показать вам эту нечистоту.
Решение заключается в том, чтобы не вмешиваться снаружи. Во время отрисовки вычисляйте результат только на основе собственных параметров и состояния.
// ✅ pure — computed from input (props) alone
function Counter({ count }: { count: number }) {
return <p>{count}</p>;
}
Вы можете изменить то, что создали во время рендеринга
Чистота кода не означает отсутствие изменений. Она означает не трогать элементы, которые существовали до начала рендеринга, такие как внешние переменные, параметры и состояние. Переменные или массивы, которые вы создаете заново во время рендеринга, находятся в вашем распоряжении для изменения. Это просто процесс вычислений, а не побочный эффект.
function ProductList({ products }: { products: Product[] }) {
// ✅ this array was just created in this render — handle it however you like
const sorted = [...products].sort((a, b) => a.price - b.price);
return (
<ul>
{sorted.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
Важен копируемый вариант, созданный с помощью [...products]. Сортировка исходного массива products, который поступает в качестве параметров, приведёт к изменению этих параметров и нарушит правило. Копия была создана во время этого отрисовывания, поэтому всё в порядке.
Первая загадка: почему StrictMode запускает код дважды
В режиме разработки StrictMode намеренно вызывает функции компонентов дважды, чтобы выявить компоненты, которые не являются чистыми. Это тот же обёрточный элемент <StrictMode>, который был добавлен вокруг приложения при его настройке.
// main.tsx — in development, the components inside this render twice
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
Чистый компонент дает одинаковые результаты при двух запусках, поэтому ничего не происходит. Компонент вроде Counter, упомянутого выше, который изменяет внешнюю переменную, сразу показывает признаки проблем: результаты меняются или значение удваивается. Режим StrictMode выявляет эти симптомы на этапе разработки, задолго до того, как они превратятся в сложные проблемы, трудные для решения после выпуска приложения.
Следовательно, двойная обработка — это не баг, а проверка работоспособности. Если двойной запуск не вызывает проблем, значит, компонент чистый. В производственной версии он выполняется только один раз.
Где действительно место побочным эффектам
Вы можете задаться вопросом: логирование, отправка данных на сервер и запуск таймера явно необходимы, так где же они должны находиться? Где угодно, кроме самой процедуры отрисовки.
- Реакция на действия пользователя, такие как клики и ввод текста, должна происходить в обработчиках событий.
- Синхронизация с внешней системой после отрисовки экрана должна находиться в разделе Эффекты.
Предположим, вы записываете информацию о просмотре страницы непосредственно в теле отрисовки: это будет происходить при каждой отрисовке, а в режиме StrictMode — вдвое чаще во время разработки. Переместите этот вызов в обработчик событий, и он будет срабатывать только тогда, когда кто-то действительно нажмёт на элемент.
// 🔴 side effect during rendering — fires on every render
function ProductPage({ id }: { id: number }) {
logView(id); // a side effect in the render body — not allowed
return <h1>Product {id}</h1>;
}
// ✅ in an event handler — runs only at the moment the user clicks
function BuyButton({ id }: { id: number }) {
return <button onClick={() => logPurchase(id)}>Buy</button>;
}
Сохраняйте структуру рендеринга «чистой» и переносите побочные эффекты в соответствующие места. Этот принцип постоянно возвращается в обсуждениях.
Вторая загадка: почему компилятор зависит от чистоты
Если вы включили React Compiler, возможно, вы помните, что он заменяет ручно написанные функции useMemo и useCallback. Именно чистота позволяет это сделать.
Компилятор работает на предпосылке, что компоненты являются чистыми. Поскольку один и тот же входной данный гарантированно приводит к одинаковому выходному результату, он считает безопасным повторное использование предыдущего результата, когда входные данные не изменились, и автоматически встраивает механизм мемоизации. Если компонент изменяет окружающую среду во время рендеринга, повторное использование может привести к неверным результатам, поэтому компилятор пропускает оптимизацию для таких компонентов. Правило ESLint, включаемое вместе с компилятором, указывает, какой именно компонент является причиной проблемы.
// 🔴 not pure → the compiler skips optimization (ESLint points at this component)
let renderCount = 0;
function RenderCounter() {
renderCount++; // mutating an external variable during render — a violation
return <p>{renderCount}</p>;
}
// ✅ pure → the compiler memoizes automatically (reuse the previous result when inputs match)
function Label({ text }: { text: string }) {
return <p>{text}</p>;
}
function App() {
return (
<>
<RenderCounter />
<Label text="Pure Component" />
</>
);
}
export default App;
Компонент, написанный в чистом виде, отображается в DevTools с меткой «Memo», даже если не было использовано объявление useMemo.
Короче говоря, чистота кода — это предпосылка для того, чтобы компилятор мог вам помочь. Пишите чистые компоненты, и улучшения производительности придут сами собой. Единственное, о чем стоит беспокоиться, — это написание чистого кода.
Восприятие интерфейса как дерева
Ещё один взгляд на ситуацию дополняет картину. Компоненты содержат другие компоненты: App содержит ProductList, который, в свою очередь, содержит несколько ProductCard. Такая вложенность естественным образом формирует дерево.
function App() {
return <ProductList products={products} />;
}
function ProductList({ products }: { products: Product[] }) {
return (
<ul>
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
);
}
По мере вложения компонентов этот код формирует следующую структуру дерева.
App
└─ ProductList
├─ ProductCard
├─ ProductCard
└─ ProductCard
По мере продвижения в изучении деревообразная структура становится всё более важной. Данные в виде параметров поступают по ней сверху вниз, а при работе со состоянием, о котором вы узнаете позже, выбор места его размещения в дереве становится ключевым аспектом проектирования. Помните о принципе «экран — это дерево компонентов», и даже сложные интерфейсы можно анализировать с точки зрения их структуры.
Итоги
- React рассматривает каждый компонент как чистую функцию: одинаковые параметры и состояние, одинаковый JSX.
- Во время отрисовки не следует изменять ничего внешнего, включая переменные, параметры и состояние. Значения, созданные в ходе текущей отрисовки, можно изменять, например при сортировке данных.
- Двойная отрисовка в режиме StrictMode во время разработки служит проверкой на наличие «нечистых» компонентов; чистые компоненты проходят проверку без изменений.
- Побочные эффекты следует размещать в обработчиках событий или в функциях Effects, никогда не в теле функции отрисовки.
- Чистота кода позволяет компилятору React автоматически его оптимизировать, поэтому написание «чистого» кода гарантирует лучшую производительность.
- Интерфейс представляет собой дерево компонентов, по которому передаются данные.
Пока что экран — это неподвижное изображение: его можно собрать из компонентов, написать маркировку с использованием JSX, передавать данные через атрибуты props и создавать динамические элементы с использованием условий и циклов, но нажатие кнопки ничего не изменяет. Следующим шагом является сделание так, чтобы экран реагировал на действия пользователя, и это начинается с обработки событий.
Официальные страницы документации React, посвящённые сохранению чистоты компонентов и пониманию интерфейса как дерева, более подробно рассматривают эти темы.