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

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

前言

在现代前端开发中,处理大量数据展示是一个常见的性能挑战。当需要渲染成千上万条数据时,传统的全量渲染会导致页面卡顿甚至崩溃。虚拟列表(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 实现,核心思想适用于所有前端框架。

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