React 记忆化三兄弟:memo、useMemo、useCallback 到底在缓存什么

30 阅读11分钟

React 性能优化三大件,本文用一条主线 + 一份可运行 demo,彻底讲清三者的区别与配合。

前言

用 React 写页面,写着写着总会遇到一个问题:明明只是改了一个数字,怎么页面上的列表、图表全跟着重渲染了一遍?

React.memo、useMemo、useCallback 就是专门回答这个问题的三兄弟。它们名字像、长得像,作用却完全不同——有人管组件、有人管函数、有人管值。很多人学完就忘,是因为把三者当成了三个独立知识点去背,而它们其实是一条线上的三个环节。

这篇文章从 React 的渲染机制讲起,用一份能直接跑起来的 demo,把三兄弟一次讲透。

一、先理解 React 的渲染机制:为什么要"记忆化"

要理解三兄弟,先得明白 React 是怎么"重渲染"的。

React 的渲染,本质就是"重新执行组件函数"。

function App() {
  const [count, setCount] = useState(0)        // ① 状态
  return <Child count={count} />               // ② 把状态传给子组件
}

当你点按钮让 count 从 0 变成 1 时:

  1. React 发现 App 的 state 变了
  2. React 重新执行 App 这个函数
  3. App 函数体里的 <Child /> 被再次调用,子组件跟着重渲染
  4. 子组件里的孙组件,再跟着重渲染……

只要父组件重渲染,整棵子树默认全部重跑一遍。 这是 React 的默认行为——它足够简单、足够安全,代价是:很多其实不用变的组件,也被白白重渲染了。

这就是"记忆化"(Memoization)的用武之地。记忆化的思想来自缓存:把"输入"和"结果"记录下来,下次输入没变,就直接返回记录的结果,不重新算。

一句话理解记忆化:同一个输入,我不想再执行一遍。

但"输入"和"结果"在不同层面有不同的形态,于是有了三个工具,各管一层:

工具它记忆的"结果"是它担心的"输入"是
memo整个组件渲染出来的 UI组件的 props
useCallback一个函数函数闭包里的依赖
useMemo一段计算结果计算用到的依赖

下面一个一个拆。


二、memo:缓存组件

2.1 它解决什么问题

看这个例子。父组件有两个状态:一个 count,一个 name。

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):它接收一个组件,返回一个"会偷懒"的新组件。

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:

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 是基本类型"时最好使。一旦你给它传了一个函数:

<MemoChild name={name} onClick={() => setName('emd')} />

这里 onClick 是内联写的,每次渲染都是一个全新的函数对象,=== 比较永远不相等——memo 立刻失效。这就是第三个 hook 出场的契机。

记住这句话:memo 怕函数 props。 而函数 props 是 React 里最普遍的写法,所以单用 memo 常常不够。


三、useCallback:缓存函数

3.1 它解决什么问题

上一节留了个尾巴:memo 遇到函数 props 就失效。为什么会失效?因为函数是引用类型:

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 的作用:让函数在依赖不变时,引用保持稳定。

import { useCallback } from 'react'

function App() {
  const [count, setCount] = useState(0)

  // 依赖 [] 永远不变 → 每次渲染拿到的是同一个函数引用
  const handleClick = useCallback(() => setCount(c => c + 1), [])

  return <MemoChild onClick={handleClick} />
}

它做的事是:

  1. 第一次渲染:创建函数,记住它
  2. 之后的每次渲染:检查依赖数组 []
    • 依赖没变 → 原样返回上次记住的那个函数(引用相同)
    • 依赖变了 → 重新创建函数

于是 handleClick 引用稳定了,传给 MemoChild 的 onClick 浅比较能通过,memo 终于能拦住这一次重渲染。

3.3 依赖数组是什么

useCallback(fn, deps) 的第二个参数 deps 是依赖数组:凡是函数内部用到、且可能变化的值,都要列进来。

// 函数内部用了 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 子组件,区别只在父组件传给它们的函数是怎么来的:

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 它解决什么问题

有些计算很贵:大数组的过滤、排序、拼接,或者计算过程里有明显耗时。这类计算如果每次渲染都重跑一遍,纯属浪费——因为很多时候输入根本没变。

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 几乎一样,区别只在于它缓存的是回调的返回值(计算结果),而不是回调本身:

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) 的语法糖。

验证一下:

// 这两行是等价的
const a = useCallback(() => doSomething(), [x])
const b = useMemo(() => () => doSomething(), [x])   // 回调返回一个函数

useCallback 缓存"函数",useMemo 缓存"回调的返回值"。当返回值恰好是个函数时,两者就是一回事。

所以记住这个对应关系:

  • useCallback(() => {...}, deps) → 返回的是函数本身
  • useMemo(() => {...}, deps) → 返回的是花括号里那句代码的运算结果

口诀:useCallback 管函数,useMemo 管值。 值可能是数字、数组、对象,也可能刚好是个函数。


五、三兄弟一张表总结

React.memouseCallbackuseMemo
形态高阶组件(HOC)HookHook
缓存对象整个组件一个函数(引用)一段计算结果(值)
防的是子组件被无谓地重新渲染函数每次渲染都被新建昂贵的计算每次渲染都被重跑
生效条件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 的依赖数组是它们的命门:

const handle = useCallback(() => {
  doSomething(count)   // 函数用了 count
}, [])                 // ❌ 但依赖没写 count → 闭包过期,永远拿到旧 count

规则:函数(或计算)里用到的、可能变化的变量,必须全部写进依赖。 写漏了程序不会报错,只会悄悄产出错误结果——最难查的那种 bug。

好消息是,如果项目用了 ESLint 的 react-hooks 插件,它会在你写漏依赖时直接给出警告。

6.3 误区三:memo 的浅比较是万能的

memo 只做浅比较。你传一个对象:

const config = { theme: 'dark' }
<MemoChild config={config} />   // 每次渲染 config 都是新对象 → memo 失效

对象每次渲染都是新引用,浅比较必然不相等。这时要么用 useMemo 把对象也缓存住,要么接受这个子组件会重渲染的事实。

6.4 最佳实践清单

  1. 用 Profiler 找瓶颈,别用感觉——大多数 React 应用真正需要记忆化的地方不超过几个
  2. memo 配 useCallback 才完整——只有函数 props 引用稳定,memo 才有意义
  3. useMemo 只给"真贵"的计算——过滤 10 条数据的数组,不值得
  4. 依赖数组当成"变量使用清单"来写——用了什么就写什么
  5. 从子组件的角度想:这个子组件是不是"props 不常变、渲染很贵"?是才值得包 memo

小结

  • React 的默认行为是"父组件重渲染,整棵子树全重跑",记忆化是用来"偷懒跳过无关渲染"的
  • memo:缓存组件,props 没变就跳过渲染——但它怕函数 props
  • useCallback:缓存函数,依赖不变就返回同一引用——专治 memo 怕函数 props 的毛病
  • useMemo:缓存计算结果,依赖不变就复用旧结果——管的是"少算"
  • 三兄弟是一条链:useCallback 稳定函数引用 → memo 拦住组件重渲染;useMemo 独立负责拦住重计算
  • 都是优化不是必需:先用起来,卡了再优化;依赖数组写对,是这一切的前提

最后送你一句贯穿全文的心法:

memo 管组件,useCallback 管函数,useMemo 管值。 想用哪个,先问自己:我要防的,是哪一层重复?