十万条数据卡到页面崩溃?从 EventLoop 到 React 虚拟列表,彻底搞定前端列表性能瓶颈

261 阅读5分钟

前端开发中,“列表渲染” 是家常便饭,但如果遇到一次性渲染 10 万条数据的场景,页面直接 “卡死” 几乎是必然结果。这背后不仅是 “JS 执行耗时” 的问题,更和浏览器的EventLoop(事件循环)、“页面渲染时机” 紧密相关。今天就从问题根源出发,一步步优化,最终用 “虚拟列表” 实现丝滑体验,并附上 React 完整实现~

从 EventLoop 看透卡顿本质

要解决卡顿,得先搞懂 “为什么会卡顿”。这一切的根源,都绕不开前端的 “铁律”——JS 是单线程的,而页面渲染和 JS 执行,还得抢同一个 “时间片”。

1.1 EventLoop 的 “执行顺序”

浏览器的 EventLoop(事件循环),决定了 JS 代码和页面渲染的执行优先级,记住这个流程,就能理解 90% 的前端性能问题:

  1. 执行同步任务:先把主线程里的同步代码执行完(比如 for 循环、变量声明)。
  2. 清空微任务队列:同步任务执行完后,先处理微任务(Promise.then、MutationObserver 等)。
  3. UI 渲染:微任务清空后,浏览器会判断是否需要渲染页面(比如更新 DOM、修改样式)。
  4. 执行宏任务队列:渲染完成后,再从宏任务队列里拿一个任务执行(setTimeout、setInterval、scroll 事件等)。
  5. 重复 1-4 步,形成 “事件循环”。

关键问题来了:如果同步任务执行时间太长(比如循环插入十万个 DOM),会直接阻塞后续的 “UI 渲染” 步骤—— 这就是页面 “卡成白板” 的原因!

1.2 实测:同步插入十万条数据有多 “坑”

咱们先看一段代码,就是直接用 for 循环插入十万个 li:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>同步插入十万条数据</title>
</head>
<body>
    <ul id="container"></ul>
    <script>
        let now = Date.now();
        const total = 100000; // 十万条数据
        let ul = document.getElementById('container');
        
        // 同步循环插入DOM
        for (let i = 0; i < total; i++) {
            let li = document.createElement('li');
            li.innerHTML = `数据${Math.random()*total}`;
            ul.appendChild(li);
        }
        
        // 打印JS执行时间(同步任务耗时)
        console.log('JS同步执行时间:', Date.now() - now); 
        // 用setTimeout(宏任务)打印总耗时(包含渲染)
        setTimeout(() => {
            console.log('总耗时(含渲染):', Date.now() - now); 
        }, 0);
    </script>
</body>
</html>

运行结果(不同设备略有差异):

image.png

为什么会卡顿?

  1. 同步任务阻塞渲染:for 循环插入十万个 DOM 是 “同步任务”,会霸占主线程 1 秒左右,这段时间浏览器根本没时间渲染页面,所以你会看到页面空白半天。
  2. DOM 操作开销爆炸:每一次appendChild都会触发 “重绘” 或 “重排”(浏览器重新计算 DOM 位置和样式并绘制),十万次 DOM 操作,相当于让浏览器 “跑十万米”,不卡才怪。

1.3 小结:卡顿的 2 个核心原因

  1. JS 同步任务执行时间过长,阻塞了 UI 渲染(EventLoop 流程被打断);
  2. 一次性插入大量 DOM 节点,导致浏览器重绘 / 重排开销过大。

初步优化:时间分片

既然 “一次性吃太多会撑死”,那咱们就把 “十万条数据插入” 这个大任务,拆成一个个 “每次插 20 条” 的小任务,分批次执行 —— 这就是时间分片(Time Slicing)  的核心思路。

2.1 用 setTimeout 实现时间分片

