React 性能优化的 1 个坑:我以为 memo 够了,直到我传了一个函数
开篇
先看一段代码。你能说出它想优化什么吗?
javascript
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
return (
<>
<button onClick={() => setCount(count + 1)}>
点击计数 {count}
</button>
<button onClick={() => setName('wxh')}>
改变名字
</button>
<RegularChild name={name} />
<MemoChild name={name} />
</>
);
}
控制台输出: 每次点「点击计数」:
markdown
App render
RegularChild render
← MemoChild 没渲染!memo 生效了
看起来 memo 很完美。 MemoChild 被 memo 包裹,count 变了 name 没变,所以跳过了渲染。
但等等------如果我加一行代码,给 MemoChild 传一个回调函数呢?
javascript
<MemoChild name={name} onDone={() => console.log('done')} />
你再猜一次。点「点击计数」,控制台会输出什么?
App render
RegularChild render
MemoChild render ← 说好的跳过呢???
memo 失效了。 就因为多传了一个箭头函数------一个你写了几百遍的东西。你的 memo、你的优化、你的性能优化文章------全部白费。
这篇文章就是解决这个问题的。
先搞清楚 memo 的判断逻辑
React.memo 的本质是浅比较 。每次父组件渲染,React 把新的 props 和旧的 props 做一个 === 对比。只有 props 变了,子组件才渲染。
对 name="少林队" 这种原始值,"少林队" === "少林队" 为 true,所以跳过渲染。一切顺利。
但问题是------不是所有值都能 ===。
函数是对象,对象是引用。 每次渲染时 () => console.log('done') 都是一个全新的函数引用------即使它做的事完全一样。浅比较看到的就是「不一样」,然后放行渲染。
memo检查的是引用是否相同 ,不是内容是否相同。
亲手造一个「memo 失效」现场
在原代码基础上,加一个场景:传回调。
javascript
import { useState, memo } from 'react';
const MemoChild = memo(({ name, onDone }) => {
console.log('MemoChild render');
return (
<div>
Hello {name}
<button onClick={onDone}>完成</button>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
// ⚠️ 每次 App 渲染,这里都生成一个全新的函数
const handleDone = () => {
console.log('done:', name);
};
return (
<>
<button onClick={() => setCount(count + 1)}>
点击计数 {count}
</button>
<MemoChild name={name} onDone={handleDone} />
</>
);
}
运行结果:
点击「点击计数」:
App render
MemoChild render ← 又渲染了!count 跟 MemoChild 毫无关系
name 没变。但 onDone 变了。 不是内容变了------是引用变了。每次 App 渲染,JavaScript 重新执行 const handleDone = () => {...},生成一个内存里全新的对象。对 memo 来说这就是「新 props」,于是放行渲染。
解法:让引用稳定下来
useCallback 做的事很简单:除非依赖变了,否则永远返回同一个函数引用。
javascript
import { useState, memo, useCallback } from 'react';
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
// 🔑 关键:name 不变,handleDone 的引用就不变
const handleDone = useCallback(() => {
console.log('done:', name);
}, [name]); // ← name 不变,handleDone 永远是同一个函数对象
return (
<>
<button onClick={() => setCount(count + 1)}>
点击计数 {count}
</button>
<MemoChild name={name} onDone={handleDone} />
</>
);
}
现在再点「点击计数」:
markdown
App render
← MemoChild 安静了!
因为 name 没变 → handleDone 引用没变 → memo 浅比较通过 → 跳过渲染。
useCallback不是「让函数变快」,是「让同一个函数一直用同一个引用」。
对照实验:可视化 memo 的失效与修复
让我们用一个实验把两边的行为放在一起对比:
javascript
// 实验:对比两种写法对 MemoChild 渲染次数的影响
import { useState, memo, useCallback } from 'react';
let renderCountGood = 0;
let renderCountBad = 0;
const GoodChild = memo(({ onClick }) => {
renderCountGood++;
return <button onClick={onClick}>Good</button>;
});
const BadChild = memo(({ onClick }) => {
renderCountBad++;
return <button onClick={onClick}>Bad</button>;
});
export default function Experiment() {
const [count, setCount] = useState(0);
// ✅ 引用稳定
const stableFn = useCallback(() => {}, []);
// ❌ 每轮渲染都是新引用
const unstableFn = () => {};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<GoodChild onClick={stableFn} />
<BadChild onClick={unstableFn} />
<p>GoodChild 渲染: {renderCountGood}</p>
<p>BadChild 渲染: {renderCountBad}</p>
</div>
);
}
点击 5 次 Count 按钮后的真实输出:
GoodChild 渲染: 1 ← 只有初始渲染
BadChild 渲染: 6 ← 初始 + 5 次跟着父组件重渲染
同一个 memo,同一个结构,唯一的区别就是传入的函数是否用了 useCallback。 差了 6 倍。你的应用里几十个组件、几百次渲染------累积起来就是卡顿。
但是------不要每个函数都套 useCallback
你可能会想:「那我所有函数都用 useCallback 好了,防患于未然。」
别。 React 团队的设计哲学是------优化有成本,不要为不需要的东西买单。useCallback 的成本是什么?
- 内存成本:React 要额外存储依赖数组和缓存的函数引用
- 比对成本:每次渲染都要检查依赖数组的每一项是否变了
- 代码复杂度 :多一层
useCallback包裹,多一层心智负担
如果子组件没被 memo 包裹 ,传 useCallback 进去没有任何收益------子组件反正每次都会渲染。React 的 diffing 算法本身就很快,不要在没有瓶颈的地方「优化」。
useCallback的价值只存在于memo+useCallback这个 pair 中。单独使用任何一个都是在浪费代码行数。
回到设计哲学:React 为什么不做自动 memo?
现在你真正理解了 memo + useCallback 的组合------你不会再想:「有没有一个 babel 插件,自动给我所有的组件包 memo、所有函数包 useCallback?」
React 核心团队确实讨论过这个方案。React Forget(后来的 React Compiler)的早期方向就是这个思路。但为什么默认行为是不优化?
因为 JavaScript 的函数和对象天生就是「每次创建新的」。React 选择不做额外工作,除非你明确需要:
- React 不假设你的性能需求:大多数应用的渲染足够快,memo 的开销大于收益
- 浅比较本身也有成本 :每个 props 都要
===一次,如果 props 很多,比重新渲染还慢的情况是存在的 - 正确性优先于性能:React 保证 UI 正确。优化是「正确之后的事」
React 核心成员 Dan Abramov 的原话精神是:「在你测量到性能问题之前,不要做优化。」 而 React DevTools Profiler 和 React Scan 这类工具,就是用来告诉你「哪里需要优化」的------不是凭空猜。
结尾
回到开篇那个场景------你加了 memo,以为优化好了,结果传了一个 arrow function 就全毁了。这不是你的错,是 JavaScript 引用语义和 React 渲染模型的「设计摩擦」。
记住一句话:memo 的优化要成立,传给它的每一个引用类型 props 都必须引用稳定。useCallback 是稳定函数引用,useMemo 是稳定对象引用。三者是一套,缺一个就漏。
memo 帮你关上了门,useCallback / useMemo 负责把门锁上。只关门不锁,风一吹就开了。
下次你写 memo 的时候,扫一眼传给它的 props------如果里面有函数或对象,就问自己:「这个引用,这一轮和上一轮是同一个吗?」
你的项目里有几个 memo 是真正的「关上门还锁了」的?去扫一眼,评论区告诉我你发现了多少。