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

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

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

相关推荐
李姆斯6 小时前
为啥Agent在coding表现这么好,但是在别的领域就是差的不少?
前端·agent·ai编程
鱼与宇9 小时前
前端Web(html+css+js+vue3)
前端
雪芽蓝域zzs10 小时前
(六)打包优化 + Nginx 部署完整配置 + 项目收尾
前端·vue.js
研☆香10 小时前
聊一聊前端的常见字 关键字
开发语言·前端·javascript
东风破_11 小时前
JWT 1:从一个登录请求开始,理解 React 项目里的 API 层与 Mock
前端·后端
东风破_11 小时前
JWT 3:为什么 Token 要放进 Authorization?Axios 拦截器到底解决了什么?
前端·后端
东风破_11 小时前
JWT 5:路由守卫是什么?把整个 JWT 登录鉴权流程串起来
前端·后端
东风破_11 小时前
JWT 2:HTTP 是无状态的,为什么登录成功后还要给 Token?
前端·后端
东风破_11 小时前
JWT 4:Zustand 到底解决了什么?为什么登录状态要放进 Store?
前端·后端
IT_陈寒11 小时前
Vite动态导入差点让我秃头,原来问题出在这
前端·人工智能·后端