ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

React useMemo实战:从渲染机制到优化场景,搞懂何时该用

React useMemo实战:从渲染机制到优化场景,搞懂何时该用 做 React 开发的人大概率都听说过useMemo但真正把它用好的却不多。我问过不少同事大家的第一反应是“缓存计算结果优化性能”可再追问一句“什么时候该用什么时候不该用”能说清楚的就很少了。useMemo这个 Hook 本身并不复杂难的是搞清楚它在整个渲染链路里到底扮演什么角色以及哪些问题它根本解决不了。这篇内容我把自己的理解和踩过的坑整理出来从 React 的渲染机制讲起再落到具体场景和代码希望能帮你把它用得明明白白。1. 先别急着用useMemo——搞懂React什么时候会做无用功1.1 函数组件每次渲染都是从头到尾执行一遍React 的函数组件本质上就是一个普通函数。每次渲染React 都会重新调用这个函数函数体里面所有代码都会重新执行一遍。很多人会把渲染函数执行和DOM 更新混为一谈其实函数体执行只是生成一份新的虚拟 DOM 描述React 会拿它和上一次的虚拟 DOM 做比较差异部分才真正落到浏览器里。这里就出现了一个容易被忽略的细节function ProductList({ products, keyword }) { // 这一行每次渲染都会重新执行 const filtered products.filter((p) p.name.includes(keyword)); return ( ul {filtered.map((item) ( li key{item.id}{item.name}/li ))} /ul ); }products.filter()这段代码组件每次渲染都会跑一遍。不管products和keyword有没有变化只要组件因为任何原因被重新渲染这个filter就会被重新计算。如果products只有几十条数据这完全无所谓但如果products是几千条甚至上万条里面再做排序、嵌套遍历、字符串处理累计起来就是肉眼可见的卡顿。打个比方你每天早上到工位都要看一眼今天要处理哪些文件读一遍清单这个动作本身很快。但如果你的清单有上千行而且每次开会、每收一条消息、每刷新一次日历你都要把清单从头到尾读一遍、重新分类一遍那这个“重新读取”的成本就很客观了。useMemo想解决的就是这个“读清单、分类清单”的重复劳动。讲到这你应该能理解一个基本事实不是所有计算都需要缓存只有当你确认这个计算本身足够重、且触发频率足够高时缓存才有意义。1.2 两个信号提醒你这里有重复计算怎么判断“该优化了”我通常看两个信号第一个信号是交互卡顿。典型表现是输入框打字明显掉帧、滚动列表有延迟感、切换 Tab 要顿一下才出来内容。这些通常意味着主线程被大量同步计算占用了渲染函数里很可能藏着重复执行的重量级逻辑。第二个信号是渲染函数里有明显的重计算逻辑你肉眼就能看出来。比如function Dashboard({ rawData, sortKey }) { const sorted [...rawData].sort((a, b) { // 这里可能做了很复杂的多字段比较 return a.score - b.score; }); const summary sorted.reduce((acc, item) { acc.total item.value; acc.avg acc.total / item.length; return acc; }, { total: 0, length: sorted.length, avg: 0 }); // 后面还有一堆基于 sorted 和 summary 的派生数据 }看到这种代码先别急着包useMemo下一步是确认这个组件的渲染频率。只在初始化时渲染一次的天花板级页面即使计算重一些也没关系真正要小心的是高频更新的组件比如配合用户输入、服务端推送、全局状态频繁变化的那类。我个人的判断流程是先打开 React DevTools 的 Profiler录一段真实操作看看组件的渲染耗时和渲染次数。如果确实存在“参数没变但渲染函数反复执行且耗时偏高”的情况再考虑用useMemo兜住。优化最忌讳的就是拍脑袋先测再改后面排查问题会轻松很多。2. useMemo到底在缓存什么核心机制拆解2.1 工作方式记住上一次的结果依赖没变就直接用useMemo的 API 很简单就两个参数const cachedValue useMemo(computeFn, deps);第一次渲染时React 会调用computeFn把返回值存起来。后续渲染时React 会比较deps数组里的每一项是否发生变化如果所有依赖项都保持不变就直接返回之前缓存的值不再执行computeFn。只有依赖项里有任何一个发生了变化才会重新调用computeFn并更新缓存。这里最容易理解偏差的是“比较”的方式。React 对依赖项的比较不是深比较而是逐项调用Object.is()做浅比较。也就是说依赖项里如果放了一个对象或者数组每次渲染都新建一个{ a: 1 }这样的字面量那么在 React 眼里这就是一个“新值”哪怕内容完全一样。看这个例子function App() { const config { retry: 3, timeout: 1000 }; const value useMemo(() { return computeHeavy(config); }, [config]); // config每次渲染都是新对象缓存必然失效 return Child config{config} /; }每次App渲染config都是一个新的{ retry: 3, timeout: 1000 }。useMemo拿新对象和上一次的旧对象做Object.is比较结果永远是false于是computeHeavy每次都重新执行。这可能是很多人用过useMemo之后发现“怎么没效果”的最常见原因。想修这个问题要么把config的创建也包进useMemo让它依赖为空数组保证引用不变要么把依赖项写成基本类型比如[config.retry, config.timeout]。我个人倾向于把配置对象拆开让依赖项保持“最小必要集合”这样代码意图更清晰也不容易踩引用比较的坑。2.2 说白了它就是在渲染函数里加了一层备忘录如果你把一次渲染理解成一次“开会”函数体里的每个计算就是想讨论的议题。没有备忘录的时候每次开会都得把所有议题重新过一遍有了备忘录只要议题内容跟上次一样直接照抄上次的结论不用重新讨论。useMemo就是这本备忘录。它记的不只是结果还记得得出这个结果需要哪些“输入”——也就是依赖数组。你需要在备忘录上写明“基于哪些输入得出这个结论”React 才会知道这次开会能不能复用旧的结论。有一个细节值得注意useMemo的computeFn是会在渲染期间被调用的所以它里面不应该包含任何有副作用的操作比如发请求、修改 DOM、写全局变量。这类操作应该放到useEffect里而不是useMemo里。违反了这条规则你的副作用可能被莫名其妙地执行多次而且很难排查。我现在做 Code Review 的时候只要看到useMemo里面出现了fetch或者document.title赋值基本就能断定作者对 Hook 的职责边界理解得不够透彻。3. 三类最值得使用useMemo的场景3.1 纯计算类大数据量的过滤、排序、聚合这是useMemo最直观的应用场景。当你在渲染函数里对数组做排序、过滤、分组、汇总而且数据量上了千、万级就应该考虑缓存。举个实际例子一个账单列表页面需要根据筛选条件从全量数据里筛出结果再按金额排序function BillingPage({ bills, filter, sortBy }) { const visibleBills useMemo(() { const filtered bills.filter((bill) { if (filter.category bill.category ! filter.category) return false; if (filter.startDate bill.date filter.startDate) return false; if (filter.minAmount bill.amount filter.minAmount) return false; return true; }); return [...filtered].sort((a, b) { if (sortBy amount) return b.amount - a.amount; if (sortBy date) return b.date.localeCompare(a.date); return 0; }); }, [bills, filter, sortBy]); return ( Table data{visibleBills} / ); }这里visibleBills只依赖bills、filter、sortBy三个变量。只要这三个引用或值不变化这个计算就永远不会重新执行。哪怕这个BillingPage因为父组件状态变化被频繁渲染也不会白白重复这轮过滤排序。要注意的是filter如果是从上一层传下来的对象调用方必须保证它的引用稳定。在我们团队里这类筛选条件通常会写成形如{ category: , startDate: , minAmount: null }的普通对象然后统一用useMemo或useState管理避免每次渲染都重建。3.2 引用稳定类给memo子组件传对象、数组、回调时这一类场景比纯计算更容易被人忽略但实际业务里出现频率极高。先看一段代码function Parent() { const [count, setCount] useState(0); const items [Apple, Banana, Cherry]; // 每次渲染都新建数组 return ( div button onClick{() setCount(count 1)}点击/button Child items{items} / /div ); } const Child React.memo(function Child({ items }) { console.log(Child 重新渲染了); return ul{items.map((i) li key{i}{i}/li)}/ul; });在这个例子里Child虽然被React.memo包裹但只要Parent里的count一变Child依然会重新渲染。原因很简单items这个数组在Parent每次渲染时都是新创建的React.memo的默认浅比较会发现items的引用变了于是判定 props 发生变化子组件不得不重新渲染。这种情况下useMemo的作用不是缓存计算结果而是提供一个引用稳定的数据const items useMemo(() [Apple, Banana, Cherry], []);把依赖数组设为空数组意味着这个items在组件整个生命周期内引用都不会变Child的React.memo才能生效。类似的情况还包括向子组件传递配置对象、选项列表、格式化方法等。关于回调函数useCallback和useMemo在语义上是相通的。useCallback(fn, deps)本质上就是useMemo(() fn, deps)的语法糖。如果你看到useMemo(() () {...}, [])其实就是想表达“保存一个引用稳定的函数”。3.3 依赖稳定类配合useEffect防止副作用重复执行useEffect的依赖数组比较机制和useMemo一样都是浅比较。当一个 effect 依赖了一个每次渲染都新建的对象副作用就会频繁执行。举个典型的反例function SearchPanel({ options }) { useEffect(() { // 这里的 options 每次渲染都是新引用这个 effect 每次渲染都会执行 doSearch(options); }, [options]); return divSearch Panel/div; }即使doSearch(options)内部做了相同请求的合并去重这个 effect 的频繁触发也会带来无谓的请求和状态更新。更稳妥的做法是让 effect 依赖最小化的参数function SearchPanel({ options }) { const { category, page, pageSize } options; useEffect(() { doSearch({ category, page, pageSize }); }, [category, page, pageSize]); return divSearch Panel/div; }这样依赖项全部变成基本类型只有当category、page、pageSize真正变化时effect 才会执行。如果因为某些原因必须直接依赖整个对象那就需要保证使用方传入的options引用稳定通常可以要求调用方自己也包一层useMemo。前一种方式我更推荐因为它把“影响副作用的因素”显式列出来了对排查问题非常友好。4. useMemo与useCallback、React.memo三者的分工4.1 三者的关系一个解决算一个解决传一个解决拦我用最直白的方式区分这三者useMemo缓存的是计算值解决“重复计算”和“引用不稳定”的问题。useCallback缓存的是函数引用本质上解决的是“回调函数传给子组件时导致的重渲染”和“effect 依赖函数导致的重执行”问题。React.memo是组件层级的优化它让函数组件在 props 浅比较结果不变时跳过重新渲染。它们经常组合使用但职责并不重叠。useMemo和useCallback是“上游控制源头的值”React.memo是“下游拦截不必要的渲染”。上游值引用不稳定下游React.memo再努力也拦不住。一个稳定引用 过渡优化的组合可能是这样的function Parent() { const [query, setQuery] useState(); const filters useMemo(() ({ keyword: query, limit: 10 }), [query]); const onLoad useCallback(() { fetchData(filters); }, [filters]); return Child filters{filters} onLoad{onLoad} /; } const Child React.memo(function Child({ filters, onLoad }) { // ... });这里的filters依赖queryquery变化时filters引用变化onLoad依赖filtersfilters变化时它也跟着变化。这套链路是合理的数据真的变了子组件确实需要重新渲染数据没变时引用稳定React.memo拦得住。这样配合是教科书式的用法。4.2 一个判断流程先问四个问题再决定要不要用很多人的纠结在于“我该不该给这个组件加优化”。我给自己定了一套快速判断流程分享出来第一个问题这个组件是否频繁重新渲染如果是静态页面或者只在路由切换时渲染一次直接跳过所有优化。第二个问题渲染函数里是否有重量级计算判断标准是数据规模大、算法复杂度高、每次计算明显耗时。没有的话useMemo纯属多余。第三个问题是否有 object/array/function 类型的 props 传给子组件并且子组件被React.memo包裹有的话需要用useMemo或useCallback稳定这些引用。第四个问题是否有 effect 依赖了引用不稳定的对象有的话要么拆分依赖成基本类型要么用useMemo稳定对象引用。四个问题问完useMemo该不该用基本就清楚了。用表格整理一下三者分工工具解决什么什么时候用常见误区useMemo缓存计算结果、稳定引用重计算、传引用给 memo 子组件所有计算都包导致缓存管理成本高useCallback稳定函数引用回调传给 memo 子组件、effect 依赖把根本不依赖任何值的函数也包一层React.memo跳过 props 未变化的子组件渲染子组件渲染成本高、且 props 浅比较够用只包裹就完事没考虑上游引用是否稳定Note that判断的关键不是“这个组件写了 memo 所以一定快”而是“整个数据链路从创建到消费是否稳定”。5. 实战给一个列表页做性能优化5.1 第一步先测量别拿手感当依据这个列表页的场景很有代表性数据源来自接口前端根据筛选条件实时过滤选中状态在页面内维护。先看优化前的代码再一步步分析问题在哪。function OrderList({ orders, region }) { const [selectedId, setSelectedId] useState(null); const [keyword, setKeyword] useState(); // 每次渲染都要重新过滤、排序 const visibleOrders orders .filter((o) o.region region) .filter((o) o.customer.includes(keyword)) .sort((a, b) b.amount - a.amount); // 每次都生成新对象传给子组件 const selectedOrder visibleOrders.find((o) o.id selectedId) || null; const stats visibleOrders.reduce( (acc, item) { acc.total item.amount; acc.count 1; return acc; }, { total: 0, count: 0 } ); return ( div input value{keyword} onChange{(e) setKeyword(e.target.value)} / OrderStats stats{stats} / OrderTable orders{visibleOrders} selectedOrder{selectedOrder} onSelect{setSelectedId} / /div ); }光看代码就能嗅到几个问题。keyword每次变化整个orders要重新 filter 两次再 sort 一次selectedOrder每次都是新对象stats每次都是新对象。如果orders有几千条数据输入一个关键词的每一次按键都会触发完整的过滤排序卡顿几乎必然出现。先用 Profiler 记录一次输入过程。录制时我先快速输入几个字符再点击某一行观察OrderList渲染耗时多少。OrderStats、OrderTable的渲染耗时多少。整个输入期间组件重新渲染了多少次。实测里纯输入阶段OrderList自身耗时已经从 20ms 上升到 100ms 左右OrderTable也跟着渲染。这已经足够说明问题了继续优化就有依据。5.2 确定三个优化落点我的优化思路分三步第一步把过滤排序计算包进useMemo依赖设为orders、region、keyword。keyword每次变化都会触发重新计算这没法避免但至少region不变、orders不变时组件其他状态变化不会触发重复计算。第二步把selectedOrder包进useMemo让它依赖visibleOrders和selectedId。这样只有当可见列表或选中 ID 真的变化时才重新查找。注意这里的依赖是visibleOrders不是orders因为我们查找的目标只可能是过滤后的列表中的元素。第三步把stats也用useMemo包一下依赖visibleOrders。stats的计算规模虽然不大但它生成的新对象会传给OrderStats如果OrderStats被 memo 包裹引用不稳定就会导致它跟着重渲染。优化后的核心代码function OrderList({ orders, region }) { const [selectedId, setSelectedId] useState(null); const [keyword, setKeyword] useState(); const visibleOrders useMemo(() { return orders .filter((o) o.region region) .filter((o) o.customer.includes(keyword)) .sort((a, b) b.amount - a.amount); }, [orders, region, keyword]); const selectedOrder useMemo(() { return visibleOrders.find((o) o.id selectedId) || null; }, [visibleOrders, selectedId]); const stats useMemo(() { return visibleOrders.reduce( (acc, item) { acc.total item.amount; acc.count 1; return acc; }, { total: 0, count: 0 } ); }, [visibleOrders]); return ( div input value{keyword} onChange{(e) setKeyword(e.target.value)} / OrderStats stats{stats} / OrderTable orders{visibleOrders} selectedOrder{selectedOrder} onSelect{setSelectedId} / /div ); }这里有个关键点selectedOrder和stats的依赖是visibleOrders而不是orders。因为selectedOrder是从visibleOrders里找出来的stats也是对visibleOrders的聚合。依赖写orders虽然也能工作但一旦orders变了即使visibleOrders没变比如keyword过滤后结果一样这两个值也会重新计算缓存的意义就打折扣了。5.3 别忽略子组件的 memo 配合只在上游做useMemo还不够OrderStats和OrderTable如果不包React.memo父组件渲染时它们依然会跟着重渲染。优化后的引用稳定给React.memo提供了发挥空间。const OrderStats React.memo(function OrderStats({ stats }) { return ( div classNameorder-stats 总金额{stats.total} 元共 {stats.count} 单 /div ); }); const OrderTable React.memo(function OrderTable({ orders, selectedOrder, onSelect }) { return ( table {/* 渲染列表 */} /table ); });做完这步再跑一次 Profiler。同样的输入操作OrderList在region和orders没变化时只有keyword变化那次会重新计算OrderTable在selectedId变化时才会重渲染OrderStats在keyword变化导致stats变化时才会重渲染。整体渲染耗时从原来的 100ms 附近降到了 30ms 左右肉眼能感觉到输入流畅了。这次优化的核心教训是性能优化是链路工程上游稳定引用 中游缓存计算 下游拦截渲染缺一环效果都打折扣。6. 必须避开的useMemo误用清单6.1 最常见的误用把所有值都包起来我见过不少代码只要渲染函数里出现变量一律套上useMemo。看起来“都优化了”实际效果却适得其反。反面典型const total useMemo(() a b, [a, b]); const name useMemo(() ${user.first} ${user.last}, [user]); const isActive useMemo(() status active, [status]);a b、模板字符串、布尔比较这些计算耗时是纳秒级的useMemo本身也有开销——依赖比较、缓存存储、闭包创建。让本来光速完成的事情多了一道缓存流程性能不但没提升还可能略微下降。更重要的是每个useMemo都要维护依赖数组依赖写错了还会引入难以排查的 bug等于花成本买风险。我给自己定了一个习惯只有当计算耗时明显可见比如超过 5ms或者是为子组件提供稳定引用时才使用useMemo。绝大多数渲染函数里的简单计算直接写就行了。6.2 依赖数组里的坑漏依赖和多余依赖漏依赖会导致缓存没有失效拿到的是上一次的旧值这是最隐蔽的一类 bug。function BadExample({ items, filter }) { // 依赖里只写了 items漏了 filter const result useMemo(() { return items.filter((item) item.type filter.type); }, [items]); return List data{result} /; }当filter.type变化时result依然使用旧缓存界面显示的数据不符合筛选条件。这种问题特别难排查因为代码看着完全正常只有交互时才发现数据不对。解决办法有两个层面。代码层面依赖数组需要完整列出计算函数里用到的每一个响应式值。工程层面强烈建议在 ESLint 中启用react-hooks/exhaustive-deps规则。这条规则能在编译阶段提示你依赖数组和代码实际使用的不一致虽然偶尔会有误报但绝大多数时候它帮了大忙。发现提示时不要直接禁用规则先搞清楚是补依赖还是重构代码。多余的依赖也有问题。例如一个计算只用了a依赖数组却写了[a, b]那么b一变化缓存就失效等于没缓存。依赖数组的根本原则是“计算函数用到了哪些外部变量就写哪些”。6.3 useMemo不是业务语义的保证别拿它当永久的保证有一个认知很重要useMemo不保证缓存永远存在。React 在特定情况下会主动丢弃缓存并重新计算结果比如组件被卸载后重新挂载或者未来版本里为了释放内存做的主动清理。官方文档也明确说了这一点。这意味着你不能把useMemo当成一种“业务上只需要计算一次”的手段。如果你觉得某个函数只要依赖不变就永远不该重新执行而且那个函数还有副作用你已经踩进了useMemo的雷区。一个典型错误是const data useMemo(() { fetchData(); // 副作用放进了 useMemo依赖不变时不执行依赖变化时执行多次 return compute(...); }, [someDep]);这种写法在依赖变化时会触发请求在依赖不变时又完全复用旧值行为很不可预测。副作用应该放在useEffect里useMemo只负责纯计算。记住这一点能少踩无数坑。还有一点需要留意不要把useMemo当“防抖”工具。有人试图用useMemo限制某些计算的频率但它没有时间维度只在依赖比较层面做优化。高频触发的计算想降频应该靠防抖节流函数而不是useMemo。6.4 别把所有「重」都推给useMemo整条链路的优化才有效有时候你给组件加了useMemo性能并没有明显改善。原因往往是用错了位置。比如问题出在一个巨大的列表组件本身渲染成本高而你只是缓存了列表外的某个数据列表的渲染次数没有减少。这时候真正的解法是把列表虚拟化或者拆分组件让状态隔离。我的经验是性能优化要按优先级来减少渲染次数拆分组件、状态提升、避免不必要的状态变更。减少单次渲染成本虚拟列表、懒渲染、精简 DOM。减少重复计算useMemo、useCallback。减少不必要的子组件渲染React.memo、稳定引用。useMemo优先级并不靠前。如果列表本身有几千个 DOM 节点缓存再多的过滤结果渲染出来的节点数也不会减少。先把组件拆到合理粒度再考虑useMemo效果会好得多。7. 常见问题排查速查表实际项目中围绕useMemo的问题来来去去就那么几类。我整理了一个速查表方便你遇到问题时快速定位。现象可能原因排查方法解决方案明明用了useMemo计算还是每次都执行依赖项里有对象/数组每次渲染引用都变在computeFn里加console.log替换依赖项为基本类型拆开基本类型做依赖或用useMemo稳定对象本身useMemo返回的引用变了导致useEffect重复执行依赖数组里写了不稳定的引用或useMemo返回值被再次包装成新对象检查effect依赖的数据类型打印前后两次引用值让effect依赖基本类型或保证useMemo返回引用稳定子组件用了React.memo但依然重渲染传给子组件的props里有新对象或新函数在子组件里打印props观察引用变化用useMemo稳定对象useCallback稳定函数缓存值一直是旧数据界面不更新依赖数组漏掉了某个变化量对照计算函数里的变量和依赖数组启用exhaustive-deps规则补全依赖项使用了useMemo后CPU占用没有改善真正的瓶颈不在计算而在渲染节点数或组件层级打开Profiler看实际渲染耗时分布虚拟列表、组件拆分、懒渲染useMemo里的函数被执行了多次次数意外computeFn里有副作用依赖项变化过于频繁确认computeFn是否纯函数检查依赖项来源副作用移到useEffect把高频变化的依赖项用稳定的引用替换这套速查表是我在实际项目里反复验证过的。补充两个排查小技巧第一在useMemo的计算函数里放一个console.log每次执行都会打印立刻能看出缓存是否失效第二用 React DevTools 的 Profiler 组件耗时能看到具体是哪个组件慢排查范围一下子缩小很多。还有一个小技巧当你想确认一个引用是否稳定时可以直接让组件渲染这个引用的console.log输出。我在排查引用问题时经常用console.log(selectedOrder 引用:, selectedOrder);两个相邻渲染输出的引用地址如果不同说明引用不稳定问题定位在上游。最后再分享一点实际项目里的体会做性能优化这几年我对useMemo的认知大致经历了三个阶段。第一阶段是“学会了就到处用”觉得缓存越多越安全第二阶段是“踩了坑以后开始禁用”凡是看不懂的useMemo先删了再说第三阶段才是“按场景按数据链路去判断”该用的时候果断用不该用的时候碰都不碰。现在我判断一个useMemo该不该存在其实就看两件事第一它是否真的避免了一次不必要的重量级计算第二它是否让某个引用变得稳定从而让下游的React.memo或useEffect正常工作。这两件事都做不了那它就是在增加维护成本的摆设。另外还要注意一点前端性能优化的目标是提升用户体验而不是让代码“看起来很专业”。如果优化了半天用户完全感知不到差别那这些useMemo就该删掉。React 本身的渲染机制已经足够快绝大多数页面的卡顿根源在 DOM 规模、布局压力、弱网请求上别把所有希望寄托在一个 Hook 上。最后留一句话给我自己也留给大家useMemo是工具不是勋章。真正成熟的代码是把工具放在刚好需要它的地方而不是堆满它。
返回列表