React 性能优化三剑客:memo、useCallback 和 useMemo 的配合艺术
2026/8/20 2:58:32 网站建设 项目流程

本文基于一个真实踩坑记录展开,带你彻底理解“为什么明明包了 memo,子组件还是跟着父组件乱渲染”。

一个典型的性能浪费现场

先看你提供的代码,它非常直观地暴露了问题:

import { useState, memo } from 'react'; function RegularChild({ name }) { console.log('渲染了RegularChild组件'); return <h1>{name}</h1>; } const MemoChild = memo(({ name }) => { console.log('MemoChild 组件渲染了'); return <h1>Hello, {name}</h1>; }); function App() { const [count, setCount] = useState(0); const [name, setName] = useState('少林队'); return ( <> <button onClick={() => setCount(count + 1)}>点击计数{count}</button> <button onClick={() => setName('峨眉队')}>改变名字</button> <RegularChild name={name} /> <MemoChild name={name} /> </> ); }

运行一下你会发现:点击“点击计数”按钮,count变化了,name没变,但是RegularChildMemoChild都会重新渲染。只有当你再次点击“改变名字”,MemoChild才会再渲染一次(因为name确实变了),而RegularChild一直傻傻跟着父组件渲染。

这里就引出了核心思路:

React 的渲染是自上而下的“瀑布流”,父组件更新,默认会带着所有子组件一起更新。如果你不做任何干预,性能优化就无从谈起。

React.memo:组件级别的浅比较盾牌

memo的作用很简单:给你一个高阶组件,它会对传入的 props 进行浅比较,如果 props 没变,就跳过渲染,直接复用上一次的结果。

在你的代码中,MemoChild正是凭借memo,在count变化而name没变时,成功避免了无效渲染——这已经是一次胜利。

但是,故事到这里才刚刚开始。很多开发者会掉进下面这个陷阱:

当 props 里有函数或对象时,memo 直接“破防”

咱们稍微改一下代码,给MemoChild加一个onClick回调:

