一、React 性能优化的整体思路
面试题 1:React 性能优化应该从哪些方面入手?
核心思路(一句话)
先测量定位性能瓶颈,再针对"渲染、计算、列表、网络加载"分别优化,最后通过指标验证优化是否真正有效。
主要矛盾
主要矛盾不是"用了哪些 React API",而是"到底哪里浪费了时间"。
性能优化最忌讳:
text
感觉这里可能慢
↓
直接加 React.memo
↓
再加 useMemo
↓
再加 useCallback
↓
代码越来越复杂
↓
性能不一定变好
正确方式应该是:
text
用户感知到卡顿 / 首屏慢 / 交互延迟
↓
建立性能指标
↓
定位瓶颈
↓
┌────────────┼─────────────┐
↓ ↓ ↓
渲染问题 计算问题 加载问题
↓ ↓ ↓
React.memo useMemo 代码分割
useCallback 计算拆分 React.lazy
组件拆分 Web Worker 预加载
↓ ↓ ↓
长列表 → 虚拟化
↓
优化后重新测量
↓
对比优化前后指标
性能问题可以分成四类
| 类型 | 常见问题 | 典型方案 |
|---|---|---|
| 组件渲染 | 父组件更新导致无关子组件更新 | React.memo |
| 引用变化 | 函数、对象、数组每次重新创建 | useCallback、useMemo |
| 计算耗时 | 大量数据计算、复杂排序过滤 | useMemo、Web Worker |
| DOM 数量 | 几千、几万条列表 | 虚拟列表 |
| 首屏加载 | JavaScript 包过大 | 代码分割、懒加载 |
| 交互优先级 | 大量低优先级更新阻塞输入 | startTransition、useDeferredValue |
| 网络 | 接口串行、资源过多 | 并行请求、缓存、预加载 |
| DOM/浏览器 | Layout、Paint、Composite 开销 | 减少 DOM、避免强制同步布局 |
二、React.memo
面试题 2:React.memo 是什么?它为什么可以减少组件重新渲染?
核心思路(一句话)
React.memo 会比较函数组件前后两次接收到的 props,如果 props 没有变化,就可以跳过这次由父组件更新导致的子组件重新渲染。
解决方案流程图
text
父组件发生更新
↓
React 准备处理子组件
↓
子组件是否使用 React.memo?
↓
是
↓
比较旧 props 和新 props
↓
props 是否变化?
↙ ↘
否 是
↓ ↓
跳过本次 重新执行组件
组件渲染 ↓
生成新的 React Element
↓
后续协调与提交
底层实现原理
假设:
jsx
const UserInfo = React.memo(function UserInfo({ name, age }) {
console.log('UserInfo render');
return (
<div>
{name} - {age}
</div>
);
});
父组件:
jsx
function App() {
const [count, setCount] = React.useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>
count: {count}
</button>
<UserInfo name="张三" age={30} />
</div>
);
}
点击按钮:
text
App state 更新
↓
App 重新执行
↓
生成新的 <UserInfo name="张三" age={30} />
↓
React.memo 比较 props
↓
name:张三 === 张三
age:30 === 30
↓
props 没有变化
↓
跳过 UserInfo 本次重新渲染
React.memo 默认怎么比较?
默认使用的是浅层比较。
可以近似理解成:
js
Object.is(prevProps.name, nextProps.name);
Object.is(prevProps.age, nextProps.age);
注意:
jsx
<UserInfo user={{ name: '张三', age: 30 }} />
即使内容一样:
js
{ name: '张三', age: 30 } !== { name: '张三', age: 30 }
因为这是两个不同的对象引用。
完整示例代码
jsx
import React from 'react';
/**
* React.memo 包装函数组件。
*
* 只有当 props 发生变化时,React 才需要重新执行这个组件。
*/
const UserInfo = React.memo(function UserInfo({ name, age }) {
console.log('UserInfo render');
return (
<div>
<p>姓名:{name}</p>
<p>年龄:{age}</p>
</div>
);
});
export default function App() {
// count 和 UserInfo 的 props 没有任何关系。
const [count, setCount] = React.useState(0);
return (
<div>
<button onClick={() => setCount((value) => value + 1)}>
count:{count}
</button>
{/*
点击按钮后 App 会重新执行。
但是:
name 始终是 "张三"
age 始终是 30
React.memo 会发现 UserInfo 的 props 没有发生变化,
因此可以跳过 UserInfo 的这次重新渲染。
*/}
<UserInfo name="张三" age={30} />
</div>
);
}
自定义比较函数
如果默认浅比较不能满足需求,可以:
jsx
const UserInfo = React.memo(
function UserInfo({ user }) {
console.log('UserInfo render');
return (
<div>
{user.name} - {user.age}
</div>
);
},
/**
* 返回 true:
* 表示新旧 props 可以认为相同,
* React 可以跳过本次重新渲染。
*
* 返回 false:
* 表示 props 发生变化,需要重新渲染。
*/
(prevProps, nextProps) => {
return (
prevProps.user.name === nextProps.user.name &&
prevProps.user.age === nextProps.user.age
);
}
);
注意:自定义比较函数不是越复杂越好
例如:
js
JSON.stringify(prevProps) === JSON.stringify(nextProps)
通常不是好方案。
因为:
text
比较本身也需要成本
↓
props 越复杂
↓
比较时间越长
↓
可能比较的成本 > 重新渲染成本
所以 React.memo 的核心不是:
"所有组件都包起来。"
而是:
组件重新渲染成本较高 + props 经常保持稳定 + 父组件经常更新。
三、React.memo 的边界和常见误区
面试题 3:React.memo 是不是可以阻止组件所有重新渲染?
核心思路(一句话)
不能,React.memo 主要阻止的是"父组件重新渲染导致的、props 没变化的子组件重新渲染"。
这是面试非常容易追问的一点
text
React.memo
│
├── 父组件重新渲染 + props 没变化
│ → 通常可以跳过
│
├── props 变化
│ → 重新渲染
│
├── 自己的 state 变化
│ → 仍然重新渲染
│
└── 读取的 Context 变化
→ 仍然可能重新渲染
所以:
jsx
const Child = React.memo(() => {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
);
});
点击按钮:
text
Child 自己的 state 改变
↓
Child 必须重新渲染
React.memo 并不会阻止它。
四、useCallback
面试题 4:useCallback 是什么?为什么它经常和 React.memo 一起使用?
核心思路(一句话)
useCallback缓存的是函数引用,在依赖不变时返回同一个函数引用,从而避免函数引用变化导致React.memo子组件失去优化效果。
关键矛盾
看这个代码:
jsx
const increment = () => {
setCount((count) => count + 1);
};
每次 App 执行:
text
第一次 App 执行
increment → 函数对象 A
第二次 App 执行
increment → 函数对象 B
第三次 App 执行
increment → 函数对象 C
虽然:
js
increment()
逻辑完全一样。
但是:
js
A !== B
B !== C
所以:
text
React.memo
↓
比较 onClick
↓
旧函数引用 !== 新函数引用
↓
认为 props 发生变化
↓
子组件重新渲染
正确组合
text
React.memo
+
useCallback
↓
稳定函数引用
↓
React.memo 可以真正发挥作用
完整示例代码
jsx
import React from 'react';
/**
* 子组件使用 React.memo。
*
* 只有当 props 发生变化时才重新渲染。
*/
const MyButton = React.memo(function MyButton({ onClick }) {
console.log('MyButton render');
return (
<button onClick={onClick}>
增加 count
</button>
);
});
export default function App() {
const [count, setCount] = React.useState(0);
const [other, setOther] = React.useState(0);
/**
* 使用函数式更新:
*
* setCount((currentCount) => currentCount + 1)
*
* 这样 increment 不需要读取外部的 count。
*
* 因此 useCallback 的依赖数组可以为空,
* 函数引用可以在整个组件生命周期中保持稳定。
*/
const increment = React.useCallback(() => {
setCount((currentCount) => currentCount + 1);
}, []);
return (
<div>
<p>count:{count}</p>
<p>other:{other}</p>
{/*
increment 的函数引用保持稳定。
因此即使 App 因为 other 改变而重新渲染,
MyButton 收到的 onClick 引用仍然相同。
React.memo 就可以跳过 MyButton 的重新渲染。
*/}
<MyButton onClick={increment} />
<button onClick={() => setOther((value) => value + 1)}>
修改 other
</button>
</div>
);
}
useCallback 的依赖数组为什么非常重要?
例如:
jsx
const handleClick = React.useCallback(() => {
console.log(count);
}, [count]);
这里:
text
count 不变
↓
函数引用保持不变
count 改变
↓
重新创建函数
↓
得到新的函数引用
这并不是 useCallback 失效,而是正确行为 。因为函数依赖了新的 count。
一个更重要的优化技巧
很多时候可以通过函数式更新减少依赖:
不推荐
jsx
const increment = React.useCallback(() => {
setCount(count + 1);
}, [count]);
更推荐
jsx
const increment = React.useCallback(() => {
setCount((currentCount) => currentCount + 1);
}, []);
原因:
text
直接读取 count
↓
依赖 count
↓
count 变化
↓
函数重新创建
而:
text
函数式更新
↓
不需要读取外部 count
↓
依赖数组可以为空
↓
函数引用长期稳定
五、useMemo
面试题 5:useMemo 和 useCallback 有什么区别?
核心思路(一句话)
useMemo 缓存计算结果,useCallback 缓存函数引用;本质上都是为了减少不必要的重复工作,但优化对象不同。
对比
| API | 缓存什么 | 常见用途 |
|---|---|---|
useMemo |
计算结果 | 大量计算、派生数据 |
useCallback |
函数引用 | 给 React.memo 子组件传函数 |
React.memo |
组件渲染结果是否需要重新执行 | 避免无意义的子组件渲染 |
完整示例代码
jsx
import React from 'react';
/**
* 模拟一个比较昂贵的计算。
*
* 实际项目中可能是:
* - 大数组排序
* - 大数组过滤
* - 复杂数据转换
* - 图表数据加工
* - 权限树计算
*/
function expensiveCalculation(number) {
console.log('执行昂贵计算');
let result = 0;
// 这里只是为了模拟 CPU 密集型计算。
for (let i = 0; i < 10000000; i += 1) {
result += (number * i) % 10;
}
return result;
}
export default function App() {
const [number, setNumber] = React.useState(1);
const [other, setOther] = React.useState(0);
/**
* 只有 number 发生变化时,
* expensiveCalculation 才会重新执行。
*
* 如果 App 因为 other 改变而重新渲染,
* 由于 number 没变化,
* React 会直接使用上一次缓存的计算结果。
*/
const result = React.useMemo(() => {
return expensiveCalculation(number);
}, [number]);
return (
<div>
<p>计算结果:{result}</p>
<button onClick={() => setNumber((value) => value + 1)}>
修改 number
</button>
<button onClick={() => setOther((value) => value + 1)}>
修改 other
</button>
<p>other:{other}</p>
</div>
);
}
六、useMemo 为什么不能滥用?
面试题 6:是不是所有计算都应该使用 useMemo?
核心思路(一句话)
不是,只有计算成本明显、重复执行频繁、依赖相对稳定且缓存收益高时,useMemo 才值得使用。
例如:
jsx
const result = useMemo(() => {
return a + b;
}, [a, b]);
通常没有必要。
因为:
text
a + b 本身极其便宜
↓
useMemo 需要维护依赖
↓
还需要缓存和检查
↓
收益可能小于成本
应该使用 useMemo 的场景
text
10000 条数据
↓
复杂 filter
↓
复杂 sort
↓
数据转换
↓
图表数据计算
比较适合。
七、虚拟列表
面试题 7:什么是虚拟列表?为什么它能够优化长列表性能?
核心思路(一句话)
虚拟列表通过窗口化技术,只为当前可视区域及少量缓冲区创建 DOM,而不是一次性渲染整个超长列表,从根源上减少 DOM 数量、布局和渲染开销。
几百、几千条数据一次性渲染会造成页面卡顿,虚拟列表的核心是只渲染视口内以及少量缓冲项。
核心架构图
假设总数据:
text
10000 条数据
普通列表:
text
DOM
│
├── item 1
├── item 2
├── item 3
├── ...
├── item 10000
虚拟列表:
text
10000 条数据
↓
当前滚动位置
↓
计算可见区
↓
计算 overscan 缓冲区
↓
只渲染例如 30 条
↓
┌─────────────────┐
│ 可视区域 │
│ item 500 │
│ item 501 │
│ item 502 │
│ ... │
│ item 520 │
└─────────────────┘
滚动:
text
scrollTop
↓
计算 startIndex
↓
计算 endIndex
↓
更新可见数据
↓
复用 / 更新有限 DOM
底层实现原理
假设:
text
每一项高度 = 50px
当前:
text
scrollTop = 5000px
那么:
text
startIndex = floor(5000 / 50)
= 100
如果容器高度:
text
600px
那么大约可以显示:
text
600 / 50 = 12 项
再增加 overscan:
text
前面额外 5 项
后面额外 5 项
实际可能渲染:
text
95 ~ 116
而不是:
text
0 ~ 9999
一个完整的固定高度虚拟列表示例
jsx
import React from 'react';
const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 400;
const OVERSCAN_COUNT = 5;
/**
* 一个简单的虚拟列表。
*
* 这个示例假设:
* 1. 每一项高度固定;
* 2. 数据数量非常大;
* 3. 通过 scrollTop 计算当前应该渲染哪些数据。
*/
export default function VirtualList() {
// 模拟 10000 条数据。
const items = React.useMemo(() => {
return Array.from({ length: 10000 }, (_, index) => ({
id: index,
name: `用户 ${index + 1}`,
}));
}, []);
const [scrollTop, setScrollTop] = React.useState(0);
/**
* 计算当前可视区域最开始的位置。
*
* 例如:
* scrollTop = 500
* ITEM_HEIGHT = 50
*
* startIndex = 10
*/
const startIndex = Math.max(
0,
Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN_COUNT
);
/**
* 根据容器高度计算理论上可以显示多少条。
*/
const visibleCount = Math.ceil(
CONTAINER_HEIGHT / ITEM_HEIGHT
);
/**
* 结束位置增加 overscan。
*
* 多渲染一些上下缓冲项,
* 可以避免快速滚动时出现白屏。
*/
const endIndex = Math.min(
items.length,
startIndex + visibleCount + OVERSCAN_COUNT * 2
);
/**
* 只截取真正需要渲染的数据。
*/
const visibleItems = items.slice(startIndex, endIndex);
return (
<div
style={{
height: CONTAINER_HEIGHT,
overflow: 'auto',
border: '1px solid #ccc',
}}
onScroll={(event) => {
/**
* 每次滚动时记录 scrollTop。
*
* scrollTop 发生变化后,
* React 会重新计算可视数据范围。
*/
setScrollTop(event.currentTarget.scrollTop);
}}
>
{/*
这个外层元素的高度仍然等于整个列表的总高度。
这样浏览器的滚动条才能表现得像有 10000 条数据。
*/}
<div
style={{
height: items.length * ITEM_HEIGHT,
position: 'relative',
}}
>
{visibleItems.map((item, index) => {
/**
* 真实索引:
*
* startIndex + index
*/
const realIndex = startIndex + index;
return (
<div
key={item.id}
style={{
position: 'absolute',
top: realIndex * ITEM_HEIGHT,
left: 0,
right: 0,
height: ITEM_HEIGHT,
display: 'flex',
alignItems: 'center',
padding: '0 12px',
boxSizing: 'border-box',
borderBottom: '1px solid #eee',
}}
>
{item.name}
</div>
);
})}
</div>
</div>
);
}
实际项目更推荐
实际项目中,如果需求复杂,通常不建议自己实现完整虚拟列表,因为还需要处理:
text
固定高度
动态高度
滚动定位
overscan
快速滚动
键盘导航
滚动锚定
数据追加
容器尺寸变化
所以,可以使用 react-window 等成熟方案:
text
简单固定高度列表
→ 可以自己实现
复杂业务长列表
→ 优先使用成熟虚拟化方案
八、虚拟列表的边界场景
面试题 8:虚拟列表是不是任何长列表都适合?
核心思路(一句话)
虚拟列表适合"大量 DOM 元素同时存在"是主要瓶颈的场景,但对于分页、服务端搜索、数据量本身不大或动态高度特别复杂的列表,需要综合考虑。
不适合直接使用的情况
例如:
text
总共只有 30 条数据
没必要虚拟化。
又比如:
text
数据量 10000 条
但是用户每次只需要分页查看 20 条
更适合:
text
分页
+
服务端查询
而不是单纯把 10000 条数据全部加载到浏览器再虚拟化。
一个非常重要的面试加分点
虚拟列表解决的是"渲染成本",不一定解决"数据获取成本"。
例如:
text
数据库 1000000 条数据
↓
接口一次返回 1000000 条
↓
浏览器拿到 1000000 条
↓
即使使用虚拟列表
↓
网络、JSON 解析、内存仍然可能成为瓶颈
所以更合理:
text
服务端分页 / 游标分页
↓
只获取当前需要的数据
↓
浏览器虚拟化
↓
只渲染当前需要的 DOM
这属于:
数据层优化 + 渲染层优化。
九、代码分割与 React.lazy
面试题 9:React 如何通过代码分割优化首屏加载?
核心思路(一句话)
把非首屏必需的 JavaScript 拆成独立代码块,在真正需要时再加载,从而降低首屏需要下载、解析和执行的 JavaScript 体积。
解决方案流程图
text
原始应用
↓
所有页面代码打进一个 JavaScript Bundle
↓
首屏下载整个 Bundle
↓
下载慢 + 解析慢 + 执行慢
优化:
text
应用代码
↓
代码分割
↓
┌───────────────┐
│ 首页 Bundle │ ← 首屏加载
├───────────────┤
│ 用户中心 Bundle │ ← 进入用户中心再加载
├───────────────┤
│ 管理后台 Bundle │ ← 进入后台再加载
└───────────────┘
完整示例
jsx
import React from 'react';
/**
* React.lazy 接收一个动态 import 函数。
*
* import('./UserCenter') 会被构建工具识别为一个独立代码块。
*
* 只有真正需要 UserCenter 时,
* 浏览器才会请求这个 JavaScript 文件。
*/
const UserCenter = React.lazy(() => import('./UserCenter'));
export default function App() {
const [showUserCenter, setShowUserCenter] =
React.useState(false);
return (
<div>
<h1>首页</h1>
<button
onClick={() => setShowUserCenter(true)}
>
打开用户中心
</button>
{/*
Suspense 用于处理 lazy 组件还没有加载完成的状态。
fallback 会在 UserCenter 的代码加载期间显示。
*/}
<React.Suspense fallback={<div>用户中心加载中...</div>}>
{showUserCenter && <UserCenter />}
</React.Suspense>
</div>
);
}
底层原理
text
React.lazy
↓
动态 import()
↓
构建工具拆分 Chunk
↓
浏览器首次不下载该 Chunk
↓
组件真正需要
↓
执行 import()
↓
发送网络请求
↓
Chunk 下载完成
↓
Promise resolve
↓
React 渲染组件
十、代码分割不是越细越好
面试题 10:代码分割是不是越多越好?
核心思路(一句话)
代码分割的目标是降低关键路径上的资源成本,而不是无限拆包;拆得过细可能增加网络请求、调度和缓存管理成本。
例如:
text
错误思路:
每一个 Button
每一个 Icon
每一个小组件
都拆成一个 Chunk
可能导致:
text
Chunk 数量暴增
↓
请求数量增加
↓
加载调度复杂
↓
反而影响性能
比较合理的是按照:
text
路由
+
业务模块
+
首屏 / 非首屏
+
访问频率
进行拆分。
十一、React DevTools Profiler
面试题 11:如何定位 React 应用中的性能问题?
核心思路(一句话)
不要凭感觉优化,应该使用 React DevTools Profiler 录制真实交互,分析哪些组件重新渲染、渲染耗时多少,再针对具体瓶颈优化。
建议在优化前通过 Profiler 分析组件渲染情况,并通过优化前后的 Profile 进行验证。
诊断流程
text
发现页面卡顿
↓
React DevTools Profiler
↓
开始录制
↓
执行真实操作
↓
停止录制
↓
观察:
├── 哪些组件渲染
├── 渲染次数
├── 渲染耗时
├── 组件之间的关系
└── 哪次交互最慢
↓
定位瓶颈
↓
选择优化方案
↓
再次录制
↓
比较优化前后
Profiler 重点关注什么?
不要只是看:
"哪个组件渲染了?"
更应该看:
text
为什么渲染?
↓
props 变了吗?
state 变了吗?
Context 变了吗?
父组件导致的吗?
↓
渲染本身耗时多少?
↓
真正值得优化吗?
十二、React 性能优化完整排查方法
面试题 12:如果面试官问"你在实际项目中是怎么做 React 性能优化的",应该怎么回答?
核心思路(一句话)
我的性能优化流程是"监控与复现 → 定位瓶颈 → 分类处理 → 小步优化 → 指标验证",而不是一上来就堆 React.memo、useMemo 和 useCallback。
把优化方法归纳为发现问题、分析瓶颈、制定策略、实时优化并验证效果,并强调避免过早优化和过度优化。
完整架构图
text
React 性能问题
│
↓
① 发现 / 复现
│
┌────────────┼────────────┐
↓ ↓ ↓
首屏慢 交互卡 滚动卡
│ │ │
↓ ↓ ↓
加载分析 渲染分析 列表分析
│ │ │
↓ ↓ ↓
Bundle 分析 Profiler DOM 数量
│ │ │
↓ ↓ ↓
代码分割 memo 系列 虚拟列表
懒加载 组件拆分 分页
预加载 状态下沉 服务端分页
│
↓
② 性能优化
│
↓
③ 再次测量
│
↓
④ 指标对比
│
┌────────┴────────┐
↓ ↓
有收益 无收益
↓ ↓
保留优化 回滚 / 换方案
十三、React 性能优化的"主要矛盾"和"次要矛盾"
主要矛盾
1. 不必要的工作
text
没有必要的组件重新渲染
没有必要的计算
没有必要的 DOM
没有必要的 JavaScript
所以核心目标是:
减少不必要的工作。
次要矛盾
具体采用哪个工具:
text
React.memo
useCallback
useMemo
虚拟列表
React.lazy
Suspense
startTransition
useDeferredValue
这些都只是解决具体问题的工具。
所以面试时不要回答成:
"React 性能优化就是 React.memo + useMemo + useCallback。"
这是层次比较低的答案。
应该回答:
text
先确定瓶颈
↓
再确定瓶颈类型
↓
最后选择工具
十四、补充:React 18 的并发相关优化
面试题 13:React 中如何处理高优先级交互和低优先级更新之间的冲突?
核心思路(一句话)
对于不需要立即完成的更新,可以降低它的更新优先级,让用户输入等高优先级交互优先得到响应。
典型 API:
jsx
startTransition
以及:
jsx
useDeferredValue
使用场景
例如搜索:
text
用户输入关键字
↓
input 更新
↓
高优先级
↓
必须立即响应
搜索结果过滤
↓
可能有几千条数据
↓
低优先级
可以把两者区分开。
完整示例
jsx
import React from 'react';
const DATA = Array.from(
{ length: 10000 },
(_, index) => `商品 ${index + 1}`
);
export default function SearchPage() {
const [keyword, setKeyword] = React.useState('');
const [filteredData, setFilteredData] =
React.useState(DATA);
const [isPending, startTransition] =
React.useTransition();
function handleChange(event) {
const value = event.target.value;
/**
* 输入框本身属于高优先级更新。
*
* 用户输入什么,应该立即显示什么。
*/
setKeyword(value);
/**
* 搜索结果属于可以延后的更新。
*
* 使用 startTransition 后,
* React 可以把这部分更新作为非紧急更新处理。
*/
startTransition(() => {
const result = DATA.filter((item) =>
item.includes(value)
);
setFilteredData(result);
});
}
return (
<div>
<input
value={keyword}
onChange={handleChange}
placeholder="搜索商品"
/>
{/*
isPending 可以告诉用户:
低优先级更新还在处理。
*/}
{isPending && <p>搜索中...</p>}
<ul>
{filteredData.slice(0, 100).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
注意
startTransition 不是:
"让 JavaScript 在后台线程执行。"
JavaScript 仍然主要运行在主线程。
它解决的是:
React 更新优先级和可中断渲染的问题。
十五、useDeferredValue 与 useMemo 的区别
这是一个很容易被面试官追问的点。
text
useMemo
↓
减少重复计算
useDeferredValue
↓
允许某个值的使用滞后一些
↓
优化交互响应
例如:
text
输入框 value
↓
立即更新
↓
useDeferredValue
↓
得到稍微滞后的 value
↓
昂贵列表使用 deferredValue
所以:
text
useMemo = "少算几次"
useDeferredValue = "可以晚一点处理"
两者解决的问题不同。
十六、性能优化中的一个高级误区:React.memo 不等于性能一定提升
假设:
jsx
const Child = React.memo(function Child({ value }) {
return <div>{value}</div>;
});
如果:
text
Child 每次渲染只花 0.01ms
但是:
text
props 比较 + memo 管理成本
可能已经比重新渲染更复杂。
因此真正的原则是:
text
优化收益
=
被减少的工作成本
-
优化机制本身的成本
所以性能优化应该基于测量。
十七、首屏性能不能只看 React
这是面试中非常容易拉开差距的一点。
完整的首屏性能链路应该是:
text
DNS
↓
TCP
↓
TLS
↓
HTML
↓
JavaScript 下载
↓
JavaScript 解析
↓
JavaScript 执行
↓
React 初始化
↓
组件渲染
↓
DOM
↓
Style/Layout
↓
Paint
↓
用户看到内容
因此:
React 性能优化只是前端性能优化的一部分。
如果首屏慢是:
text
JavaScript 包 5MB
你应该考虑:
text
代码分割
Tree Shaking
依赖分析
懒加载
资源压缩
缓存
而不是:
text
给所有组件加 React.memo
十八、性能优化的完整决策表
| 发现的问题 | 第一考虑方案 | 第二考虑方案 |
|---|---|---|
| 父组件更新导致子组件无意义更新 | React.memo |
状态下沉、组件拆分 |
| 函数 props 引用频繁变化 | useCallback |
调整组件结构 |
| 对象/数组 props 引用频繁变化 | useMemo |
状态结构调整 |
| 昂贵计算重复执行 | useMemo |
Web Worker、算法优化 |
| 数千条列表 | 虚拟列表 | 分页、服务端分页 |
| 首屏 JavaScript 太大 | 代码分割 | 懒加载、依赖优化 |
| 非首屏页面 | React.lazy |
路由级代码分割 |
| 输入导致复杂列表卡顿 | useDeferredValue |
startTransition |
| 非紧急状态更新阻塞交互 | startTransition |
状态结构调整 |
| 不知道哪里慢 | React DevTools Profiler | 浏览器 Performance |
| DOM 太多 | 虚拟列表 | 减少 DOM |
| 接口数据太大 | 分页 / 游标分页 | 服务端过滤 |
| 计算阻塞主线程 | 算法优化 | Web Worker |
十九、性能优化面试中最容易犯的错误
错误 1:所有组件都加 React.memo
错误思想:
text
React.memo 越多
=
性能越好
正确:
text
React.memo
=
有明确收益时才使用
错误 2:所有函数都 useCallback
错误:
jsx
const add = useCallback(() => {
return 1 + 1;
}, []);
如果:
text
函数非常简单
+
没有作为稳定 props 传递
通常没有意义。
错误 3:所有计算都 useMemo
错误:
jsx
const total = useMemo(() => price * count, [
price,
count
]);
如果计算非常简单,缓存成本可能没有意义。
错误 4:把 useCallback 当成"阻止组件渲染"的 API
错误:
useCallback 可以防止组件重新渲染。
正确:
useCallback主要是稳定函数引用;如果这个函数作为 props 传递给React.memo子组件,那么可以帮助子组件避免因函数引用变化而产生的无意义重新渲染。
错误 5:虚拟列表只解决 DOM 问题
不完整。
应该说:
text
虚拟列表
↓
减少 DOM 数量
↓
减少渲染 / 布局 / 绘制压力
但是:
text
数据获取
JSON 解析
内存
网络
仍然可能成为瓶颈。
错误 6:看到性能问题就优化
正确:
text
发现问题
↓
测量
↓
定位
↓
优化
↓
再次测量
而不是:
text
感觉慢
↓
开始乱加 memo
应该小步优化,并通过优化前后的 Profile 对比验证效果。
二十、最终整理:React 性能优化满分答案
下面这段可以直接作为面试时的最终口述答案。
面试题:React 项目中你是如何进行性能优化的?
核心思路(一句话)
React 性能优化的核心不是盲目使用某个 API,而是先通过性能工具定位瓶颈,再从减少不必要的渲染、减少不必要的计算、减少 DOM 数量和减少首屏资源几个方向针对性优化,最后通过指标验证优化效果。
一、我通常会按照这条链路进行优化
text
发现性能问题
↓
复现问题并建立指标
↓
React DevTools Profiler / 浏览器 Performance 定位
↓
判断属于哪一类问题
↓
┌──────────┬──────────┬──────────┬──────────┐
│ 渲染问题 │ 计算问题 │ 列表问题 │ 加载问题 │
└────┬─────┴────┬─────┴────┬─────┴────┬─────┘
↓ ↓ ↓ ↓
React.memo useMemo 虚拟列表 代码分割
useCallback 算法优化 分页 React.lazy
组件拆分 Web Worker 服务端分页 懒加载
状态下沉
↓
小步优化
↓
重新 Profile
↓
比较优化前后指标
二、第一类:减少不必要的组件渲染
如果发现父组件更新导致某些子组件频繁重新渲染,而这些子组件实际上没有任何变化,我会考虑使用 React.memo。
jsx
const UserInfo = React.memo(function UserInfo({ name, age }) {
return (
<div>
<p>{name}</p>
<p>{age}</p>
</div>
);
});
React.memo 默认会对 props 做浅层比较。
如果:
text
旧 props === 新 props
React 就可以跳过这次因为父组件更新而导致的子组件重新渲染。
但是需要注意,React.memo 并不能阻止组件自己的 state 更新,也不能阻止所有 Context 变化导致的更新。
三、第二类:解决函数引用变化
如果子组件使用了 React.memo,但是父组件每次重新渲染都会重新创建一个回调函数,那么:
jsx
const handleClick = () => {};
每次都会产生新的函数引用。
于是:
text
旧 handleClick !== 新 handleClick
即使函数逻辑完全一样,React.memo 的浅比较仍然会认为 props 发生变化。
这种情况下可以使用 useCallback:
jsx
const handleClick = React.useCallback(() => {
setCount((currentCount) => currentCount + 1);
}, []);
所以我会强调:
useCallback本身不是为了直接阻止组件重新渲染,而是为了稳定函数引用,尤其是在函数作为 props 传给React.memo子组件时非常有价值。
四、第三类:减少昂贵计算
如果组件每次重新渲染都会执行大量排序、过滤、数据转换或者图表数据计算,可以使用 useMemo:
jsx
const result = React.useMemo(() => {
return expensiveCalculation(data);
}, [data]);
这样只有 data 发生变化时才重新计算。
但是我不会滥用 useMemo。
如果只是:
jsx
const total = price * count;
这种非常廉价的计算,一般没有必要增加记忆化逻辑。
所以使用 useMemo 的前提是:
text
计算成本较高
+
重复执行频繁
+
依赖相对稳定
+
缓存收益明显
五、第四类:解决超长列表
如果页面需要展示几千甚至几万条数据,一次性创建所有 DOM 会带来明显的渲染、布局和内存压力。
这时候我会使用虚拟列表。
虚拟列表的核心思想是:
text
10000 条数据
↓
计算当前 scrollTop
↓
计算可视区域
↓
增加少量 overscan
↓
只渲染几十个列表项
用户滚动时动态更新可见范围。
这样可以把:
text
一次渲染 10000 个 DOM
变成:
text
只维护几十个 DOM
实际项目中,如果涉及动态高度、滚动定位、overscan 等复杂逻辑,我通常优先使用成熟的虚拟化方案,而不是自己重新实现。
同时还要注意:
虚拟列表解决的是渲染层问题,并不意味着可以把几十万条数据全部一次性从服务器传到浏览器。
如果数据本身很大,还应该配合:
text
服务端分页
+
游标分页
+
服务端过滤
+
虚拟列表
六、第五类:优化首屏加载
如果发现首屏慢是因为 JavaScript Bundle 太大,我会分析 Bundle 构成,然后使用代码分割。
例如:
jsx
const UserCenter = React.lazy(
() => import('./UserCenter')
);
然后:
jsx
<React.Suspense fallback={<div>加载中...</div>}>
<UserCenter />
</React.Suspense>
底层原理是:
text
动态 import()
↓
构建工具拆分代码块
↓
非首屏代码不会进入首屏关键资源
↓
真正访问时才请求
↓
下载完成
↓
React 渲染组件
因此可以明显减少首屏需要下载、解析和执行的 JavaScript。
但代码分割也不是越细越好,需要根据路由、业务模块和访问频率合理拆分。
七、第六类:处理交互响应问题
如果是搜索框、筛选器等场景:
text
用户输入
↓
立即更新输入框
↓
同时触发昂贵列表计算
↓
页面卡顿
可以考虑 React 的并发更新能力。
例如使用 startTransition 把非紧急更新标记出来:
jsx
const [isPending, startTransition] =
React.useTransition();
function handleChange(event) {
const value = event.target.value;
// 输入框更新必须及时响应。
setKeyword(value);
// 搜索结果可以稍后处理。
startTransition(() => {
setResults(search(value));
});
}
这里需要特别说明:
startTransition不是让 JavaScript 在另一个线程执行,而是让 React 能够区分更新优先级,让高优先级交互更及时。
八、我会使用 React DevTools Profiler 定位问题
性能优化不能靠猜。
我通常会:
text
打开 React DevTools Profiler
↓
开始录制
↓
执行真实用户操作
↓
停止录制
↓
分析组件渲染
↓
查看渲染次数和耗时
↓
确定真正的性能瓶颈
例如发现:
text
点击搜索框
↓
App 更新
↓
100 个子组件全部重新渲染
↓
其中 90 个组件实际上不需要更新
那么我才会进一步考虑:
text
React.memo
状态下沉
组件拆分
useCallback
而不是直接给整个项目所有组件都加 React.memo。
九、性能优化最重要的是验证
优化之后,我会再次使用相同的操作进行测试。
例如比较:
text
优化前:
组件渲染次数:100
渲染耗时:80ms
优化后:
组件渲染次数:20
渲染耗时:25ms
如果优化没有实际收益,甚至增加了复杂度,就应该考虑回滚或者换方案。
十、最终原则
所以我理解的 React 性能优化,本质上是**减少不必要**:
text
减少不必要的工作
↓
减少不必要的组件渲染
↓
减少不必要的计算
↓
减少不必要的 DOM
↓
减少首屏需要加载的资源
↓
提高高优先级交互的响应速度
具体工具只是手段:
text
React.memo
→ 减少不必要的组件重新渲染
useCallback
→ 稳定函数引用
useMemo
→ 缓存昂贵计算结果
虚拟列表
→ 减少长列表 DOM 数量
React.lazy + Suspense
→ 代码分割和按需加载
startTransition / useDeferredValue
→ 优化非紧急更新对交互的影响
React DevTools Profiler
→ 定位和验证 React 渲染性能问题
最重要的是:
先测量、后优化;先定位、再选择工具;小步优化、持续验证,避免过早优化和过度优化。