实现虚拟滚动列表 - 优化超长列表(React)

2,473 阅读8分钟

前言

大家好,我是风骨,在工作中我们会遇到在一个滚动容器中渲染一组数据列表的需求。

如果数据比较少,一次性全部渲染到列表中到是没什么问题,但对于较长的列表,比如现在有 1000 条数据,如果同时将这 1000 个数据渲染在页面上,就会生成 1000 个 DOM 容器。这样容易导致浏览器在渲染阶段占用主线程时间过长,用户在操作页面时出现卡顿。

针对此问题,我们可以在数据渲染时做优化:对于用户而言,并不关心你是否是一次性将数据全部渲染到列表容器中,只要在容器的可视区域内的 DOM 数据能够正常看到就行。因此我们优化思路是:结合滚动条位置和容器的可视区域,将对应的数据项渲染到滚动容器中

在 npm.js 上开源的组件库:react-tiny-virtual-list 提供了虚拟滚动列表的功能实现。本文也将基于 react-tiny-virtual-list 的内部实现,使用 React Hooks 结合 TS 来编写一个虚拟滚动组件,实现代码 140 行左右。

1、测试用例

import { VirtualList } from "../src/index";

const rowStyle = { padding: "0 10px", borderBottom: "1px solid grey", lineHeight: "50px" };

function App() {
  const renderItem = ({ style, index }: { style: any; index: number }) => {
    return (
      <div style={{ ...rowStyle, ...style }} key={index}>
        Row #{index}
      </div>
    );
  };

  return (
    <VirtualList
      width="auto"
      height={400}
      itemCount={1000}
      renderItem={renderItem}
      itemSize={50}
      className="VirtualList"
    />
  );
}

// 样式
.VirtualList {
  margin: 20px;
  background: #FFF;
  border-radius: 2px;
  box-shadow:
    0 2px 2px 0 rgba(0,0,0,.14),
    0 3px 1px -2px rgba(0,0,0,.2),
    0 1px 5px 0 rgba(0,0,0,.12);
}

通过虚拟滚动列表,渲染后的 DOM 结构如下:

截屏2021-06-10 下午9.40.07.png

2、代码实现

2.1、Props 定义

在熟悉和使用一个组件前,相信大家首要工作就是了解该组件暴露给用户有哪些属性,基于 TS 开发的组件,在阅读层面也变得更加容易。

export interface IProps {
  width: number | string; // list宽度
  height: number | string; // list高度
  itemCount: number; // item 个数
  itemSize: number; // item 固定的高度/宽度(取决于滚动方向)
  renderItem(itemInfo: { index: number, style: ItemStyle }): React.ReactNode; // item 渲染节点
  className?: string;
  style?: React.CSSProperties;
  scrollDirection?: DIRECTION; // 列表应该垂直还是水平滚动。“垂直”(默认)或“水平”之一
  overscanCount?: number; // 上方/下方渲染的额外缓冲区项数
  scrollOffset?: number; // 可用于设置初始化滚动偏移量
  onScroll?(offset: number, event: Event): void;
}

从 Props 定义上,我们可以看到有 5 个必需属性:

  • width:滚动列表的宽度;
  • height:滚动列表的高度,如上面测试用例,是一个高为 400px 的滚动列表;
  • itemCount:滚动列表中数据的个数,如:1000 条数据;
  • itemSize:单个项目的尺寸,默认是垂直滚动,itemSize 就代表每条数据要渲染的高度;
  • renderItem:每条数据渲染的 DOM 节点。

2.2、思路分析

根据上面的几个必需属性中,就可以完成虚拟滚动列表的实现。以垂直滚动为例:

  • 首先需要一个 div 作为滚动容器,Props 中的 width、height 将作用于该元素,并且在这个容器上定义 overflow: auto 使其垂直方向可以滚动;

  • 接着需要一个 div 作为滚动容器的 child,这个 div 的 height 会根据 Props 中的 itemCount、itemSize 相乘计算得出,从而得到一个很长很长的长列表,让滚动容器能够滚动;

  • 有了长列表,那 item 的位置如何设置呢?注意,并不是所有的 item 都要同时渲染在长列表内,只有可视区域内的 item 需要渲染,因此布局方式采用 脱离文档流(定位) 方式来实现。通过 position: absolute 控制 top 来确定 item 的位置;

  • 如何控制可视区域显示的 item 个数呢?容器的尺寸(height)和 item 的尺寸(itemSize)相除,就可以计算出容器的可视区域能显示多少个 item。

