ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

React性能优化实战:减少不必要的渲染,掌握useMemo与memo的正确用法

React性能优化实战:减少不必要的渲染,掌握useMemo与memo的正确用法 1. 先搞清楚React的性能瓶颈到底在哪做React性能优化第一件事不是上来就甩memo、甩useMemo而是先想清楚一个问题React的渲染机制里哪些环节会卡。React从状态更新到页面变化一般会经过三个阶段render阶段、reconcile阶段、commit阶段。render阶段是调用组件函数生成新的React元素树reconcile阶段是把新老虚拟DOM做对比找出差异commit阶段才是真正把差异应用到真实DOM上。大部分性能问题其实出现在前两个阶段也就是说你的组件函数被反复调用diff的工作量变得很大最终拖慢了整个页面。很多刚接触React的人有一个误区以为只要页面卡了就一定是DOM操作太多。其实不是。React内部有Fiber架构做的是异步可中断的渲染commit阶段的DOM操作往往已经被优化得比较高效了。真正的问题通常是一个状态更新导致了一整棵组件树重新render哪怕其中99%的组件根本没有用到这个状态。这就是我们常说的“不必要的渲染”。举个很典型的场景页面上有一个搜索框输入内容时setState更新query值。如果你的输入框组件和下面的列表组件在同一个父组件里父组件的state一变下面整个列表全部重新render。列表如果只有几十条还好一旦有成百上千条复杂行卡顿感就非常明显。这种情况下哪怕列表的数据压根没变它也会跟着重新跑一遍render函数。另一个常见瓶颈是在render函数内部写了太重的计算逻辑比如遍历一个很大的数组做过滤、排序、格式化。这些计算每次渲染都要从头执行一次即使输入的数据没变。这种问题用useMemo就可以解决但要真正理解它的适用边界不然会变成滥用。还有一类问题隐藏在事件绑定和子组件传参里。父组件每次渲染都会创建新的函数引用比如onClick{() handleClick(id)}这种写法会导致子组件即使用了React.memo也失效因为每次传过去的props都是新的引用。性能优化的本质是减少不必要的渲染、降低单次渲染的消耗、减少提交阶段的DOM操作。理解了这三句话后面所有优化手段都能对号入座。2. 从组件层面入手memo、useMemo与useCallback的正确用法2.1 React.memo到底挡住了什么React.memo是一个高阶组件它的作用是当组件的props没有变化时跳过这个组件的重新渲染。听起来很简单但实际用起来有很多细节。先看一个典型例子。假设有这样一个父组件function Parent() { const [count, setCount] useState(0); const [name, setName] useState(张三); return ( div button onClick{() setCount(count 1)}count: {count}/button Child name{name} / /div ); } function Child({ name }) { console.log(Child render); return div{name}/div; }父组件里点击按钮更新countChild组件明明只依赖name却会跟着一起重新渲染。给Child包一层React.memo就能避免这种无谓渲染const Child memo(function Child({ name }) { console.log(Child render); return div{name}/div; });这样count变化时Child不会重新渲染因为它的props没有变化。但问题马上来了如果父组件传给Child的是一个函数呢function Parent() { const [count, setCount] useState(0); // 每次渲染都生成一个新的函数引用 const handleClick () { console.log(click); }; return ( div button onClick{() setCount(count 1)}count: {count}/button Child onClick{handleClick} / /div ); }这种情况下React.memo的浅比较会发现onClick的引用变了于是Child照常重新渲染。解决方式就是配合useCallbackconst handleClick useCallback(() { console.log(click); }, []);useCallback会缓存函数的引用只要依赖数组不变返回的就是同一个函数引用。这样React.memo才能发挥作用。这里有个很重要的使用边界React.memo只做浅比较如果你的props是对象、数组这种引用类型哪怕内容没变只要每次父组件渲染时重新创建了新的对象memo就会失效。常见的写法误区是Child style{{ color: red }} /这条style内联对象每次render都是新引用memo直接失效。正确的做法是把style提取成常量或者用useMemo缓存。注意不要给每个组件都包一层React.memo。memo本身也有开销它需要做props的浅比较。对于本来就轻量、渲染成本极低的组件用memo反而浪费。经验法则是只有当组件渲染成本较高、且props变化频率较低时memo才有明显收益。2.2 useMemo与useCallback缓存要花在刀刃上useMemo用于缓存计算结果useCallback用于缓存函数引用。两者的本质逻辑是一样的都依赖“依赖数组”只有当依赖变化时才重新计算/重新生成。先看useMemo的经典场景function List({ data, keyword }) { const filteredData useMemo(() { return data.filter(item item.name.includes(keyword)); }, [data, keyword]); return ( ul {filteredData.map(item li key{item.id}{item.name}/li)} /ul ); }data有几千条时filter操作每次渲染都要执行。用useMemo缓存后只有在data或keyword变化时才重新过滤。再举个更实际的例子格式化日期。假设有一个时间戳要格式化成“2026-03-15 14:30”这样的字符串。toLocaleString这个操作其实不小如果组件频繁渲染每次都重复格式化同一个时间戳纯属浪费。const formattedTime useMemo(() { return new Date(timestamp).toLocaleString(zh-CN, { year: numeric, month: 2-digit, day: 2-digit, hour: 2-digit, minute: 2-digit }); }, [timestamp]);useCallback的典型场景则集中在把函数传给子组件、把函数作为useEffect的依赖、把函数传给自定义Hook。const fetchData useCallback(async () { const result await api.getList(); setList(result); }, []);但这里有个常见的坑如果fetchData内部用到了某个状态值而你把依赖数组写成了空就会形成闭包陷阱拿到的是旧的state。正确做法是把用到的变量全部加进依赖数组const fetchData useCallback(async () { const result await api.getList({ page: currentPage }); setList(result); }, [currentPage]);我刚入行时也踩过这个坑因为漏写了依赖导致接口一直请求第一页的数据。排查了很久才发现是useCallback的闭包问题。还有一个很常见的滥用场景把所有的函数都包上useCallback。这完全没有必要。useCallback本身也要付出内存和比较的代价如果一个函数不会作为props传给子组件、也不会作为useEffect依赖那它完全不需要缓存。优化不能靠无差别堆砌API要有针对性。2.3 key的正确使用别再稀里糊涂用index了列表渲染的key问题是React面试里高频考、日常开发中高频错的点。key的作用是帮助React识别哪些列表项发生变化、被添加或被移除。key选得好diff效率高key选得不好不仅影响性能还会引发状态混乱。最通用的建议是使用数据中唯一且稳定的id比如后端返回的数据库主键。如果数据是前端生成的可以用crypto.randomUUID()或者一个自增计数器来生成id。用index做key的典型问题列表头部插入一项时所有列表项的index都会变化。React会认为所有列表项都变了导致整列重新渲染。更严重的是如果你的列表项内部有非受控输入框或本地状态用index做key会出现状态错位的诡异bug输入框里的内容跑到别的行去了。举个例子一个可编辑的列表用户在第一行输入了“Hello”如果此时在列表顶部插入一条新数据使用index做key的话原来“Hello”的实际状态可能会挂到新插入的那一行上。这就是我在实际项目中踩过的真实问题。key还有一个容易忽略的点同列表内的key必须唯一但不同列表之间的key可以重复因为key的作用范围是兄弟节点之间不是全局唯一。2.4 组件拆分把大的render函数拆开组件层面的优化除了memo和hooks还有一个容易被忽略的手段组件拆分。如果一个大组件内部同时管理了很多毫无关联的状态任何一次setState都会导致整个组件重新渲染。与其用memo去堵不如直接拆分组件把状态隔离到各自的组件内部。举个例子一个仪表盘页面包含筛选区、图表区、表格区、统计卡片区。如果所有状态都放在一个组件里操作筛选器时图表、表格全部重新render。拆分成独立的子组件各自管理自己的状态天然就能减少无谓渲染。这是最值得优先做的优化比强行上memo更干净。拆分的好处不只是性能代码的可维护性也会大幅提升。每次改一个模块只关注一个组件不用在一坨上千行的代码里翻来翻去。3. 代码层面的性能优化拆包、并发特性与状态设计3.1 代码分割React.lazy和Suspense怎么用对很多人把代码分割等同于性能优化其实它优化的是“首屏加载速度”而不是“页面运行时的流畅度”。这两者要分开来看。Webpack和Vite都支持基于动态import的代码分割。React官方推荐的方式是React.lazy配合Suspenseconst Dashboard lazy(() import(./pages/Dashboard)); const UserList lazy(() import(./pages/UserList)); function App() { return ( Routes Route path/dashboard element{ Suspense fallback{PageLoading /} Dashboard / /Suspense } / Route path/users element{ Suspense fallback{PageLoading /} UserList / /Suspense } / /Routes ); }React.lazy会让打包工具把Dashboard和UserList拆分成单独的chunk只有在路由匹配到时才加载对应的JS文件。实际项目中使用时有几个细节要注意。第一Suspense的fallback要足够轻通常是一个简单的loading指示器不要搞一个很重的组件进去否则反而拖慢渲染。第二要配合路由级的懒加载来做不要在页面内随意使用lazy因为每次组件mount都会触发异步加载造成闪烁。第三如果用户切换路由很频繁除了Suspense之外还可以考虑使用import()预加载例如在鼠标hover到导航项时提前拉取对应chunkconst prefetchDashboard () import(./pages/Dashboard); // 在onMouseEnter时调用prefetchDashboard()这种资源预加载的思路在很多大型后台管理系统里非常实用能让用户感知到页面几乎“秒开”。3.2 状态设计别让Context成为性能黑洞状态管理选型直接影响性能。React自带的Context虽然方便但有一个公认的问题Context的值一旦变化所有消费了这个Context的组件都会重新渲染。假设这样一个场景const AppContext createContext({ user: null, theme: light, cart: [] }); function App() { const [user, setUser] useState(null); const [theme, setTheme] useState(light); const [cart, setCart] useState([]); return ( AppContext.Provider value{{ user, theme, cart }} Header / MainContent / Footer / /AppContext.Provider ); }在Provider的value里直接写对象字面量这是一个极其容易踩的坑。App每次渲染都会生成一个新的value对象导致所有消费Context的组件统统重新渲染哪怕你的user、theme、cart这些值一个都没变。解决办法有两种。第一种是用useMemo缓存valueconst value useMemo(() ({ user, theme, cart }), [user, theme, cart]);第二种是把Context拆细把userContext、themeContext、cartContext分开声明让组件可以按需消费。这样主题变化时只有消费themeContext的组件重新渲染不会牵连到用户信息和购物车。const UserContext createContext(null); const ThemeContext createContext(light); const CartContext createContext([]);在实际项目里如果全局状态很多我更推荐使用zustand、jotai这类轻量级状态库。zustand的原理是订阅-发布模式组件通过selector精确选择自己需要的state片段只有选中的片段变化时组件才会重新render。相比于Context的“一刀切”这种细粒度的订阅机制在高频更新的场景下优势非常明显。拿zustand来写的话之前的例子会变成import { create } from zustand; const useStore create((set) ({ user: null, theme: light, cart: [], setUser: (user) set({ user }), setTheme: (theme) set({ theme }), addToCart: (item) set((state) ({ cart: [...state.cart, item] })), })); // 在组件中只选择需要的字段 const theme useStore((state) state.theme);这样的话只有theme变化时选中的组件才会重新渲染user和cart的更新根本不会影响它。3.3 并发特性startTransition和useDeferredValue的使用场景React 18推出的并发特性是理解React性能优化新思路的关键。核心思想是把更新区分为紧急更新和非紧急更新给紧急更新最高优先级让非紧急更新可被中断、可被延后。startTransition非常适合处理这类场景用户输入关键词同时需要根据关键词过滤一个很长的列表。输入动作本身是紧急的必须立刻响应过滤列表是非紧急的可以延后处理。const [keyword, setKeyword] useState(); const [isPending, startTransition] useTransition(); const handleChange (e) { const value e.target.value; setKeyword(value); // 紧急更新 startTransition(() { setFilteredList(filterData(value)); // 非紧急更新 }); };使用startTransition后当用户持续打字时React会先处理输入框的更新让输入保持流畅过滤列表的操作则会在后台进行如果还有新的输入进来之前的过滤任务会被中断。这种体验上的提升在长列表过滤的场景中非常明显。useDeferredValue的适用场景和startTransition有些相似但它更像是“自动版”的过渡更新。你不需要手动把setState包在startTransition里只需要定义一个“延迟值”const [keyword, setKeyword] useState(); const deferredKeyword useDeferredValue(keyword); const filteredList useMemo( () filterData(deferredKeyword), [deferredKeyword] );keyword每次变化时useDeferredValue会返回一个延迟更新的值。React会在后台用新的keyword重新渲染列表而输入框本身一直保持响应。实际使用中需要注意useDeferredValue并不是“防抖”延迟时间不是固定的毫秒数而是由React根据设备性能动态调整。设备性能越好延迟越短设备性能越差延迟越长。这个设计非常聪明等于把性能适配交给了框架。不过这两个特性并不适合所有场景。如果非紧急更新的计算量只有几百条数据渲染也就几毫秒没必要用transition增加复杂度反而得不偿失。合理的使用前提是更新本身的计算或渲染成本确实很高且用户能感知到卡顿。4. 性能分析的实操流程定位瓶颈比瞎优化重要一百倍4.1 用React DevTools Profiler定位二次渲染做性能优化最忌讳的就是“我感觉这里慢了”就开始动手改。先测量再定位最后优化这是我一直坚持的流程。React DevTools的Profiler面板就是定位渲染瓶颈的首选工具。具体操作很简单打开React DevTools切到Profiler标签页点击左上角的录制按钮然后在页面上操作再停止录制。Profiler会展示这段时间内每一次组件渲染的火焰图。火焰图里有两个关键信息一是哪些组件发生了渲染二是每次渲染耗时多少。如果有组件被灰色或者其他颜色标记说明它被memo包裹但仍发生了重新渲染这时就要去检查props引用是否变化了——通常是父组件传入的函数或对象没有做好缓存。选中一个组件右侧面板会显示它渲染的原因比如“Parent re-rendered”或者“Props changed: onClick”。这个信息在排查memo失效问题时非常有用可以精准定位是哪个props引用变了。4.2 优化三步走测量、定位、对比验证我总结了一套稳定可行的优化流程每一步都不能跳第一步用Profiler录制记录优化前的渲染次数和耗时基线。比如列表组件一次操作要渲染15个组件、总耗时80ms把这些数据记下来。第二步根据火焰图找出渲染次数异常或耗时过高的组件判断引起重渲染的触发源。通常问题集中在大列表没有加memo、函数引用每次都变、Context的value没有缓存。第三步针对问题做优化再用Profiler录制一次对比优化前后的渲染次数和耗时数据。如果优化后数据明显变好说明方向对了如果变化很小别急着加更多优化先确认瓶颈是否真的在这里。提示性能优化最怕的是“优化了A但瓶颈在B”。制定对比验证的习惯能帮你避开大量的无效工作。4.3 浏览器Performance面板的补充价值React DevTools适合定位组件层面的重渲染但页面卡顿不一定全是React的问题。渲染结果最终要落到真实DOM上浏览器本身的布局和绘制也可能成为瓶颈。当你用Profiler发现React渲染很快但页面还是卡时就需要打开Chrome的Performance面板。录制一段操作查看Main线程的火焰图重点关注两件事一是是否有长时间运行的JavaScript任务二是是否频繁触发Reflow和Repaint。如果发现布局抖动Layout Thrashing通常是因为在同一个事件循环里先读取布局属性比如offsetHeight又修改了DOM样式导致浏览器反复强制同步布局。这种问题属于浏览器层面的性能问题React的memo解决不了需要在代码层面把读写操作分离。总体来说全套分析工具配合使用React DevTools负责组件层Performance面板负责浏览器层两者结合才能覆盖绝大多数性能问题。5. 常见问题与避坑记录5.1 这些优化反而会让性能更差踩过不少坑之后我总结出了几个“看似优化、实则负优化”的典型操作。第一个是给所有组件无脑包memo。memo的比较也是要花时间的。如果一个组件本身特别轻渲染一次只需要不到0.1ms那么memo的浅比较开销可能比省下的渲染时间还多。memo适合用在高频更新父组件中的重子组件上而不是所有场景。第二个是为所有的值都用useMemo。useMemo需要额外的内存来存储缓存值如果计算本身很快或者依赖变化很频繁比如每次鼠标移动都要更新坐标useMemo根本起不到缓存作用反而增加依赖比较的开销。比如const rect useMemo(() ({ width: 100, height: 200 }), []); const style useMemo(() ({ width: rect.width, height: rect.height }), [rect]);这种链式useMemo完全没必要直接把对象定义在组件外部或者用普通变量就行。第三个是过度拆分组件。组件拆分的初衷是把状态隔离但如果只是为了“看起来更组件化”而把一个简单的展示逻辑拆成五六个嵌套组件反而会增加props传递和diff的层级深度性能不见得变好代码可读性却变差了。第四个是无限提升优化目标。性能优化也要算成本收益比。如果应用本身只有几千行代码、几十个组件就算肉眼能看出卡顿问题可能不在React渲染层面而是网络请求、图片加载或者浏览器API选择的问题。别拿着锤子到处找钉子。5.2 高频bug排查清单在实际开发中下面这些现象经常出现遇到卡顿问题建议按清单排查页面输入框打字卡顿先检查输入框的setState是否引发了整个页面的重新渲染输入框是否应该拆成独立组件再检查onChange里是否执行了重计算或同步请求。列表滚动卡顿检查列表项是否使用了memo列表项的props是否引用了内联函数或对象key是否使用index列表数据量过大时考虑虚拟滚动。首屏白屏时间长检查路由是否做了懒加载图片是否做了懒加载入口chunk是否过大可以通过构建工具的分析来确认bundle size。切换路由白屏延迟检查是否有大组件没有做代码分割Suspense的fallback是否过重是否有同步加载的大型第三方库。状态更新频繁导致页面卡顿检查是否使用了过多的useState是否应该用useReducer合并相关状态Context是否拆分是否误用了全局状态库。5.3 一个完整的优化案例复盘最后分享一个我实际处理的案例。一个后台报表页面左侧是筛选条件右侧是一个几千行的数据表格。问题现象是每次调整筛选条件表格刷新时页面会有明显的卡顿。先用Profiler录制发现表格组件本身渲染耗时还好但表格的每一行组件都发生了重新渲染总渲染次数高达几千次。再看触发原因发现表格组件接收了一个筛选函数作为props这个函数是在父组件中直接定义的每次父组件渲染都生成新引用。解决方案分两步走第一步用useCallback包住筛选函数确保依赖不变化时引用不变第二步给表格行组件包上React.memo阻断父组件的渲染传导。修改后再次用Profiler录制渲染次数从几千次降到了只有筛选条件真正变化时的那几次卡顿问题基本消失。这次排查完全依赖Profiler的数据没有靠猜。性能优化做久了就会发现90%的卡顿问题都出在“不必要的渲染”上而定位这个问题的关键就是准确的测量工具和清晰的排查思路。React性能优化这件事没什么高深的理论核心就是“让该渲染的渲染不该渲染的别渲染”。熟练使用分析工具、理解渲染机制、按合理流程操作多数性能问题都可以有效解决。
返回列表