前端性能救星:虚拟列表原理与实现,让你的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循环了,试试虚拟列表,让你的应用飞起来!

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

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

相关推荐
我是天龙_绍2 小时前
前端驼峰,后端下划线,问:如何统一?
前端
知识分享小能手2 小时前
微信小程序入门学习教程,从入门到精通,微信小程序常用API(下)——知识点详解 + 案例实战(5)
前端·javascript·学习·微信小程序·小程序·vue·前端开发
code_YuJun2 小时前
nginx 配置相关
前端·nginx
对不起初见4 小时前
PlantUML 完整教程:从入门到精通
前端·后端
东方掌管牛马的神4 小时前
oh-my-zsh 配置与使用技巧
前端
你的人类朋友4 小时前
HTTP请求结合HMAC增加安全性
前端·后端·安全
aidingni8884 小时前
掌握 TCJS 游戏摄像系统:打造动态影院级体验
前端·javascript
有梦想的攻城狮5 小时前
从0开始学vue:npm命令详解
前端·vue.js·npm