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

24 阅读4分钟

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

从 5 万条 IP 标签卡死页面说起


一、问题场景

1.1 业务背景

在 IP 组管理页面,需要展示 IP 地址列表。每个 IP 显示为一个 <Tag> 标签:

// 简单粗暴的实现
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 为什么会卡?

// 一次性渲染 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)

// 同步执行:阻塞主线程
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 状态设计

/** 每批渲染的数量 */
const BATCH_SIZE = 500;

/** 是否展开全部标签 */
const [displayAll, setDisplayAll] = useState(false);

/** 当前已渲染的标签数量 */
const [renderCount, setRenderCount] = useState(0);

/** RAF 的 ID,用于取消 */
const rafRef = useRef<number>();

4.2 核心逻辑

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 渲染逻辑

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 为什么第一批要立即渲染?

// 错误:第一批也用 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下一帧与浏览器同步,自动暂停

七、性能对比

指标优化前优化后
首次渲染时间10s100ms
主线程阻塞10s每帧 100ms
用户可操作时间10s 后立即可操作

八、总结

核心要点

  1. 问题:大量 DOM 一次性渲染阻塞主线程
  2. 方案:用 requestAnimationFrame 分批渲染
  3. 关键:第一批同步渲染保证即时反馈,后续批次用 RAF 调度
  4. 注意:组件卸载时清理 RAF,避免内存泄漏

代码模板

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 调度,主线程不被阻塞,用户操作始终可响应。