虚拟列表

250 阅读3分钟
  1. 什么是虚拟列表?

    虚拟列表其实是按需显示的一种实现。监控滚动事件,截取可见区域内的内容进行渲染,对非可见区域中的数据不渲染或部分渲染,来达到性能优化的目的。

  2. 为什么要用虚拟列表?

    假设有10000条数据需要展示到页面上,且不能分页,如果采用一次性渲染的方式,渲染时间会比较长,交互时也会有卡顿的现象出现,为了解决这个问题,可以使用虚拟列表的方式。

  3. 怎么实现虚拟列表?

    • 首先应该有一个用于展示高度固定的容器,高度由内容撑开,监听scroll事件
    • 存在一个占位,高度为真实列表的高度(真实高度如何计算?)
    • 可视区域:内容由列表截取,startIndex为开始截取位置,endIndex为结束位置,并存在一个位置偏移startOffset

think.jpg

    <div className="visual-list-container">
      <div className="occupancy-list"></div>
      <div className="render-list"></div>
    </div>

visual-list-container为容器;occupancy-list为占位高度为总列表高度,用于形成滚动条;render-list为渲染区域,真实渲染列表。

占位列表高度以及startIndex如何计算?滚动距离:scrollTop, 项目高度:itemHeight

  • 项目高度固定的情况:

    • 真实高度:listHeight = itemHeight * list.length
    • 显示的数量:count = Math.ceil(height / itemHeight)
    • 开始索引:startIndex = Math.floor(scrollTop / itemHeight)
    • 结束索引:endIndex = startIndex + count
    • 偏移:startOffset = startIndex * itemHeight
    • 渲染列表 renderList = list.slice(startIndex, endIndex)
  • 项目高度不固定的情况(更常见): 可以先预测项目高度estimatedHeight,并存储包含每一项height、top、bottom信息positions,然后当项目高度发生改变时进行更新,高度的变化可以通过ResizeObserver进行监控

    // 初始化positions
    positions = list.map((v, index) => ({
       height: estimatedHeight,
       top: index * estimatedHeight,
       bottom: (index + 1) * estimatedHeight,
    }))
    
    • 真实高度:listHeight = positions[positions.length - 1].bottom
    • 偏 移:startOffset = positions[startIndex].top

具体代码如下:

VisualList.tsx

import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import VisualItem from './VisualItem.tsx';
import './visual.css';

interface IVisualListProps {
 list: any[];
 height: string; // 高度
 estimatedHeight: number;  // 预测高度
 buffer: number; // 缓冲区
 children: (data: any) => ReactNode;
}

// 二分查找
const binarySearch = (list, value) => {
 let start = 0;
 let end = list.length - 1;
 let tempIndex: number | null = null;

 while (start <= end) {
   let midIndex = Math.floor((start + end) / 2);
   let midValue = list[midIndex].bottom;
   if (midValue === value) {
     return midIndex + 1;
   } else if (midValue < value) {
     start = midIndex + 1;
   } else if (midValue > value) {
     if (tempIndex === null || tempIndex > midIndex) {
       tempIndex = midIndex;
     }
     // 由于是要找到第一个比value大的,所以是end-1
     end = end - 1;
   }
 }
 return tempIndex;
};

const VisualList = ({
  list,
  height,
  children,
  estimatedHeight,
  buffer = 4,
}: IVisualListProps) => {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const [positions, setPositions] = useState<
    { height: number; top: number; bottom: number }[]
  >(
    list.map((v, index) => ({
      height: estimatedHeight,
      top: index * estimatedHeight,
      bottom: (index + 1) * estimatedHeight,
    }))
  );
  const [count, setCount] = useState(0); // 渲染的个数
  const [startIndex, setStartIndex] = useState(0); // 开始索引

  const formatList = useMemo(
    () => list.map((v, index) => ({ ...v, __index: index })),
    [list]
  ); // 格式化list, 设置index

  // 计算需要渲染的个数
  useEffect(() => {
    if (containerRef.current) {
      const container = containerRef.current.getBoundingClientRect();
      const containerHeight = container.height;
      const count = Math.ceil(containerHeight / estimatedHeight);
      setCount(count);
    }
  }, [estimatedHeight]);

  // 渲染列表
  const renderList = useMemo(() => {
    // 无缓冲
    // const start = startIndex;
    // const end = startIndex + count;
    // return formatList.slice(start, end);
    const start = startIndex < buffer ? 0 : startIndex - buffer;
    const end = startIndex + count + buffer;
    return formatList.slice(start, end);
  }, [formatList, startIndex, count, buffer]);

  // 滚动事件
  const onScroll = (e) => {
    const scrollTop = e.target.scrollTop;
    const index = binarySearch(positions, scrollTop);
    if (index !== null) {
      setStartIndex(index);
    }
  };

  // 更新position、startIndex
  const onMeasure = (index, height) => {
    const diffHeight = height - positions[index].height;
    if (diffHeight) {
      positions[index].height = height;
      positions[index].bottom = positions[index].bottom + diffHeight;
      // 更新其他的
      for (let k = index + 1; k < positions.length; k++) {
        positions[k].top = positions[k - 1].bottom;
        positions[k].bottom = positions[k].bottom + diffHeight;
      }
      setPositions(positions);
    }
  };

  let offset = 0;
  if (startIndex >= 1) {
    const bufferTop =
      startIndex < buffer
        ? positions[0].top
        : positions[startIndex - buffer].top;
    const bufferSize = positions[startIndex].top - bufferTop;
    offset = positions[startIndex].top - bufferSize; // ?bufferTop
  }

  return (
    <div
      className="visual-list-container"
      ref={containerRef}
      style={{ height }}
      onScroll={onScroll}
    >
      <div
        className="occupancy-list"
        style={{ height: positions[positions.length - 1]?.bottom || 0 }}
      ></div>
      <div
        className="render-list"
        style={{
          transform: `translate3d(0, ${offset}px, 0)`,
        }}
      >
        {renderList.map((v) => (
          <VisualItem
            key={v.__index}
            measure={(height) => onMeasure(v.__index, height)}
          >
            {children(v)}
          </VisualItem>
        ))}
      </div>
    </div>
  );
};

export default VisualList;

.visual

VisualItem.tsx

import React, { ReactNode, useEffect, useRef } from 'react';
interface IVisualItem {
  children: ReactNode,
  measure: (height: number) => void
}

const VisualItem = ({ children, measure }: IVisualItem) => {
  const ref = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    let observer;
    if (ref.current) {
      observer = new ResizeObserver(() => {
        if (ref.current && ref.current.offsetHeight) {
          measure(ref.current.offsetHeight);
        }
      });
      observer.observe(ref.current);
    }
    return () => {
      if (observer) {
        observer.disconnect();
      }
    };
  }, []);

  return (
    <div ref={ref} className="render-item">
      {children}
    </div>
  );
};

export default VisualItem;
.visual-list-container {
  border: 1px solid #000;
  border-radius: 4px;
  position: relative;
  overflow: auto;
}
.occupancy-list {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: -1;
}
.render-list {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1;
}
.render-item {
  border-bottom: 1px solid saddlebrown;
}

App.js

import VisualList from './components/VisualList.tsx';
import faker from 'faker';

let data = [];
for (let id = 0; id < 10000; id++) {
  const item = {
    id,
    value: faker.lorem.paragraphs(), // 长文本
  };
  data.push(item);
}

function App() {
  return (
    <div className="App">
      <VisualList
        height="400px"
        list={data}
        estimatedHeight={100}
        children={(v) => (
          <div>
            <span style={{ color: '#f40', fontSize: 16 }}>{v.id}</span>
            {v.value}
          </div>
        )}
      />
    </div>
  );
}

export default App;   

参考:

前端进阶」高性能渲染十万条数据(虚拟列表)

长列表优化之虚拟列表