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

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

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

相关推荐
ruanCat2 小时前
pnpm 踩坑实录:用 public-hoist-pattern 拯救被严格隔离坑掉的依赖
前端·npm·node.js
yuki_uix2 小时前
渲染优化三件套:React.memo、useMemo、useCallback 的使用边界
前端·react.js
徐同保2 小时前
如何为 Node.js 多层子进程启动调试(以 OpenClaw 为例)
前端
滕青山2 小时前
基于 pdf-lib 的图片转PDF工具核心JS实现
前端·javascript·vue.js
yuki_uix2 小时前
前端异步编程三板斧:从面试题到底层思维
前端·javascript
会联营的陆逊2 小时前
Vite + Vue3 构建优化:CDN 外部化方案
前端·vue.js
毛骗导演2 小时前
对话历史越来越长,OpenClaw 是怎么「压缩」掉的?——深读 Compaction 机制源码
前端·架构
广州华水科技2 小时前
单北斗GNSS变形监测如何在大坝安全中发挥关键作用?
前端
外派叙利亚2 小时前
uniapp 颜色卡条拖动
前端·javascript·uni-app