在前几篇关于 Hooks 的文章里,React.memo、useMemo、useCallback 已经零散出现了好几次。每次提到它们,都是作为某个具体问题的解法——"用 useCallback 稳定函数引用"、"用 React.memo 跳过子树 reconciliation"。
但放在一起审视,我发现自己其实没有想清楚几个问题:三者到底各自解决什么?它们是怎么协作的?什么时候该用,什么时候加了反而是负担?
这篇文章想把散落在之前内容里的碎片收拢起来,同时回答一个更实用的问题:面对一个有渲染性能问题的页面,优化的思路和顺序应该是什么?
一、三者分别解决什么问题
先把概念辨析清楚,再谈协作关系。
React.memo:跳过子组件的重渲染
React.memo 是一个高阶组件,它的作用是:当父组件重渲染时,如果子组件的 props 没有变化,跳过这个子组件的渲染。
// 环境:React
// 场景:用 React.memo 阻断父组件重渲染的"传染"
const ChildComponent = React.memo(({ title, count }) => {
console.log('ChildComponent rendered');
return <div>{title}: {count}</div>;
});
function ParentComponent() {
const [tick, setTick] = useState(0);
return (
<div>
<button onClick={() => setTick(t => t + 1)}>Tick: {tick}</button>
{/* tick 变化,Parent 重渲染,但 Child 的 props 没变,会被跳过 */}
<ChildComponent title="Score" count={42} />
</div>
);
}
React.memo 的默认比较行为是浅比较(shallow comparison):对每一个 prop 用 Object.is 做比较。基本类型(字符串、数字、布尔值)按值比较,引用类型(对象、数组、函数)按引用比较。
如果默认的浅比较不够用,可以传入第二个参数——一个自定义比较函数:
// 环境:React
// 场景:自定义比较逻辑,只关心 id 字段是否变化
const UserCard = React.memo(
({ user }) => <div>{user.name}</div>,
(prevProps, nextProps) => {
// 返回 true 表示"props 没变,跳过渲染"
// 返回 false 表示"props 变了,需要重渲染"
return prevProps.user.id === nextProps.user.id;
}
);
注意自定义比较函数的语义和 shouldComponentUpdate 相反:返回 true 是"不需要更新",返回 false 是"需要更新"。这个方向容易搞反。
useMemo:缓存计算结果
useMemo 缓存的是一个计算值,只有依赖项变化时才重新计算:
// 环境:React
// 场景:缓存过滤列表的计算结果
function FilteredList({ items, filter }) {
// ❌ 每次渲染都重新过滤,items 很大时有性能开销
const filtered = items.filter(item => item.type === filter);
// ✅ 只有 items 或 filter 变化时才重新计算
const filteredMemo = useMemo(
() => items.filter(item => item.type === filter),
[items, filter]
);
return filteredMemo.map(item => <Item key={item.id} data={item} />);
}
useMemo 还有另一个常见用途:稳定对象引用,为 React.memo 创造条件:
// 场景:稳定传给子组件的配置对象引用
function Parent({ theme, lang }) {
// ❌ 每次 Parent 渲染都产生新对象,即使 theme 和 lang 没变
const config = { theme, lang };
// ✅ theme 和 lang 都没变时,config 引用稳定
const configMemo = useMemo(() => ({ theme, lang }), [theme, lang]);
return <Child config={configMemo} />;
}
useCallback:缓存函数引用
useCallback 缓存的是一个函数,本质上等价于 useMemo(() => fn, deps),只是语义更明确:
// 环境:React
// 场景:稳定事件处理函数的引用,配合 React.memo 使用
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
// ❌ 每次渲染都创建新函数,TodoItem 的 memo 会失效
const handleDelete = (id) => {
// ...
};
// ✅ filter 没变时,handleDelete 引用稳定
const handleDeleteMemo = useCallback((id) => {
// ...
}, [filter]); // 如果删除逻辑依赖 filter,需要放进依赖
return todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onDelete={handleDeleteMemo} />
));
}
const TodoItem = React.memo(({ todo, onDelete }) => {
// 只有 todo 或 onDelete 引用变化时才重渲染
return <li onClick={() => onDelete(todo.id)}>{todo.text}</li>;
});
三者的关联
三者之间有一条清晰的协作逻辑:
React.memo 是"门卫",决定子组件要不要渲染;useMemo 和 useCallback 是"稳定 props 引用"的工具,让门卫的比较真正有意义。三者需要配合使用才能发挥作用,单独使用任何一个往往效果有限。
二、React.memo 最常见的失效场景
React.memo 包裹了组件,但发现子组件依然每次都在重渲染——这是实际开发中很常见的困惑。失效的原因几乎都指向同一个根本:props 的引用在每次父组件渲染时都变了。
// 环境:React
// 场景:四种让 React.memo 失效的常见写法
const Child = React.memo(({ style, data, onClick, children }) => {
console.log('Child rendered');
return <div style={style} onClick={onClick}>{children}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Child
// ❌ 1. 每次渲染产生新对象字面量
style={{ color: 'red' }}
// ❌ 2. 每次渲染产生新数组
data={[1, 2, 3]}
// ❌ 3. 每次渲染产生新函数
onClick={() => console.log('clicked')}
>
{/* ❌ 4. JSX 作为 children 传入,每次渲染产生新的 React element */}
<span>Hello</span>
</Child>
</div>
);
}
前三种情况可以用 useMemo 和 useCallback 修复。第四种情况(children 是 JSX)相对少被注意——JSX 本质上是 React.createElement 的调用,每次渲染都会产生新的 React element 对象,引用自然不同。
对于 children 的情况,一种解法是把子内容提升到父组件外部,或者用 useMemo 包裹:
// 稳定 children 引用
const stableChildren = useMemo(() => <span>Hello</span>, []);
<Child>{stableChildren}</Child>
不过在我看来,如果需要到这种程度,往往是组件结构本身值得重新审视——这是后面"先问结构"原则的前置。
三、useCallback 和闭包陷阱的微妙关系
在第一篇 Hooks 文章里详细讨论过闭包陷阱。useCallback 在这里有一个容易被忽视的矛盾:
useCallback 缓存了函数引用,但函数体里捕获的变量值,依然是创建那一刻的快照。
// 环境:React
// 场景:useCallback 缓存引用,但内部值可能是旧的
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
// query 在依赖数组里:query 变化时重新创建函数,引用会变
const handleSearch = useCallback(() => {
onSearch(query); // 能读到最新的 query
}, [query, onSearch]);
// ❌ 如果依赖数组写错了,缓存了旧的 query
const handleSearchStale = useCallback(() => {
onSearch(query); // query 永远是初始值 ''
}, []); // 空依赖:函数引用稳定,但 query 是旧的
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
这里存在一个真实的张力:依赖数组越完整,函数引用越不稳定(缓存失效更频繁);依赖数组越精简,函数引用越稳定,但越容易读到旧值。
解决这个矛盾的方式在 Hooks 篇里提到过——用 useRef 保存最新值,在回调里读取 ref.current,同时保持函数引用稳定:
// 场景:引用稳定 + 始终读取最新值
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
const queryRef = useRef(query);
useEffect(() => {
queryRef.current = query; // 每次 query 更新,同步到 ref
}, [query]);
// 依赖数组为空,函数引用永远稳定
// 同时通过 ref 读取最新的 query
const handleSearch = useCallback(() => {
onSearch(queryRef.current);
}, []);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
这个模式有一定复杂度,不是所有场景都需要。大多数时候,把依赖项补全是更直接的选择。
四、缓存的代价:什么时候加了反而更差?
这是实际开发中最容易被忽视的一点:useMemo 和 useCallback 本身是有成本的。
每次渲染,React 需要:
- 取出上一次的依赖数组
- 用
Object.is逐个比较新旧依赖 - 如果没变,返回缓存的值;如果变了,重新执行并缓存
这些操作不是免费的。对于轻量计算,这个开销可能比直接重新计算更大。
// 环境:React
// 场景:没必要的 useMemo,增加了开销却没有收益
function Component({ a, b }) {
// ❌ 加法运算极其轻量,useMemo 的开销反而更大
const sum = useMemo(() => a + b, [a, b]);
// ✅ 直接计算
const sumDirect = a + b;
// ✅ 值得用 useMemo 的场景:复杂计算 + 结果被 memo 子组件使用
const processedData = useMemo(
() => largeArray.filter(Boolean).map(transform).sort(comparator),
[largeArray]
);
}
一个我觉得比较实用的判断框架,需要同时满足两个条件,才值得考虑 useMemo 或 useCallback:
条件一:计算本身有明显开销(复杂过滤/排序/转换),或者需要稳定引用(传给 memo 组件、放入 useEffect 依赖)。
条件二:这个组件/计算确实存在不必要的重复执行(最好通过 Profiler 确认,而不是靠猜)。
两个条件缺一,加缓存的收益就存疑了。
五、系统思路:有渲染性能问题,应该怎么排查?
这是把三件套放回真实场景的问题。我的理解是,优化不应该从"加 memo"开始,而应该有一个更有层次的排查顺序。
第一步:先用 Profiler 定位,而不是靠感觉
Profiler 能区分两类不同性质的问题:单次渲染太慢(Flamegraph),和渲染次数太多(Ranked Chart)。两类问题的解法方向不同,混淆了会走弯路。
第二步:先问组件结构,再问缓存 API
这是 Dan Abramov 在 Before You memo() 里强调的思路,我觉得非常值得内化。
很多时候,渲染问题的根源不是"缺少缓存",而是"组件结构导致不相关的状态变化触发了不必要的重渲染":
// 环境:React
// 场景:通过拆分组件解决重渲染,而不是加 memo
// ❌ 原始结构:SlowComponent 和高频更新的状态在同一个组件里
function App() {
const [color, setColor] = useState('red'); // 高频变化
return (
<div style={{ color }}>
<input onChange={e => setColor(e.target.value)} />
<SlowComponent /> {/* 每次 color 变化都会重渲染 */}
</div>
);
}
// ✅ 方案一:状态下移,把高频状态隔离到小组件里
function ColorInput({ children }) {
const [color, setColor] = useState('red');
return (
<div style={{ color }}>
<input onChange={e => setColor(e.target.value)} />
{children} {/* children 从外部传入,不受 color 影响 */}
</div>
);
}
function App() {
return (
<ColorInput>
<SlowComponent /> {/* 不再因为 color 变化而重渲染 */}
</ColorInput>
);
}
这个重构不需要任何缓存 API,但彻底解决了问题。状态下移 和 内容提升(lifting content up)是两种常见的结构调整手段,值得在加 memo 之前先考虑。
第三步:确认 memo 失效的根因,再决定是否用 useMemo / useCallback
只有在结构调整之后问题依然存在,且通过 Profiler 确认了具体的重渲染路径,才到三件套登场的时候。此时的顺序是:
- 用
React.memo包裹需要保护的子组件 - 排查是哪个 prop 引用不稳定,导致
memo失效 - 用
useMemo或useCallback稳定对应的 prop
这个顺序很重要——不要在没有 React.memo 的情况下先加 useCallback。子组件没有 memo 保护,函数引用稳不稳定根本无所谓,因为父组件重渲染时子组件无论如何都会跟着渲染。
// 这样做没有意义:子组件没有 memo,useCallback 不产生任何收益
function Parent() {
const handleClick = useCallback(() => {}, []); // 引用稳定了,但没人用这个稳定性
return <Child onClick={handleClick} />; // Child 没有 memo,照样每次都渲染
}
六、小结
把三件套的关系用一句话概括:React.memo 是优化的实施点,useMemo 和 useCallback 是让 React.memo 真正有效的配套工具。
但更重要的,可能是这三件套在整个优化流程里的位置:它们应该是"结构调整之后、数据确认有问题之后"的手段,而不是开发时的默认配置。
回顾这个系列写到现在,有一条隐约的主线:React 的很多设计——Hooks 的调用顺序、Context 的广播机制 —— 都是在某种约束下做的取舍。三件套的存在,本质上也是在"函数组件每次渲染都重新执行"这个约束下,给开发者提供的逃生舱。
理解了约束,才能更好地判断什么时候需要逃生,什么时候其实可以不逃。
参考资料
- React 官方文档 - memo -
React.memo官方 API 文档 - React 官方文档 - useMemo -
useMemo官方文档,包含何时不需要使用的说明 - React 官方文档 - useCallback -
useCallback官方文档 - Dan Abramov - Before You memo() - 在使用 memo 之前,先考虑组件结构问题
- React 官方文档 - You Might Not Need an Effect - 官方对过度使用缓存和副作用的整理