setTimeout 是宏任务,能让拆分后的小任务 “插队” 到 EventLoop 的宏任务队列,避免阻塞同步执行和渲染。代码如下:

<ul id="container"></ul>
<script>
    let ul = document.getElementById("container");
    const total = 100000; // 总数据量
    const once = 20; // 每次插入20条(分片大小)
    let index = 0; // 当前插入的索引

    // 递归执行分片插入
    function loop(curTotal, curIndex) {
        if (curTotal <= 0) return; // 数据插完了,终止递归

        // 本次插入的数量(最后一次可能不足20条)
        const pageCount = Math.min(curTotal, once);
        
        // 宏任务:把本次插入放到下一轮EventLoop
        setTimeout(() => {
            for (let i = 0; i < pageCount; i++) {
                let li = document.createElement('li');
                li.innerText = `数据${curIndex + i}${Math.random()*total}`;
                ul.appendChild(li);
            }
            // 递归处理剩下的数据
            loop(curTotal - pageCount, curIndex + pageCount);
        }, 0);
    }

    loop(total, index);
</script>

优化效果:

页面不再空白半天,而是 “逐步加载” 数据,滚动时也不会完全卡死。但有个小问题:偶尔会出现 “掉帧” —— 比如滚动时突然停顿一下。

20250903-0340-15.8925554.gif

2.2 用 requestAnimationFrame 替代 setTimeout

为什么 setTimeout 会掉帧?因为 setTimeout 的 “延迟时间” 是预估的(实际可能受主线程忙碌程度影响),比如你设了 0ms,实际可能 16ms 后才执行,而浏览器的屏幕刷新频率通常是 60fps(约 16.6ms 刷新一次),两者不同步就会掉帧。

requestAnimationFrame(rAF)  完美解决了这个问题:它会 “跟紧” 浏览器的刷新节奏,每刷新一次执行一次回调,确保不会掉帧。修改代码如下:

<ul id="container"></ul>
<script>
    let ul = document.getElementById("container");
    const total = 100000;
    const once = 20;
    let index = 0;

    function loop(curTotal, curIndex) {
        if (curTotal <= 0) return;
        const pageCount = Math.min(curTotal, once);

        // 替换setTimeout为rAF,跟浏览器刷新同步
        requestAnimationFrame(() => {
            for (let i = 0; i < pageCount; i++) {
                let li = document.createElement('li');
                li.innerText = `数据${curIndex + i}${Math.random()*total}`;
                ul.appendChild(li);
            }
            loop(curTotal - pageCount, curIndex + pageCount);
        });
    }

    loop(total, index);
</script>

效果提升:

滚动时的流畅度明显提高,掉帧问题基本消失。但还有个隐患:随着数据加载,DOM 节点会越来越多(最终还是十万个) ,内存占用会越来越大,滚动时浏览器依然要 “扛着” 十万个节点计算,时间长了还是会慢。

2.3 用 DocumentFragment 减少 DOM 操作

每次循环里appendChild20 次,还是会触发 20 次 DOM 更新。有没有办法 “批量插入”?答案是DocumentFragment—— 它是一个 “内存中的 DOM 容器”,可以先把 20 个 li 放到里面,再一次性插入 ul,这样只触发 1 次 DOM 更新。

修改代码,加入 DocumentFragment:

<ul id="container"></ul>
<script>
    let ul = document.getElementById("container");
    const total = 100000;
    const once = 20;
    let index = 0;

    function loop(curTotal, curIndex) {
        if (curTotal <= 0) return;
        const pageCount = Math.min(curTotal, once);

        requestAnimationFrame(() => {
            // 创建DocumentFragment(内存中的容器)
            const fragment = document.createDocumentFragment();
            for (let i = 0; i < pageCount; i++) {
                let li = document.createElement('li');
                li.innerText = `数据${curIndex + i}${Math.random()*total}`;
                fragment.appendChild(li); // 先插入内存容器
            }
            ul.appendChild(fragment); // 一次性插入DOM,只触发1次更新
            loop(curTotal - pageCount, curIndex + pageCount);
        });
    }

    loop(total, index);
