React 性能优化入门:用 memo 避免无关的子组件重复渲染
在 React 应用中,一个很常见的场景是:父组件同时保存了多个状态,而某个子组件只依赖其中一部分状态。当父组件中与子组件无关的状态发生变化时,父组件会重新渲染,普通子组件也会跟着重新执行。
如果子组件的显示结果根本没有发生变化,这次重复渲染就没有带来新的页面内容。对于很小的组件,这种开销可能并不明显;但理解它是如何发生的,以及如何使用 memo 跳过它,是学习 React 性能优化的重要一步。
本文通过一个"计数 + 队伍名称"的例子,详细观察普通子组件和经过 memo 包装的子组件在父组件更新时有什么不同。
一、先看完整示例
jsx
import { useState, memo } from "react";
function RegularChild({ name }) {
console.log("渲染了RegularChild");
return (
<>
<h1>{name}</h1>
</>
);
}
const MemoChild = memo(({ name }) => {
console.log("MemoizedChild渲染了");
return <div>Hello,{name}</div>;
});
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState("少林队");
console.log("App组件渲染");
return (
<>
<button onClick={() => setCount(count + 1)}>
点击计数{count}
</button>
<button onClick={() => setName("峨眉队")}>改变名字</button>
<RegularChild name={name} />
<MemoChild name={name} />
</>
);
}
export default App;
这个例子虽然不长,但包含了观察组件重复渲染所需的全部条件:
App是父组件,同时持有count和name两个状态;RegularChild是普通子组件;MemoChild是通过memo包装的子组件;- 两个子组件都只接收
name,完全不使用count; - 三个组件中都加入了日志,方便观察组件函数是否重新执行。
二、父组件为什么会重新渲染
父组件中声明了两份彼此独立的状态:
jsx
const [count, setCount] = useState(0);
const [name, setName] = useState("少林队");
count 的初始值是 0,用于记录点击次数;name 的初始值是"少林队",用于向两个子组件传递队伍名称。
第一个按钮负责修改 count:
jsx
<button onClick={() => setCount(count + 1)}>
点击计数{count}
</button>
每点击一次,事件处理函数都会把当前的 count 加一,再将新值交给 setCount。状态改变后,React 会重新渲染 App,所以按钮中的计数也会显示最新结果。
第二个按钮负责修改 name:
jsx
<button onClick={() => setName("峨眉队")}>改变名字</button>
点击后,队伍名称从"少林队"变成"峨眉队"。因为 name 也属于 App 的状态,所以这次更新同样会使 App 重新渲染。
也就是说,无论更新的是 count 还是 name,下面这行日志都会随着父组件重新执行:
jsx
console.log("App组件渲染");
三、问题的关键:子组件只依赖 name
父组件将同一个 name 分别传给两个子组件:
jsx
<RegularChild name={name} />
<MemoChild name={name} />
两个子组件都通过属性解构取得 name:
jsx
function RegularChild({ name }) {
// ...
}
jsx
const MemoChild = memo(({ name }) => {
// ...
});
它们都没有接收或使用 count。因此,从子组件的角度看,点击计数按钮时,它们所依赖的数据并没有发生任何变化:点击前传入的是"少林队",点击后传入的仍然是"少林队"。
但"子组件用不到某个状态"和"子组件不会重新渲染"是两回事。父组件重新渲染时,普通子组件默认也会重新渲染。这正是 RegularChild 所表现出的行为。
四、普通子组件为什么会重复渲染
RegularChild 是一个普通函数组件:
jsx
function RegularChild({ name }) {
console.log("渲染了RegularChild");
return (
<>
<h1>{name}</h1>
</>
);
}
当 App 因为 count 改变而重新渲染时,RegularChild 也会重新执行,于是控制台再次输出:
text
渲染了RegularChild
这次执行并不是因为 RegularChild 的 name 改变了,而是因为它的父组件更新了。重新执行后产生的内容仍然是同一个队伍名称,所以从页面显示来看没有变化。
这个现象可以整理成一条清晰的更新链路:
text
点击计数按钮
↓
setCount 更新 count
↓
App 重新渲染
↓
RegularChild 跟随父组件重新渲染
示例中的 RegularChild 只渲染一个标题,重新执行的代价很小。这里真正重要的不是节省了多少时间,而是看清问题:父组件持有多个状态时,其中一个状态的改变可能会让不依赖该状态的普通子组件也重新渲染。
五、用 memo 记住上一次的渲染结果
另一个子组件使用了 memo:
jsx
const MemoChild = memo(({ name }) => {
console.log("MemoizedChild渲染了");
return <div>Hello,{name}</div>;
});
memo 从 React 中导入:
jsx
import { useState, memo } from "react";
它接收一个组件,并返回一个经过记忆化处理的组件。这里可以把"记忆化"直观地理解为:React 记住组件上一次接收的属性和渲染结果;父组件再次渲染时,先比较子组件的新旧属性,再决定是否需要重新执行子组件。
对于当前例子,MemoChild 只接收一个 name 属性。当点击计数按钮时:
count从0变成1;App重新渲染;- 传给
MemoChild的name仍然是"少林队"; memo比较后发现属性没有变化;- React 可以复用上一次的渲染结果,跳过
MemoChild的本次渲染。
因此,更新计数时,MemoizedChild渲染了 这条日志不会因为父组件的这次更新而再次出现。
对应的更新链路变成了:
text
点击计数按钮
↓
setCount 更新 count
↓
App 重新渲染
↓
memo 比较 MemoChild 的新旧属性
↓
name 没变,跳过 MemoChild 的重新渲染
六、memo 做的是属性浅比较
memo 判断是否跳过渲染的关键,是对组件接收的新旧属性进行浅层比较。
当前示例传入的是字符串:
jsx
<MemoChild name={name} />
点击计数按钮时,新旧两次传入的 name 都是同一个字符串值"少林队",所以比较结果是没有变化。MemoChild 因此可以拒绝这次与自己无关的重复渲染。
这里要特别注意:memo 并不是让组件从此不再更新,也不是只渲染一次。它只是根据属性比较结果来决定是否跳过某一次渲染。
只要属性真的发生变化,组件仍然需要重新渲染。
七、修改 name 时,两个子组件都会更新
点击"改变名字"按钮后会执行:
jsx
setName("峨眉队");
这次更新与点击计数按钮不同。name 从"少林队"变成了"峨眉队",而两个子组件都依赖 name。
对于 RegularChild,父组件重新渲染后,它会照常重新执行,并显示新的标题:
jsx
<h1>{name}</h1>
对于 MemoChild,虽然有 memo,但新旧 name 已经不同。属性比较无法通过,因此它也会重新执行,并显示:
text
Hello,峨眉队
完整过程如下:
text
点击改变名字按钮
↓
setName 更新 name
↓
App 重新渲染
├─ RegularChild 重新渲染
└─ memo 发现 name 已改变,MemoChild 重新渲染
这说明 memo 跳过的是"属性没有变化时的重复渲染",而不会阻止组件响应真正与自己有关的数据变化。
八、用控制台验证两种行为
组件中分别准备了三条日志:
jsx
console.log("App组件渲染");
console.log("渲染了RegularChild");
console.log("MemoizedChild渲染了");
可以按下面的顺序进行观察。
1. 首次渲染
页面第一次渲染时,父组件和两个子组件都需要生成自己的内容,因此三类日志都会出现。
2. 点击"点击计数"
此时只有 count 改变:
App重新渲染;RegularChild跟着重新渲染;MemoChild的name没变,因此被memo跳过。
3. 点击"改变名字"
此时 name 发生变化:
App重新渲染;RegularChild重新渲染;MemoChild的属性改变,因此也重新渲染。
通过这种对照,可以直接看到 memo 优化的边界:它只在父组件更新、但传给子组件的属性保持不变时发挥作用。
九、为什么开发环境里日志可能出现多次
应用入口使用了 StrictMode:
jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);
因此,在开发环境中观察控制台时,可能会看到组件渲染日志出现多次。这里使用日志的目的,是比较同一次状态变化中普通子组件和记忆化子组件的行为差异。不要只根据日志的绝对次数下结论,而应重点观察:更新 count 时,RegularChild 会跟随父组件执行,而属性未变的 MemoChild 可以被跳过。
入口代码还完成了两件事:通过 document.getElementById("root") 找到页面中的根节点,再由 createRoot(...).render(...) 将 React 应用渲染到该节点中。页面对应的挂载节点是:
html
<div id="root"></div>
十、memo 解决的到底是什么问题
现在可以把整个示例归纳为三个层次。
第一层,状态更新会让持有该状态的父组件重新渲染:
jsx
setCount(count + 1);
setName("峨眉队");
第二层,父组件重新渲染时,普通子组件也会重新渲染,即使它使用的属性没有改变:
jsx
<RegularChild name={name} />
第三层,用 memo 包装子组件后,React 会对新旧属性进行浅层比较。当属性保持不变时,可以跳过该子组件的本次渲染:
jsx
const MemoChild = memo(({ name }) => {
return <div>Hello,{name}</div>;
});
所以,memo 在这个例子中的价值并不是阻止父组件更新,而是把影响范围限制得更准确:更新 count 时只更新真正需要响应计数变化的部分;当 name 改变时,再让依赖名称的子组件正常更新。
十一、总结
通过这个例子,可以掌握以下几点:
- 父组件可以通过多个
useState分别保存不同状态; - 任意一份状态变化,都可能使父组件重新渲染;
- 普通子组件会跟随父组件重新渲染,即使本次变化与它使用的属性无关;
memo会对组件的新旧属性进行浅层比较;- 属性没有变化时,经过
memo包装的组件可以跳过重复渲染; - 属性真正变化时,记忆化组件仍然会正常更新;
- 开发环境使用
StrictMode时,控制台日志可能出现多次,观察重点应放在不同组件之间的行为差异上。
memo 的核心可以浓缩成一句话:记住组件上一次的属性和渲染结果,在属性未变化时跳过没有必要的重复渲染。
当父组件持有多个状态,而子组件只依赖其中一部分状态时,这种优化思路尤其容易理解:先确认子组件真正依赖哪些属性,再观察父组件更新时这些属性有没有改变,最后用 memo 避免与子组件无关的渲染。