React 性能优化三大件,本文用一条主线 + 一份可运行 demo,彻底讲清三者的区别与配合。
前言
用 React 写页面,写着写着总会遇到一个问题:明明只是改了一个数字,怎么页面上的列表、图表全跟着重渲染了一遍?
React.memo、useMemo、useCallback 就是专门回答这个问题的三兄弟。它们名字像、长得像,作用却完全不同------有人管组件、有人管函数、有人管值。很多人学完就忘,是因为把三者当成了三个独立知识点去背,而它们其实是一条线上的三个环节。
这篇文章从 React 的渲染机制讲起,用一份能直接跑起来的 demo,把三兄弟一次讲透。
一、先理解 React 的渲染机制:为什么要"记忆化"
要理解三兄弟,先得明白 React 是怎么"重渲染"的。
React 的渲染,本质就是"重新执行组件函数"。
jsx
function App() {
const [count, setCount] = useState(0) // ① 状态
return <Child count={count} /> // ② 把状态传给子组件
}
当你点按钮让 count 从 0 变成 1 时:
- React 发现
App的 state 变了 - React 重新执行
App这个函数 App函数体里的<Child />被再次调用,子组件跟着重渲染- 子组件里的孙组件,再跟着重渲染......
只要父组件重渲染,整棵子树默认全部重跑一遍。 这是 React 的默认行为------它足够简单、足够安全,代价是:很多其实不用变的组件,也被白白重渲染了。
这就是"记忆化"(Memoization)的用武之地。记忆化的思想来自缓存:把"输入"和"结果"记录下来,下次输入没变,就直接返回记录的结果,不重新算。
一句话理解记忆化:同一个输入,我不想再执行一遍。
但"输入"和"结果"在不同层面有不同的形态,于是有了三个工具,各管一层:
| 工具 | 它记忆的"结果"是 | 它担心的"输入"是 |
|---|---|---|
memo |
整个组件渲染出来的 UI | 组件的 props |
useCallback |
一个函数 | 函数闭包里的依赖 |
useMemo |
一段计算结果 | 计算用到的依赖 |
下面一个一个拆。
二、memo:缓存组件
2.1 它解决什么问题
看这个例子。父组件有两个状态:一个 count,一个 name。
jsx
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('少林队')
return (
<>
<button onClick={() => setCount(c => c + 1)}>点我计数</button>
<button onClick={() => setName('emd')}>改变名字</button>
<BigList /> {/* 渲染很贵的大列表 */}
</>
)
}
BigList 渲染很贵(成千上万的节点)。但它完全不依赖 count 和 name。然而只要点任意一个按钮,App 重渲染,BigList 就被迫重跑一遍------明明它显示的东西一个字都没变。
2.2 用 memo 包一层
React.memo 是个高阶组件(HOC):它接收一个组件,返回一个"会偷懒"的新组件。
jsx
import { memo } from 'react'
const BigList = memo(function BigList() {
console.log('BigList 重新渲染了')
// ... 渲染很贵的内容
return <div>很大的列表</div>
})
被 memo 包裹后,BigList 在每次父组件重渲染时会先浅比较它的 props:
- props 没变 → 跳过这次渲染,直接复用上次的结果
- props 变了 → 正常渲染
因为 BigList 没有 props(或者 props 都是基本类型且没变),它就能安稳地躲过每一次无关重渲染。
2.3 用 console.log 验证
在组件里打印日志,是观察"有没有被重渲染"最直接的手段。完整 demo:
jsx
import { useState, memo } from 'react'
// 普通组件:父组件一渲染,它必渲染
function RegularChild({ name }) {
console.log(' [RegularChild] 重渲染了')
return <p>RegularChild: {name}</p>
}
// memo 组件:props 没变就跳过
const MemoChild = memo(function MemoChild({ name }) {
console.log(' [MemoChild] 重渲染了')
return <p>MemoChild: {name}</p>
})
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('少林队')
console.log('[App] 渲染了')
return (
<div>
<button onClick={() => setCount(c => c + 1)}>点我计数(count 变了)</button>
<button onClick={() => setName('emd')}>改变名字(name 变了)</button>
<p>count: {count}</p>
<RegularChild name={name} />
<MemoChild name={name} />
</div>
)
}
export default App
控制台输出预测:
- 点「改变名字」 :
name真的变了 → 两个子组件的 props 都变了 → 两个都重渲染(这是正常的,props 确实不同) - 点「点我计数」 :
count变了,但name没变 →[RegularChild]打印了,[MemoChild]没有打印 ✅
这就是 memo 的全部价值:props 相同,不重渲染。
2.4 一个关键前提
memo 的比较是浅比较 :基本类型比值,引用类型比引用(===)。所以 memo 只有在"props 是基本类型"时最好使。一旦你给它传了一个函数:
jsx
<MemoChild name={name} onClick={() => setName('emd')} />
这里 onClick 是内联写的,每次渲染都是一个全新的函数对象 ,=== 比较永远不相等------memo 立刻失效。这就是第三个 hook 出场的契机。
记住这句话:memo 怕函数 props。 而函数 props 是 React 里最普遍的写法,所以单用 memo 常常不够。
三、useCallback:缓存函数
3.1 它解决什么问题
上一节留了个尾巴:memo 遇到函数 props 就失效。为什么会失效?因为函数是引用类型:
jsx
function App() {
const [count, setCount] = useState(0)
// 每次 App 重渲染,这里都会创建一个全新的函数
const handleClick = () => setCount(c => c + 1)
return <MemoChild onClick={handleClick} />
}
handleClick 每次渲染都是新的对象、新的引用 。就算它的逻辑一模一样,MemoChild 做浅比较时发现 onClick !== onClick,于是照常重渲染------memo 白包了。
3.2 用 useCallback 包一层
useCallback 的作用:让函数在依赖不变时,引用保持稳定。
jsx
import { useCallback } from 'react'
function App() {
const [count, setCount] = useState(0)
// 依赖 [] 永远不变 → 每次渲染拿到的是同一个函数引用
const handleClick = useCallback(() => setCount(c => c + 1), [])
return <MemoChild onClick={handleClick} />
}
它做的事是:
- 第一次渲染:创建函数,记住它
- 之后的每次渲染:检查依赖数组
[]- 依赖没变 → 原样返回上次记住的那个函数(引用相同)
- 依赖变了 → 重新创建函数
于是 handleClick 引用稳定了,传给 MemoChild 的 onClick 浅比较能通过,memo 终于能拦住这一次重渲染。
3.3 依赖数组是什么
useCallback(fn, deps) 的第二个参数 deps 是依赖数组:凡是函数内部用到、且可能变化的值,都要列进来。
jsx
// 函数内部用了 count,就必须把 count 写进依赖
const handleAdd = useCallback(() => {
setCount(count + 1) // 用了 count
}, [count]) // 依赖 count
// 如果写成 [],count 变了,handleAdd 里的 count 还是旧值 → 闭包过期
const handleAdd = useCallback(() => {
setCount(count + 1)
}, []) // ❌ 闭包陷阱
这里有个 React 面试常考的坑:依赖数组写少了,函数闭包会"卡在"旧值上。 因为 useCallback 依赖不变就返回旧函数,旧函数闭包里捕获的自然是旧值。而 useCallback 依赖数组不存在的日子------每次渲染都是新函数、自然能拿到新值。所以加 useCallback 本质是在拿"闭包新鲜度"换"引用稳定",这个交易要看清。
3.4 完整的对照 demo
下面这个 demo 同时放两个功能一模一样的 memo 子组件,区别只在父组件传给它们的函数是怎么来的:
jsx
import { useState, memo, useCallback } from 'react'
const MemoWithInline = memo(({ name, onClick }) => {
console.log(' [内联函数版本] 重渲染了')
return (
<div>
<p>{name}</p>
<button onClick={onClick}>+1(内联函数)</button>
</div>
)
})
const MemoWithCallback = memo(({ name, onClick }) => {
console.log(' [useCallback版本] 重渲染了')
return (
<div>
<p>{name}</p>
<button onClick={onClick}>+1(useCallback)</button>
</div>
)
})
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('少林队')
// ❌ 每次 App 重渲染都新建函数
const inlineFn = () => setCount(c => c + 1)
// ✅ 依赖 [] 不变,引用永远稳定
const stableFn = useCallback(() => setCount(c => c + 1), [])
console.log('[App] 渲染了, count =', count)
return (
<div>
<button onClick={() => setName('emd')}>改变名字</button>
<button onClick={() => setCount(c => c + 1)}>父组件计数</button>
<p>count: {count}</p>
<MemoWithInline name={name} onClick={inlineFn} />
<MemoWithCallback name={name} onClick={stableFn} />
</div>
)
}
export default App
控制台验证:
- 点「父组件计数」 (count 变、name 不变):
[内联函数版本]打印了 ------inlineFn是新引用,memo 拦不住 ❌[useCallback版本]没打印 ------stableFn引用没变 +name没变,memo 生效 ✅
- 点「改变名字」(name 变):两个都打印------props 真的变了,正常
两个子组件接收的函数功能完全等价,一个因为引用不稳定被 memo 拦不住,一个因为引用稳定被 memo 拦住了。这就是 useCallback 的全部价值:它不是用来"快"的,是用来"让 memo 生效"的。
四、useMemo:缓存计算结果
4.1 它解决什么问题
有些计算很贵:大数组的过滤、排序、拼接,或者计算过程里有明显耗时。这类计算如果每次渲染都重跑一遍,纯属浪费------因为很多时候输入根本没变。
jsx
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('少林队')
// 100 万条数据,每次都重新算一遍,哪怕输入没变
const sorted = bigList
.filter(x => x.active)
.sort((a, b) => b.score - a.score)
return <div>{sorted.length}</div>
}
每次 App 重渲染(哪怕只是改个 name),这段过滤排序都重跑一遍。而 bigList 没变,算出来的 sorted 也不可能变。
4.2 用 useMemo 包一层
useMemo 的用法和 useCallback 几乎一样,区别只在于它缓存的是回调的返回值(计算结果),而不是回调本身:
jsx
import { useMemo } from 'react'
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('少林队')
const sorted = useMemo(() => {
console.log(' [useMemo] 重新计算中...')
return bigList
.filter(x => x.active)
.sort((a, b) => b.score - a.score)
}, [bigList]) // 依赖:只有 bigList 变了才算
return <div>{sorted.length}</div>
}
依赖数组 [bigList] 没变,就直接返回上一次算好的结果 ,回调一行都不跑。控制台里 [useMemo] 重新计算中... 只会在 bigList 变化时出现。
4.3 useMemo 与 useCallback 的关系
很多人卡在这两个身上,其实一句话就能戳破:
useCallback(fn, deps)就是useMemo(() => fn, deps)的语法糖。
验证一下:
jsx
// 这两行是等价的
const a = useCallback(() => doSomething(), [x])
const b = useMemo(() => () => doSomething(), [x]) // 回调返回一个函数
useCallback 缓存"函数",useMemo 缓存"回调的返回值"。当返回值恰好是个函数时,两者就是一回事。
所以记住这个对应关系:
useCallback(() => {...}, deps)→ 返回的是函数本身useMemo(() => {...}, deps)→ 返回的是花括号里那句代码的运算结果
口诀:useCallback 管函数,useMemo 管值。 值可能是数字、数组、对象,也可能刚好是个函数。
五、三兄弟一张表总结
React.memo |
useCallback |
useMemo |
|
|---|---|---|---|
| 形态 | 高阶组件(HOC) | Hook | Hook |
| 缓存对象 | 整个组件 | 一个函数(引用) | 一段计算结果(值) |
| 防的是 | 子组件被无谓地重新渲染 | 函数每次渲染都被新建 | 昂贵的计算每次渲染都被重跑 |
| 生效条件 | props 浅比较通过 | 依赖数组不变 | 依赖数组不变 |
| 什么时候失效 | props 变了(含函数引用变) | 依赖变了 | 依赖变了 |
| 典型搭档 | 配 useCallback 用 | 配合 memo 用 | 独自使用即可 |
一条主线串起来:
父组件重渲染
├─ memo 拦住「props 没变」的子组件 → 管组件
│ └─ 但函数 props 是引用类型,浅比较总失败
│ └─ useCallback 稳住函数引用 → 管函数
└─ useMemo 拦住「依赖没变」的昂贵计算 → 管值
三者是一条链 :useCallback 保住函数引用 → memo 才能靠浅比较拦住组件 → 两者都是为了少渲染;useMemo 独当一面,管的是少计算。
六、常见误区和最佳实践
6.1 误区一:把三兄弟当"必装件"
这是新手最容易犯的错------到处加 memo / useMemo,以为越多越快。
事实恰恰相反:
- 每个 memo 包装都会引入一次 props 浅比较的开销,组件渲染本身很轻的话,比较的开销可能比重渲染还大
- useMemo 会额外占用内存去存结果
- 依赖数组写错,会引入隐蔽的 bug(闭包过期),让代码更难维护
React 官方原话是:这些是优化手段,不是正确性要求。 项目先跑起来、功能正确,然后找到真正的性能瓶颈(用 React DevTools 的 Profiler 看哪块渲染耗时),再对症下药。
一句话:先让它正确,再让它快。
6.2 误区二:写不好依赖数组
useCallback / useMemo 的依赖数组是它们的命门:
jsx
const handle = useCallback(() => {
doSomething(count) // 函数用了 count
}, []) // ❌ 但依赖没写 count → 闭包过期,永远拿到旧 count
规则:函数(或计算)里用到的、可能变化的变量,必须全部写进依赖。 写漏了程序不会报错,只会悄悄产出错误结果------最难查的那种 bug。
好消息是,如果项目用了 ESLint 的 react-hooks 插件,它会在你写漏依赖时直接给出警告。
6.3 误区三:memo 的浅比较是万能的
memo 只做浅比较。你传一个对象:
jsx
const config = { theme: 'dark' }
<MemoChild config={config} /> // 每次渲染 config 都是新对象 → memo 失效
对象每次渲染都是新引用,浅比较必然不相等。这时要么用 useMemo 把对象也缓存住,要么接受这个子组件会重渲染的事实。
6.4 最佳实践清单
- 用 Profiler 找瓶颈,别用感觉------大多数 React 应用真正需要记忆化的地方不超过几个
memo配useCallback才完整------只有函数 props 引用稳定,memo 才有意义useMemo只给"真贵"的计算------过滤 10 条数据的数组,不值得- 依赖数组当成"变量使用清单"来写------用了什么就写什么
- 从子组件的角度想:这个子组件是不是"props 不常变、渲染很贵"?是才值得包 memo
小结
- React 的默认行为是"父组件重渲染,整棵子树全重跑",记忆化是用来"偷懒跳过无关渲染"的
memo:缓存组件,props 没变就跳过渲染------但它怕函数 propsuseCallback:缓存函数,依赖不变就返回同一引用------专治 memo 怕函数 props 的毛病useMemo:缓存计算结果,依赖不变就复用旧结果------管的是"少算"- 三兄弟是一条链:useCallback 稳定函数引用 → memo 拦住组件重渲染;useMemo 独立负责拦住重计算
- 都是优化不是必需:先用起来,卡了再优化;依赖数组写对,是这一切的前提
最后送你一句贯穿全文的心法:
memo 管组件,useCallback 管函数,useMemo 管值。 想用哪个,先问自己:我要防的,是哪一层重复?