从0到1实现一个简单的虚拟滚动列表01

109 阅读3分钟

为什么需要虚拟滚动列表?

前端虚拟滚动技术是一种通过优化滚动列表的性能来提高Web应用程序性能的技术。在传统的滚动列表中,当用户滚动列表时,浏览器需要渲染所有的列表项,即使它们在屏幕外也需要渲染,这会导致性能问题。但是,使用虚拟滚动技术,只有可见的列表项才会被渲染,而其他列表项则在滚动时根据需要动态加载。

虚拟滚动技术通常使用一些技术来实现,例如在滚动时动态加载列表项,使用样式来隐藏屏幕外的列表项,以及在滚动停止时重新计算要渲染的列表项。虚拟滚动技术可以显著提高Web应用程序的性能和响应性,并在需要处理大量数据的情况下特别有用,例如社交媒体、电子商务等应用程序。

具体实现步骤

  1. 数据准备:首先,该示例使用 data 数组模拟包含 200000 个列表项的长列表,每个列表项包含 idtext 两个字段。ITEM_HEIGHTCONTAINER_HEIGHT 分别表示每个列表项的高度和可见区域的高度。

  2. 状态管理:使用 useState 钩子管理列表项的起始索引,即可见列表项的第一个索引。初始值为 0。使用 useRef 钩子引用可见区域的容器元素。

  3. 计算可见列表项:使用 Math.ceil 函数计算可见列表项的数量,并使用 slice 函数从 data 数组中获取可见列表项的数组。每个列表项的高度为 ITEM_HEIGHT,因此可见区域的高度除以每个列表项的高度即为可见列表项的数量。

  4. 处理滚动事件:通过 onScroll 事件监听容器元素的滚动事件,并根据滚动位置计算可见列表项的起始索引。具体地,使用 scrollTop 属性获取容器元素的滚动位置,然后将滚动位置除以每个列表项的高度并取整数部分,即可得到可见列表项的第一个索引。将该索引设置为起始索引即可。

  5. 渲染列表项:使用 map 函数遍历可见列表项的数组,渲染每个列表项的文本。为了占据正确的位置,对于未渲染的列表项,使用 paddingTop 样式填充空白区域。这里将 paddingTop 设置为起始索引乘以每个列表项的高度,即 paddingTop: startIndex * ITEM_HEIGHT

具体代码

import React, { useState, useRef } from "react";
import "./style.css";

interface Item {
  id: number;
  text: number;
}

const data: Item[] = new Array(200000).fill(0).map((_, index) => ({
  id: index,
  text: Math.random() + index,
}));

const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 300;

export default function App() {
  const [startIndex, setStartIndex] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  const visibleItemCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT);

  const endIndex = startIndex + visibleItemCount;

  const visibleData = data.slice(startIndex, endIndex);

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    const { scrollTop } = e.currentTarget;
    const index = Math.floor(scrollTop / ITEM_HEIGHT);
    setStartIndex(index);
  };

  return (
    <div
      className="container"
      style={{ height: CONTAINER_HEIGHT, width: "100%" }}
      ref={containerRef}
      onScroll={handleScroll}
    >
      {/* 通过 paddingTop 填充空白区域 */}
      <div
        style={{
          height: data.length * ITEM_HEIGHT,
          paddingTop: startIndex * ITEM_HEIGHT,
        }}
      >
        {visibleData.map((item, index) => (
          <div
            key={item.id}
            style={{
              height: ITEM_HEIGHT,
              backgroundColor: index % 2 === 0 ? "blue" : "green",
              color: "white",
              display: "flex",
              justifyContent: "center",
              alignItems: "center",
            }}
          >
            {item.text}
          </div>
        ))}
      </div>
    </div>
  );
}

样式部分

.container {
  border: 1px solid #ccc;
  overflow-y: scroll;
}

.item {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  border-bottom: 1px solid #ccc;
}

实现原理图

改进

1.以上代码关键的滚动区域需要加上 boxSizing: 'border-box'

2.计算后的starIndex不能无限增大,限制大小为

 if(index + visibleItemCount <= data.length) {
      setStartIndex(index);
    }

3.防止底部出现空白的现象,增加底部buffer


 let endIndex = startIndex + visibleItemCount;

 if(endIndex + 1 <= data.length) {
    endIndex += 1
 }
  

组件化

提取出核心逻辑,组件化后可以供其他人使用

import React, { useState, useRef } from "react";
import "./style.css";

interface Item {
  id: number;
  text: number;
}

const data: Item[] = new Array(200).fill(0).map((_, index) => ({
  id: index,
  text: Math.random() + index,
}));

const ITEM_HEIGHT = 50;
const CONTAINER_HEIGHT = 300;

interface VirtualScrollProps {
  data: Item[];
  itemHeight: number;
  containerHeight: number;
}

const VirtualScroll: React.FC<VirtualScrollProps> = ({
  data,
  itemHeight,
  containerHeight,
}) => {
  const [startIndex, setStartIndex] = useState(0);
  const visibleItemCount = Math.ceil(containerHeight / itemHeight);
  let endIndex = startIndex + visibleItemCount;

  if (endIndex + 1 <= data.length) {
    endIndex += 1;
  }

  let visibleData = data.slice(startIndex, endIndex);

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    const { scrollTop } = e.currentTarget;
    const index = Math.floor(scrollTop / itemHeight);

    if (index + visibleItemCount <= data.length) {
      setStartIndex(index);
    }
    
  };

  return (
    <div
      className="virtual-scroll-container"
      style={{ height: containerHeight}}
      onScroll={handleScroll}
    >
      <div
        className="virtual-scroll-content"
        style={{
          height: data.length * itemHeight,
          paddingTop: startIndex * itemHeight,
          boxSizing: "border-box",
        }}
      >
        {visibleData.map((item, index) => (
          <div
            className="virtual-scroll-item"
            key={item.id}
            style={{
              height: itemHeight,
              backgroundColor: index % 2 === 0 ? "blue" : "green",
            }}
          >
            <span>{item.text}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

export default function App() {
  return (
    <VirtualScroll
      data={data}
      itemHeight={ITEM_HEIGHT}
      containerHeight={CONTAINER_HEIGHT}
    />
  );
}

代码仓库