3.1 性能优化


文档摘要

3.1 性能优化 本节摘要:React 性能优化的核心不是「用对某个 API」,而是「让 Diff 的三条假设成立」。本节从 React.memo、useMemo、useCallback 三个记忆化工具讲起,再到虚拟滚动、代码分割、图片优化,最后给出 Profiler 的分析流程与「优化前先测量」的工程纪律。 本节目标 阅读完本节,你应当能够: 用 React.memo 避免纯函数组件的不必要重渲染 用 useMemo 缓存计算结果、useCallback 缓存函数引用 理解三个记忆化工具的依赖数组与适用边界 用虚拟滚动解决长列表性能问题 用 React.

3.1 性能优化

本节摘要:React 性能优化的核心不是「用对某个 API」,而是「让 Diff 的三条假设成立」。本节从 React.memo、useMemo、useCallback 三个记忆化工具讲起,再到虚拟滚动、代码分割、图片优化,最后给出 Profiler 的分析流程与「优化前先测量」的工程纪律。

本节目标

阅读完本节,你应当能够:

  1. 用 React.memo 避免纯函数组件的不必要重渲染
  2. 用 useMemo 缓存计算结果、useCallback 缓存函数引用
  3. 理解三个记忆化工具的依赖数组与适用边界
  4. 用虚拟滚动解决长列表性能问题
  5. 用 React.lazy 与 Suspense 做代码分割
  6. 用 Profiler 定位性能瓶颈,遵循「先测量后优化」

一、问题与直觉:优化到底在优化什么

性能优化最常听到的问题是「为什么我用了 React.memo 反而变卡」。答案藏在第 2.1 节的结论里:React 的渲染成本主要在于「重新调用组件函数、重建虚拟 DOM」。优化就是要减少不必要的重建。

一个典型场景:父组件有个 state 每秒变化,子组件其实只依赖某个不变的数据。父组件每次重渲染,子组件都被迫跟着重渲染——即使它该显示的界面根本没变。这就是「不必要的渲染」,是 React 应用最常见的性能浪费。

React 给的武器是「跳过」:React.memo 让子组件比较 props,没变就跳过;useCallback 让传给子组件的函数引用稳定,配合 memo 生效;useMemo 让重计算只发生在依赖变化时。三个工具解决三个不同层面的「重复劳动」。

二、核心原理:三个记忆化工具

React.memo:跳过组件的重渲染

React.memo 是高阶组件,接收一个组件,返回它的记忆化版本。默认浅比较 props,props 没变就跳过重渲染:

import { memo } from 'react'; const MyComponent = memo(function MyComponent(props) { return <div>{props.data}</div>; });

memo 还可以传自定义比较函数,精细控制「什么算变了」:

const areEqual = (prevProps, nextProps) => { return prevProps.data === nextProps.data; }; const MyComponent = memo(MyComponentInner, areEqual);

适用场景:纯函数组件、props 更新频繁但实际数据变化不大、大型组件树的叶子节点。

useMemo:缓存计算结果

useMemo 缓存函数调用的返回值,依赖不变就不重算:

