大数据量渲染优化:分批渲染技术详解
从 5 万条 IP 标签卡死页面说起
一、问题场景
1.1 业务背景
在 IP 组管理页面,需要展示 IP 地址列表。每个 IP 显示为一个 <Tag> 标签:
tsx
// 简单粗暴的实现
function DisplayIPTags({ data }: { data: string[] }) {
return (
<div>
{data.map(ip => <Tag key={ip}>{ip}</Tag>)}
</div>
);
}
1.2 问题出现
当 IP 数量较少时(几十个),页面正常。但当用户点击"展开"查看全部 IP 时:
| IP 数量 | 表现 |
|---|---|
| 100 个 | 正常 |
| 1,000 个 | 轻微卡顿 |
| 5,000 个 | 卡顿 2-3 秒 |
| 50,000 个 | 页面卡死 10+ 秒 |
二、问题分析
2.1 为什么会卡?
tsx
// 一次性渲染 5 万个 Tag
{data.map(ip => <Tag key={ip}>{ip}</Tag>)}
这行代码背后发生了什么?
flowchart LR
A[开始渲染] --> B[创建 5万个虚拟DOM]
B --> C[计算 5万个节点diff]
C --> D[创建 5万个真实DOM]
D --> E[浏览器重排重绘]
E --> F[渲染完成]
单线程模型:JavaScript 是单线程的,这 4 步全部在主线程执行,期间无法响应用户操作。
2.2 时间都去哪了?
用 Chrome DevTools 分析:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| React Reconcile | ~2s | 计算 5 万个节点的 diff |
| DOM Insertion | ~3s | 创建 5 万个 DOM 元素 |
| Layout & Paint | ~5s | 浏览器计算布局、绘制 |
| 总计 | ~10s | 主线程完全阻塞 |
三、解决思路
3.1 核心思想:分而治之
不要一次性做太多事,把大任务拆成小任务
错误方案:一次性渲染 50,000 个 → 主线程阻塞 10 秒 → 用户操作无响应
正确方案:分 100 批,每批 500 个 → 每批约 100ms → 期间可响应用户操作
3.2 如何实现分批?
关键问题:如何在 JavaScript 中"暂停"执行,让浏览器有机会响应用户?
答案 :requestAnimationFrame(简称 RAF)
javascript
// 同步执行:阻塞主线程
for (let i = 0; i < 100; i++) {
renderBatch(i); // 连续执行,无法打断
}
// RAF 分批:不阻塞主线程
function renderBatch(batchIndex) {
// 渲染这一批...
if (batchIndex < 100) {
requestAnimationFrame(() => renderBatch(batchIndex + 1));
}
}
renderBatch(0); // 开始第一批
RAF 的特点:
- 回调在浏览器下一帧执行
- 每帧约 16.6ms(60fps)
- 期间浏览器可以处理用户交互
四、完整实现
4.1 状态设计
tsx
/** 每批渲染的数量 */
const BATCH_SIZE = 500;
/** 是否展开全部标签 */
const [displayAll, setDisplayAll] = useState(false);
/** 当前已渲染的标签数量 */
const [renderCount, setRenderCount] = useState(0);
/** RAF 的 ID,用于取消 */
const rafRef = useRef<number>();
4.2 核心逻辑
tsx
useEffect(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
if (displayAll) {
// 第一步:立即渲染第一批
setRenderCount(Math.min(BATCH_SIZE, data.length));
// 第二步:如果有更多数据,用 RAF 继续渲染
if (data.length > BATCH_SIZE) {
let currentCount = BATCH_SIZE;
const renderBatch = () => {
currentCount += BATCH_SIZE;
const nextCount = Math.min(currentCount, data.length);
setRenderCount(nextCount);
if (nextCount < data.length) {
rafRef.current = requestAnimationFrame(renderBatch);
}
};
rafRef.current = requestAnimationFrame(renderBatch);
}
} else {
setRenderCount(0);
}
}, [displayAll, data.length]);
4.3 渲染逻辑
tsx
const visibleData = useMemo(() => {
if (displayAll) {
return data.slice(0, renderCount);
}
return data.slice(0, defaultShowCount);
}, [data, displayAll, renderCount]);
return (
<div>
{visibleData.map(ip => <Tag key={ip}>{ip}</Tag>)}
{renderCount < data.length && <Text>加载中...</Text>}
</div>
);
五、执行流程图解
5.1 时间线
sequenceDiagram
participant U as 用户
participant R as React
participant B as 浏览器
U->>R: 点击展开
Note over R: 帧1:同步执行
R->>R: setRenderCount(500)
R->>B: 触发渲染,显示500个Tag
Note over U: 用户感知:页面有反应了
Note over R,B: 帧2:RAF回调
B->>R: 执行RAF回调
R->>R: setRenderCount(1000)
R->>B: 触发渲染,显示1000个Tag
Note over U: 用户感知:继续加载中
Note over R,B: 帧3~N:持续RAF回调
loop 直到渲染完成
B->>R: 执行RAF回调
R->>R: renderCount += 500
end
Note over R: renderCount >= data.length
Note over U: 渲染完成
5.2 与同步渲染对比
同步渲染:主线程连续忙碌 10 秒,用户操作无响应
分批渲染:忙 100ms → 空闲 → 忙 100ms → 空闲... 用户操作可在空闲时响应
六、关键细节
6.1 为什么第一批要立即渲染?
tsx
// 错误:第一批也用 RAF
requestAnimationFrame(() => setRenderCount(500));
// 用户点击后要等一帧才能看到效果
// 正确:第一批立即渲染
setRenderCount(500);
// 用户点击后立即看到反馈
原因:用户点击"展开"后,期望立即看到效果。
6.2 为什么需要清理 RAF?
sequenceDiagram
participant U as 用户
participant C as 组件
participant RAF as requestAnimationFrame
U->>C: 点击展开
C->>RAF: 调度渲染任务
Note over C: 渲染到第3批...
U->>C: 切换页面,组件卸载
alt 不清理RAF
RAF->>C: 执行回调
C->>C: setRenderCount()
Note over C: 报错:组件已卸载
else 清理RAF
C->>RAF: cancelAnimationFrame()
Note over C: 安全卸载
end
6.3 为什么用 RAF 而不是 setTimeout?
| 方案 | 延迟 | 问题 |
|---|---|---|
setTimeout(fn, 0) |
最小 4ms | 可能被浏览器节流 |
setInterval |
固定间隔 | 需手动清理,容易泄漏 |
requestAnimationFrame |
下一帧 | 与浏览器同步,自动暂停 |
七、性能对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首次渲染时间 | 10s | 100ms |
| 主线程阻塞 | 10s | 每帧 100ms |
| 用户可操作时间 | 10s 后 | 立即可操作 |
八、总结
核心要点
- 问题:大量 DOM 一次性渲染阻塞主线程
- 方案 :用
requestAnimationFrame分批渲染 - 关键:第一批同步渲染保证即时反馈,后续批次用 RAF 调度
- 注意:组件卸载时清理 RAF,避免内存泄漏
代码模板
tsx
const BATCH_SIZE = 500;
const [renderCount, setRenderCount] = useState(0);
const rafRef = useRef<number>();
useEffect(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (shouldRender) {
setRenderCount(BATCH_SIZE); // 第一批立即渲染
if (data.length > BATCH_SIZE) {
let count = BATCH_SIZE;
const renderNext = () => {
count += BATCH_SIZE;
setRenderCount(Math.min(count, data.length));
if (count < data.length) {
rafRef.current = requestAnimationFrame(renderNext);
}
};
rafRef.current = requestAnimationFrame(renderNext);
}
}
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [shouldRender, data.length]);
一句话总结:把大任务拆成小任务,用 RAF 调度,主线程不被阻塞,用户操作始终可响应。