</script>

关键优化点:

DocumentFragment 不会被渲染到页面上,只是临时存储 DOM 节点,批量插入后会自动清空,能大幅减少 DOM 更新次数,进一步提升性能。

 时间分片的局限:DOM 节点依然 “泛滥”

时间分片解决了 “一次性阻塞渲染” 的问题,但没解决 “DOM 节点过多” 的根本问题 —— 十万条数据最终还是会生成十万个 DOM 节点,内存占用高,滚动时浏览器需要计算大量节点的位置,流畅度还是会受影响。

这时候,就需要 “终极方案”—— 虚拟列表登场了。

终极方案:虚拟列表 —— 只渲染 “看得见的” 内容

虚拟列表的核心思想特别简单:只渲染当前视窗内(用户能看到的)的内容,视窗外的内容一概不渲染。比如视窗高度 500px,每个 item 高度 50px,那视窗内只能放 10 个 item,不管总数据有十万还是一百万,页面上永远只渲染 20 个左右的 DOM 节点(视窗内 10 个 + 预渲染 10 个)。

怎么实现呢?记住 3 个关键步骤:

3.1 虚拟列表的核心原理(3 步走)

  1. 撑起滚动条:计算所有数据的总高度(totalHeight = data.length * itemHeight),用一个 “占位元素” 把容器的滚动条撑起来,让用户觉得 “所有数据都在里面”(欺骗滚动条)。
  2. 计算可视范围:监听容器的滚动事件,根据滚动距离(scrollTop)计算当前视窗内要渲染的 “起始索引” 和 “结束索引”。
  3. 定位渲染内容:用transform: translateY()把要渲染的内容 “挪” 到正确的位置,只渲染视窗内 + 预渲染(overscan)的 item,确保滚动时不空白。

3.2 关键计算公式(必背)

假设:

  • 容器高度 = containerHeight(比如 500px)
  • 每个 item 高度 = itemHeight(比如 50px)
  • 滚动距离 = scrollTop(比如 100px)
  • 预渲染数量 = overscan(比如 3,避免滚动空白)

则:

  • 起始索引 = Math.floor(scrollTop / itemHeight)(滚过了多少个 item,就是起始位置)
  • 可视 item 数量 = Math.ceil(containerHeight / itemHeight)(视窗内能放多少个 item)
  • 结束索引 = 起始索引 + 可视item数量 + overscan(加上预渲染,避免空白)
  • 偏移量 = 起始索引 * itemHeight(用 transform 把内容挪到正确位置)

实战:手写 React 虚拟列表组件

理解了原理,咱们来补全用户给的 React 虚拟列表组件,实现一个可直接用的版本。

4.1 组件设计思路

先明确组件需要的 props:

props类型作用
dataarray总数据(比如十万条)
heightnumber容器高度(固定,比如 500px)
itemHeightnumber每个 item 的高度(固定)
renderItemfunction自定义 item 渲染(插槽)
overscannumber预渲染数量(默认 3)

然后需要管理的状态:

  • startIndex:当前要渲染的起始索引
  • offset:渲染内容的偏移量(用于 transform)

4.2 完整代码实现(逐行解释)

// components/VirtualList.jsx
import { useRef, useState, useEffect } from 'react';