import { useMemo, useState } from 'react'; function MyComponent() { const [count, setCount] = useState(0); const expensiveValue = useMemo(() => { let result = 0; for (let i = 0; i < 1000000; i++) result += i; return result; }, [count]); // count 变化才重算 return <p>{expensiveValue}</p>; }

关键在依赖数组:[count] 告诉 React「count 没变就用缓存」。适用场景:计算密集型操作、会被多次读取的派生值。

useCallback:缓存函数引用

useCallback 缓存函数本身,避免每次渲染都创建新引用:

import { useCallback, useState } from 'react'; function Parent() { const [count, setCount] = useState(0); const handleClick = useCallback(() => { setCount(prev => prev + 1); }, []); // 空依赖,函数引用永远不变 return <Child onClick={handleClick} />; }

它的价值不是「省了创建函数的开销」——创建函数本身很便宜。它的价值是:让传给 memo 子组件的 props 引用稳定,否则 memo 的浅比较每次都发现新函数引用而失效。

💡 关键直觉:React.memo、useCallback、useMemo 是同一套思路的三个维度——React.memo 拦组件,useCallback 稳引用,useMemo 缓存计算。它们的共同前提是依赖数组要写对:漏写依赖读到旧值,多写依赖缓存失效。

工具 拦什么 何时用 代价
React.memo 组件重渲染 纯组件、频繁 props 更新 多一层比较
useCallback 函数重建 传函数给 memo 子组件 多一个闭包
useMemo 重复计算 计算密集、派生值 多一份内存

三、工程实践:三个工具的配合

真正项目里三者经常组合。看一个「子组件只依赖少量 props 但父组件高频更新」的经典场景:

function TodoItem({ todo, onToggle }) { return ( <li onClick={() => onToggle(todo.id)}> {todo.text} - {todo.done ? '已完成' : '未完成'} </li> ); } const MemoTodoItem = memo(TodoItem); function TodoList({ todos }) { const [filter, setFilter] = useState('all'); const handleToggle = useCallback((id) => { console.log('切换', id); }, []); // 稳定引用,配合 memo return ( <div> <button onClick={() => setFilter('done')}>只看已完成</button> <ul> {todos.map(todo => ( <MemoTodoItem key={todo.id} todo={todo} onToggle={handleToggle} /> ))} </ul> </div> ); }

filter 状态变化让 TodoList 重渲染,但每个 MemoTodoItem 的 props(todo 和 handleToggle)都没变,memo 跳过它们的重渲染。没有 memo,filter 一变,整个列表重建;没有 useCallback,handleToggle 每次新引用,memo 失效。

该不该优化的判断

优化有代价:代码变复杂、内存占用增加。三个场景别优化:

  • 组件本来就重渲染成本低(简单的静态展示)
  • 依赖数组不稳定,缓存频繁失效
  • 过早优化——「优化前先测量」,没有数据支撑的优化是在赌

虚拟滚动:长列表的解法

几千上万项列表,memo 救不了——每次滚动都要渲染新的一批。虚拟滚动的思路是只渲染可见区域内的项,用绝对定位模拟滚动:

import { FixedSizeList as List } from 'react-window'; const Row = ({ index, style }) => ( <div style={style}>Row {index}</div> ); function MyList({ data }) { return ( <List height={150} itemCount={data.length} itemSize={35} width={300} > {Row} </List> ); }

react-window 的 FixedSizeList 适用于等高的项,VariableSizeList 适用于高度不一的项。虚拟列表把渲染量从「总数」降到「视口能看到的数量」,长列表从「卡顿」变「流畅」。

代码分割:按需加载

代码分割把应用拆成多个小 bundle,按需加载,减少首屏体积。React.lazy 配 Suspense:

import { lazy, Suspense } from 'react'; const MyComponent = lazy(() => import('./MyComponent')); function App() { return ( <Suspense fallback={<div>加载中...</div>}> <MyComponent /> </Suspense> ); }

动态 import() 还能在事件里按需加载:

async function handleClick() { const module = await import('./MyModule'); module.doSomething(); }

图片优化

SOURCE 原文列了四条:压缩(TinyPNG 类工具)、WebP 格式、懒加载(loading="lazy")、CDN 加速。图片体积往往是首屏体积的大头,这四条优先级高、成本低。

useMemo 与 useCallback 的误用警示

这两个工具被过度使用的频率很高。先说清楚它们的成本:useMemo 会缓存计算结果,占一份内存;useCallback 会缓存函数引用,多一个闭包。如果缓存的值很少被用到、或者依赖频繁变化导致缓存经常失效,那缓存不仅没省事,反而多了维护负担。

判断是否需要 useMemo,可以问自己两个问题:第一,这个计算真的贵吗?数组排序、简单过滤这类操作在几百条数据量级下开销可以忽略,useMemo 反而成了负担。第二,这个值会作为依赖传给 memo 子组件吗?如果是,useMemo 才有意义——它能稳定引用,让 memo 生效。脱离这两个前提用 useMemo,属于「为了优化而优化」。

useCallback 同理。它真正有价值的场景只有一个:把函数传给 React.memo 包裹的子组件。如果子组件没有 memo,每次渲染传新函数没有任何额外代价——React 本来就会重新渲染它,函数新不新无所谓。所以「给所有函数套 useCallback」是典型的反模式。

React DevTools 的排查辅助

排查「组件为什么重渲染」时,React DevTools 有一个隐藏好用的功能:在组件树里选中组件,能高亮显示它何时重渲染。配合 Profiler 的「渲染原因」面板,可以看到触发渲染的具体 props 或 state 变化来源。

还有个小技巧:在渲染函数里临时打印,直接看哪些组件被重复渲染:

function TodoItem({ todo }) { console.log('TodoItem 渲染', todo.id); return <li>{todo.text}</li>; }

打开控制台观察:点击无关按钮时,如果 TodoItem 的日志还在刷,说明它在做无意义的重渲染——这就是 memo 该上场的地方。观察完记得删掉 console.log,别让调试代码留在生产里。

四、避免不必要的渲染

记忆化是「主动优化」,还有一类是「避免作死」——别在渲染期制造变化:

  • 渲染期新建对象/函数<Child config={{x:1}} /> 每次渲染都新对象,memo 浅比较必失
  • 父组件更新不相关的 state:父组件一重渲染,子组件跟着遭殃,哪怕子组件没变
  • 不稳定的依赖:依赖数组里的对象每次渲染都是新引用,缓存形同虚设

五、用 Profiler 找到瓶颈

React DevTools 的 Profiler 标签能记录每次渲染的耗时与原因。排查顺序:

  1. 录一段交互(点击、输入、滚动)
  2. 看 Profiler 里哪些组件渲染耗时高
  3. 看渲染原因——是 props 变化还是父组件连带
  4. 对症下药:memo、useCallback、状态提升、虚拟滚动

React.Profiler 组件还能在代码里测量特定部分:

<Profiler id="TodoList" onRender={(id, phase, actualDuration) => { console.log(id, phase, actualDuration); }}> <TodoList /> </Profiler>

⚠️ 常见坑:把所有组件都包上 memo。memo 有比较成本,全部包等于处处增加开销,还可能因为依赖写错引入 bug。正确做法是「Profiler 指哪打哪」,只优化确认有问题的组件。

💡 关键直觉:性能优化的黄金法则是「先测量,再优化,再测量」。没有测量就动手,等于闭眼修水管——修完可能更糟。React 的优化工具都在等一个「确切的瓶颈」,别让它们空转。

状态提升与下移:另一种优化维度

记忆化是从「跳过渲染」入手,还有一种思路是「从源头减少渲染」。状态提升把共享状态放到更靠近使用处的公共父组件,避免无关组件被连带渲染;状态下移把只属于局部的 state 移进局部组件,让父组件不感知、不重渲染。

举个例子:一个表单里有个只在提交时用到的「草稿状态」,如果把它放在页面级组件里,每次输入都让整个页面重渲染。把它下移进表单组件内部,页面组件就不再被输入动作打扰。这类结构性调整往往比堆 memo 更彻底——它减少的是渲染本身,而不是在渲染发生后试图跳过。

生产环境的性能差异

开发模式下 React 会做额外检查(propTypes、警告、严格模式双调用),性能表现和线上不同。优化效果的验证要以生产构建为准:用构建产物本地起一个预览服务,再录 Profiler。别用开发环境的卡顿吓自己,也别用开发环境的流畅安慰自己。

性能优化决策矩阵

性能优化决策矩阵

这张图把常见问题与对应解法对应起来:横向是症状,纵向是工具,交叉点是使用条件。排查性能问题时不逐个试工具,先看症状属于哪一类,再选对应的解法。

六、一个完整的优化流程

把本节知识串成可操作的五步:

  1. 测量:Profiler 录制,找到耗时最高的渲染
  2. 分析原因:是 props 变化、依赖不稳定,还是长列表/大计算
  3. 对症:props 变化用 memo + useCallback;大计算用 useMemo;长列表用虚拟滚动;首屏大用代码分割
  4. 验证:再录一次 Profiler,对比优化前后耗时
  5. 收敛:确认有效后再继续下一个瓶颈,别一次改一堆

这个流程比任何「优化技巧清单」都重要——它把性能优化从「玄学」变成「工程」。

常见性能问题快答

「用了 memo 反而更慢了,怎么回事?」 排查两处:一是比较成本是否大于渲染成本——一个只有两个 span 的静态组件,每次浅比较的开销可能和直接渲染相当,memo 白加;二是依赖是否稳定——父组件每次渲染都传新的内联对象或箭头函数,memo 的浅比较每次都命中「变了」,等于空转。前者该去掉 memo,后者该配合 useCallback/useMemo 把引用稳住。memo 是「props 稳定时才生效」的工具,前提不成立,结果必然失望。

「首屏体积优化到底从哪下手?」 先分清体积构成,再动手。打开构建产物分析面板,按体积排序看三大块:依赖库(antd、echarts 这类动辄几百 KB)、业务代码、图片资源。依赖库大的,先看能不能按需引入或换轻量替代;业务代码大的,用路由级代码分割按访问拆包;图片大的,压缩换格式。一次性把三块全压,改动面太大,先挑占比最高的一块做,验证有效再继续。

「Suspense 和错误边界能配合吗?」 能,而且应该配合。Suspense 管「加载中」,错误边界管「加载失败」,两者是同一个异步渲染流程的两个阶段。用 React.lazy 懒加载的组件,Suspense 的 fallback 只在加载期间显示;一旦模块加载失败(比如网络断了),错误会在 Suspense 之上抛给最近的错误边界。把这两个一起用,用户得到的体验是「先看到加载占位,失败了看到友好的降级页」——比单独用任何一个都完整。这也是第 3.8 节错误边界与本节懒加载联动的典型场景。

核心回顾

  • 优化本质:减少不必要的组件重建,让 Diff 假设成立
  • React.memo:浅比较 props,跳过重渲染,纯组件适用
  • useCallback:稳定函数引用,配合 memo 传给子组件
  • useMemo:缓存计算结果,依赖数组决定何时重算
  • 三个工具配合:memo 拦组件、useCallback 稳引用、useMemo 缓存算
  • 别过度记忆化:useMemo/useCallback 有价值的前提是「计算贵」或「传给 memo 子组件」
  • 虚拟滚动:只渲染可见区域,长列表必备
  • 代码分割:lazy + Suspense 按需加载,减小首屏
  • 图片优化:压缩、WebP、懒加载、CDN
  • 避免作死:渲染期别新建对象,依赖别不稳定
  • 状态提升与下移:从源头减少渲染,比堆 memo 更彻底
  • 先测量后优化:Profiler 指哪打哪,生产构建验证效果

下一节进入路由管理——性能让应用「跑得快」,路由让应用「走得通」。React Router 的多页面导航、动态参数、嵌套路由,是任何多页面应用的骨架。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U