React 函数组件的 re-render 原理与优化
函数组件 re-render 的根因只有一条:引用变了。state 更新、props 变化、父组件渲染、context 值变化——本质上都是"某个引用不等于上一次的引用"。
- React.memo 决定"要不要渲染":props 浅比较通过就跳过,不通过就走正常流程
- useCallback / useMemo 保证"引用 稳定":让 memo 的浅比较真正生效,否则每次渲染新引用,memo 形同虚设
- 三者是组合拳:只用 memo 不缓存引用 → 白用;只缓存不用 memo → 也白用
- 拆分组件 +状态下移 往往比加 memo 更治本:把频繁更新的状态隔离在小范围组件内
- 列表用 id 做 key,保证增删排序时最小 DOM 更新;纯分页无排序删除可用 index,反而更高效
- React 19 的 React Compiler 自动处理记忆化,但理解原理仍然是排查性能问题的基础
为什么函数组件会重渲染
函数组件的重渲染,说白了就是函数被再次执行。React 每次渲染都会重新调用你的组件函数,里面的变量、函数、对象全部重新创建。
JavaScript 里 {} === {} 永远是 false,(() => {}) === (() => {}) 也永远是 false。这跟内容无关,是引用语义决定的。函数组件每次执行都在生产全新的引用,这才是 re-render 问题的根源。
触发重渲染有四种情况:
- 自身 state 更新 —
setState被调用,即使新旧值完全相同,默认也会触发渲染 - 父组件重渲染 — 父组件渲染时子组件默认跟着渲染,不管 props 变没变
- props 引用变化 — 父组件传下来的值,浅比较判 定为"不等于上一次"
- context 值变化 — 组件消费的 context 变了,所有订阅者无条件重渲染
其中第 2 条是实际开发中最常见的性能问题来源:页面顶层一个不相关的 state 变了,整个组件树跟着抖一遍。
React.memo:决定"要不要渲染"
React.memo 是函数组件的浅比较守卫。它对 props 做一层浅比较,相等就直接复用上次的渲染结果:
const Child = React.memo(({ name, items }) => {
return <div>{name}: {items.length}</div>;
});
memo 默认只做浅比较。如果 items 是数组,每次父组件渲染都会创建新数组引用,浅比较必然判定为"不等",memo 等于没加。
可以传第二个参数自定义比较逻辑:
const Child = React.memo(
({ user }) => <div>{user.name}</div>,
(prev, next) => prev.user.id === next.user.id
);
但说实话,自定义比较器在实际项目里用得不多。更常见的做法是用 useCallback/useMemo 从源头稳定引用,让默认的浅比较就能正常工作。
useCallback / useMemo:稳定引用
这两个 hook 的核心作用不是"性能优化",而是让引用在多次渲染间保持不变:
// ❌ 每次渲染 handleClick 都是新函数,子组件的 memo 形同虚设
function Parent({ id }) {
const handleClick = () => fetchData(id);
return <MemoChild onClick={handleClick} />;
}
// ✅ id 不变时,返回同一个函数引用
function Parent({ id }) {
const handleClick = useCallback(() => fetchData(id), [id]);
return <MemoChild onClick={handleClick} />;
}
对象和数组同理:
// ❌ 每次都是新对象
const style = { color: 'red', fontSize: 14 };
// ✅ 依赖不变时,返回同一个对象引用
const style = useMemo(() => ({ color: 'red', fontSize: 14 }), []);
一个容易忽略的点:传原始值(string、number、boolean)不需要缓存引用。浅比较直接比值,"hello" === "hello" 就是 true。只有传引用类型(对象、数组、函数)给 memo 组件时才需要。
三者怎么配合
真正的优化链路:
useCallback → 函数引用稳定
useMemo → 对象/数组引用稳定
↓
React.memo → 浅比较通过 → 跳过渲染
缺一环整条链就断了。只用 memo 不缓存引用,浅比较过不了;只缓存引用不用 memo,React 根本不比较,直接渲染。
const ItemRow = React.memo(({ item, onDelete }) => (
<tr>
<td>{item.name}</td>
<td><button onClick={() => onDelete(item.id)}>删除</button></td>
</tr>
));
function List({ data }) {
const [filter, setFilter] = useState('');
const filtered = useMemo(
() => data.filter(d => !filter || d.category === filter),
[data, filter]
);
const handleDelete = useCallback((id) => {
// ...
}, []);
return filtered.map(item => (
<ItemRow key={item.id} item={item} onDelete={handleDelete} />
));
}
比 memo 更治本:拆分组件 +状态下移
很多性能问题的根因不是"没加 memo",而是状态放得太高了:
// ❌ count 变化 → App 重渲染 → HeavyComponent 无辜重渲染
function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<HeavyComponent />
</>
);
}
// ✅ count 下沉到 Counter,App 不再持有这个状态
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
function App() {
return (
<>
<Counter />
<HeavyComponent /> {/* 不再被牵连 */}
</>
);
}
这个技巧叫 state colocation——把状态放在真正需要它的最小组件里,而不是全部堆在顶层。很多时候这个优化做完,memo/useCallback 根本就不需要了。
列表的 key
key 不只是消除 React 警告,它直接影响 diff 算法的复用策略:
- 增删排序频繁:用数据 id 做 key,保证组件实例和 DOM 节点正确复用
- 纯分页展示(无排序、无删除、无新增行):用 index 反而更快,省去了 id→index 的映射开销
千万别用 Math.random() 或 Date.now() 做 key——每次渲染 key 都变,React 会销毁重建所有节点,性能比不加 key 还烂。
什么时候不该优化
过早优化是万恶之源。以下场景加 memo/useCallback/useMemo 是负优化:
- 组件就渲染一个
<div>加几行文字——比较 props 的成本可能比直接渲染还高 - props 每次必然变化——加了 memo 也多一次无意义的比较
- 传的是原生 HTML 元素的回调(
onClick、onChange)——没有 memo 子组件,缓存函数引用毫无意义
先开 React DevTools Profiler,找到真正的瓶颈,再动手。 不看 profiling 数据直接加 memo,和乱吃药没区别。
React Compiler:未来已来
React 19 引入了实验性的 React Compiler,编译时自动注入等效的 memo、useCallback、useMemo。你写普通函数组件,编译器在幕后处理记忆化。
但理解 re-render 原理仍然重要——Compiler 不是魔法,它只是自动做了你本该手动做的事。遇到性能问题,知道它"在做什么"才能排查,而不是对着 DevTools 干瞪眼。