虚拟列表完全指南:从零到一手写实现

虚拟列表完全指南:从零到一手写实现

前言

在现代前端开发中,处理大量数据展示是一个常见的性能挑战。当需要渲染成千上万条数据时,传统的全量渲染会导致页面卡顿甚至崩溃。虚拟列表(Virtual List)技术应运而生,它是解决大数据量渲染性能问题的核心方案。

本文将带你从零开始理解虚拟列表的原理,并手写一个完整的实现。

什么是虚拟列表?

问题场景

假设你需要渲染 10 万条用户数据:

javascript 复制代码
// 传统做法 - 性能灾难
const users = new Array(100000)
  .fill(0)
  .map((_, i) => ({ id: i, name: `用户${i}` }));

return (
  <div>
    {users.map((user) => (
      <div key={user.id}>{user.name}</div>
    ))}
  </div>
);

这样做会创建 10 万个 DOM 节点,浏览器直接卡死。

虚拟列表的解决方案

核心思想:用户屏幕只能看到有限的条目(比如 20 条),我们只需要渲染这 20 条,其他的用空白撑开滚动条即可。

scss 复制代码
可见区域示意图:
┌─────────────────┐
│ 项目1 (渲染)    │ ← 用户能看到
│ 项目2 (渲染)    │ ← 用户能看到
│ 项目3 (渲染)    │ ← 用户能看到
├─────────────────┤
│ 项目4 (不渲染)  │ ← 用空白代替
│ 项目5 (不渲染)  │
│ ...             │
│ 项目100000      │
└─────────────────┘

核心原理详解

1. 关键计算公式

虚拟列表的核心是 4 个计算公式:

javascript 复制代码
// 1. 用户滚动了多少距离?
const scrollTop = 容器.scrollTop;

// 2. 第一个可见项目的索引
const startIndex = Math.floor(scrollTop / itemHeight);

// 3. 最后一个可见项目的索引
const endIndex = startIndex + Math.ceil(containerHeight / itemHeight);

// 4. 可见区域的偏移量
const offsetY = startIndex * itemHeight;

2. 实际计算示例

假设:

  • 容器高度:300px
  • 每项高度:50px
  • 用户滚动了:250px

计算过程:

javascript 复制代码
scrollTop = 250;
itemHeight = 50;
containerHeight = 300;

// 第一个可见项索引:250 ÷ 50 = 5
startIndex = Math.floor(250 / 50) = 5;

// 最后一个可见项索引:5 + (300 ÷ 50) = 5 + 6 = 11
endIndex = 5 + Math.ceil(300 / 50) = 11;

// 可见区域偏移:5 × 50 = 250px
offsetY = 5 * 50 = 250;

结果:渲染第 5-11 项,并将它们向下偏移 250px。

DOM 结构设计

虚拟列表需要 3 层 DOM 结构:

html 复制代码
<!-- 第1层:外层滚动容器 -->
<div class="scroll-container" style="height: 300px; overflow-y: auto;">
  <!-- 第2层:撑开滚动条的容器 -->
  <div class="total-container" style="height: 5000000px; position: relative;">
    <!-- 第3层:可见项目容器 -->
    <div
      class="visible-container"
      style="position: absolute; transform: translateY(250px);"
    >
      <div>项目5</div>
      <div>项目6</div>
      <div>项目7</div>
      <!-- ... -->
    </div>
  </div>
</div>

要写成通用组件的话就把这里的常数换成通过参数计算的变量就行 各层作用

  • 第 1 层:提供滚动能力,监听滚动事件
  • 第 2 层:撑开滚动条,高度 = 总数据量 × 每项高度
  • 第 3 层:实际渲染可见项目,通过 transform 定位

手写实现步骤

第 1 步:搭建基础框架

javascript 复制代码
import { useRef, useState, useCallback } from "react";

const VirtualList = ({
  data, // 数据数组
  height, // 容器高度
  itemHeight, // 每项高度
  renderItem, // 渲染函数
}) => {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);

  return (
    <div
      ref={containerRef}
      style={{
        height,
        overflowY: "auto",
        position: "relative",
      }}
    >
      {/* 内容待实现 */}
    </div>
  );
};

第 2 步:添加滚动监听

