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认为每个组件都是纯函数。在相同的props和state条件下,它每次都必须返回相同的JSX内容。
type Props = { name: string };
// ✅ pure — same name, always the same result
function Greeting({ name }: Props) {
return <h1>Hello, {name}!</h1>;
}
将这个组件的名称设置为“Jane”,得到的结果永远是 <h1>Hello, 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:2、4、6,以此类推。如果没有 StrictMode,数值只会逐个增加1。
关于这个演示需要注意一点:为什么状态要放在 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能在开发阶段就将这些问题暴露出来,从而避免在应用上线后变成难以追踪的谜团。
因此,多次执行并非错误,而是一种健康检查。如果两次执行都不会出现问题,那就说明该组件是纯组件。在正式发布的版本中,它只会被执行一次。
副作用应出现的场景
你可能会提出一个合理的问题:日志记录、向服务器发送数据以及启动计时器显然是必要的,那它们应该放在哪里呢?显然不能放在渲染逻辑中。
- 对用户操作(如点击、输入)的反应应放在事件处理函数中。
- 在屏幕内容绘制完成后与外部系统同步的操作应放在 Effects 中处理。
假设你在渲染代码中直接记录页面浏览次数:每次渲染时都会触发该操作,而在开发模式下使用 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;
即使没有编写 useMemo,纯函数组件在 DevTools 中也会显示“Memo”标签。
简而言之,纯度是让编译器能够提供帮助的前提条件。编写纯函数组件,性能提升便会随之而来。唯一需要关注的就是如何编写纯代码。
将 UI 视为树结构
还有一个视角可以进一步完善这一理解:组件包含其他组件——App 包含 ProductList,而 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中,绝不能放在渲染主体中。
目前屏幕还只是一幅静态的画面:你可以用组件来组装它,用 JSX 编写标记,通过属性传递数据,并利用条件语句和循环构建动态视图,但点击按钮却没有任何反应。下一步就是让屏幕对用户操作做出响应,而这一切都始于事件处理。
React 官方文档中关于保持组件纯度以及将用户界面视为树结构的章节对此进行了更深入的阐述。