☰
React性能优化三件套:memo、useMemo与useCallback实战指南
2026/10/24 2:51:34 网站建设 项目流程

作为一个在业务系统里摸爬滚打多年的前端工程师,我几乎每天都要跟 React 性能问题打交道。尤其是遇到那种“一个输入框带动整个页面卡顿”的场景,排查到最后十有七八都落在memo、useMemo 和 useCallback这三个 API 上。这三个 API 是 React 性能优化里最常被提起的三件套,但同时也是被误用得最离谱的三件套。

这篇文章我不打算讲教科书式的概念定义,而是从一次真实的页面卡顿排查出发,带你彻底搞清楚这三个工具各自解决什么问题、它们的边界在哪、什么时候用它们反而是帮倒忙。无论你是刚写完一个组件想让它更快一点的新手,还是已经在项目里被各种缓存问题折磨了很久的中级开发者,我都把这些年实际跑过、测过、踩过坑的经验直接抖出来。看完这篇文章,你不仅能正确使用这三个 API,还能建立起一套属于自己的性能优化判断框架。

1. 追根溯源:React 渲染流程里的无用开销到底从哪里来

1.1 从 state 到 DOM:一次完整渲染事件里发生了什么

开始动手优化之前,必须先弄明白一个问题:React 组件“重新渲染”到底干了什么活。很多人误以为只要组件函数执行了,浏览器就会操作一次真实 DOM,实际上远没有这么简单。

当一个组件因为 state 或 props 变化被 React 标记为需要更新时,会经历两个阶段。第一个阶段是render 阶段,React 会重新执行你的组件函数,从头到尾把组件内部的 JSX 编译成新的 React Element 树,然后跟之前的组件树做一次 diff,找出到底哪些地方变了。第二个阶段是commit 阶段,React 将 diff 出来的差异提交到真实 DOM 上。

关键点来了:如果一个组件函数本身没有产生任何区别,也就是说这次执行生成的 Element 树和上次完全一致,React 在 diff 时会认为“没啥要改的”,那么 commit 阶段就不会产生任何 DOM 操作。但不管最终 DOM 动不动,这个组件函数本身是实打实又重新执行了一遍的。

简单类比一下就懂了:你可以把组件函数想象成一份菜谱,React 每次渲染都像厨师重新读一遍菜谱、重新把食材按菜谱摆一遍盘。最终这道菜端出来可能跟上一盘一模一样,但配菜、摆盘、检查的工序一样都没省。如果一个组件内部有海量的列表渲染、复杂的日期计算、大量的 JSX 元素拼接工作,那每一次“重新读菜谱”都是实打实的 CPU 开支。

所以性能优化的核心指标不是“你的 DOM 有没有被更新”,而是“你的组件函数有没有被无谓地执行”。搞清楚这个概念之后,很多性能问题就变得非常清晰了。

1.2 无用渲染的典型生产场景:父组件动一下,整棵子树跟着抖

理解了 React 的机制,我们再来看一个极其常见的业务场景。假设你有一个待办事项页面,顶部是输入框,下面是一长串待办列表,列表里的每个条目都有若干子元素和计算逻辑,渲染成本不低。

const App = () => { const [inputValue, setInputValue] = useState(''); const [todos, setTodos] = useState(initialTodos); return ( <div> <SearchInput value={inputValue} onChange={setInputValue} /> <TodoList todos={todos} /> </div> ); };

这段代码看起来很正常,但实际运行起来会面临一个问题:每当用户在输入框里敲下一个字符,inputValue就会变化,导致App重新渲染。而App重新渲染意味着它的整个子组件树——包括SearchInput、TodoList以及下面所有待办条目——全部跟着重新执行一遍。

这合理吗?用户只是在输入框里打了个字,待办列表的todos数据根本没变,列表对应的 DOM 也不需要做任何更新。但 React 默认并不认识这种“没必要”的情况,它只知道父组件要重新执行,所以子组件也全都重新跑一遍。组件树越深、列表越长,这种无谓的开销就越明显。

这就是 React 给所有开发者埋下的一颗性能地雷。要拆掉它,就需要用到今天的主角之一memo,以及围绕它工作的useMemo和useCallback。

2. 三件套深度拆解:memo、useMemo、useCallback 各自管什么事情

2.1 memo:给组件装上一道 props 比较闸门

memo是三个 API 中最基础的一个,它的工作原理非常朴素:用一层浅比较来包裹组件,如果父组件重新渲染时传给这个组件的 props 没有发生变化,就跳过这个组件的重新渲染,直接复用上一次的渲染结果。