javascript 复制代码
const onScroll = useCallback(() => {
  if (containerRef.current) {
    setScrollTop(containerRef.current.scrollTop);
  }
}, []);

// 在div上添加onScroll事件
<div
  ref={containerRef}
  onScroll={onScroll}
  style={{...}}
>

第 3 步:核心计算逻辑

javascript 复制代码
// 计算总高度
const totalHeight = data.length * itemHeight;

// 计算可见区域的起始和结束索引
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
  startIndex + Math.ceil(height / itemHeight),
  data.length - 1
);

// 计算偏移量
const offsetY = startIndex * itemHeight;

第 4 步:构建可见项目数组

javascript 复制代码
// 生成可见项目列表
const visibleItems = [];
for (let i = startIndex; i <= endIndex; i++) {
  visibleItems.push({
    index: i,
    data: data[i],
  });
}

第 5 步:完成 DOM 渲染

javascript 复制代码
return (
  <div
    ref={containerRef}
    onScroll={onScroll}
    style={{
      height,
      overflowY: "auto",
      position: "relative",
    }}
  >
    {/* 撑开滚动条的容器 */}
    <div style={{ height: totalHeight, position: "relative" }}>
      {/* 可见项目容器 */}
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${offsetY}px)`,
        }}
      >
        {/* 渲染可见项目 */}
        {visibleItems.map(({ index, data: item }) => (
          <div
            key={index}
            style={{
              height: itemHeight,
              overflow: "hidden",
            }}
          >
            {renderItem(item, index)}
          </div>
        ))}
      </div>
    </div>
  </div>
);

完整代码实现

javascript 复制代码
import { useRef, useState, useCallback } from "react";

const VirtualList = ({
  data,
  height,
  itemHeight,
  renderItem,
  overscan = 3, // 缓冲区,默认3项
}) => {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);

  // 计算总高度
  const totalHeight = data.length * itemHeight;

  // 计算可见区域的起始和结束索引
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex + Math.ceil(height / itemHeight),
    data.length - 1
  );

  // 考虑缓冲区的实际渲染范围
  const visibleStartIndex = Math.max(0, startIndex - overscan);
  const visibleEndIndex = Math.min(data.length - 1, endIndex + overscan);

  // 可见项目数组
  const visibleItems = [];
  for (let i = visibleStartIndex; i <= visibleEndIndex; i++) {
    visibleItems.push({
      index: i,
      data: data[i],
    });
  }

  // 滚动事件处理
  const onScroll = useCallback(() => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
  }, []);

  // 可见区域的偏移量
  const offsetY = visibleStartIndex * 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(${offsetY}px)`,
          }}
        >
          {/* 渲染可见项目 */}
          {visibleItems.map(({ index, data: item }) => (
            <div
              key={index}
              style={{
                height: itemHeight,
                overflow: "hidden",
              }}
            >
              {renderItem(item, index)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

性能优化技巧

1. 缓冲区(Overscan)

在可见区域前后多渲染几项,避免快速滚动时出现白屏:

javascript 复制代码
// 不使用缓冲区:滚动时可能出现白屏
const visibleItems = data.slice(startIndex, endIndex + 1);

// 使用缓冲区:前后各多渲染3项
const bufferStart = Math.max(0, startIndex - 3);
const bufferEnd = Math.min(data.length - 1, endIndex + 3);
const visibleItems = data.slice(bufferStart, bufferEnd + 1);

2. 滚动事件优化

使用useCallback避免滚动事件处理函数重复创建:

javascript 复制代码
const onScroll = useCallback(() => {
  if (containerRef.current) {
    setScrollTop(containerRef.current.scrollTop);
  }
}, []); // 空依赖数组,函数只创建一次

3. GPU 加速

使用willChange提示浏览器优化渲染:

javascript 复制代码
style={{
  willChange: 'transform', // 提示浏览器该元素会发生变换
  transform: `translateY(${offsetY}px)` // 使用transform而非top
}}

4. 节流优化(可选)

对于极高频率的滚动,可以添加节流:

javascript 复制代码
import { throttle } from "lodash";

const onScroll = useCallback(
  throttle(() => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
  }, 16), // 约60fps
  []
);

使用示例

javascript 复制代码
import VirtualList from "./VirtualList";

const App = () => {
  // 生成10万条测试数据
  const data = new Array(100000).fill(0).map((_, i) => ({
    id: i,
    name: `用户${i}`,
    email: `user${i}@example.com`,
  }));

  // 自定义渲染函数
  const renderItem = (item, index) => (
    <div
      style={{
        padding: "10px",
        borderBottom: "1px solid #eee",
        display: "flex",
        justifyContent: "space-between",
      }}
    >
      <span>{item.name}</span>
      <span>{item.email}</span>
    </div>
  );

  return (
    <div style={{ padding: "20px" }}>
      <h1>虚拟列表示例 - 10万条数据</h1>
      <VirtualList
        data={data}
        height={400} // 容器高度400px
        itemHeight={60} // 每项高度60px
        renderItem={renderItem}
        overscan={5} // 缓冲区5项
      />
    </div>
  );
};

进阶功能扩展

1. 动态高度支持

实际项目中,每项的高度可能不同:

javascript 复制代码
// 使用高度映射表
const itemHeights = new Map(); // 存储每项的实际高度

// 计算累积高度
const getOffsetY = (index) => {
  let offset = 0;
  for (let i = 0; i < index; i++) {
    offset += itemHeights.get(i) || estimatedHeight;
  }
  return offset;
};

2. 水平虚拟滚动

将垂直滚动的逻辑应用到水平方向:

javascript 复制代码
// 水平滚动的关键变化
const scrollLeft = containerRef.current.scrollLeft; // 使用scrollLeft
const startIndex = Math.floor(scrollLeft / itemWidth); // 使用itemWidth
const offsetX = startIndex * itemWidth; // 使用offsetX
transform: `translateX(${offsetX}px)`; // 使用translateX

3. 二维虚拟滚动

同时支持水平和垂直虚拟滚动,适用于表格场景。

常见问题与解决方案

Q1: 滚动时出现白屏怎么办?

A: 增加缓冲区(overscan)参数,在可见区域前后多渲染几项。

Q2: 滚动性能仍然不够好?

A:

  • 使用transform而非top/left定位
  • 添加willChange: 'transform'
  • 考虑使用requestAnimationFrame优化滚动事件

Q3: 如何处理不等高的项目?

A: 需要维护一个高度映射表,并使用累积高度计算偏移量。

Q4: 能否支持无限滚动加载?

A : 可以在滚动到底部时触发数据加载,并动态更新data数组。

总结

虚拟列表是前端性能优化的重要技术,核心原理是:

  1. 只渲染可见部分:大幅减少 DOM 节点数量
  2. 动态计算索引:根据滚动位置确定渲染范围
  3. 使用 transform 定位:利用 GPU 加速提升性能
  4. 添加缓冲区:优化滚动体验

掌握虚拟列表不仅能解决大数据量渲染问题,更能让你深入理解前端性能优化的核心思想。在面试中,这也是考察候选人技术深度的经典题目。

希望通过本文,你能完全理解虚拟列表的原理,并具备从零实现的能力。记住核心公式:滚动距离 ÷ 项目高度 = 起始索引起始索引 × 项目高度 = 偏移距离


本文示例代码基于 React 实现,核心思想适用于所有前端框架。

相关推荐
hj5914_前端新手3 小时前
深入分析 —— JavaScript 深拷贝
前端·javascript
jqq6663 小时前
解析ElementPlus打包源码(二、buildFullBundle)
前端·javascript·vue.js
YaeZed3 小时前
TypeScript6(class类)
前端·typescript
织_网3 小时前
UniApp 页面通讯方案全解析:从 API 到状态管理的最佳实践
前端·javascript·uni-app
emojiwoo4 小时前
前端视觉交互设计全解析:从悬停高亮到多维交互体系(含代码 + 图表)
前端·交互
xxy.c4 小时前
嵌入式解谜日志—多路I/O复用
linux·运维·c语言·开发语言·前端
yuehua_zhang4 小时前
uni app 的app端 写入运行日志到指定文件夹。
前端·javascript·uni-app
IT_陈寒5 小时前
SpringBoot 3.x实战:5种高并发场景下的性能优化秘籍,让你的应用快如闪电!
前端·人工智能·后端
麦文豪(victor)5 小时前
自动化流水线
前端