const MemoChild = memo(({ name, onUpdate }) => { console.log('MemoChild 渲染了'); return ( <> <h1>Hello, {name}</h1> <button onClick={onUpdate}>内部改名</button> </> ); }); function App() { const [count, setCount] = useState(0); const [name, setName] = useState('少林队'); // 父组件里定义了一个回调函数 const handleUpdate = () => { setName('内部更新的队名'); }; return ( <> <button onClick={() => setCount(count + 1)}>点击计数{count}</button> <MemoChild name={name} onUpdate={handleUpdate} /> </> ); }

猜一下:点击“点击计数”按钮,MemoChild会渲染吗?

答案是:会!尽管name没变,MemoChild依然重渲染了,你的memo盾牌好像被什么东西击穿了。

原因很简单:每次App组件渲染,里面的handleUpdate都是一个全新的函数引用。对于MemoChild来说,onUpdate这个 prop 的引用变了,浅比较就判定为“props 变化了”,于是照常渲染。

memo 很忠诚,但它只看引用——函数、对象、数组,只要引用不同,就会触发渲染。这才是性能优化的暗礁区。

为什么函数每次都是“全新的引用”?

你可能会有疑问:明明函数内容一模一样,为什么每次都会被当成不同的东西?

这要从 React 函数组件的本质说起。函数组件就是一个普通的 JavaScript 函数,每次状态更新,React 都会重新调用这个函数,生成一份全新的“快照”。你写的handleUpdate是在函数体内部用const声明的:

const handleUpdate = () => { setName('内部更新的队名'); };

这行代码每执行一次,就会在内存里新创建一个函数对象,并给handleUpdate分配一个新的地址。即便两个函数的内容完全一样,它们在内存中的位置也不同。就像一个高级私房菜馆,你每次都拿一张临时手写的点菜单,老板看的是纸本身,不是上面的内容——纸不一样,就按新单子处理。

对于memo来说,它比较 props 时用的是浅比较,基本类型比“值”,引用类型比“地址”。onUpdate是一个函数,属于引用类型,所以只要地址变了,memo就会认为 prop 更新了,于是老老实实渲染子组件。

你可以直接在代码里加上一行验证:

console.log('handleUpdate 引用:', handleUpdate);

每点一次按钮,控制台显示的“地址标识”都会发生变化。

useCallback:给函数一个“固定住”的引用

要解决这个问题,我们就需要让handleUpdate在多次渲染之间保持同一个地址,除非它真正依赖的某个值变化了。useCallback就是干这个的:

const handleUpdate = useCallback(() => { setName('内部更新的队名'); }, []);

useCallback会在依赖项(这里是空数组[])不变的情况下,直接返回上一次缓存的函数引用。现在,无论你点击“点击计数”多少次,handleUpdate指向的都是同一块内存地址,memo浅比较通过,子组件也就不会被带着跑了。

一句话总结
当你需要把函数传给用memo包裹的子组件时,请用useCallback包裹它,否则函数引用的变化会让memo形同虚设。

每次渲染都产生新函数,旧地址去哪了?会不会内存泄漏?

你可能会担心:既然每次渲染都要新建函数,那旧函数不会被垃圾清理掉吗?会不会内存越用越多?

放心,旧函数会被 JavaScript 的垃圾回收机制自动清理。只要上一轮渲染产生的handleUpdate没有被其他地方“抓住”(比如传入全局变量、未清理的定时器或者闭包泄漏),它就变成了“不可达对象”,垃圾回收器会在合适的时机回收它占用的内存。

useCallback的主要目的并不是节省内存(事实上它反而需要多占用一点点缓存空间),而是保持引用稳定,从而避开memo的无效渲染。这点开销在避免昂贵渲染的收益面前,完全值得。

React 不会让旧函数堆积成山。它只是在每次渲染时短暂存在,随后被清扫,你真正要关心的是“引用变化”带来的重渲染,而不是内存泄漏。

当然,如果你在useEffect里错误地添加了未清理的监听器,且监听了某个内联函数,那确实有可能造成内存泄漏,但那是另一个需要避免的坑了,和这里的场景无关。

useMemo:不只是缓存函数引用,还能缓存任何值

useMemouseCallback原理相似,但适用范围更广:

  • useCallback缓存函数本身
  • useMemo缓存函数执行的结果(可以是任意值)

最常见的一个场景:昂贵的计算 + 子组件传递

const MemoList = memo(({ data }) => { console.log('MemoList 渲染了'); return <div>{data.join(',')}</div>; }); function App() { const [count, setCount] = useState(0); const [items] = useState([1, 2, 3, 4, 5]); // 假设这是一个复杂过滤/排序逻辑 const processedData = items.filter(item => item > 2).sort((a, b) => b - a); return ( <> <button onClick={() => setCount(count + 1)}>计数{count}</button> <MemoList data={processedData} /> </> ); }

点击按钮,MemoList依然重渲染。因为processedData在每次渲染时都是一个新数组引用。哪怕内容完全一样,浅比较也会认为它变了。

这时候就该useMemo出场了:

const processedData = useMemo(() => { return items.filter(item => item > 2).sort((a, b) => b - a); }, [items]); // items 不变,结果引用就不变

现在processedData的引用在items不变时会保持稳定,memo(子组件)也就能正常工作。

另附一个经典踩坑:有很多人直接用useMemo缓存 JSX,比如:

const child = useMemo(() => <MemoChild name={name} />, [name]); return <>{child}</>;

这确实能避免子组件不必要的渲染,但代码可读性差,且破坏了 React 的 diff 逻辑。
原则上更推荐用memo包裹子组件 +useCallback/useMemo稳定 props,而不是用useMemo直接把 JSX 缓存掉。

它们之间的配合关系

  1. memo是子组件侧的防御机制:props 不变,我就不渲染。
  2. useCallbackuseMemo是父组件侧的稳定工具:确保传给子组件的 props(函数、对象、数组)引用不变。
  3. 三者配合,才能真正做到“不相关状态更新时,子组件纹丝不动”。

再补一句很多人忽视的事实:useCallbackuseMemo本身也有开销。如果你传给的只是一个普通<div>,或者根本没有被memo包裹的子组件,那反而是一种浪费。它们只在结合memo使用,或作为其他 Hook 的依赖项时,才真正发挥威力。

一个“可落地”的优化步骤 checklist

如果你在项目中遇到某个页面渲染卡顿,或者子组件无缘无故跟着父亲乱抖,可以按下面的步骤排查:

  1. 确认渲染范围
    console.log或者 React DevTools 的 Highlight 功能,看哪些组件在不必渲染时也渲染了。
  2. 对纯展示、且依赖稳定的子组件包上memo
    注意:只对 props 基本稳定的组件做,别一股脑全包。
  3. 检查传给子组件的引用类型 props
    如果有函数、对象、数组,看看它们在父组件中是否每次渲染都重新创建。
  4. useCallback稳定函数,用useMemo稳定对象/数组/计算结果
    依赖项要写准确,避免闭包陷阱(比如忘了依赖某个 state)。
  5. 确认优化有效
    再用 console 或 DevTools profiler 验证一下,子组件是否真的跳过了渲染。

最后说一个容易被忽略的思维升级

很多教程会说useCallbackuseMemo是用来“缓存函数”和“缓存计算结果”的,但它们真正的定位是引用稳定性。你的目标是让那些被memo保护的子组件收到的 props 引用不要无意义地变化,从而避免重渲染。

写性能优化的代码,本质上是在管理“变化” —— 你告诉 React 什么真正变化了,什么只是看起来变化了。memo 是盾,useCallback/useMemo 是矛,你要把矛头准确扎向“假变化”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询