首页 / 资讯中心 / 文章详情

React性能优化三件套:memo、useMemo、useCallback的适用边界与实战

React性能优化三件套:memo、useMemo、useCallback的适用边界与实战 ★ FEATURED ARTICLE
做前端的朋友估计都有过这种体验组件写得好好的数据变化也正常可页面就是卡。打开面板一看明明只是输入框里多敲了一个字结果整棵组件树像是被按着头重新渲染了一遍。到了这一步很多人第一反应就是祭出 React 性能优化三件套——memo、useMemo、useCallback一通操作猛如虎结果该卡的还是卡甚至比之前更卡。问题出在哪这三个 API 从来不是加了就快的魔法它们各自有明确的适用边界和搭配逻辑。这篇文章我打算从 React 的重渲染机制讲起把 memo、useMemo、useCallback 的原理、用法、坑位一次说清楚再聊聊哪些场景纯属白忙活。适合正在写 React 项目、被重复渲染困扰或者刚接触这几个 API 想系统搞懂它们的朋友。1. 重渲染的底层逻辑React 为什么把全家都重画一遍1.1 一次 setState 背后render 阶段到底做了什么当一个组件调用 setState或者 useState 返回的更新函数React 内部会走两个阶段。先是 render 阶段重新执行这个组件的函数体生成一份全新的虚拟 DOM 树然后进入 commit 阶段把新旧两棵虚拟 DOM 做对比算出差异最后只对真正变化的部分操作真实 DOM。很多人只盯着最后的 DOM 操作觉得我就改了输入框的值DOM 又没怎么动为什么会卡。这里有个误区卡顿往往不是 DOM 操作造成的而是 render 阶段本身太贵。render 阶段要重新执行组件函数体里的所有表达式、遍历列表、创建 JSX 元素对象还要做整棵子树的 reconciliation 对 diff。当组件层级深、列表长、数据转换复杂时一次无谓的 render 可能牵扯上百个组件函数的执行累加起来就是肉眼可见的卡顿。理解这个机制后你会发现性能优化的核心目标不是少操作 DOM而是少执行无谓的 render。memo、useMemo、useCallback 本质上都是在干预 render 阶段让 React 在某些情况下偷个懒。1.2 子组件为什么会被连坐这是 React 新手最容易困惑的点。默认情况下父组件重新渲染React 会递归地重新渲染它的所有子组件不管子组件的 props 有没有变化。我经常用一个例子来解释这就像公司一有风吹草动所有员工都被叫起来重新填一遍表格不管这张表跟你有没有关系。React 为什么这么笨因为它无从选择。在函数式组件的模型下React 并不知道某个子组件内部到底依赖哪些 state 和 props也不知道这个子组件的重渲染成本是多少。唯一安全、不会漏掉任何更新的策略就是从更新点开始把整棵子树全部重新执行一遍然后再靠 diff 判断哪些 DOM 真的需要改。这是宁可多算不可漏算的保守策略。大多数时候React 这种策略是正确的——多渲染一次谈不上多大代价。但总有例外某个子组件渲染特别重几百行的表格、复杂的图表、频繁调用的昂贵计算或者某个操作触发了高频重新渲染键盘输入、拖拽、实时刷新这时候连坐的成本就捂不住了。而 memo、useMemo、useCallback就是用来打破这种连坐的。1.3 唯一正确的比较姿势Object.is 浅比较要理解这三个 API必须先理解 React 默认拿什么判断props 有没有变化。答案是浅比较对每个 prop 属性值用 Object.is基本等价于 比较新旧值。基本类型比的是值本身而对象、数组、函数比的是引用地址。这里有个反直觉的结论**两个内容完全相同的对象在 React 眼里是不同的。**因为只要组件函数重新执行代码里每次创建对象字面量、数组字面量、箭头函数都会在内存里产生一个新的引用。哪怕对象的字段一模一样新旧引用不同浅比较的结果就是不相等。所以我没改数据子组件为什么还是重渲染这个问题答案往往藏在这里父组件重新渲染时顺手创建了新的内联对象、内联函数传给了子组件。表面上看 props 没变实际上引用全变了。后面三节的所有优化手段本质上都在和这个引用相等性作斗争。2. React.memo给子组件装一道浅比较安检门2.1 memo 的基本写法与拦截原理React.memo 是函数组件版的 PureComponent。它接收一个组件返回一个被包装过的新组件。被包装的组件收到新 props 时会先做一次浅比较如果每个 props 都和上一次渲染完全相同就直接复用上一次的结果跳过整个 render 阶段只有比较出差异才真正重新渲染。最基础的用法是import { memo } from react; function Counter({ count, onReset }) { console.log(Counter 渲染了); return ( div 计数{count} button onClick{onReset}归零/button /div ); } export default memo(Counter);用前面那个例子验证父组件里同时维护 count 和 name 两个 state输入框绑定 nameCounter 只接收 count。不包 memo 时每敲一个字符Counter 都会跟着重新渲染包了 memo 之后只要 count 不变化Counter 就纹丝不动。这个对比我在带新人时经常现场演示效果比讲十分钟理论都直观。memo 的本质是在值没变和重新渲染之间加了一道安检。但是请记住安检本身也有成本它每次都要逐一比较 props。所以 memo 不是免费的后面第 5 节我会专门讲它什么时候会变成负优化。2.2 自定义比较函数能写但最好别用深比较memo 还有第二个参数允许你传入一个自定义比较函数接管默认的浅比较const Counter memo( function Counter({ count }) { // ...组件内容 }, (prevProps, nextProps) { // 返回 true 表示新旧 props 相等跳过渲染 // 返回 false 表示不相等需要渲染 return prevProps.count nextProps.count; } );这里有个反直觉的坑返回值是 true 时反而跳过渲染初次接触很容易写反。我见过不止一个同事在这里栽跟头组件怎么都不更新查了半天才发现是自定义比较函数的返回逻辑反了。但我的建议是自定义比较函数能不用就不用更不要在里面做深比较。React 官方文档明确警告过深比较会导致组件对某些 props 变化视而不见——嵌套对象内部某个字段改了深比较判定结果为相等组件跳过渲染UI 就停留在旧值上。这种 bug 极难排查因为它不报错、不警告只是界面莫名其妙不刷新。默认的浅比较虽然粗暴但它是安全的宁可多渲染绝不错过更新。2.3 memo 拦不住的两类场景children 透传与 Context 更新memo 不是万能的有两类高频失效场景必须先知道。第一类是children 透传。如果你这样写Card ExpensiveList / /Card就算 Card 包了 memo只要父组件重新渲染JSX 里ExpensiveList /这段代码每行字都会执行一遍创建出新的 React 元素对象。Card 的浅比较会发现 children 属性的引用变了于是放弃拦截Card 照常重渲染ExpensiveList 也会被带着一起渲染。这就是明明包了 memo 却没效果的第一大原因。第二类是Context 更新。memo 只比较 props管不了 Context。如果 memo 组件内部消费了某个 Context而 Context 的 value 更新了哪怕新旧值内容一样只要引用变了组件就必然重新渲染。这是设计如此不是 bug。想靠 memo 拦截 Context 引发的重渲染是行不通的正确方向是把 Context value 本身用 useMemo 稳定下来减少无谓的连带更新。3. useMemo把昂贵计算关进结果缓存3.1 先判断它是不是真的昂贵useMemo 的作用是缓存计算结果把计算函数和依赖数组传进去只有依赖变化时才重新执行计算否则直接返回上一次缓存的结果。const total useMemo(() { return products .filter((item) item.inStock) .reduce((sum, item) sum item.price, 0); }, [products]);很多人第一步就理解偏了。useMemo 不是套上就变快的魔法它有自己的开销每次渲染要创建依赖数组、逐一比较依赖项、维护缓存槽。如果依赖频繁变化缓存形同虚设还白白搭进去比较的时间。所以第一个要回答的问题是这个计算真的贵吗判断标准很简单在 render 阶段执行它会不会造成明显卡顿。大数组几百上千条的 filter、sort、reduce、复杂对象转换、深拷贝、正则匹配、日期格式化这些才算昂贵值得缓存。一个 10 个元素的列表求和useMemo 带来的负优化比收益更明显。我在实际项目里见过不少把filter(...).length也包进 useMemo 的代码纯属仪式感没有任何性能价值。3.2 依赖数组的契约漏了会过期多了会失效依赖数组是 useMemo 的灵魂规则只有一句话计算函数里读取的所有响应式值都必须放到依赖数组里。漏掉依赖会拿到上一次的过期结果多塞依赖缓存频繁失效约等于没缓存。但放进去也有讲究。常见的坑是塞了整个对象而不是最小依赖。比如const filtered useMemo(() { return allItems.filter((item) item.id ! currentUser.id); }, [allItems, currentUser]);这里把整个 currentUser 放进依赖。如果 currentUser 对象本身经常被重新创建比如接口每次返回新对象哪怕它的 id 一直没变useMemo 也会每次都重新计算。更精确的写法是只依赖真正参与计算的字段const filtered useMemo(() { return allItems.filter((item) item.id ! currentUser.id); }, [allItems, currentUser.id]);这样既保证了正确性又减少了无效重算。另外要明确useMemo 缓存的只是上一次 render 的结果它不保证永不重算只保证依赖不变就不重算。所以计算函数里绝不能塞有副作用的逻辑发请求、改 DOM那是 useEffect 的职责。3.3 useMemo 更常见的真实用途稳定对象引用实际上我在真实业务里用 useMemo 来稳定对象引用的次数远多于缓存昂贵计算。原因是当你要把一个对象作为 props 传给被 memo 包裹的子组件时如果这个对象每次渲染都被重新创建刚才讲的浅比较就会立刻失效——memo 拦不住子组件照样重渲染。这时候用 useMemo 包一层让对象在依赖不变时保持同一份引用memo 才能重新生效。const config useMemo( () ({ theme: dark, compact: true }), [] ); MemoizedPanel config{config} /这种用法严格来说不是在省计算而是在让引用稳定。它往往比缓存昂贵计算更有实战价值因为引用不稳定是 memo 失效的罪魁祸首而业务里到处都在创建新对象。网上说useMemo 的真正价值是保证引用稳定这个说法有点极端但确实点中了它在真实项目里最普适的用途。4. useCallback给函数一张不会过期的引用身份证4.1 为什么每次渲染函数都是新的在 React 函数组件里每次渲染都会重新执行整个函数体。这意味着你在组件里定义的每一个函数、每个箭头函数都是一次全新的创建。哪怕代码长得一模一样function App() { const handleClick () {}; // 第二次渲染时handleClick 是另一个全新的函数对象 }JavaScript 里函数的比较是引用比较两个内容完全相同的函数用判断也是 false。所以当你把内联函数传给子组件时子组件每次收到的 props 在引用层面都变了。对普通子组件来说无所谓它们本来就会跟着重渲染但对 memo 子组件来说就是致命伤——浅比较发现函数引用变了直接放弃拦截子组件照常重渲染你包的 memo 就此失效。4.2 useCallback memo一对必须同时出现的搭档useCallback 就是用来解决函数引用稳定性的传入函数和依赖数组只要依赖不变就返回上一次创建的同一个函数引用。const handleReset useCallback(() { setCount(0); }, []);这里必须强调一个被无数人误解的点useCallback 本身不会阻止组件重渲染。它只是把函数引用保存下来。只有当这个函数被传给 memo 包裹的子组件时子组件才能在浅比较中发现props 没变从而跳过重渲染。换句话说useCallback 和 memo 是搭档关系单独给函数包 useCallback但子组件没有包 memo那效果为零。函数引用稳不稳定对普通子组件毫无影响它照样跟着父组件重渲染。常见的正确组合写法是这样的const Counter memo(function Counter({ count, onReset }) { console.log(Counter 渲染了); return button onClick{onReset}重置 {count}/button; }); function App() { const [count, setCount] useState(0); const [name, setName] useState(); const handleReset useCallback(() { setCount(0); }, []); return ( div input value{name} onChange{(e) setName(e.target.value)} / Counter count{count} onReset{handleReset} / /div ); }在这个例子里输入框每次输入都会让 App 重渲染但 handleReset 因为 useCallback 保持了原引用count 也没变所以 Counter 从头到尾只渲染了一次。你把 useCallback 去掉试试Counter 立刻跟着输入框一起重渲染。我建议亲手跑一下这个对比比看一百篇文档都直观。4.3 依赖链上的隐藏价值useEffect 不会频繁重跑useCallback 还有一个容易被忽略的价值当函数作为 useEffect 的依赖时。一个没有被 useCallback 包裹的函数每次渲染都是新引用写进 useEffect 的依赖数组后效果就等于每次渲染都触发容易造成副作用反复执行甚至无限循环。const fetchData useCallback(async () { const res await fetch(/api/data); // ...处理数据 }, []); useEffect(() { fetchData(); }, [fetchData]);用 useCallback 稳定引用后依赖数组里的函数引用不变effect 只会在真正依赖变化时才重新执行。这条useCallback 稳定引用 → useEffect 依赖稳定 → 副作用按预期触发的链条在复杂业务里能省掉大量调试时间。当然如果 useCallback 的依赖数组里某个变量变了函数会得到新引用useEffect 也会重新执行——这是符合预期的行为不是 bug。5. 什么时候不该用三个 API 的反向操作与实测验证5.1 过度优化的代价memo 自己也有成本这三个 API 不是免费的午餐。memo 每接住一波 props 都要执行一遍浅比较useMemo 和 useCallback 都要维护缓存、比较依赖数组这些都算额外开销。对于渲染成本极低的组件——一个纯展示的静态标签、一个简单 icon——memo 的比较开销可能比它省下的渲染开销还大属于典型的负优化。我见过一些团队把所有组件一律包 memo当成默认规范这其实是个坏习惯。memo 应该用在渲染成本高、且 props 变化频率低的组件上。判断渲染成本高不高不要靠感觉要靠工具去测。一个静态的、几乎不会重渲染的叶子组件包上 memo 只是多了个心理安慰。5.2 三个典型反模式命中任何一个都说明你在白忙反模式一memo 组件 每次渲染都新建的内联 props。对象、数组、函数全部内联传进去memo 的浅比较永远会发现变了拦截彻底失效代码还多了一层比较开销。先解决内联引用再谈 memo。反模式二useMemo/useCallback 包裹无意义的廉价内容。比如useMemo(() a b, [a, b])或者useCallback((e) setValue(e.target.value), [])这种。输入框场景特别常见每次输入都会重渲染useCallback 确实稳定了函数但输入框本来就不是 memo 组件稳定函数毫无收益反而平添噪音。反模式三在 memo 的自定义比较函数里做深比较。前面说过深比较会漏更新产生界面不刷新的诡异 bug。如果你发现自己特别想做深比较更合理的做法是重新设计 props 结构或者把数据引用稳定下来而不是在比较函数里强行聪明。反模式实际效果正确做法memo 组件 内联对象/函数 props浅比较永远失败memo 形同虚设先用 useMemo/useCallback 稳定引用useMemo 包裹廉价计算增加依赖比较开销负优化只在计算昂贵大数组、复杂转换时使用useCallback 单独使用子组件未包 memo无任何性能效果useCallback 必须与 memo 配合memo 自定义深比较可能漏更新UI 不刷新保持引用稳定让浅比较生效5.3 先测量再优化用 Profiler 说话最后给一套我自己的实操流程。装上 React 开发者工具打开 Profiler 面板开启记录模式后在页面上执行一次关键操作——输入文字、翻页、展开列表——录制一段火焰图。然后重点看三件事哪些组件被标记为重新渲染了这些组件的渲染耗时高不高点开某个组件看它渲染的原因到底是 props 变了、state 变了还是父组件渲染导致的连带。只有同时满足渲染成本高 触发频率高 原因确实是父组件连带这三个条件的组件才值得上 memo。上完 memo 后再录制一次同样的操作对比优化前后的渲染次数和耗时确认效果是真实的而不是心理安慰。我见过太多人跳过测量直接全局套 memo结果问题没解决还引入了新的引用问题。正确姿势恰好相反先用 Profiler 定位再精准优化。真正需要 memo 的组件往往比想象中少得多但每处改动都能看到实打实的效果。最后聊点我个人实际项目里的体会。我给自己定过一条规矩这三个 API 是手术刀不是创可贴。默认不封装先让 React 跑默认行为用 Profiler 抓到真正的瓶颈再动刀。动刀之前想清楚三件事——这个计算是否真的昂贵、这个引用是否真的不稳定、这个子组件是否真的需要被 memo——想清楚再写比不管三七二十一全包一层省心得多。另外分享一个小习惯每次给组件上 memo 之前我会先删掉之前优化留下的无效代码顺便想一下它当初加在这里是为了解决什么问题。性能优化最怕的不是不优化而是优化过的代码没人敢动最后堆成一团谁也无法判断还有没有意义的防御性包装。
阅读完成 · 觉得有帮助?
咨询建站