1. 核心痛点:组件渲染的"连锁反应"
在 React 应用开发中,性能优化往往不是从项目开始就存在的,而是在业务复杂度上升后被迫面对的难题。最典型的场景莫过于:父组件状态更新导致的子组件无效渲染。
1.1 问题复现
想象一个这样的场景:父组件 App 维护了两个状态,一个是控制计数的 count,一个是控制用户名的 name。页面上有两个子组件,分别依赖这两个状态,或者只依赖其中一个。
当我们点击按钮更新 count 时,React 的默认行为是:只要父组件重新渲染,其内部所有的子组件无论 Props 是否变化,都会无条件地重新渲染。
这种"一人得道,鸡犬升天"的渲染机制,在组件层级深、计算逻辑重时,会带来严重的性能浪费。
1.2 基础解决方案:React.memo
为了解决这个问题,React 提供了 memo(Memorize,请记住我)高阶组件。它的核心逻辑是浅比较(Shallow Compare) :如果传入组件的 Props 没有发生变化,则直接复用上一次渲染的结果,跳过渲染过程。
让我们通过一段代码来直观感受 RegularChild(普通组件)与 MemoChild(记忆化组件)的区别:
javascript
import { useState, memo } from 'react'
// 普通子组件:每次父组件渲染,它都会跟着渲染
function RegularChild(props) {
console.log('RegularChild 渲染')
return (
<>
<div>{props.name}</div>
</>
)
}
// 记忆化子组件:只有 props.name 变化时才会渲染
const MemoChild = memo((props) => {
console.log('MemoChild 渲染')
return (
<>
<div>{props.name}</div>
</>
)
})
function App() {
console.log('App 渲染')
const [count, setCount] = useState(0)
const [name, setName] = useState('陈sir')
const handleClick = () => {
setCount(count + 1)
}
return (
<>
<button onClick={handleClick}>点击计数{count}</button>
<button onClick={() => setName("王sir")}>改变名字</button>
{/* 场景 A:更新 count,name 没变 */}
{/* RegularChild 会渲染(因为它无脑渲染) */}
{/* MemoChild 不会渲染(因为 name 没变) */}
<RegularChild name={name} />
<MemoChild name={name} />
</>
)
}
export default App
在上述代码中,当你点击"点击计数"按钮时,控制台只会打印 App 渲染 和 RegularChild 渲染,而 MemoChild 保持了沉默。这就是 memo 的威力。
然而,在实际工程中,事情往往没有这么简单。很多时候你会发现,明明 Props 看起来没变,memo 却失效了。这就引出了我们今天的核心主角------引用类型带来的陷阱,以及 useCallback 与 useMemo 的登场。
2. 进阶陷阱:为什么 memo 会失效?
在实际生产环境中,我们很少像上面那样只传递简单的字符串或数字。更多时候,我们会传递函数 或者对象。
2.1 引用类型的"身份危机"
JavaScript 中的对象和函数是引用类型。对于引用类型,比较的是内存地址,而不是内容。
请看下面的反例:
javascript
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('陈sir');
// 陷阱:每次 App 渲染,handleClick 都会被重新创建
// 它是一个全新的函数,内存地址变了!
const handleClick = () => {
setCount(count + 1);
}
return (
<>
<button onClick={handleClick}>Count: {count}</button>
{/*
即使 MemoChild 内部没有使用 handleClick,
但如果你把它传下去了,或者父组件重渲染导致
这里重新执行,对于依赖函数的子组件来说,
Props 变了(因为函数地址变了),memo 失效。
*/}
<MemoChild name={name} onClick={handleClick} />
</>
)
}
当 count 变化导致 App 重渲染时,handleClick 函数被重新定义。对于接收该函数的子组件来说,prevProps.onClick !== nextProps.onClick 成立,因此 memo 判定 Props 发生变化,子组件被迫重新渲染。
这就是我们需要 useCallback 和 useMemo 的根本原因:稳定引用。
3. 核心武器:useCallback & useMemo
这两个 Hooks 是为了配合 memo 而生的性能优化利器,它们的本质都是缓存(Cache) 。
3.1 useCallback:缓存函数定义
useCallback 的作用是返回一个记忆化(Memoized) 的回调函数。只有当依赖项发生变化时,它才会生成一个新的函数实例。
语法:
const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);
实战解析:
我们将上面的反例修正:
javascript
import { useState, memo, useCallback } from 'react'
const MemoChild = memo(({ name, onClick }) => {
console.log('MemoChild 渲染')
return (
<div onClick={onClick}>{name}</div>
)
})
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('陈sir')
// ✅ 优化:只有 count 变化时,才生成新函数
// 当 name 变化导致 App 重渲染时,handleClick 保持同一个引用
const handleClick = useCallback(() => {
setCount(prev => prev + 1)
}, [count]) // 注意:如果 setCount 使用函数式更新 prev => prev + 1,依赖数组可以为空 []
const handleNameChange = useCallback(() => {
setName("王sir")
}, [])
return (
<>
<button onClick={handleClick}>点击计数{count}</button>
<button onClick={handleNameChange}>改变名字</button>
{/*
现在,当点击"改变名字"时:
1. App 重渲染
2. handleClick 引用不变(因为 count 没变)
3. MemoChild 的 props (name, onClick) 中 onClick 没变,但 name 变了 -> 渲染
如果有一个 ChildC 只依赖 onClick 不依赖 name:
1. App 重渲染
2. handleClick 引用不变
3. ChildC 的 props 完全没变 -> **不渲染(优化成功)**
*/}
<MemoChild name={name} onClick={handleClick} />
</>
)
}
底层原理:
React 内部维护了一个链表(Hook List)。useCallback 在首次渲染时将函数存入链表节点。在后续渲染中,React 对比依赖数组(Dependency Array)。如果依赖项浅比较相等,直接返回链表节点中存储的旧函数;如果不等,则更新节点中的函数并返回新函数。
3.2 useMemo:缓存计算结果
如果说 useCallback(fn, deps) 等价于 useMemo(() => fn, deps),那么 useMemo 则是更通用的缓存方案。它不仅用于缓存函数,更常用于缓存昂贵的计算结果 或缓存对象/数组引用。
场景:
假设我们需要根据 list 过滤出一个 expensiveList,这个过滤操作非常耗时。
javascript
import { useState, useMemo } from 'react'
function ExpensiveComponent({ list, filterKey }) {
// ❌ 错误做法:每次渲染都执行耗时计算
// const filteredList = list.filter(item => item.key === filterKey);
// ✅ 正确做法:只有 list 或 filterKey 变化时才重新计算
const filteredList = useMemo(() => {
console.log('正在进行昂贵的计算...');
return list.filter(item => item.key === filterKey);
}, [list, filterKey]);
return (
<ul>
{filteredList.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
)
}
实战解析:
- 避免重复计算:如上例,将 O(N) 甚至 O(N^2) 的计算结果缓存起来。
- 保持引用稳定 :如果你需要把一个对象传给子组件,必须用
useMemo包裹,否则子组件的memo会因为对象引用变化而失效。
ini
// 传递给子组件的 style 对象必须缓存
const style = useMemo(() => ({ color: 'red', fontSize: '12px' }), []);
<Child style={style} />
4. 深度原理:React 是如何"记住"的?
要真正掌握这两个 Hooks,必须理解 React Fiber 架构下的工作机制。
4.1 Hook 的链表结构
在 Function Component 中,Hooks 的执行顺序必须是固定的(这也是为什么不能在循环、条件判断中写 Hooks 的原因)。
React 在每个 Fiber 节点上维护了一个 memoizedState 指针,指向一个单向链表:
vbscript
Fiber Node
|
v
Hook 1 (useState) --> Hook 2 (useEffect) --> Hook 3 (useCallback) --> null
| | |
state: 0 cleanup: ... memoizedValue: fn_ref
next: Hook 2 next: Hook 3 next: null
deps: null deps: [...] deps: [count]
4.2 更新时的比对逻辑
当组件更新(Re-render)时,React 会创建一个新的 WorkInProgress Fiber,并复用当前树的 Hook 链表结构。
对于 useCallback(fn, deps):
- React 找到链表中对应的 Hook 节点。
- 取出上一次存储的
deps(旧依赖)。 - 执行
areHookInputsEqual(nextDeps, prevDeps)。这是一个简单的循环遍历,使用Object.is进行浅比较。 - 如果相等 :直接返回
hook.memoizedState(即上一次的函数引用),忽略传入的新fn。 - 如果不等 :将新的
fn和nextDeps更新到节点中,并返回新的fn。
这就是为什么我们说 useCallback 并不是"免费"的。它虽然避免了子组件渲染,但自身也有比对依赖的开销。如果依赖项极其复杂或者函数本身创建成本极低,滥用 useCallback 反而可能得不偿失。
5. 最佳实践与避坑指南
在实际的大型项目开发中,遵循以下原则可以让你的性能优化事半功倍。
5.1 什么时候使用?
- 传递给
memo组件的 Props :这是最标准的使用场景。如果子组件包裹了memo,父组件传递的函数和对象必须使用 Hooks 缓存。 - 作为其他 Hooks 的依赖 :例如
useEffect(() => { ... }, [callback])。如果callback没有用useCallback包裹,会导致 Effect 每次渲染都执行,引发死循环或性能问题。 - 昂贵的计算 :毫无疑问,使用
useMemo。
5.2 什么时候不要用?
- 原生 DOM 元素 :
<div onClick={handleClick}>。DOM 元素的绑定开销很小,没必要缓存函数。 - 未包裹
memo的子组件:如果子组件每次都要渲染,你在父组件缓存了函数传给它,除了增加父组件的内存占用和比对开销外,没有任何收益。 - 依赖项过多且频繁变化 :如果
useCallback的依赖项几乎每次都变,那它就退化成了普通的函数定义,还额外增加了 React 内部的比对逻辑。
5.3 常见误区
-
误区一:
useCallback能防止函数内部引用的变量过期?- 真相 :不能。
useCallback只是锁住了函数的引用 。如果依赖数组没写全,函数内部确实可能拿到旧闭包的值(Stale Closure)。务必严格遵守 ESLint 的exhaustive-deps规则。
- 真相 :不能。
-
误区二:把所有函数都包一层
useCallback就是优化?- 真相:这是过度优化。代码的可读性也是性能的一部分。只有当 profiler 检测到瓶颈,或者明确知道子组件渲染代价高昂时,才引入这些 Hooks。
6. 总结
React 的性能优化是一个系统工程,memo、useCallback 和 useMemo 是其中精密的齿轮。
memo是盾牌,阻挡不必要的渲染传播。useCallback和useMemo是磨刀石,确保传递给盾牌的武器(Props)足够稳定,不会因为引用的抖动而击穿防御。
理解它们的底层原理------基于 Fiber 链表的依赖比对机制,能帮助我们在面对复杂的业务场景时,不再盲目猜测,而是能够精准地定位性能损耗点,写出既优雅又高效的 React 代码。