Skip to main content

React 函数组件的 re-render 原理与优化

· 6 min read

函数组件 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 问题的根源。

触发重渲染有四种情况:

  1. 自身 state 更新setState 被调用,即使新旧值完全相同,默认也会触发渲染
  2. 父组件重渲染 — 父组件渲染时子组件默认跟着渲染,不管 props 变没变
  3. props 引用变化 — 父组件传下来的值,浅比较判定为"不等于上一次"
  4. 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 元素的回调(onClickonChange)——没有 memo 子组件,缓存函数引用毫无意义

先开 React DevTools Profiler,找到真正的瓶颈,再动手。 不看 profiling 数据直接加 memo,和乱吃药没区别。

React Compiler:未来已来

React 19 引入了实验性的 React Compiler,编译时自动注入等效的 memo、useCallback、useMemo。你写普通函数组件,编译器在幕后处理记忆化。

但理解 re-render 原理仍然重要——Compiler 不是魔法,它只是自动做了你本该手动做的事。遇到性能问题,知道它"在做什么"才能排查,而不是对着 DevTools 干瞪眼。