2.3、DOM 结构

const sizeProp = {
  [DIRECTION.VERTICAL]: "height",
  [DIRECTION.HORIZONTAL]: "width",
};

const positionProp = {
  [DIRECTION.VERTICAL]: "top",
  [DIRECTION.HORIZONTAL]: "left",
};

const STYLE_WRAPPER: React.CSSProperties = {
  overflow: "auto",
  willChange: "transform",
  WebkitOverflowScrolling: "touch",
};

const STYLE_INNER: React.CSSProperties = {
  position: "relative",
  minWidth: "100%",
  minHeight: "100%",
};

const VirtualList: React.FC<IProps> = props => {
  const {
    width,
    height,
    itemCount,
    itemSize,
    renderItem,
    style = {},
    onScroll,
    scrollOffset,
    overscanCount = 3,
    scrollDirection = DIRECTION.VERTICAL,
    ...otherProps
  } = props;

  // ...

  const wrapperStyle = { ...STYLE_WRAPPER, ...style, height, width };
  const innerStyle = {
    ...STYLE_INNER,
    [sizeProp[scrollDirection]]: itemCount * itemSize, // 计算列表整体高度/宽度(取决于垂直或水平)
  };

  return (
    <div ref={rootNode} {...otherProps} style={wrapperStyle}>
      <div style={innerStyle}>{items}</div>
    </div>
  );
};

从上面代码中可以看到,DOM 结构已经满足 实现思路 中的前两步。

2.4、可视范围(区间)

有了容器之后,我们就可以计算出容器在可视区域下能容纳的 item 范围:

const getVisibleRange = () => {
  // 获取可视范围
  const { clientHeight = 0, clientWidth = 0 } = (rootNode.current as HTMLDivElement) || {};
  const containerSize: number = scrollDirection === DIRECTION.VERTICAL ? clientHeight : clientWidth;
  let start = Math.floor(offset / itemSize - 1); // start --> 向下取整 (索引是从0开始,所以 - 1)
  let stop = Math.ceil((offset + containerSize) / itemSize - 1); // stop --> 向上取整
  return {
    start: Math.max(0, start - overscanCount),
    stop: Math.min(stop + overscanCount, itemCount - 1),
  };
};

const { start, stop } = getVisibleRange();

以垂直滚动为例:

  • rootNode 是通过 ref 绑定的滚动容器 DOM,通过它我们可以获取到容器的高度 clientHeight
  • offset 是组件的内部状态,记录当前滚动条的位置,通过 offset / itemSize 就可以计算出当前滚动环境下,容器可视区域的第一个节点位于数据列表中的索引,即得到 start
  • offset + containerSize 可以计算得出容器可视区域下最后一个节点位于数据列表中的索引,即得到 stop
  • overscanCount 是在可见项上方/下方渲染的额外缓冲区项目数。可以帮助减少浏览器滚动过程中出现的间断或闪烁。

到这里相信大家会发现一个问题:Hooks 函数组件在执行时调用了 getVisibleRange 方法,此时 DOM 还没有真正渲染到页面中,此时通过 rootNode.current 访问容器节点,肯定是拿不到容器高度的。

有一个简便的方式是:因为组件 Props 上传递了 height,也就是容器的高度,直接拿来用就行了,也省去了执行获取操作。

但如果用户传递的不是一个 number 类型的 height 呢?他就是想传一个 string,比如:height="80%",那这样就无法通过 height 来计算可视范围了。

