React 虚拟列表(部分渲染) 代码实现

76 阅读4分钟

虚拟列表

基础知识储备

js操作要比dom渲染快的多

浏览器dom渲染流程如下

构建DOM树-->样式计算-->创建布局树-->分层绘制-->栅格化(raster)操作-->合成与显示

从过程来看,也比js操作要复杂许多

下面我们来从实际代码运行的角度 来进行时间分析

const total = 100000;
const now = Date.now();
document.write('<ul id="container"></ul>');
const ul = document.getElementById("container");
for (let i = 0; i < total; i++) {
  const li = document.createElement("li");
  li.innerText = ~~(Math.random() * total);
  ul.appendChild(li);
}
console.log("JS运行时间:", Date.now() - now);
setTimeout(() => {
  console.log("总耗时:", Date.now() - now);
 }, 0);

image.png 我们对十万条记录进行循环操作,JS的运行时间为200ms左右(和电脑性能有关),还是蛮快的,但是最终渲染完成后的总时间确是5s左右。(和电脑性能有关)

简单说明一下,为何两次console.log的结果时间差异巨大,并且是如何简单来统计JS运行时间和总渲染时间:

  • 在 JS 的Event Loop中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
  • 第一个console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间
  • 第二个console.log是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次Event Loop中执行的

依照两次console.log的结果,可以得出结论:

对于大量数据渲染的时候,JS运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段。

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

简单实现

我们先看一下下方的列表渲染图

image.png 从图中可以看出,我们可以将列表分成三块区域:可视区,缓冲区和虚拟区。

我们主要针对可视区和缓冲区进行渲染。 先罗列一下相关数据:

数据列表:list 数组,包含列表的数据总数,比如渲染一个100000条的列表数据

容器高度:clientHeight

每项高度:itemHeight

占位区高度:listHeight

距离顶部高度:scrollTop 渲染区域的计算点:其实我们渲染的数据只是可视区和缓冲区,我们可以利用slice对list进行截取,所以在我们还需要知道:

  • 索引的起始位置:start
  • 索引的结束位置:end
  • 缓冲个数:bufferCount
  • 需要渲染的节点数量: renderCount(可视区能渲染几个节点)
// 渲染节点的数量 = 容器高度/子列表高度(向上取整)+ 缓冲个数
renderCount = Math.ceil(clientHeight / itemHeight) + bufferCount;
// 起始位置
start = Math.floor(scrollTop / itemHeight)
// 结束位置
end = start + renderCount + 1;
// 渲染的数据
data = list.slice(start, end);

实现虚拟列表的代码如下

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

/**
 * @param {object} props
 * @param {number} [props.height] 容器高度
 * @param {number} [props.rowCount] 多少条数据
 * @param {number} [props.rowHeight] 每列数据高度,固定高度
 * @param {number} [props.bufferRows] 缓冲值,既渲染可视区域内额外的列表条数
 * @param {Function} [props.rowRenderer] 列表内容渲染函数
 */
function VirtualList(props) {
  // 可见区域的数据
  const [visibleRows, setVisibleRows] = useState([]);
  // 可见区域起始数据的 startIndex
  const startIndex = useRef(0);
  // 可见区域结束数据的 endIndex
  const endIndex = useRef(0);
  // 可见区域的条数
  const visibleCount = useRef(0);
  // 缓存列表
  const cache = useRef([]);
  // 外部容器Ref引用
  const wrapperRef = useRef();

  const { height, rowCount, rowHeight, bufferRows = 4, rowRenderer } = props;

  // componentDidMount
  useEffect(() => {
    // 计算可视区域内可渲染的列表个数
    visibleCount.current = Math.ceil(height / rowHeight) + bufferRows;
    // 结束数据的 endIndex
    endIndex.current = startIndex.current + visibleCount.current;
  }, [height, rowHeight, bufferRows]);

  useEffect(() => {
    store();
    calculateVisibleRows();
  }, [rowCount, rowHeight]);

  const store = useCallback(() => {
    if (cache.current.length > 0) {
      cache.current = [];
    }
    // 使用绝对定位显示列表数据
    // 记录每条数据的top,并缓存下来
    for (let i = 0; i < rowCount; i++) {
      const top = i * rowHeight;
      cache.current.push({
        index: i,
        top,
        style: {
          position: "absolute",
          top,
          width: "100%"
        }
      });
    }
  }, [rowCount, rowHeight]);

  // 计算当前可见区域的数据
  const calculateVisibleRows = () => {
    const visibleRows = cache.current.slice(
      startIndex.current,
      endIndex.current
    );
    setVisibleRows(visibleRows);
  };

  // 滚动,计算 startIndex 和 endIndex
  const calculatBoundaryIndex = (scrollTop) => {
    const startIdx = Math.floor(scrollTop / rowHeight);
    startIndex.current = startIdx;
    endIndex.current = startIndex.current + visibleCount.current;
  };

  // 滚动,计算可视区域列表数据
  const handleScroll = () => {
    const { scrollTop } = wrapperRef.current;
    calculatBoundaryIndex(scrollTop);
    calculateVisibleRows();
  };

  return (
    <div
      ref={wrapperRef}
      style={{ height, overflow: "auto", position: "relative" }}
      onScroll={throttle(handleScroll, 50)}
    >
      {/* 内部容器,高度为列表总数和单列高度相乘 */}
      <div style={{ height: rowCount * rowHeight }}>
        {visibleRows.map((item) => rowRenderer(item))}
      </div>
    </div>
  );
}

export default VirtualList;

如下使用该组件

import VirtualList from "./VirtualList";

export default function App() {
  const data = [...Array(10000)].map((value, index) => index);

  const renderCell = ({ index, style }) => {
    return (
      <div
        key={index}
        style={{
          display: "flex",
          alignItems: "center",
          boxSizing: "border-box",
          height: "48px",
          borderBottom: "1px solid #e0e0e0",
          ...style
        }}
      >
        {`虚拟列表测试数据-${data[index]}`}
      </div>
    );
  };

  return (
    <div className="App">
      <VirtualList
        height={300}
        rowCount={10000}
        rowHeight={48}
        rowRenderer={renderCell}
      />
    </div>
  );
}