大数据量渲染优化:分批渲染技术详解

大数据量渲染优化:分批渲染技术详解

从 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 后 立即可操作

八、总结

核心要点

  1. 问题:大量 DOM 一次性渲染阻塞主线程
  2. 方案 :用 requestAnimationFrame 分批渲染
  3. 关键:第一批同步渲染保证即时反馈,后续批次用 RAF 调度
  4. 注意:组件卸载时清理 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 调度,主线程不被阻塞,用户操作始终可响应。

相关推荐
FungLeo2 小时前
成为全栈·Vue 管理后台篇·Vue Router如何守住多角色后台边界
前端·javascript·vue.js·rbac·vue router·路由守卫·前端权限
FungLeo2 小时前
成为全栈·Vue 管理后台篇·分页只显示第一页:从接口total 定位到前端修复
前端·vue.js·状态模式·分页·naive ui·服务端分页·前端问题排查
ZhengEnCi3 小时前
苹果滑动为什么有「肉感」:橡皮筋越界阻尼与弹簧动画的物理原理
前端
Moment3 小时前
不会后期的我,发现了一个开源胶片神器😍😍😍
前端·后端·开源
L1mbo3 小时前
Lite模式 缓解微信开发者工具卡顿,附 uni-app 实践方案
前端·微信小程序
FungLeo3 小时前
成为全栈·Vue管理后台篇·仪表盘不是几张卡片:组合统计接口与角色视图
前端·javascript·vue.js·rbac·仪表盘·管理后台·tanstack query
SameX3 小时前
HarmonyOS 做桌面卡片 onAddForm 要同步返回但数据在数据库里的解法
前端
deli0073 小时前
手机夜景模式为什么要举 3 秒?8 帧 RAW 堆栈 SNR 实测
前端
松仔log4 小时前
Kotlin中级——callbackFlow
java·前端·kotlin
三小河4 小时前
SWC 凭什么比 Babel 快 ?项目优化迁移踩坑记录
前端·后端