一、面试题:React 应用出现性能问题时,你会如何系统地定位性能瓶颈?
核心思路(一句话)
先复现问题并确定性能指标,再通过 React 组件层、浏览器运行时层、网络与页面加载层逐层缩小范围,最后定位到具体代码并通过优化前后数据验证效果。
主要矛盾
性能问题最大的矛盾不是"不会优化",而是:
不知道到底哪里慢、为什么慢,就盲目使用
memo、缓存、懒加载等优化手段。
次要矛盾
具体表现可能来自:
- React 不必要的重新渲染;
- 单个组件 render 计算量过大;
- JavaScript 长任务阻塞主线程;
- 大量 DOM 操作;
- 强制同步布局;
- 网络请求或者资源过大;
- JavaScript 包体积过大;
- 图片、字体等资源加载过慢;
- 第三方库执行耗时;
- 内存泄漏或者频繁创建对象导致垃圾回收压力。
解决方案流程图
text
用户反馈"页面卡 / 点击慢 / 滚动掉帧 / 首屏慢"
↓
先定义问题类型
↓
┌──────────┼──────────┐
↓ ↓ ↓
加载慢 交互卡顿 页面抖动
↓ ↓ ↓
Lighthouse Performance Performance
↓ ↓ ↓
网络/资源 Main Thread Layout/Paint
↓
是否与 React 渲染有关?
↓
是
↓
React DevTools Profiler
↓
找到高耗时 / 高频更新组件
↓
分析 props / state / context
↓
是否存在不必要重新渲染?
↓ ↓
是 否
↓ ↓
React.memo / 稳定引用 查 render 计算
useMemo / useCallback 查第三方库
状态拆分 / 状态下沉 查 Effect
↓ ↓
再次录制验证
↓
优化前后数据对比
↓
问题闭环
二、面试题:React DevTools Profiler 是什么?什么时候使用?
核心思路(一句话)
Profiler 解决的是"React 到底哪些组件渲染了、渲染了多久、为什么频繁渲染"的问题。
适用场景
例如:
text
点击一个按钮
↓
父组件 state 改变
↓
整个页面大量组件重新渲染
↓
页面明显卡顿
此时首先应该怀疑 React 组件层面的更新问题。
Profiler 非常适合分析:
- 哪些组件发生了渲染;
- 哪些组件渲染耗时较高;
- 哪次更新产生了大量组件渲染;
- 某个组件是否频繁重新渲染;
- 不同交互对应哪些 commit;
- 优化前后渲染耗时有没有下降。
解决方案流程图
text
打开 React Developer Tools
↓
进入 Profiler
↓
点击 Record
↓
执行真实用户操作
↓
停止 Record
↓
选择一次 Commit
↓
查看组件渲染情况
↓
Flamegraph
+
Ranked
+
组件更新信息
↓
找到异常组件
↓
检查 props / state / context
↓
寻找重新渲染原因
底层实现原理
React 的更新大致可以理解为:
text
状态更新
↓
创建/调度更新
↓
Render Phase
↓
计算新的 React Fiber Tree
↓
比较前后节点
↓
Commit Phase
↓
更新 DOM / 执行相关副作用
Profiler 的价值就在于:
把 React 内部这次更新过程转换成开发者能够观察的数据。
需要特别区分:
Render ≠ DOM 更新
一个组件执行函数或者 render 方法,并不代表一定产生 DOM 修改。
例如:
jsx
function Counter({ count }) {
return <div>{count}</div>;
}
父组件重新渲染可能导致 Counter 重新执行。
但是 React 比较新旧 Fiber 后发现 DOM 没有实际变化,就可能不会产生对应 DOM 修改。
因此:
text
组件重新渲染
≠
DOM 一定发生变化
这是性能面试非常容易被追问的点。
三、面试题:如何阅读 React Profiler 的 Flamegraph 和 Ranked?
核心思路(一句话)
Flamegraph 看一次更新的组件层级和时间分布,Ranked 看当前 Commit 中哪些组件耗时最高。
Flamegraph
可以理解成:
text
App
├── Header
├── Sidebar
└── Content
├── List
│ ├── Item
│ ├── Item
│ └── Item
└── Footer
每个节点对应一个组件。
重点观察:
text
组件
↓
渲染持续时间
↓
子组件耗时
↓
更新关系
一个重要纠正
不要简单背:
"火焰图越宽,说明这个组件自己越慢。"
更加准确的说法是:
节点持续时间反映该组件及其渲染子树相关工作的时间,因此需要进一步区分组件自身计算耗时和子组件贡献。
否则面试官继续问:
"父组件很宽,但是它自己其实没做什么计算,为什么?"
你就很容易答不上来。
Ranked
Ranked 更适合回答:
这次更新里面,哪些组件耗时最高?
例如:
text
Item#10 35ms
List 20ms
Item#20 5ms
Header 2ms
Footer 1ms
此时可以快速锁定:
text
Item#10
然后进一步分析:
text
为什么 Item#10 慢?
而不是直接开始加 React.memo。
四、面试题:Profiler 发现大量组件重新渲染,你会如何定位原因?
核心思路(一句话)
先判断"为什么渲染",再判断"是否值得阻止渲染",最后才决定是否使用缓存优化。
解决方案流程图
text
组件重新渲染
↓
检查自身 state
↓
是否 state 改变?
┌────┴────┐
是 否
↓ ↓
正常更新 检查 props
↓
props 是否变化?
┌────┴────┐
是 否
↓ ↓
引用变化? 检查 Context
↓ ↓
对象/函数 Context 更新?
每次创建? ↓
↓ 是
React.memo ↓
比较失败 状态设计问题
高频问题 1:对象引用每次变化
错误写法:
jsx
function Parent() {
const [count, setCount] = React.useState(0);
const user = {
name: "张三",
age: 20,
};
return (
<>
<button onClick={() => setCount(count + 1)}>
{count}
</button>
<UserCard user={user} />
</>
);
}
即使用户数据逻辑上没有变化:
text
第一次:
user !== 第二次的 user
因为每次 render 都创建了一个新对象。
如果:
jsx
const UserCard = React.memo(function UserCard({ user }) {
console.log("UserCard render");
return <div>{user.name}</div>;
});
React.memo 默认进行浅比较:
text
旧 user === 新 user
结果:
text
false
所以还是会重新渲染。
优化方案
jsx
import React from "react";
/**
* UserCard 使用 React.memo。
*
* React.memo 默认会对 props 做浅比较。
* 如果 user 的引用没有变化,就可以跳过这次组件重新渲染。
*/
const UserCard = React.memo(function UserCard({ user }) {
console.log("UserCard render");
return (
<div>
<p>姓名:{user.name}</p>
<p>年龄:{user.age}</p>
</div>
);
});
export default function App() {
/**
* count 改变时,App 会重新执行。
*/
const [count, setCount] = React.useState(0);
/**
* useMemo 可以保证:
*
* 只要 name 和 age 没有变化,
* user 对象就复用之前的引用。
*
* 注意:
* useMemo 不是为了"让所有代码都更快",
* 它是为了在引用稳定本身具有价值时进行缓存。
*/
const user = React.useMemo(() => {
return {
name: "张三",
age: 20,
};
}, []);
return (
<div>
<button onClick={() => setCount((value) => value + 1)}>
count:{count}
</button>
<UserCard user={user} />
</div>
);
}
五、面试题:React.memo、useMemo、useCallback 应该什么时候使用?
核心思路(一句话)
它们不是"性能开关",而是用来降低重复计算或稳定引用;必须先通过性能分析证明缓存有收益。
三者解决的问题
| API | 解决什么问题 |
|---|---|
React.memo |
避免组件在 props 没变化时重新渲染 |
useMemo |
缓存计算结果 |
useCallback |
缓存函数引用 |
1. React.memo
jsx
const ListItem = React.memo(function ListItem({ item }) {
return <div>{item.name}</div>;
});
只有当 props 的浅比较没有发现变化时,才可能跳过重新渲染。
2. useMemo
jsx
const filteredList = React.useMemo(() => {
return list.filter((item) => item.name.includes(keyword));
}, [list, keyword]);
适合:
text
计算成本高
+
依赖变化频率低
+
结果可以复用
3. useCallback
jsx
const handleDelete = React.useCallback((id) => {
deleteItem(id);
}, []);
最典型的使用场景是:
text
父组件
↓
React.memo 子组件
↓
子组件接收函数 props
如果函数每次都是新引用:
jsx
const handleDelete = (id) => {
deleteItem(id);
};
那么:
text
旧函数 !== 新函数
即使逻辑完全相同,也可能导致 React.memo 无法跳过渲染。
但是不要滥用
错误理解:
"所有函数都应该 useCallback。"
这是不正确的。
因为缓存本身也有:
text
依赖管理成本
+
内存成本
+
比较成本
+
代码复杂度
真正的原则应该是:
text
先发现性能问题
↓
确认重复渲染
↓
确认引用变化是原因
↓
确认子组件渲染成本较高
↓
再使用 useCallback / useMemo / React.memo
六、面试题:Chrome DevTools Performance 面板和 React Profiler 有什么区别?
核心思路(一句话)
React Profiler 看"React 在干什么",Performance 看"整个浏览器主线程和渲染流水线在干什么"。
对比
| 工具 | 核心关注点 |
|---|---|
| React Profiler | React 组件渲染 |
| Performance | 浏览器整体运行过程 |
| Lighthouse | 页面质量和性能审计 |
| Why Did You Render | 辅助定位不必要重新渲染 |
Performance 关注的范围更大
例如:
text
用户点击
↓
Event Handler
↓
JavaScript
↓
React Update
↓
Style Recalculation
↓
Layout
↓
Paint
↓
Composite
如果页面卡顿:
text
可能是 React
也可能不是 React
例如:
text
React render:5ms
第三方图表库:
JavaScript:120ms
这时候你光看 React Profiler 很可能找不到真正的瓶颈。
七、面试题:如何使用 Performance 面板定位页面卡顿?
核心思路(一句话)
录制真实用户操作,沿着 Main Thread 找长任务,再通过调用栈追到具体 JavaScript 代码,同时检查 Layout、Paint、网络和 React Performance tracks。
解决方案流程图
text
打开 Chrome DevTools
↓
Performance
↓
开始录制
↓
执行问题操作
↓
停止录制
↓
观察时间线
↓
Main Thread
↓
寻找长任务
↓
展开调用栈
↓
找到具体函数
↓
继续分析
┌──────┼────────┐
↓ ↓ ↓
JS Layout Paint
↓ ↓ ↓
计算 强制布局 绘制压力
Long Task
主线程长时间执行 JavaScript:
text
JavaScript
████████████████████████████████████████
如果一个任务超过约 50ms,就可能成为 Long Task。
例如:
jsx
function handleClick() {
// 模拟非常耗时的同步计算。
// 在真实项目中可能来自:
// 复杂数据处理、JSON 转换、排序、第三方库计算等。
const start = performance.now();
while (performance.now() - start < 100) {
// 故意阻塞主线程 100ms。
}
console.log("计算结束");
}
执行期间:
text
JavaScript 主线程
↓
持续执行 100ms
↓
浏览器无法及时处理其他任务
↓
用户感觉"点击没反应"
八、面试题:什么是强制同步布局?如何通过 Performance 定位?
核心思路(一句话)
避免在同一个 JavaScript 任务中反复交替读写布局信息,让浏览器被迫频繁执行 Layout。
错误示例
jsx
function moveBoxes(boxes) {
boxes.forEach((box) => {
/**
* 写操作:修改 DOM。
*/
box.style.width = "200px";
/**
* 读操作:读取布局信息。
*
* 如果前面的 DOM 修改导致布局失效,
* 浏览器为了返回准确的 offsetHeight,
* 可能必须立即重新计算布局。
*/
console.log(box.offsetHeight);
});
}
如果大量循环:
text
写 DOM
↓
读布局
↓
强制 Layout
↓
写 DOM
↓
读布局
↓
强制 Layout
就可能形成严重的 Layout Thrashing。
更好的方案
把读操作和写操作分离:
jsx
function measureAndUpdate(boxes) {
/**
* 第一阶段:统一读取布局信息。
*
* 此阶段尽量不要修改 DOM。
*/
const heights = boxes.map((box) => {
return box.offsetHeight;
});
/**
* 第二阶段:统一写入 DOM。
*
* 避免读写交替导致浏览器反复重新计算布局。
*/
boxes.forEach((box, index) => {
box.style.width = `${heights[index]}px`;
});
}
Performance 中重点观察:
text
Main
↓
Layout
↓
Recalculate Style
↓
调用栈
如果看到大量 Layout 与 JavaScript 交替出现,就应该重点排查 DOM 读写模式。
九、面试题:如何使用 Performance.mark 和 Performance.measure 分析业务代码?
核心思路(一句话)
用 User Timing API 给关键业务流程打点,把"浏览器耗时"与"业务代码"建立对应关系。
完整示例:
jsx
import React from "react";
export default function SearchPage() {
const [keyword, setKeyword] = React.useState("");
const [result, setResult] = React.useState([]);
/**
* 模拟一个耗时的数据处理过程。
*/
function processData(data) {
return data
.filter((item) => item.name.includes(keyword))
.sort((a, b) => a.name.localeCompare(b.name));
}
async function handleSearch() {
/**
* 标记整个搜索流程开始。
*
* Performance 面板可以看到这个标记。
*/
performance.mark("search-start");
/**
* 模拟请求。
*/
const response = await fetch("/api/search");
const data = await response.json();
/**
* 对数据进行处理。
*/
const processedData = processData(data);
/**
* 更新 React 状态。
*/
setResult(processedData);
/**
* 标记搜索流程结束。
*/
performance.mark("search-end");
/**
* 计算两个标记之间的耗时。
*
* 这样可以在 Performance 面板中看到:
*
* search-start
* ↓
* 业务逻辑
* ↓
* search-end
*
* 总耗时 = search-duration
*/
performance.measure(
"search-duration",
"search-start",
"search-end"
);
/**
* 读取测量结果。
*/
const entries = performance.getEntriesByName("search-duration");
console.log("搜索耗时:", entries[0]?.duration);
/**
* 清理标记,避免长期累积。
*/
performance.clearMarks("search-start");
performance.clearMarks("search-end");
performance.clearMeasures("search-duration");
}
return (
<div>
<input
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
}}
placeholder="请输入关键词"
/>
<button onClick={handleSearch}>
搜索
</button>
<ul>
{result.map((item) => (
<li key={item.id}>
{item.name}
</li>
))}
</ul>
</div>
);
}
这个方法的价值是:
text
Performance
↓
看到 search-duration
↓
展开调用栈
↓
定位业务函数
↓
知道究竟哪一段代码慢
十、面试题:Lighthouse 和 Performance 面板有什么区别?
核心思路(一句话)
Lighthouse 是"体检报告",Performance 是"事故现场监控录像"。
Lighthouse 适合回答
text
这个页面整体性能怎么样?
↓
首屏加载怎么样?
↓
资源有没有优化?
↓
JavaScript 是否过大?
↓
图片是否过大?
↓
缓存是否合理?
↓
Core Web Vitals 表现如何?
Performance 适合回答
text
为什么点击按钮会卡?
↓
为什么这 100ms 页面没有响应?
↓
是哪一个 JavaScript 函数执行太久?
↓
为什么发生大量 Layout?
↓
哪一次 React 更新导致卡顿?
十一、面试题:Lighthouse 中应该重点关注哪些性能指标?
核心思路(一句话)
性能指标必须和用户体验关联,重点观察加载、交互和视觉稳定性,而不是只看一个总分。
重点理解:
Largest Contentful Paint
表示主要内容加载出来的速度。
text
页面开始加载
↓
主要内容出现
↓
Largest Contentful Paint
重点反映:
用户什么时候感觉"页面主要内容出来了"。
Interaction to Next Paint
重点衡量用户交互之后,页面多久能够给出下一次视觉反馈。
例如:
text
用户点击按钮
↓
事件处理
↓
JavaScript 计算
↓
浏览器更新画面
↓
Interaction to Next Paint
它对于"点击以后页面卡不卡"非常重要。
Cumulative Layout Shift
衡量页面视觉布局是否发生意外移动。
例如:
text
用户准备点击按钮
↓
图片突然加载
↓
内容向下移动
↓
用户点到了其他位置
这就是典型的布局稳定性问题。
Total Blocking Time
它是 Lighthouse 中非常重要的实验室指标。
核心关注:
text
主线程是否被大量长任务阻塞
但是必须注意:
Total Blocking Time 不应该和 Core Web Vitals 混为一谈。
十二、面试题:Why Did You Render 是什么?什么时候使用?
核心思路(一句话)
Why Did You Render 用于开发阶段辅助回答"这个组件为什么又渲染了一次"。
典型问题:
jsx
function Parent() {
const [count, setCount] = React.useState(0);
const user = {
name: "张三",
};
return (
<>
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
<User user={user} />
</>
);
}
如果:
jsx
const User = React.memo(function User({ user }) {
return <div>{user.name}</div>;
});
那么:
text
count 改变
↓
Parent render
↓
user 创建新对象
↓
user 引用发生变化
↓
React.memo 比较失败
↓
User render
Why Did You Render 的价值就是帮助开发者快速发现这种问题。
但是它不是首选性能工具
正确理解:
text
发现页面卡顿
↓
Performance
↓
发现 React 渲染异常
↓
React Profiler
↓
怀疑具体 props / 引用导致重复渲染
↓
Why Did You Render
它更像:
组件重渲染原因分析器
而不是完整的性能分析平台。
另外,由于 Why Did You Render 依赖 React 内部行为,升级 React 后应该检查兼容性;其 GitHub 项目中也可以看到与 React 新版本、Vite 等环境相关的问题记录。
十三、面试题:React 性能优化是不是"发现重新渲染就使用 React.memo"?
核心思路(一句话)
不是,性能优化的目标是减少用户可感知的成本,而不是单纯减少 render 次数。
这是一个非常重要的高级面试点。
例如:
text
组件渲染 100 次
每次 0.1ms
总成本:
text
10ms
另一个组件:
text
只渲染 2 次
每次 30ms
总成本:
text
60ms
所以:
text
render 次数
不是唯一指标。
真正应该关注:
text
用户交互延迟
+
渲染耗时
+
主线程阻塞
+
布局/绘制耗时
+
网络加载
+
内存
十四、面试题:如果一个 React 页面卡顿,你的完整排查流程是什么?
核心思路(一句话)
先量化问题,再分层定位,最后针对真正的瓶颈进行优化并通过数据验证。
标准流程
text
① 明确问题
↓
"点击卡顿?"
"滚动掉帧?"
"首屏慢?"
"切换页面慢?"
↓
② 建立基线
↓
记录操作耗时 / 性能指标
↓
③ Performance
↓
确认是不是主线程瓶颈
↓
④ React Profiler
↓
确认是不是 React 渲染瓶颈
↓
⑤ 分析根因
↓
├─ state 更新范围太大
├─ props 引用频繁变化
├─ Context 更新范围太大
├─ render 计算复杂
├─ Effect 触发连锁更新
├─ 大量 DOM
├─ 第三方库耗时
├─ Layout Thrashing
├─ 网络资源过大
└─ JavaScript 包体积过大
⑥ 针对性优化
↓
⑦ 再次录制
↓
⑧ 对比优化前后数据
↓
⑨ 确认没有引入新的问题
十五、面试题:React 性能问题常见根因有哪些?
核心思路(一句话)
React 性能问题本质上通常是"更新范围过大、计算成本过高、主线程被阻塞"三类问题。
第一类:更新范围过大
例如:
text
App state
↓
整个 App 更新
↓
大量子组件重新执行
优化方向:
text
状态下沉
状态拆分
组件拆分
React.memo
Context 拆分
选择器
第二类:单次计算成本过高
例如:
jsx
const result = hugeList
.filter(...)
.sort(...)
.map(...);
优化方向:
text
useMemo
算法优化
分页
虚拟列表
Web Worker
增量计算
第三类:主线程阻塞
例如:
text
100ms JavaScript
+
Layout
+
Paint
优化方向:
text
拆分任务
requestIdleCallback
requestAnimationFrame
Web Worker
React 并发更新
减少同步计算
十六、面试题:React 18/19 的并发能力与性能分析有什么关系?
核心思路(一句话)
并发能力解决的是"高优先级交互不要被低优先级更新长时间阻塞",但它不能消灭高成本计算本身。
例如搜索:
text
用户输入
↓
更新 input
↓
立即响应
同时:
text
根据关键词过滤 10000 条数据
↓
渲染结果
可以把非紧急更新放入 transition:
jsx
import React from "react";
export default function SearchPage({ items }) {
const [keyword, setKeyword] = React.useState("");
const [filteredItems, setFilteredItems] = React.useState(items);
/**
* useTransition 用来告诉 React:
*
* filteredItems 的更新不是当前最紧急的更新。
*
* 输入框本身仍然应该保持及时响应。
*/
const [isPending, startTransition] = React.useTransition();
function handleChange(event) {
const value = event.target.value;
/**
* 这是用户正在进行的直接交互。
* 应该尽快更新。
*/
setKeyword(value);
/**
* 将昂贵的列表更新标记为非紧急更新。
*
* 注意:
* 这并不意味着 JavaScript 计算突然变快了。
*
* 它主要是让 React 可以根据优先级调度更新,
* 避免低优先级更新过度阻塞高优先级交互。
*/
startTransition(() => {
const nextItems = items.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(nextItems);
});
}
return (
<div>
<input
value={keyword}
onChange={handleChange}
placeholder="搜索"
/>
{isPending && <div>正在更新列表...</div>}
<ul>
{filteredItems.map((item) => (
<li key={item.id}>
{item.name}
</li>
))}
</ul>
</div>
);
}
关键理解:
text
并发更新
≠
多线程
并发更新
≠
让 100ms 计算变成 10ms
它解决的是:
text
如何更合理地调度工作
而不是:
text
如何凭空减少工作量
十七、面试题:React Performance tracks 是什么?和传统 Performance 分析有什么区别?
核心思路(一句话)
React Performance tracks 把 React 自身的调度和组件工作直接放进浏览器 Performance 时间线,让"React 为什么卡"和"浏览器为什么卡"可以在同一条时间轴上分析。
现代 React 提供了 React Performance tracks,其中可以看到 Scheduler、Components 等 React 相关轨道。Components track 可以显示组件渲染及其耗时,Scheduler track 可以帮助理解不同优先级的 React 工作。
架构关系
text
Chrome Performance
│
├── Network
│
├── Main Thread
│ ├── JavaScript
│ ├── Style
│ ├── Layout
│ ├── Paint
│ └── Composite
│
├── Browser Events
│
└── React Performance tracks
├── Scheduler
├── Components
└── Effects / Server 相关轨道
这意味着现在可以更加自然地进行:
text
浏览器长任务
↓
找到 React 更新
↓
看到 React Scheduler
↓
找到具体组件
↓
继续查看调用栈
这比单纯把 React 和浏览器性能完全割裂开来更加有效。
十八、面试题:如果让我选择性能工具,你会怎么选择?
核心思路(一句话)
根据问题所在的层次选择工具,而不是固定使用某一个工具。
| 问题 | 首选工具 | 主要目的 |
|---|---|---|
| React 哪些组件渲染慢 | React DevTools Profiler | 组件级定位 |
| 哪些组件频繁重新渲染 | React DevTools Profiler | 找更新范围 |
| 为什么 props 导致重新渲染 | Why Did You Render | 辅助分析 |
| JavaScript 执行慢 | Performance | 主线程分析 |
| 页面点击卡顿 | Performance | Long Task / 调用栈 |
| Layout 很慢 | Performance | Layout / Style |
| Paint 很慢 | Performance | Paint / Composite |
| 首屏慢 | Lighthouse + Performance | 加载链路 |
| Core Web Vitals | Lighthouse / 真实用户监控 | 用户体验 |
| 资源过大 | Lighthouse + Network | 资源优化 |
| React 调度问题 | Performance + React Performance tracks | React 调度分析 |
十九、面试题:为什么不能一上来就优化?
核心思路(一句话)
没有性能数据的优化,本质上是在猜。
例如:
text
"我觉得这个组件应该 memo。"
这是猜测。
正确流程:
text
Profiler
↓
发现 Item 组件频繁渲染
↓
发现 props 中的对象引用每次变化
↓
发现 Item 本身渲染耗时 20ms
↓
使用 React.memo + 稳定引用
↓
重新 Profiling
↓
20ms → 2ms
这才是完整的性能优化闭环。
二十、面试题:如何设计一个真正完整的 React 性能优化闭环?
核心思路(一句话)
性能优化不是"加缓存",而是"测量 → 定位 → 归因 → 优化 → 验证 → 持续监控"。
完整架构图
text
性能问题
│
▼
建立性能基线
│
┌────────────┼────────────┐
▼ ▼ ▼
加载 交互 渲染
│ │ │
▼ ▼ ▼
Lighthouse Performance React Profiler
│ │ │
│ ▼ ▼
│ Main Thread Component
│ │ Render
│ │ │
└────────────┼────────────┘
▼
定位根因
│
┌────────────┼─────────────┐
▼ ▼ ▼
更新过多 计算过重 浏览器瓶颈
│ │ │
▼ ▼ ▼
React.memo useMemo Layout
状态拆分 算法优化 Paint
Context拆分 虚拟列表 Long Task
│ │ │
└────────────┼─────────────┘
▼
重新测量
│
▼
对比性能数据
│
▼
是否改善?
┌────┴────┐
否 是
│ │
▼ ▼
继续定位 固化方案
│
▼
持续监控
二十一、完整实战示例:一个典型的 React 性能问题如何定位?
下面模拟一个非常典型的面试场景。
问题
页面:
text
Counter
+
1000 个 ListItem
点击 Counter:
text
页面明显卡顿
有问题的代码
jsx
import React from "react";
/**
* 一个模拟比较昂贵的列表项。
*/
function ListItem({ item, onSelect }) {
/**
* 模拟耗时计算。
*
* 真实项目中可能对应:
*
* - 数据格式化
* - 大量计算
* - 图表数据处理
* - Markdown 解析
* - 大量字符串处理
*/
let result = 0;
for (let i = 0; i < 100000; i++) {
result += i;
}
return (
<li onClick={() => onSelect(item.id)}>
{item.name} - {result}
</li>
);
}
export default function App() {
const [count, setCount] = React.useState(0);
const [selectedId, setSelectedId] = React.useState(null);
const items = React.useMemo(() => {
return Array.from({ length: 1000 }, (_, index) => ({
id: index,
name: `Item ${index}`,
}));
}, []);
/**
* 问题:
*
* App 每次 render 时都会创建新的函数。
*
* 如果 ListItem 使用 React.memo,
* 这个函数引用变化仍然会导致 memo 失效。
*/
const handleSelect = (id) => {
setSelectedId(id);
};
return (
<div>
<h1>React Performance Demo</h1>
<button
onClick={() => {
setCount((value) => value + 1);
}}
>
count:{count}
</button>
<p>selectedId:{selectedId}</p>
<ul>
{items.map((item) => (
<ListItem
key={item.id}
item={item}
onSelect={handleSelect}
/>
))}
</ul>
</div>
);
}
二十二、如何通过工具分析这个问题?
第一步:Performance
录制:
text
点击 count
观察:
text
Main Thread
如果看到:
text
JavaScript
████████████████████████████████
说明主线程存在明显计算压力。
第二步:React Profiler
录制同一个操作。
发现:
text
App
↓
1000 个 ListItem
↓
全部重新渲染
此时性能根因已经非常明确:
text
count 改变
↓
App render
↓
ListItem 全部 render
↓
每个 ListItem 都进行昂贵计算
↓
主线程被阻塞
二十三、优化版本
jsx
import React from "react";
/**
* 使用 React.memo。
*
* 如果 item 和 onSelect 的引用都没有变化,
* React 可以跳过 ListItem 的重新渲染。
*/
const ListItem = React.memo(function ListItem({
item,
onSelect,
}) {
/**
* 模拟昂贵计算。
*
* 如果组件没有重新渲染,
* 这段计算也就不会重复执行。
*/
let result = 0;
for (let i = 0; i < 100000; i++) {
result += i;
}
return (
<li onClick={() => onSelect(item.id)}>
{item.name} - {result}
</li>
);
});
export default function App() {
const [count, setCount] = React.useState(0);
const [selectedId, setSelectedId] = React.useState(null);
/**
* items 本身不会因为 count 改变而重新创建。
*
* 这样可以保证 item 的引用稳定。
*/
const items = React.useMemo(() => {
return Array.from({ length: 1000 }, (_, index) => ({
id: index,
name: `Item ${index}`,
}));
}, []);
/**
* 使用 useCallback 保持函数引用稳定。
*
* 因此:
*
* count 改变
* ↓
* App render
* ↓
* handleSelect 引用不变
* ↓
* item 引用不变
* ↓
* ListItem 的 props 没变化
* ↓
* React.memo 可以跳过 ListItem render
*/
const handleSelect = React.useCallback((id) => {
setSelectedId(id);
}, []);
return (
<div>
<h1>React Performance Demo</h1>
<button
onClick={() => {
setCount((value) => value + 1);
}}
>
count:{count}
</button>
<p>selectedId:{selectedId}</p>
<ul>
{items.map((item) => (
<ListItem
key={item.id}
item={item}
onSelect={handleSelect}
/>
))}
</ul>
</div>
);
}
二十四、但是上面的优化还不是最终答案
高级面试官可能继续问:
"如果有 10000 个 Item 呢?"
这时候继续使用:
text
React.memo
+
useCallback
不一定是最终方案。
因为即使不重新渲染:
text
10000 个 DOM 节点
本身也可能造成:
text
DOM 数量过大
+
Layout 成本
+
Paint 成本
+
内存成本
这时更应该考虑:
虚拟列表
核心思想:
text
10000 条数据
↓
实际上只显示 20 条
↓
DOM 只保留可视区域附近的数据
架构:
text
10000 数据
↓
Virtual List
↓
┌─────────────────┐
│ Item 100 │
│ Item 101 │
│ Item 102 │
│ ... │
│ Item 120 │
└─────────────────┘
这说明:
性能优化不是固定使用某个 API,而是根据瓶颈减少真正昂贵的工作量。
二十五、React 性能优化中最重要的"主要矛盾"
可以把所有性能问题归纳成:
text
性能成本
=
做了多少工作
×
每次工作有多贵
×
工作发生得有多频繁
因此优化方向只有三类:
1. 减少工作量
text
虚拟列表
分页
代码分割
懒加载
减少 DOM
2. 降低单次成本
text
算法优化
缓存计算
Web Worker
减少复杂渲染
3. 降低工作频率
text
React.memo
useMemo
useCallback
状态拆分
Context 拆分
防抖
节流
这比简单背:
text
React.memo
useMemo
useCallback
更加高级。
二十六、面试中如何回答"React 性能优化有哪些手段?"
可以按照四个层次回答。
text
第一层:减少重新渲染
↓
React.memo
状态下沉
状态拆分
Context 拆分
稳定 props 引用
第二层:减少单次渲染成本
↓
useMemo
算法优化
组件拆分
避免重复计算
第三层:减少浏览器工作
↓
虚拟列表
减少 DOM
减少 Layout
减少 Paint
避免强制同步布局
第四层:减少加载成本
↓
代码分割
懒加载
资源压缩
图片优化
缓存
减少 JavaScript 包体积
二十七、最终满分答案
面试官:React 应用出现性能瓶颈时,你会怎么定位?
满分回答
我的思路不是直接进行性能优化,而是先定位瓶颈,再根据数据选择优化方案。
我一般会先明确问题属于哪一类,例如是首屏加载慢、点击交互卡顿、滚动掉帧,还是组件更新过多。
如果怀疑是 React 组件渲染问题,我会首先使用 React Developer Tools Profiler,录制出现问题的真实交互,然后重点观察 Commit、Flamegraph 和 Ranked,定位哪些组件渲染频繁、哪些组件耗时较高。
定位到组件以后,我会继续分析它为什么重新渲染,例如 state 更新范围是否过大、props 是否发生了不必要的引用变化、Context 是否导致大量组件更新,以及 render 中是否存在昂贵计算。
如果发现问题可能不只是 React,而是 JavaScript 执行、第三方库、Layout、Paint 或主线程阻塞,我会使用 Chrome DevTools Performance 进行分析。通过录制真实用户操作,重点查看 Main Thread,寻找长任务,再通过调用栈定位具体的 JavaScript 代码,同时检查 Style、Layout、Paint,以及现代 React 提供的 React Performance tracks,从而把浏览器性能和 React 更新联系起来。
如果问题是首屏加载或者整体 Web 性能,我会使用 Lighthouse 做宏观审计,重点关注 Largest Contentful Paint、Interaction to Next Paint、Cumulative Layout Shift 等用户体验指标,同时结合网络资源、JavaScript 包体积、图片和缓存情况分析。
对于比较具体的"为什么组件又渲染了"问题,在开发阶段也可以使用 Why Did You Render 辅助分析 props、state 和引用变化,但它属于辅助工具,不应该替代 React Profiler 和浏览器 Performance。
定位到根因之后,我会根据瓶颈选择方案:
text更新范围过大 → 状态下沉 / 状态拆分 / React.memo / Context 拆分 引用频繁变化 → useMemo / useCallback / 调整数据结构 单次计算成本高 → 算法优化 / useMemo / Web Worker DOM 数量过多 → 虚拟列表 / 分页 主线程长任务 → 拆分任务 / 降低同步计算 / Web Worker / 合理使用并发更新 Layout 过多 → 避免强制同步布局 / 优化 DOM 读写 首屏加载慢 → 代码分割 / 懒加载 / 图片优化 / 资源压缩 / 缓存最后我一定会重新录制性能数据,用优化前后的实际指标进行对比,而不是认为"代码看起来更优雅"就代表性能真的变好了。
所以我理解 React 性能优化最核心的闭环是:
text发现问题 ↓ 建立基线 ↓ 性能录制 ↓ 定位瓶颈 ↓ 分析根因 ↓ 针对性优化 ↓ 再次测量 ↓ 对比验证 ↓ 持续监控本质上不是为了减少 render 次数,而是减少用户真正需要等待的工作量、降低单次工作成本,并合理调度必须执行的工作。
最后记忆版
面试时可以直接记住这一句话:
React 性能定位我会采用"React 层 + 浏览器层 + 页面加载层"三层分析:React Profiler 定位组件渲染问题,Performance 定位主线程、JavaScript、Layout 和 Paint 等底层问题,Lighthouse 做整体 Web 性能审计;定位之后再根据根因选择 React.memo、useMemo、useCallback、状态拆分、虚拟列表、代码分割、Web Worker 等方案,并通过优化前后的性能数据验证结果。
再进一步记住:
text
Profiler
= React 在干什么?
Performance
= 浏览器到底在干什么?
Lighthouse
= 页面整体健康状况怎么样?
Why Did You Render
= 为什么这个组件又渲染了?
最终目标
= 找到真正的性能瓶颈,而不是机械地添加缓存。