前端性能救星:虚拟列表原理与实现,让你的10万条数据流畅如丝!

大家好,我是你们的老朋友掘金创作者FogLetter。今天我们来聊聊一个既常见又让人头疼的问题------海量数据渲染

想象一下这个场景:产品经理兴冲冲跑过来:"我们要做个消息中心,用户可能有几万条消息,要保证流畅滚动哦!" 你内心OS:"一次性渲染10万条数据?这不是要浏览器老命吗!"

别急,虚拟列表就是为此而生的性能救星!

从一次惨痛经历说起

小明刚入行时,接到一个需求:在后台管理系统展示用户操作日志。测试时数据不多,一切正常。上线后,随着数据积累,有一天运营同事反馈:"页面卡得动不了啦!"

打开开发者工具一看,好家伙,10万条日志记录,页面元素多达10万个DOM节点。浏览器内存占用直奔2GB,不卡才怪!

html 复制代码
<!-- 这就是我们曾经的噩梦 -->
<ul id="container">
  <li>日志记录1</li>
  <li>日志记录2</li>
  <!-- ... 省略99997个li ... -->
  <li>日志记录100000</li>
</ul>

<script>
// 创建10万条数据
let now = Date.now();
const total = 100000;
let ul = document.getElementById('container');
for (let i = 0; i < total; i++) {
  let li = document.createElement('li');
  li.innerText = `操作日志 ${i}: 用户于${new Date()}执行了操作`;
  ul.appendChild(li);
}
console.log('JS运行时间', Date.now() - now); // 输出:JS运行时间 3250ms
</script>

这种暴力渲染的方式,JS执行就要3秒多,这还不算后续的样式计算、布局、绘制时间。用户在这期间只能面对白屏干瞪眼。

为什么海量数据会卡?

要理解虚拟列表,先得明白瓶颈在哪:

1. JS执行时间过长

创建10万个DOM元素,JS需要同步执行很久,阻塞了事件循环。

2. 内存占用巨大

每个DOM元素都要占用内存,10万个LI元素轻松吃掉几百MB内存。

3. 渲染性能瓶颈

浏览器需要计算10万个元素的样式、布局,每次重排重绘都是巨大开销。

4. 事件监听器负担

如果每个列表项都有交互,事件委托还好,要是每个都绑事件...恭喜你,卡顿大礼包已送达!

解决方案演进史

第一代方案:时间分片

既然一次性渲染太卡,那我们分批渲染不就行了?

html 复制代码
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
let total = 100000;
let once = 20; // 每次渲染20条
let page = total / once;
let index = 0;

function loop(curTotal, curIndex) {
  if (curTotal <= 0) return;
  
  let pageCount = Math.min(curTotal, once);
  
  // 使用setTimeout分批次执行
  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>

进步: 确实不卡死浏览器了,但会有明显的"白屏-出现-白屏-出现"的闪烁现象。

第二代方案:requestAnimationFrame + DocumentFragment

html 复制代码
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
let total = 100000;
let once = 20;
let page = total / once;
let index = 0;

function loop(curTotal, curIndex) {
  if (curTotal <= 0) return;
  
  let pageCount = Math.min(curTotal, once);
  
  // 使用requestAnimationFrame在浏览器重绘前执行
  requestAnimationFrame(function() {
    // 使用DocumentFragment减少重排次数
    let 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);
    loop(curTotal - pageCount, curIndex + pageCount);
  });
}

loop(total, index);
</script>

进步: 动画更流畅,减少了布局抖动,但本质上还是在渲染所有数据,内存问题没解决。

终极方案:虚拟列表

虚拟列表的核心思想很简单:只渲染可视区域的内容

好比你家有个100层的书架,但你只能看到眼前的5层。虚拟列表就像个聪明的图书管理员,你往下滑动时,他把下面的书拿上来,把上面的书收起来,始终保持你眼前只有那几本书。

虚拟列表三要素

  1. 容器高度 - 你能看到的区域高度
  2. 滚动位置 - 你现在看到哪了
  3. 项目高度 - 每本书的厚度(可以是固定或动态)

React虚拟列表实现

让我们手写一个精简版虚拟列表:

jsx 复制代码
import { useState, useRef, useMemo } from 'react';