import { memo } from 'react'; const TodoList = memo(function TodoList({ todos }) { console.log('TodoList render'); return ( <ul> {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} /> ))} </ul> ); });

我们把上文的TodoList用memo包一层之后,再回到那个输入框场景。用户敲击键盘时,App重新渲染,TodoList的todosprops 没有变,memo的浅比较发现前后值相同,于是直接跳过了TodoList的重新渲染,整个待办列表子树都得到了豁免。

这个浅比较默认使用Object.is进行逐字段对比。对基础类型来说很直观——数字变了就是变了,没变就是没变。但对引用类型来说有个隐藏坑:如果父组件每次渲染都在 props 里传入了一个全新的对象引用,memo 就完全失效了。因为哪怕对象内容一模一样,引用不同,Object.is比较结果依然是false。

// 这个写法会让 memo 形同虚设 const App = () => { const [count, setCount] = useState(0); return <TodoList todos={[{ id: 1, text: 'A' }]} />; };

每次App重新渲染,都会在 JSX 里构造一个全新的todos数组,传给TodoList时引用已经变了,memo浅比较发现 props 变了,于是老老实实重新渲染。这正好引出了一个关键认知:memo 保住的只是“不该变就不会变”的部分,对于每次都是全新引用的 props,它无能为力。要解决引用稳定问题,必须用到useMemo。

2.2 useMemo:给计算值和高开销函数安一个稳定的家

useMemo从字面上看是“记忆计算值”,但我在实际项目里感受到它的真正职责其实是两层。第一层是避免重复执行高开销计算。比如一个组件里有一段非常耗时的数组过滤、排序、聚合逻辑。

const stats = useMemo(() => { return todos .filter(t => !t.completed) .reduce((acc, todo) => acc + todo.weight, 0); }, [todos]);

这段计算的代价可能很大,如果组件因为其他毫不相关的 state(比如弹窗开关、某个 hover 状态)频繁重新渲染,每次都要重新算一遍,就是巨大的浪费。用useMemo包裹后,只有当todos引用变化时才会重新计算。

第二层职责更加隐蔽但实践价值极高:稳定 props 的引用身份,让 memo 能正常工作。想一下,你在父组件里定义了一个对象或数组,每次父组件渲染它都是新引用。这个值如果传给一个memo包裹的子组件,子组件会因为引用变化而依然重新渲染。这时候你就可以用useMemo把对象缓存起来。

const config = useMemo(() => ({ theme, columns }), [theme, columns]);

只要theme和columns不变,config的引用就是同一个,传给memo子组件时浅比较可以通过,从而真正起到作用。这段实践让我意识到一个重要的心法:useMemo 的价值百分之七十在于帮 memo 稳定引用,只有百分之三十在于真的减少计算量。如果你理解不了这一点,很容易把 useMemo 当成一个“计算加速器”乱用。

2.3 useCallback:让函数引用在一波波渲染中保持不变

如果对象需要用useMemo来稳定引用,那函数呢?答案是useCallback。毕竟函数在 React 里也是一种引用类型,每次组件重新渲染,函数都会被重新创建。

const App = () => { const handleDelete = useCallback((id) => { setTodos(prev => prev.filter(t => t.id !== id)); }, []); return <TodoList todos={todos} onDelete={handleDelete} />; };

如果没有useCallback,handleDelete在每次App渲染时都会是新的函数引用。它传给TodoList作为 props 时,即使TodoList包了memo,浅比较依然会发现onDelete变了,从而打破 memo 的跳过逻辑。

useCallback还有一个非常实际的应用场景:与useEffect配合。当一个函数被用于useEffect的依赖数组时,如果每次渲染都会生成新函数引用,Effect 就会在每次渲染后都执行。可能造成不必要的接口请求、事件绑定或大量 re-fetch。用useCallback稳定函数引用后,effect 只会在依赖真正变化时触发。

从实现机制上讲,useCallback(fn, deps)就是useMemo(() => fn, deps)的语法糖。它本质上是在帮你把“函数本身”当作一个值来缓存。所以你会发现,它是三个 API 里最纯粹的“引用稳定器”。

2.4 三件套关系速查表

为了帮你压缩信息量,我把三者差异整理成一张对照表,实际写代码前可以拿出来扫一眼。

对比维度memouseMemouseCallback
使用位置包裹整个组件函数组件内部函数组件内部
缓存对象组件的渲染结果任意计算结果或对象引用函数引用
依赖参数无内建依赖,可传自定义比较函数依赖数组依赖数组
核心作用跳过 props 未变时的子组件重渲染缓存高开销计算,稳定值引用稳定函数引用
主要配合对象配合 useMemo/useCallback 稳定引用配合 memo 和 useEffect 依赖配合 memo 和 useEffect 依赖
误用的代价比较函数复杂后维护成本高依赖写错导致脏数据或缓存永不失效闭包捕获过时变量的风险