既然函数组件执行时不能获取到 DOM 信息,那等它挂载后触发一次组件更新来获取 DOM 信息。这将通过一次更新来换取功能实现,但不用担心,很多场景下都是需要通过一次可以忽略不计的更新来换取功能实现。

经过上面分析后,我们可以在 useEffect 中去完成它,当然还需要借助与一个 state

const VirtualList: React.FC<IProps> = props => {
  const [isMount, setMount] = useState<boolean>(false);

  useEffect(() => {
    if (!isMount) setMount(true); // 强制更新一次,供 getVisibleRange 方法获取 DOM 挂载后 containerSize
  }, []);

  // ...
};

2.5、item 渲染

有了可视范围,我们就可以计算 top 将该范围内的 item 渲染到滚动容器中:

const VirtualList: React.FC<IProps> = props => {
  // ...

  const getStyle = (index: number) => {
    const style = styleCache[index];
    if (style) return style;

    return (styleCache[index] = {
      position: "absolute",
      top: 0,
      left: 0,
      width: "100%",
      [sizeProp[scrollDirection]]: props.itemSize, // height / width
      [positionProp[scrollDirection]]: props.itemSize * index, // top / left
    });
  };

  // ...

  const items: React.ReactNode[] = [];
  const { start, stop } = getVisibleRange();
  for (let index = start; index <= stop; index++) {
    items.push(renderItem({ index, style: getStyle(index) }));
  }

  return (
    <div ref={rootNode} {...otherProps} style={wrapperStyle}>
      <div style={innerStyle}>{items}</div>
    </div>
  );
};

2.6、滚动 onScroll

剩下最后一个步骤,就是容器的监听滚动事件,通过获取滚动的距离 offsetTop 更新到 offset(组件内部存储滚动距离的 state),进而触发组件的重新渲染来更新容器可视区域内的 item 节点。

const VirtualList: React.FC<IProps> = props => {
  const {
    width,
    height,
    itemCount,
    itemSize,
    renderItem,
    style = {},
    onScroll,
    scrollOffset,
    overscanCount = 3,
    scrollDirection = DIRECTION.VERTICAL,
    ...otherProps
  } = props;

  const [offset, setOffset] = useState<number>(scrollOffset || 0);

  useEffect(() => {
    if (!isMount) setMount(true); // 强制更新一次,供 getVisibleRange 方法获取 DOM 挂载后 containerSize

    rootNode.current?.addEventListener("scroll", handleScroll);
    return () => {
      rootNode.current?.removeEventListener("scroll", handleScroll);
    };
  }, []);

  const handleScroll = (event: Event) => {
    const { scrollTop = 0, scrollLeft = 0 } = rootNode.current as HTMLDivElement;
    const newOffset = scrollDirection === DIRECTION.VERTICAL ? scrollTop : scrollLeft;
    if (newOffset < 0 || newOffset === offset || event.target !== rootNode.current) {
      return;
    }
    setOffset(newOffset);
    if (typeof onScroll === "function") {
      onScroll(offset, event);
    }
  };

  // ...
};

完整代码

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

export enum DIRECTION {
  HORIZONTAL = "horizontal",
  VERTICAL = "vertical",
}

export interface ItemStyle {
  position: "absolute";
  top?: number;
  left: number;
  width: string | number;
  height?: number;
  marginTop?: number;
  marginLeft?: number;
  marginRight?: number;
  marginBottom?: number;
  zIndex?: number;
}

export interface IProps {
  width: number | string; // list宽度
  height: number | string; // list高度
  itemCount: number; // item 个数
  itemSize: number; // item 固定的高度/宽度(取决于滚动方向)
  renderItem(itemInfo: { index: number; style: ItemStyle }): React.ReactNode; // item 渲染节点
  className?: string;
  style?: React.CSSProperties;
  scrollDirection?: DIRECTION; // 列表应该垂直还是水平滚动。“垂直”(默认)或“水平”之一
  overscanCount?: number; // 上方/下方渲染的额外缓冲区项数
  scrollOffset?: number; // 可用于设置初始化滚动偏移量
  onScroll?(offset: number, event: Event): void;
}