const VirtualList = ({
  data = [],          // 总数据,默认空数组
  height = 500,       // 容器高度,默认500px
  itemHeight = 50,    // 每个item高度,默认50px
  renderItem,         // 自定义渲染item的函数
  overscan = 3        // 预渲染数量,默认3
}) => {
  // 1. 用ref获取容器DOM,避免每次render重新获取
  const containerRef = useRef(null);
  
  // 2. 管理起始索引和偏移量(状态变了才重新渲染)
  const [startIndex, setStartIndex] = useState(0);
  const [offset, setOffset] = useState(0);

  // 3. 计算总高度(撑起滚动条用)
  const totalHeight = data.length * itemHeight;

  // 4. 计算可视范围的核心函数
  const calculateVisibleRange = () => {
    if (!containerRef.current) return;

    const { scrollTop } = containerRef.current; // 获取滚动距离

    // 计算起始索引:滚过的距离 / 每个item高度,向下取整
    const newStartIndex = Math.max(0, Math.floor(scrollTop / itemHeight));
    
    // 计算可视item数量:容器高度 / 每个item高度,向上取整(避免漏一个)
    const visibleCount = Math.ceil(height / itemHeight);
    
    // 计算结束索引:起始 + 可视数量 + 预渲染,不超过总数据长度
    const newEndIndex = Math.min(
      data.length,
      newStartIndex + visibleCount + overscan
    );

    // 计算偏移量:起始索引 * 每个item高度(用于定位)
    const newOffset = newStartIndex * itemHeight;

    // 只有起始索引变了,才更新状态(避免频繁render)
    if (newStartIndex !== startIndex) {
      setStartIndex(newStartIndex);
      setOffset(newOffset);
    }

    // 返回当前要渲染的索引范围(用于slice数据)
    return { start: newStartIndex, end: newEndIndex };
  };

  // 5. 监听滚动事件:滚动时重新计算可视范围
  const onScroll = () => {
    calculateVisibleRange();
  };

  // 6. 初始化时计算一次(页面加载时就渲染正确的内容)
  useEffect(() => {
    calculateVisibleRange();
    // 依赖:容器高度、item高度、数据长度变化时,重新计算
  }, [height, itemHeight, data.length]);

  // 7. 获取当前要渲染的数据(切片:只取可视范围+预渲染的数据)
  const { start = 0, end = 0 } = calculateVisibleRange() || {};
  const visibleData = data.slice(start, end);

  return (
    <div
      ref={containerRef}
      onScroll={onScroll}
      style={{
        height: `${height}px`,        // 固定容器高度
        overflowY: 'auto',            // 垂直滚动
        position: 'relative',         // 子元素绝对定位用
        willChange: 'transform',      // 性能优化告诉浏览器要变transform提前优化
        border: '1px solid #eee'      // 加个边框好看点
      }}
    >
      {/* 1. 占位元素:撑起滚动条,高度=总数据高度 */}
      <div
        style={{
          height: `${totalHeight}px`,
          width: '100%',
          position: 'absolute',
          top: 0,
          left: 0,
          zIndex: -1 // 放在最下面不影响内容
        }}
      />

      {/* 2. 实际渲染的内容:只渲染可视范围+预渲染的数据 */}
      <div
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          // 关键用transform把内容挪到正确位置
          transform: `translateY(${offset}px)`
        }}
      >
        {/* 调用renderItem渲染每个item,传入item和index */}
        {visibleData.map((item, idx) => {
          // 注意:这里的真实索引是 start + idx(不是idx)
          const realIndex = start + idx;
          return renderItem(item, realIndex);
        })}
      </div>
    </div>
  );
};

export default VirtualList;

4.3 组件使用示例(App.jsx)

用用户给的 App 组件,传入十万条数据,测试效果:

// App.jsx
import { useState } from 'react';
import './App.css';
import VirtualList from './components/VirtualList';