这张表其实揭示了一个很有意思的结论:三件套并不是三个平行的工具,而是层层配合的关系。memo是闸门,useMemo和useCallback是稳定水质的两道净化器。缺少后两者,闸门基本就是摆设。

3. 实操作业:一步步把“拖沓”的老页面改出性能

3.1 一个可被复现的性能现场

前两年我在做一个数据可视化后台项目时,遇到过一个非常典型的页面。页面左侧是一个筛选表单,右侧是一张按筛选条件渲染的报表。报表组件渲染成本极高:内部有上千行数据要分组、排序、做多级表头拼接。

刚接手时的代码大概是这样的:

const Dashboard = () => { const [keyword, setKeyword] = useState(''); const [filters, setFilters] = useState({}); const [rawData, setRawData] = useState([]); // 每次渲染都重新执行全量计算 const reportRows = buildReportRows(rawData, filters); const handleFilterChange = (key, value) => { setFilters(prev => ({ ...prev, [key]: value })); }; return ( <div> <SearchInput value={keyword} onChange={setKeyword} /> <FilterPanel filters={filters} onChange={handleFilterChange} /> <ReportTable rows={reportRows} /> </div> ); };

用户只要在搜索框里敲一个字,keyword一变,Dashboard重新渲染,buildReportRows这个重计算就要从头跑一遍,然后ReportTable也跟着重新渲染。页面在数据量稍大时能明显感觉到打字掉帧。

我当时在浏览器 Performance 面板里录了一段操作,发现一次按键就要触发接近 400ms 的主线程占用。整个页面在输入时给人一种“粘手指”的感觉,非常难受。

3.2 第一次优化:用 useMemo 隔离重计算

这个场景里最痛的点就是buildReportRows被无谓地重新执行。它的依赖非常明确:rawData和filters。跟keyword没有任何关系,但Dashboard每次都全量执行它。

我的第一刀就是给这段计算加上useMemo:

const reportRows = useMemo( () => buildReportRows(rawData, filters), [rawData, filters] );

改动就这么一行。敲击键盘触发keyword变化时,rawData和filters都没变,useMemo直接返回缓存的计算结果,几百毫秒的重计算就被拦下来了。

但注意,这一刀只解决了“重计算”的问题,还没有解决“组件树重渲染”的问题。Dashboard本身还是会因为keyword变化而重新执行,ReportTable作为子组件也还是会重新渲染。只是现在重新渲染过程中不再执行那个重计算了,组件函数本身的执行负担小了很多。

如果ReportTable内部的 JSX 结构庞大、子组件众多,重新渲染的开销依然存在。下一步就是让它更彻底一点。

3.3 第二次优化:memo 缓存 ReportTable,useCallback 稳定回调函数

我先用memo把ReportTable包起来。

const ReportTable = memo(function ReportTable({ rows, onRowClick }) { // 大量渲染逻辑 });

现在Dashboard因为keyword重新渲染时,ReportTable的 props 只要引用不变,它就会被跳过。但问题来了:rows经过useMemo包裹后引用是稳定的,可onRowClick如果只是普通声明函数,每次渲染依然会生成新引用。

所以我紧接着对回调函数做useCallback处理:

const handleRowClick = useCallback((rowId) => { // 处理行的点击逻辑 }, []);

至此,ReportTable的每一项 props 引用都稳定了,memo的浅比较才能完全生效。输入框敲字时,ReportTable被完全跳过,性能问题得到了质的改善。

这个过程中我非常想强调的一件事是:如果只包 memo 而不用 useCallback/useMemo 稳定 props 引用,memo 几乎等于没写。这也是很多开发者做性能优化时“包了 memo 但一点效果都没有”的根本原因。

3.4 如何用数据证明优化有效:Profiler 与渲染日志的实操

很多人只凭“感觉变快了”来验收性能优化,这是很不专业的。我一直习惯用 React DevTools 的 Profiler 来做前后数据对比。

操作流程很简单:打开浏览器开发者工具,切到 React 面板,点 Profiler 标签,点一下录制按钮,然后在页面上做同样的操作——比如在输入框里打三个字——再点停止。Profiler 会生成一张火焰图,里面清晰地展示出哪一次点击、哪一次操作触发哪些组件重新渲染,各自的渲染耗时是多少毫秒。

优化之前,我在 Profiler 里能看到Dashboard一更新,整棵子树全部亮起彩色,ReportTable的渲染耗时经常占据整个火焰图的半壁江山。优化之后同样的操作,火焰图里ReportTable相关部分是一整片灰色——灰色代表 React 跳过了它的渲染。这个对比非常直观,也特别适合用来跟产品经理或者老同事汇报优化成果。

除了看火焰图,我也喜欢在关键组件里埋一个临时的渲染日志:

const ReportTable = memo(function ReportTable(props) { console.log('ReportTable render', new Date().toLocaleTimeString()); // ... });

这个日志配合console.clear()使用,能够快速判断一个组件是否被不必要的重新渲染。如果我在输入框打字时打印日志里疯狂刷出ReportTable render,就说明优化没有生效。这个方法虽然土,但在排查的时候比分析工具更直接。

4. 常见误区与实战决策:什么情况值得加,什么情况别乱加

4.1 盲目给所有组件套 memo:收益没多少,维护成本倒涨了

我在评审同事代码的时候,经常看到有人为了“保险起见”,给项目里所有子组件统统包一层memo。这种做法的直接后果是:代码被大量memo()包裹,可读性下降,而且相当一部分memo因为 props 不稳定而根本没生效,变成了心理安慰。

要知道memo并不是零成本的。React 每次渲染父组件时,即使memo能成功跳过子组件渲染,它也需要执行一次 props 浅比较,也就是遍历 props 的每个 key。如果组件 props 数量巨大、嵌套很深,浅比较自身的成本也是实打实的。

我的判断标准很简单:如果一个组件渲染自身很昂贵,而且它的父组件常常会因为无关状态重新渲染,就值得加 memo。反过来,如果组件本身的渲染只是一堆轻量 JSX,加上 props 数量又多,那 memo 带来的跳过收益可能抵消不了浅比较成本,甚至会出现负优化。

4.2 用 useCallback 包所有函数:闭包陷阱与没必要的重渲染

另一个常见误区是给每个函数都套上useCallback,美名其曰“优化”。实际情况是,useCallback需要维护依赖数组,每个依赖项都意味着额外的心智负担。如果依赖数组写得不全,很容易遇到经典的闭包陷阱——函数内部捕获了过期的 state,页面表现诡异,还不容易排查。

而且从性能收益角度看,如果一个函数不是传给 memo 包裹的子组件,也不是用于 useEffect 的依赖数组,那么 useCallback 帮不上任何忙。组件自身重新渲染时,它创建新函数引用的开销极小,远没有到值得额外管理的地步。

所以关于useCallback的合理使用,我总结成一句话:函数要向下传给一个被 memo 包裹的组件,或者被某个 useEffect 依赖,才值得用 useCallback 稳定引用。除此之外,绝大多数函数声明都不需要额外处理。

4.3 useMemo 的误用:只为“看起来快”而缓存无法复用的值

useMemo最大的误用是把它当成“纯 JS 的性能魔法”,随便包在某个简单计算外面。举个例子:

const fullName = useMemo(() => `${user.firstName} ${user.lastName}`, [user]);

这种字符串拼接计算量几乎为零,用useMemo缓存不会带来任何性能收益,反而让依赖关系成为额外负担。真正的表演舞台应该是那些统计、过滤、排序、深度嵌套对象处理等重计算场景。判断标准同样朴素:计算成本必须显著大于缓存查找成本,才值得缓存。

另外还有一个被很多人忽视的细节:useMemo 不保证缓存一定不被丢弃。React 在内存紧张或组件被临时隐藏时,完全有可能丢缓存重新计算。所以千万不要写依赖useMemo来保证副作用只执行一次的代码。

4.4 性能优化决策树:按需求而不是按惯性选择方案

综合这些年在项目中踩过的坑,我把遇到性能问题时的选择流程整理成一个简易决策模型,按顺序问自己几个问题:

第一问:组件真的被无关状态反复拖下水了吗?如果没有频繁的父级重渲染逼着子组件做无谓重渲染,直接用优化手段纯属多此一举。

第二问:这个组件的渲染成本真的高吗?如果只是一句话的 JSX,加了 memo 反而可能赔上多余的浅比较开销。成本高的组件类型通常是长列表、复杂表格、图表节点、富文本编辑器等。

第三问:重计算的量级值得缓存吗?如果输入数据量小、计算快,也用不着 useMemo。

第四问:必须确保传给子组件的 props 引用稳定吗?这一点适用于所有向 memo 子树传递函数、数组、对象的情境。

把这四个问题理清楚,很多时候你根本不需要动 memo/useMemo/useCallback,直接通过结构调整就能解决问题。

5. 依赖三件套但不止于此:更底层的组件拆分方法论

5.1 状态下沉:与其缓存渲染,不如从源头避免渲染

前面讲的都是“渲染避让”的思路:组件结构不动,用缓存手段让 React 跳过不必要的子树渲染。但组件结构本身也有巨大的优化空间,最有效的一招就是状态下沉。

回到最开始那个待办事项的例子。输入框的keyword状态声明在App根组件里,导致App以下的所有组件都被影响。如果我能把输入框的状态声明“下沉”到SearchInput组件内部,问题就从源头消失了。

const SearchInput = () => { const [keyword, setKeyword] = useState(''); return <input value={keyword} onChange={e => setKeyword(e.target.value)} />; }; const App = () => ( <div> <SearchInput /> <TodoList todos={todos} /> </div> );

这种情况下,不管SearchInput内部怎么敲键盘,状态变化都被限制在它自己内部,App和TodoList完全不受影响。这种优化思路比任何缓存手段都更根本——压根不让变化发生,就谈不上需不需要跳过渲染。状态下沉在法律上是“不战而屈人之兵”。

5.2 children 透传:让不相关的组件远离频繁变化的上下文

还有一种结构优化技巧,是我在处理复杂组件布局时才彻底理解的——children 透传模式。

它解决的是这样一个问题:某层组件持有会频繁变化的 state,但有部分子组件其实完全不需要感知这个 state。如果把这部分组件作为children或者 props 传下去,那即使父组件重新渲染,children 依然保持着上一次的引用,React 可以跳过。

const App = () => { const [keyword, setKeyword] = useState(''); const header = <Header />; const footer = <Footer />; return ( <Layout header={header} footer={footer}> <SearchInput value={keyword} onChange={setKeyword} /> <TodoList todos={todos} /> </Layout> ); };

这里Header和Footer作为 props 传入Layout,它们的渲染结果是在App渲染前就构造好的 Element。当App因为keyword重新渲染时,header和footer的引用不会变,Layout可以跳过它们的重渲染。这种方式在构建大型业务布局时非常实用,能有效避免“改了右上角按钮,整页侧栏跟着闪一下”的问题。

5.3 Context 拆分:别让一个全局状态拖垮整个消费子树

最后再讲一个非常容易与三件套混淆的优化点——Context 的性能陷阱。

当你在某个 Provider 上放了一个对象 value,哪怕这个对象内容没变,只要 Provider 所在组件重新渲染,value 引用就会变化,所有消费这个 Context 的组件都会强制重新渲染。这是我见过最容易导致“明明用了 memo 还是全量重渲染”的隐藏杀手。

解决方案很明确:把 value 用 useMemo 包裹。

const value = useMemo(() => ({ theme, locale }), [theme, locale]); return <AppContext.Provider value={value}>{children}</AppContext.Provider>;

同时还要养成拆分 Context 的习惯。你完全可以把theme和locale拆成两个 Provider,这样只有真正关心theme的组件会在主题变化时重新渲染,关心locale的组件则不会受到无谓影响。细粒度的 Context 结构配合 useMemo,才能真正把全局状态的开销控制住。

5.4 优化心法总结:让该变的变,让不该变的彻底别动

用完整套优化手段之后,再回过头去看 React 性能这件事,其实核心思想就一句话:让该变的变,让不该变的彻底别动。

从下往上我们有三层手段:第一层是组件结构调整,通过状态下沉、children 透传、Context 拆分从源头减少“该变”的范围;第二层是引用稳定,用 useMemo 和 useCallback 确保不该变的 props 引用始终不变;第三层是渲染避让,用 memo 在不该变的组件入口处直接拦停。

这套分层思维跟具体 API 的使用同样重要。因为在我实际项目中,只依赖 API 而不调整组件结构的优化,效果通常只有三分钟热度。很多页面你用了 memo、useMemo、useCallback,跑起来是快了,但代码已经变得又乱又难维护。而真正健康的 React 应用,往往是那些懂得通过设计让性能问题根本不出现、再用缓存手段补齐残余收益的项目。

如果你只能从这篇文章里带走一件事,那我的建议是:下一次遇到性能问题,先用 Profiler 定位到具体是哪个组件在反复重渲染,再按照“状态下沉 → 拆分 Context → 稳定引用 → memo 兜底”这个顺序去动手,绝大多数性能问题都轮不到你盲目地去给每个组件包一层缓存。

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

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

立即咨询