const STYLE_WRAPPER: React.CSSProperties = {
  overflow: "auto",
  willChange: "transform",
  WebkitOverflowScrolling: "touch",
};

const STYLE_INNER: React.CSSProperties = {
  position: "relative",
  minWidth: "100%",
  minHeight: "100%",
};

const sizeProp = {
  [DIRECTION.VERTICAL]: "height",
  [DIRECTION.HORIZONTAL]: "width",
};

const positionProp = {
  [DIRECTION.VERTICAL]: "top",
  [DIRECTION.HORIZONTAL]: "left",
};

const VirtualList: React.FC<IProps> = props => {
  const {
    width,
    height,
    itemCount,
    itemSize,
    renderItem,
    style = {},
    onScroll,
    scrollOffset,
    overscanCount = 3,
    scrollDirection = DIRECTION.VERTICAL,
    ...otherProps
  } = props;

  const rootNode = useRef<HTMLDivElement | null>(null);
  const [offset, setOffset] = useState<number>(scrollOffset || 0);
  const [styleCache] = useState<{ [id: number]: ItemStyle }>({});
  const [isMount, setMount] = useState<boolean>(false);

  useEffect(() => {
    if (!isMount) setMount(true); // 强制更新一次,供 getVisibleRange 方法获取 DOM 挂载后 containerSize
    if (scrollOffset) scrollTo(scrollOffset);

    rootNode.current?.addEventListener("scroll", handleScroll);
    return () => {
      rootNode.current?.removeEventListener("scroll", handleScroll);
    };
  }, []);

  const handleScroll = (event: Event) => {
    const { scrollTop = 0, scrollLeft = 0 } = rootNode.current as HTMLDivElement;
    const newOffset = scrollDirection === DIRECTION.VERTICAL ? scrollTop : scrollLeft;
    if (newOffset < 0 || newOffset === offset || event.target !== rootNode.current) {
      return;
    }
    setOffset(newOffset);
    if (typeof onScroll === "function") {
      onScroll(offset, event);
    }
  };

  const scrollTo = (value: number) => {
    if (scrollDirection === DIRECTION.VERTICAL) {
      rootNode.current!.scrollTop = value;
    } else {
      rootNode.current!.scrollLeft = value;
    }
  };

  const getVisibleRange = () => {
    // 获取可视范围
    const { clientHeight = 0, clientWidth = 0 } = (rootNode.current as HTMLDivElement) || {};
    const containerSize: number =
      scrollDirection === DIRECTION.VERTICAL ? clientHeight : clientWidth;
    let start = Math.floor(offset / itemSize - 1); // start --> 向下取整 (索引是从0开始,所以 - 1)
    let stop = Math.ceil((offset + containerSize) / itemSize - 1); // stop --> 向上取整
    return {
      start: Math.max(0, start - overscanCount),
      stop: Math.min(stop + overscanCount, itemCount - 1),
    };
  };

  const getStyle = (index: number) => {
    const style = styleCache[index];
    if (style) return style;

    return (styleCache[index] = {
      position: "absolute",
      top: 0,
      left: 0,
      width: "100%",
      [sizeProp[scrollDirection]]: props.itemSize, // height / width
      [positionProp[scrollDirection]]: props.itemSize * index, // top / left
    });
  };

  const wrapperStyle = { ...STYLE_WRAPPER, ...style, height, width };
  const innerStyle = {
    ...STYLE_INNER,
    [sizeProp[scrollDirection]]: itemCount * itemSize, // 计算列表整体高度/宽度(取决于垂直或水平)
  };
  const items: React.ReactNode[] = [];
  const { start, stop } = getVisibleRange();
  for (let index = start; index <= stop; index++) {
    items.push(renderItem({ index, style: getStyle(index) }));
  }

  return (
    <div ref={rootNode} {...otherProps} style={wrapperStyle}>
      <div style={innerStyle}>{items}</div>
    </div>
  );
};

export default VirtualList;

感谢阅读。

参考:react-tiny-virtual-list