// 生成十万条测试数据
const generateData = (count) =>
  Array.from({ length: count }, (_, i) => ({
    id: i,
    name: `商品 ${i + 1}`,
    price: ${(Math.random() * 1000).toFixed(2)}`,
    description: `这是第${i + 1}个商品,用虚拟列表渲染,DOM节点只有20个左右`
  }));

function App() {
  // 生成十万条数据(页面加载时生成)
  const data = generateData(100000);

  // 自定义item渲染(插槽,灵活适配不同场景)
  const renderItem = (item, index) => (
    <div
      key={item.id} // 必须加keyReact优化用
      style={{
        padding: '12px',
        borderBottom: '1px solid #f5f5f5',
        backgroundColor: index % 2 === 0 ? '#fff' : '#fafafa',
        height: `${80}px`, // 对应VirtualList的itemHeight=80
        boxSizing: 'border-box' // 避免padding撑高item
      }}
    >
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <strong>[{index + 1}]</strong>
        <span style={{ color: '#ff4400' }}>{item.price}</span>
      </div>
      <h3 style={{ margin: '8px 0' }}>{item.name}</h3>
      <p style={{ margin: 0, fontSize: '0.9em', color: '#666' }}>
        {item.description}
      </p>
    </div>
  );

  return (
    <div style={{ padding: '20px', fontFamily: 'Arial', maxWidth: '800px', margin: '0 auto' }}>
      <h1>十万条商品数据 · React虚拟列表</h1>
      <p style={{ color: '#666' }}>
        实测:DOM节点仅20个左右,滚动帧率保持60fps(打开F12→Elements查看DOM数量)
      </p>
      
      {/* 使用虚拟列表组件 */}
      <VirtualList
        data={data}
        height={window.innerHeight - 150} // 容器高度=窗口高度-150(避免溢出)
        itemHeight={80} // 每个item高度=80px(和renderItem的height一致)
        renderItem={renderItem} // 自定义渲染item
        overscan={3} // 预渲染上下各3个避免滚动空白
      />
    </div>
  );
}

export default App;

image.png

4.4 性能测试:DOM 节点数量对比

  • 传统渲染:100000 个 DOM 节点
  • 虚拟列表:约 Math.ceil((window.innerHeight-150)/80) + 3*2 ≈ 10 + 6 = 16 个 DOM 节点

差距一目了然!滚动时浏览器只需要处理 16 个节点,流畅度直接拉满。

虚拟列表的应用场景与面试亮点

5.1 适用场景

虚拟列表不是银弹,但在这些场景下是 “性能救星”:

  • 大数据列表:商品列表、订单列表、日志展示
  • 长表格:比如后台管理系统的数据分析表格
  • 下拉加载:比如无限滚动列表(结合虚拟列表,避免 DOM 爆炸)

5.2 面试时怎么说?(加分话术)

“我在项目中遇到过十万条商品数据渲染卡顿的问题,一开始用时间分片解决了同步阻塞,但 DOM 节点还是太多。后来深入理解了 EventLoop 和浏览器渲染机制,用虚拟列表重构了组件:

  1. 先通过 EventLoop 分析卡顿原因:同步任务阻塞渲染,大量 DOM 导致重绘重排;
  2. 用时间分片 + requestAnimationFrame 初步优化,解决了阻塞问题;
  3. 最后用虚拟列表实现终极优化:只渲染视窗内内容,DOM 节点从十万减到 20 个,滚动帧率保持 60fps;
  4. 还做了预渲染(overscan)和 GPU 加速(willChange),避免滚动空白和掉帧。”

5.3 扩展:动态 item 高度怎么处理?

上面的例子是 “固定 item 高度”,如果 item 高度不固定(比如内容长短不一),可以用以下方案:

  1. 预估高度:先给一个预估高度,渲染后再修正真实高度(用 ResizeObserver 监听);
  2. 用成熟库:推荐 react-window 或 react-virtualized(处理了动态高度、横向滚动等边缘场景)。

结语:从 “知其然” 到 “知其所以然”

解决十万条数据卡顿的过程,本质是 “理解原理→逐步优化” 的过程:

  • 从 EventLoop 看透卡顿根源,避免 “头痛医头”;
  • 用时间分片解决 “同步阻塞”,是 “治标”;
  • 用虚拟列表解决 “DOM 泛滥”,是 “治本”。