const VirtualList = ({
  data,
  height,
  itemHeight,
  renderItem,
  overscan = 3
}) => {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);
  
  // 计算可见区域
  const visibleRange = useMemo(() => {
    // 开始索引:滚动距离 / 项目高度
    const startIndex = Math.floor(scrollTop / itemHeight);
    // 结束索引:开始索引 + 可见项目数
    const visibleItemsCount = Math.ceil(height / itemHeight);
    const endIndex = startIndex + visibleItemsCount;
    
    // 考虑预渲染(overscan)避免滚动时白屏
    const overscanStart = Math.max(0, startIndex - overscan);
    const overscanEnd = Math.min(data.length, endIndex + overscan);
    
    return {
      startIndex: overscanStart,
      endIndex: overscanEnd,
      offset: overscanStart * itemHeight
    };
  }, [scrollTop, height, itemHeight, data.length, overscan]);
  
  const onScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };
  
  const totalHeight = data.length * itemHeight;
  
  return (
    <div
      ref={containerRef}
      onScroll={onScroll}
      style={{
        height,
        overflowY: 'auto',
        position: 'relative',
        willChange: 'transform', // 性能优化提示
      }}
    >
      {/* 撑开容器高度的占位元素 */}
      <div style={{ height: totalHeight, position: 'relative' }}>
        {/* 实际渲染的内容 */}
        <div style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${visibleRange.offset}px)`,
        }}>
          {data
            .slice(visibleRange.startIndex, visibleRange.endIndex)
            .map((item, index) => 
              renderItem(item, visibleRange.startIndex + index)
            )
          }
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

使用示例

jsx 复制代码
// 生成测试数据
const generateData = (count) =>
  Array.from({ length: count }, (_, index) => ({
    id: index,
    name: `消息 ${index}`,
    content: `这是第${index}条消息的内容,可能很长很长...`,
    time: new Date(Date.now() - index * 60000).toLocaleTimeString()
  }));

function App() {
  const data = generateData(100000);
  
  const renderItem = (item, index) => (
    <div
      key={item.id}
      style={{
        padding: '12px 16px',
        borderBottom: '1px solid #e8e8e8',
        backgroundColor: index % 2 === 0 ? '#fafafa' : '#fff',
        height: '80px', // 固定高度,简化计算
        boxSizing: 'border-box',
      }}
    >
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <strong>{item.name}</strong>
        <span style={{ fontSize: '0.8em', color: '#666' }}>{item.time}</span>
      </div>
      <p style={{ 
        margin: '8px 0 0 0', 
        fontSize: '0.9em', 
        color: '#666',
        overflow: 'hidden',
        textOverflow: 'ellipsis',
        whiteSpace: 'nowrap'
      }}>
        {item.content}
      </p>
    </div>
  );
  
  return (
    <div style={{ padding: '20px' }}>
      <h1>消息中心</h1>
      <p>共 {data.length} 条消息,滚动流畅无压力</p>
      
      <VirtualList 
        data={data}
        height={600}
        itemHeight={80}
        renderItem={renderItem}
        overscan={5} // 上下预渲染5个额外项
      />
    </div>
  );
}

性能对比

让我们看看虚拟列表的威力:

方案 DOM数量 内存占用 首次加载 滚动流畅度
暴力渲染 100000 ~500MB 3s+ 卡顿严重
时间分片 100000 ~500MB 2s 有明显闪烁
虚拟列表 ~30 ~10MB 50ms 极度流畅

进阶优化技巧

1. 动态高度支持

上面的实现假设所有项目高度固定,现实中往往需要支持动态高度:

jsx 复制代码
// 思路:维护一个位置索引,记录每个项目的累计高度
const useDynamicHeightVirtualList = (data, estimateHeight) => {
  const [positions, setPositions] = useState(() => 
    data.map((_, index) => ({
      index,
      height: estimateHeight,
      top: index * estimateHeight,
      bottom: (index + 1) * estimateHeight
    }))
  );
  
  // 项目渲染后更新实际高度
  const updatePosition = (index, height) => {
    if (Math.abs(positions[index].height - height) > 1) {
      // 重新计算后续所有项目的位置
      const newPositions = [...positions];
      newPositions[index].height = height;
      newPositions[index].bottom = newPositions[index].top + height;
      
      for (let i = index + 1; i < newPositions.length; i++) {
        newPositions[i].top = newPositions[i-1].bottom;
        newPositions[i].bottom = newPositions[i].top + newPositions[i].height;
      }
      
      setPositions(newPositions);
    }
  };
  
  return { positions, updatePosition };
};

2. 滚动节流

避免scroll事件触发太频繁:

jsx 复制代码
const useThrottledScroll = (callback, delay = 16) => {
  const lastExec = useRef(0);
  const timeoutId = useRef(null);
  
  return useCallback((e) => {
    const elapsed = Date.now() - lastExec.current;
    
    const execute = () => {
      callback(e);
      lastExec.current = Date.now();
    };
    
    if (timeoutId.current) {
      clearTimeout(timeoutId.current);
    }
    
    if (elapsed > delay) {
      execute();
    } else {
      timeoutId.current = setTimeout(execute, delay - elapsed);
    }
  }, [callback, delay]);
};

生产环境建议

在实际项目中,我推荐使用成熟的虚拟列表库:

  • react-window: Facebook官方出品,API简洁
  • react-virtualized: 功能丰富,社区成熟
  • @tanstack/react-virtual: TanStack出品,现代且高性能
bash 复制代码
npm install react-window
jsx 复制代码
import { FixedSizeList as List } from 'react-window';

const Row = ({ index, style }) => (
  <div style={style}>
    第 {index} 行
  </div>
);

const App = () => (
  <List
    height={600}
    itemCount={100000}
    itemSize={35}
  >
    {Row}
  </List>
);

总结

虚拟列表不是什么黑科技,它的核心思想就是"按需渲染"。通过只渲染可视区域的内容,我们实现了:

极致的性能 - 无论多少数据,只渲染几十个元素

流畅的体验 - 滚动如丝般顺滑

低内存占用 - 告别内存泄漏烦恼

快速首屏 - 用户无需等待

下次遇到海量数据渲染需求,别再暴力for循环了,试试虚拟列表,让你的应用飞起来!

希望这篇笔记对你有帮助!如果你有更好的虚拟列表实践,欢迎在评论区分享交流~

思考题: 虚拟列表适合所有列表场景吗?什么情况下不适合使用虚拟列表?

相关推荐
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅7 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端
爱敲代码的小鱼7 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax