使用React实现定高和不定高虚拟列表

322 阅读7分钟

有时候面试经常会被问到,如果页面上同时渲染10w条数据,该怎么设计呢?虽然已经有很多库已经实现,但是我们还是需要了解一下具体过程。

首先我们来看下直接渲染会出现什么问题?

因为我们知道同时渲染10w条数据,就意味着在页面上创建了10w个DOM,然后进行渲染,这就给了浏览器很大压力。内存占用高、首次加载慢、滚动卡顿(甚至页面崩溃)。

所以就出现了虚拟列表的解决方案。

定高列表实现思路

定高列表指的是每个列表项的高度是固定的。由于每个项的高度相同,计算列表中可见项的数量变得非常简单。

因为我们知道设备都有可视区域,那我们岂不是只需要渲染可视区域内的DOM,滚动的时候,通过计算滚动了多少,再计算现在应该渲染哪些DOM,这个问题不就解决了?

比如1w条数据,我的可视窗口为500px, 每一条的高度是50px,那么第一次渲染的时候,只需要在1w条数据中取出前10条展示DOM即可。

此时如果发生滚动,通过监听滚动事件,发现此时滚动了150px,也就是向上滚动了三条,这个时候可视区域就变成了 4- 13.

实现

有了上面的例子,也就有了实现思路:

  • 需要知道可视区域的高度(固定,取决于我们自己设定)
  • 需要知道当前可视区域渲染多少条数据(可视区域高度 / item高度 )
  • 需要知道当前渲染的哪些数据(计算出起始位置索引和终点位置的索引)
  • 需要知道真实列表的高度(根据数据量可以计算出真实的高度)
  • 需要知道偏移量,因为当真实列表向上滚动的时候,我们需要将可视区域保持不动,否则就会跟着正式列表一起向上走了; 计算偏移量让我们要渲染的这个div始终保持在当前的区域(只要知道了起始位置索引就知道了偏移量)

先看效果

代码实现

import React, { useState, useEffect, useRef, useCallback } from 'react';

const VirtualList = ({ data, itemHeight, containerHeight = 400 }) => {
  const containerRef = useRef(null); // 用于引用容器 DOM 元素,通过它来获取当前滚动位置 (scrollTop)。
  const [visibleData, setVisibleData] = useState([]);// 存储当前可见的列表项。
  const [totalHeight, setTotalHeight] = useState(0);// 列表的总高度,基于数据项的数量和每项的高度计算得出。
  const [scrollTop, setScrollTop] = useState(0);// 当前滚动的位置,用于计算可见项的位置。
  const [offsetY, setOffsetY] = useState(0);// 视口的偏移量,影响当前显示的内容的位置。

  // 计算可见区域
  const calculateVisibleData = useCallback(() => {
    if (!containerRef.current) return;

    // 计算可见项数量(多渲染2个作为缓冲区)
    const visibleItemCount = Math.ceil(containerHeight / itemHeight) + 2;
    
    // 根据 scrollTop 计算出当前显示区域的开始和结束索引。
    const startIndex = Math.floor(scrollTop / itemHeight);
    const endIndex = startIndex + visibleItemCount;
    
    // 截取出当前可见的数据。使用 Math.max 和 Math.min 防止越界。
    const newVisibleData = data.slice(
      Math.max(0, startIndex - 1), 
      Math.min(data.length, endIndex + 1)
    );

    // 计算偏移量,通过滚动位置 (scrollTop) 来确定内容的偏移位置,从而使得可见部分正确对齐。
    const newOffsetY = startIndex * itemHeight;

    setVisibleData(newVisibleData);
    setOffsetY(newOffsetY);
    setTotalHeight(data.length * itemHeight);
  }, [scrollTop, data, itemHeight, containerHeight]);

  // 初始化 + 滚动时重新计算
  useEffect(() => {
    calculateVisibleData();
  }, [calculateVisibleData]);

  // 滚动事件
  const handleScroll = useCallback(() => {
    // 使用 requestAnimationFrame 优化一下,不然会出现不连续问题
    requestAnimationFrame(() => {
      if (containerRef.current) {
        setScrollTop(containerRef.current.scrollTop);
      }
    });
  }, []);

  return (
    <div 
      ref={containerRef}
      style={{ 
        height: containerHeight, 
        overflowY: 'auto',
        position: 'relative' 
      }}
      onScroll={handleScroll}
    >
      {/* 占位容器,撑开滚动条 */}
      <div style={{ height: totalHeight }}>
        {/* 实际渲染内容的容器,通过transform定位 */}
        <div style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          transform: `translateY(${offsetY}px)`
        }}>
          {visibleData.map((item, index) => (
            <div 
              key={`${index}-${item.id || index}`} 
              style={{ height: itemHeight }}
            >
              {item.content || item}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;



//测试用例

// const data = Array.from({ length: 1000 }, (_, index) => `Item ${index + 1}`);
// const itemHeight = 50; // 每项的高度

不定高列表实现思路

起始从《不定高》这个名字,就可以知道,不定高列表的实现难点在于:高度未知。

首先,定高虚拟列表的核心是计算可见项的起始索引和偏移量,这可以通过简单的除法完成。

而不定高的情况下,每个项目的高度不固定,需要动态测量并维护每个项目的位置信息。初始化的时候,我们需要给他预设一个高度,类似于骨架屏,这样做的目的是可以让页面平滑过渡,不会出现抖动。

定高列表中维护了每一项的高度就可以计算偏移量,不定高就不能单纯的记录每一项的高度了,还要记录他的height/top/bottom,这样就可以方便元素定位和滚动计算。

有了大致思路,我们就可以实现了,再来看看步骤:

  • 初始化预设高度
  • 渲染页面,计算渲染项的高度,更新后续整个列表的信息
  • 监听滚动,计算偏移量,使用二分法找到起始索引,计算出终点索引,找到可视的数据,进行数据渲染。
  • 注册观察器,监听高度是否发生变化,触发后,再次更新该项的高度,同时更新后续所有列表。

看效果:

实现:

import React, { useState, useEffect, useRef, useCallback } from "react";

const DynamicHeightVirtualList = ({
  data,
  estimatedItemHeight = 50,
  containerHeight = 400,
}) => {
  // 标记外层DOm
  const containerRef = useRef(null);
  const [visibleData, setVisibleData] = useState([]);
  //   interface Position {
  //     height: number; // 实际测量高度
  //     top: number;    // 相对于列表顶部的起始位置
  //     bottom: number; // 起始位置 + 高度
  //   }
  // 记录每一项的位置信息
  const [positions, setPositions] = useState([]);
  // 实际列表的总高
  const [totalHeight, setTotalHeight] = useState(0);
  // 记录滚动的高度
  const [scrollTop, setScrollTop] = useState(0);

  //存储每个列表项的DOM元素引用
  const itemsRef = useRef({});


  const resizeObserverRef = useRef(null);

  // 初始化或调整位置缓存
  useEffect(() => {
    const newLength = data.length;
    const oldLength = positions.length;

    if (newLength === oldLength) return;

    let newPositions = [...positions];

    // 初始化计算每一项的信息
    for (let i = 0; i < data.length; i++) {
      const prevBottom = i === 0 ? 0 : newPositions[i - 1]?.bottom || 0;
      newPositions[i] = {
        height: estimatedItemHeight,
        top: prevBottom,
        bottom: prevBottom + estimatedItemHeight,
      };
    }

    // 处理数组长度变化
    if (newLength > oldLength) {
      for (let i = oldLength; i < newLength; i++) {
        const prevBottom = i === 0 ? 0 : newPositions[i - 1]?.bottom || 0;
        newPositions[i] = {
          height: estimatedItemHeight,
          top: prevBottom,
          bottom: prevBottom + estimatedItemHeight,
        };
      }
    } else {
      newPositions = newPositions.slice(0, newLength);
    }

    // 重新计算所有项的位置
    let currentTop = 0;
    const updatedPositions = newPositions.map((pos) => {
      const newPos = {
        ...pos,
        top: currentTop,
        bottom: currentTop + pos.height,
      };
      currentTop = newPos.bottom;
      return newPos;
    });

    setPositions(updatedPositions);
  }, [data.length, estimatedItemHeight]);

  // 更新总高度
  useEffect(() => {
    if (positions.length > 0) {
      setTotalHeight(positions[positions.length - 1].bottom);
    }
  }, [positions]);

  // 重新更新每一项的高度
  const updatePosition = (index, height) => {
    setPositions((prev) => {
      const newPositions = [...prev];
      if (newPositions[index]?.height === height) return prev;

      newPositions[index] = {
        ...newPositions[index],
        height,
        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;
      }

      return newPositions;
    });
  };

  // 二分法查询
  const findStartIndex = (scrollTop) => {
    let left = 0,
      right = positions.length - 1;
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      if (positions[mid].bottom < scrollTop) {
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }
    return left;
  };

  // 计算可视区域的数据
  useEffect(() => {
    if (!positions.length) return;

    const startIndex = findStartIndex(scrollTop);
    const endIndex = findStartIndex(scrollTop + containerHeight);

    //多缓存两项
    const bufferStart = Math.max(0, startIndex - 2);
    const bufferEnd = Math.min(data.length, endIndex + 2);

    // 得到可视区域的数据
    setVisibleData(
      data.slice(bufferStart, bufferEnd).map((item, i) => ({
        ...item,
        index: bufferStart + i,
      }))
    );
  }, [scrollTop, positions, data]);

  // 初始化交叉观察器
  useEffect(() => {
    resizeObserverRef.current = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        const index = parseInt(entry.target.dataset.index);
        const height = entry.contentRect.height;
        updatePosition(index, height);
      });
    });

    return () => resizeObserverRef.current?.disconnect();
  }, [updatePosition]);

  const handleScroll = useCallback(() => {
    requestAnimationFrame(() => {
      setScrollTop(containerRef.current?.scrollTop || 0);
    });
  }, []);

  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        overflowY: "auto",
        position: "relative",
      }}
      onScroll={handleScroll}
    >
      <div style={{ height: totalHeight }}>
        <div
          style={{
            position: "absolute",
            width: "100%",
            transform: `translateY(${
              visibleData[0] ? positions[visibleData[0].index]?.top || 0 : 0
            }px)`,
          }}
        >
          {visibleData.map((item) => (
            <div
              key={item.id}
              data-index={item.index}
              //   当元素被挂载或卸载时会执行这个回调
              ref={(el) => {
                const realIndex = item.index;
                if (el) {
                    // 如果el存在,再判断itemsRef 是否有了该元素的引用,如果没有,则需要添加交叉观察器观察该项,
                  if (!itemsRef.current[realIndex]) {
                    itemsRef.current[realIndex] = el;
                    resizeObserverRef.current?.observe(el);
                    const height = el.getBoundingClientRect().height;
                    if (height !== positions[realIndex]?.height) {
                      updatePosition(realIndex, height);
                    }
                  }
                } else {
                    // 如果el不存在,则说明元素被卸载,需要移除该项的交叉观察器和引用,避免内存泄漏
                  const oldEl = itemsRef.current[realIndex];
                  if (oldEl) {
                    resizeObserverRef.current?.unobserve(oldEl);
                    delete itemsRef.current[realIndex];
                  }
                }
              }}
            >
              {item.content}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

export default DynamicHeightVirtualList;



// 测试用例
// const dynamicData = Array(1000).fill().map((_, i) => ({
//   id: i,
//   content: <div style={{ height: 30 + Math.random() * 100 ,border:'1px #ccc solid'}} >Item {i}</div>
// }));

参考

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