多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

React 性能优化实战:从 memo 渲染对照到 useCallback 函数缓存

React 性能优化实战:从 memo 渲染对照到 useCallback 函数缓存 React 性能优化实战从 memo 渲染对照到 useCallback 函数缓存前言1. 先理解问题父组件更新为何会牵动子组件1.1 React 的渲染是一次重新计算1.2 memo 的判断依据是属性是否保持一致2. 建立普通渲染与记忆化渲染的对照组2.1 两个子组件为什么要这样写2.2 memo 如何完成一次跳过判断3. 用两类状态更新验证优化效果3.1 父组件如何制造可观察的渲染路径3.2 三次交互分别会发生什么4. useCallback 登场当函数也成为子组件属性4.1 为什么只使用 memo 仍可能失效4.2 useCallback 缓存的是函数引用4.3 依赖数组与闭包陷阱5. 优化边界什么时候值得缓存什么时候保持简单5.1 memo、useCallback 与 useMemo 的职责分工5.2 不要把记忆化变成默认仪式总结前言React 应用变复杂以后一个很常见的性能问题是父组件只更新了自己的计数某些与计数无关的子组件却也跟着执行了一遍。页面未必真的卡顿但如果子组件包含大列表、复杂计算或高频交互这些无效工作会逐渐累积成肉眼可见的延迟。解决这类问题不能一上来就给所有函数套上useCallback。更可靠的思路是先回答三个问题谁触发了渲染、哪些属性真的发生了变化、跳过这次渲染是否有实际收益。下面通过两段核心代码建立渲染对照再进一步说明memo、useCallback与useMemo各自解决什么问题以及它们为什么经常组合出现。性能优化的关键不是“彻底消灭渲染”而是让组件在其依赖的数据没有变化时避免执行没有价值的重复工作。1. 先理解问题父组件更新为何会牵动子组件1.1 React 的渲染是一次重新计算函数组件本质上是描述界面的函数。当state更新后React 会安排一次新的渲染重新调用相关组件根据新返回的 JSX 计算界面应该是什么样子。默认情况下父组件进入渲染流程时它包含的子组件也会继续参与这次计算。这里需要区分两个容易混淆的概念组件渲染React 调用函数组件得到新的 JSX 描述。DOM 更新React 比较前后结果只把真正变化的部分提交到浏览器 DOM。因此控制台出现“子组件渲染了”并不等于浏览器一定改动了对应 DOM。即使最终界面没有变化组件函数的执行、JSX 创建以及组件内部计算仍然会发生而这正是渲染优化关注的成本。1.2memo的判断依据是属性是否保持一致memo接收一个组件并返回它的记忆化版本。父组件再次渲染时React 会比较该子组件本次与上次接收的每一项props。默认比较遵循浅层语义每个属性值使用类似Object.is的方式判断是否相同全部相同就可以跳过本次子组件渲染有任意一项不同则继续执行。memo优化的是“父组件重新渲染时属性未变化的子组件能否跳过渲染”它不会阻止组件因自身状态或所订阅上下文发生变化而更新。这也意味着字符串、数字等原始值通常容易保持相等对象、数组和函数则依赖引用身份。内容看起来一样但引用不同memo仍会认为属性发生了变化。2. 建立普通渲染与记忆化渲染的对照组2.1 两个子组件为什么要这样写第一段定义了两个都只接收name的子组件functionRegularChild({name}){console.log(渲染了RegularChild);return(h1{name}/h1/);}constMemoChildmemo(({name}){console.log(MemoizedChild 渲染了);returndivHello,{name}/div;});点击计数打印第一次点击改变名字打印RegularChild是对照组。只要父组件重新执行并继续渲染子树它就会再次执行因此控制台会再次输出日志。MemoChild是实验组组件外层由memo包装。两者接收相同来源的name可以排除业务数据差异把观察重点集中到memo的属性比较机制上。这段设计分别承担三个目的为什么这样写用几乎一致的展示逻辑制造单一变量方便观察有无memo时的渲染差异。产生什么效果父组件因无关状态更新时普通子组件仍会执行而name未变化的记忆化子组件可以被跳过。最终目的是什么证明memo能切断一部分由父组件更新引起、但与子组件输入无关的重复渲染。2.2memo如何完成一次跳过判断初次挂载时没有可复用的上一次结果所以RegularChild与MemoChild都会执行。之后父组件再次渲染React 会对MemoChild做如下判断Object.is(previousProps.name,nextProps.name);如果前后都是字符串少林队结果为trueReact 可以复用上一次渲染结果如果name从少林队变成峨眉队结果为falseMemoChild必须重新执行确保界面展示最新名称。这说明memo并不是让组件“永远只渲染一次”而是增加了一道带条件的缓存门槛。是否跳过取决于props而不是开发者主观判断组件“应该不变”。场景RegularChildMemoChild原因首次挂载渲染渲染两者都没有可复用结果父组件更新name不变渲染跳过memo判断前后name相同name发生变化渲染渲染子组件输入已变化必须生成新结果子组件自身状态变化渲染渲染memo不能屏蔽组件自身更新子组件读取的 Context 变化渲染渲染Context 是独立的更新来源3. 用两类状态更新验证优化效果3.1 父组件如何制造可观察的渲染路径第二段在父组件中维护count与name两个彼此独立的状态并提供两个按钮触发更新functionApp(){const[count,setCount]useState(0);console.log(App 组件渲染);const[name,setName]useState(少林队);return(button onClick{()setCount(count1)}点击计数{count}/buttonbutton onClick{()setName(峨眉队)}改变名字/buttonRegularChild name{name}/MemoChild name{name}//);}这两个状态并不是随意安排的。count用来制造与子组件输入无关的父组件更新name用来制造与子组件输入直接相关的父组件更新。把二者放在同一个父组件中就能清晰验证记忆化逻辑是否正确。点击“点击计数”后setCount安排状态更新App重新执行。RegularChild随父组件再次执行MemoChild收到的name仍是少林队因此通过属性比较并跳过渲染。点击“改变名字”后name变成峨眉队两个子组件都需要重新渲染因为它们展示的内容确实依赖这个值。3.2 三次交互分别会发生什么忽略开发环境下 Strict Mode 的额外检查核心日志规律如下操作阶段App日志RegularChild日志MemoChild日志结果说明首次进入页面有有有完成初次挂载count从 0 变为 1有有无name未变memo命中缓存name变为“峨眉队”有有有name已变缓存条件失效这组结果揭示了memo的真正价值它并不减少父组件自己的渲染也不改变状态更新流程而是在父子边界上根据属性稳定性决定是否继续向下执行。计数按钮中的内联箭头函数每次渲染都会产生新引用但它只交给原生button并没有作为属性传给memo子组件所以此处没有必要为了“看起来优化”而使用useCallback。计数更新更推荐写成函数式更新button onClick{()setCount(currentCountcurrentCount1)}点击计数{count}/button函数式更新明确表达“基于上一次状态得到下一次状态”在连续更新与批处理场景下更稳健。它与useCallback的目的不同前者解决状态计算方式后者解决函数引用稳定性。开发模式启用StrictMode时React 可能额外调用组件渲染逻辑来检查不纯行为因此控制台日志次数可能比表格更多。判断生产性能时不应只数日志而应结合 React DevTools Profiler 查看提交耗时与渲染原因。4.useCallback登场当函数也成为子组件属性4.1 为什么只使用memo仍可能失效当前两个子组件只接收字符串name很容易保持相同。真实业务中父组件经常还会把点击、提交、删除等回调传给子组件。假设给记忆化子组件增加onRenameconstMemoChildmemo(({name,onRename}){console.log(MemoChild 渲染了);return(divspanHello,{name}/spanbutton onClick{onRename}切换队名/button/div);});functionApp(){const[count,setCount]useState(0);const[name,setName]useState(少林队);consthandleRename(){setName(峨眉队);};return(button onClick{()setCount(valuevalue1)}点击计数{count}/buttonMemoChild name{name}onRename{handleRename}//);}每次App执行函数表达式都会创建一个新的函数对象。即使函数体一字未变前后两次引用也不相等constfirst()setName(峨眉队);constsecond()setName(峨眉队);console.log(Object.is(first,second));// false于是点击计数按钮时虽然name没变onRename却变成了新引用。memo只要发现一个属性不同就不能跳过渲染。此时问题并不在memo而在函数属性缺少稳定身份。4.2useCallback缓存的是函数引用useCallback的基本形式如下constcachedFunctionuseCallback(callback,dependencies);首次渲染时React 返回传入的回调后续渲染时它使用Object.is比较依赖项。依赖都没变化就返回上一次保存的函数引用任意依赖变化就返回本次的新函数并更新缓存。useCallback缓存的是函数本身的引用不是函数执行后的结果。它也不会阻止父组件渲染只有当引用稳定性参与某项比较或 Hook 依赖判断时缓存才有实际意义。在函数属性场景中可以把队名切换逻辑改为import{memo,useCallback,useState}fromreact;constMemoChildmemo(functionMemoChild({name,onRename}){console.log(MemoChild 渲染了);return(divspanHello,{name}/spanbutton onClick{onRename}切换队名/button/div);});functionApp(){const[count,setCount]useState(0);const[name,setName]useState(少林队);consthandleRenameuseCallback((){setName(currentNamecurrentName少林队?峨眉队:少林队);},[]);return(button onClick{()setCount(valuevalue1)}点击计数{count}/buttonMemoChild name{name}onRename{handleRename}//);}exportdefaultApp;这里使用函数式状态更新回调不需要读取渲染时的name因此依赖数组可以是[]。当count更新时handleRename仍保持同一个引用MemoChild接收的name与onRename都没有变化于是能够真正跳过渲染当点击“切换队名”导致name变化时子组件仍会正常更新。这段优化同样可以从三个角度理解为什么这样写memo的浅比较会把函数引用纳入判断需要用useCallback保持onRename的身份稳定。产生什么效果无关的count更新不再击穿MemoChild的记忆化边界队名变化仍能正确触发渲染。最终目的是什么让函数属性只在其真实依赖变化时改变为昂贵子组件减少无效工作同时保持数据流正确。4.3 依赖数组与闭包陷阱useCallback会记住创建函数时所处的词法环境。如果回调读取了某次渲染中的count却故意把依赖数组写成空数组函数就可能长期读取旧值consthandleAdduseCallback((){setCount(count1);},[]);// 错误回调使用了 count却没有声明依赖这不是缓存失效而是缓存得“过于成功”函数引用一直没变闭包中的count也一直停留在创建时的值。可以选择如实声明依赖consthandleAdduseCallback((){setCount(count1);},[count]);也可以在只需依据旧状态计算新状态时改用函数式更新以移除对count的读取consthandleAdduseCallback((){setCount(currentCountcurrentCount1);},[]);两种写法都正确但引用稳定范围不同。第一种会在count变化后得到新函数第二种可以持续复用同一引用。不要为了维持空依赖而遗漏响应式值应优先保证逻辑正确并让 Hooks ESLint 规则帮助检查依赖完整性。写法函数何时改变是否有旧值风险适用情况useCallback(fn, [count])count变化时正确声明后没有回调必须读取当前countuseCallback(fn, []) 函数式更新通常保持稳定没有读取旧count只需依据前一状态更新useCallback(fn, []) 直接读取count不随count改变有应避免5. 优化边界什么时候值得缓存什么时候保持简单5.1memo、useCallback与useMemo的职责分工三者都与“记忆化”有关但缓存对象和解决问题并不相同工具类型缓存或比较对象典型目标常见组合memoReact API子组件前后props在属性未变时跳过子组件渲染memo 稳定属性useCallbackHook函数引用稳定函数属性或其他 Hook 的函数依赖memouseCallbackuseMemoHook计算结果避免昂贵计算或稳定对象、数组引用memouseMemo可以把它们理解为一条完整链路memo在子组件入口负责“要不要继续渲染”useCallback让函数属性在依赖不变时保持相同useMemo则让计算结果、对象或数组在依赖不变时保持相同。只写memo却持续传入新的函数或对象优化很容易被某一个“永远是新的”属性击穿。5.2 不要把记忆化变成默认仪式记忆化本身也有成本React 需要保存上次结果、比较依赖或属性代码也会增加认知负担。对于渲染开销很小的组件比较成本可能与直接重渲染相差无几。更合理的使用条件包括子组件渲染确实昂贵并且父组件更新频繁。子组件已经由memo包装函数属性的不稳定引用正好使优化失效。某个函数作为useEffect、另一个useCallback或自定义 Hook 的依赖需要稳定引用来控制触发时机。React DevTools Profiler 已经显示某条渲染路径存在可观测的性能成本。反过来如果函数只交给原生元素、子组件本身非常轻量或每次渲染时依赖本来就会变化useCallback往往没有收益。此时保持直接、易读的实现更合适。当前示例使用 React 19并且 React Compiler 未启用因此手动设置的memo与useCallback行为很容易观察。启用 React Compiler 后编译阶段能够自动完成更广泛的记忆化手写这些优化的需求会减少不过理解属性比较、引用身份和闭包依赖仍然重要因为它们决定了数据流是否正确也有助于定位复杂渲染问题。实际优化可以遵循一条简洁路线先用 Profiler 定位慢点再确认无效渲染的触发源随后选择memo、useCallback或useMemo最后重新测量。没有测量依据的缓存只是猜测有前后数据的优化才是工程结论。延伸阅读React 官方文档memoReact 官方文档useCallbackReact 官方文档useMemo总结这组渲染对照展示了一条完整的 React 性能优化思路父组件状态变化会启动新的渲染普通子组件默认随之执行memo通过比较前后属性为输入未变的子组件建立跳过边界。当子组件只接收字符串时这个边界容易生效一旦加入函数属性每次渲染产生的新引用就可能让memo失去作用此时useCallback才有明确价值。它依据依赖数组返回稳定的函数引用并可结合函数式状态更新减少不必要的依赖。整个过程应始终以正确性为前提以 Profiler 测量为依据避免为了形式而滥用记忆化。
返回列表