React 性能优化三剑客:memo、useCallback 和 useMemo 的配合艺术

📅 发布时间:2026/8/15 23:02:25
React 性能优化三剑客:memo、useCallback 和 useMemo 的配合艺术
本文基于一个真实踩坑记录展开带你彻底理解“为什么明明包了 memo子组件还是跟着父组件乱渲染”。一个典型的性能浪费现场先看你提供的代码它非常直观地暴露了问题import { useState, memo } from react; function RegularChild({ name }) { console.log(渲染了RegularChild组件); return h1{name}/h1; } const MemoChild memo(({ name }) { console.log(MemoChild 组件渲染了); return h1Hello, {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没变但是RegularChild和MemoChild都会重新渲染。只有当你再次点击“改变名字”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 ( h1Hello, {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不只是缓存函数引用还能缓存任何值useMemo和useCallback原理相似但适用范围更广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 缓存掉。它们之间的配合关系memo是子组件侧的防御机制props 不变我就不渲染。useCallback和useMemo是父组件侧的稳定工具确保传给子组件的 props函数、对象、数组引用不变。三者配合才能真正做到“不相关状态更新时子组件纹丝不动”。再补一句很多人忽视的事实useCallback和useMemo本身也有开销。如果你传给的只是一个普通div或者根本没有被memo包裹的子组件那反而是一种浪费。它们只在结合memo使用或作为其他 Hook 的依赖项时才真正发挥威力。一个“可落地”的优化步骤 checklist如果你在项目中遇到某个页面渲染卡顿或者子组件无缘无故跟着父亲乱抖可以按下面的步骤排查确认渲染范围用console.log或者 React DevTools 的 Highlight 功能看哪些组件在不必渲染时也渲染了。对纯展示、且依赖稳定的子组件包上memo注意只对 props 基本稳定的组件做别一股脑全包。检查传给子组件的引用类型 props如果有函数、对象、数组看看它们在父组件中是否每次渲染都重新创建。用useCallback稳定函数用useMemo稳定对象/数组/计算结果依赖项要写准确避免闭包陷阱比如忘了依赖某个 state。确认优化有效再用 console 或 DevTools profiler 验证一下子组件是否真的跳过了渲染。最后说一个容易被忽略的思维升级很多教程会说useCallback和useMemo是用来“缓存函数”和“缓存计算结果”的但它们真正的定位是引用稳定性。你的目标是让那些被memo保护的子组件收到的 props 引用不要无意义地变化从而避免重渲染。写性能优化的代码本质上是在管理“变化” —— 你告诉 React 什么真正变化了什么只是看起来变化了。memo 是盾useCallback/useMemo 是矛你要把矛头准确扎向“假变化”。