是的, 我也想手撸一个虚拟滚动!!!!

引言

虚拟滚动 对于前端来说应该并不是一个陌生的词汇, 它常常用于处理大规模数据列表!!!

所谓 虚拟滚动:

  • 其实就是在大批量列表数据中, 只渲染用户能够看到的那部分数据, 超出视口部分不进行渲染
  • 并且通过设置样式, 让内容足够大从而撑开容器使得容器出现滚动条, 给用户一种数据量很大的 "错觉"
  • 然后随着用户的每次滚动都去动态的渲染用户视口所能看到的数据
  • 对于用户来说就是正常滚动查看大批量数据, 但从代码层面来看其实我们就是随着用户的滚动, 只渲染他所能看到那部分数据

下面我们将从代码层面简单介绍下, 如果是我, 我会如何实现一个 虚拟滚动...

一、开始前准备

开始前, 假设我们需要一次性展示 10W 条的数据, 这里我们直接通过 mapReact 里一次性渲染所有的数据!!

如下代码所示:

  • 通过 Array.from 我们伪造了 10W 条数据
  • 然后在 React 中直接使用 map 进行了渲染
  • 每行高度固定为 40px
js 复制代码
// Static.jsx
import React from 'react';
import scss from './index.module.scss';

const DATA_SOURCE = Array.from({ length: 100000 }, (_, i) => `第 ${i + 1}条数据`); // 模拟数据

export default () => (
  <div className={scss.wrapper}>
    {DATA_SOURCE.map((v, index) => (
      <div 
        key={index}
        className={scss.item}>
        {v}
      </div>
    ))}
  </div>
);
scss 复制代码
// static.module.scss
.wrapper {
  overflow: auto;
  width: 200px;
  max-height: 400px;

  border-radius: 8px;
  border: 20px solid #fff;
  background-color: #fff;
  box-shadow: 0 0 10px rgba($color: #000, $alpha: 30%);
}

.item {
  height: 40px;
  display: flex;
  align-items: center;
}

上面代码效果如下, 通过 chrome 性能测试会发现单是在渲染上就花了 1S 多, 这还是在页面比较简单的情况下, 在没行数据的布局复杂的情况下估计页面会有明显的卡顿

下面我们开始基于上面代码来实现一个 虚拟滚动, 从而优化用户体验....

二、静态(每条数据高度固定)

这里我们假设每条数据, 在渲染的时候高度都是固定的为 40px。在高度固定情况下, 我们就很容易计算出列表整个的一个高度, 以及可视区域能够展示的数据量!

2.1 原理讲解 & 演示

如下图, 是本节 虚拟滚动 方案的原理图:

  • 图中黑色部分为整体内容区域, 高度为 列表总数 乘以 每行高度(40px)
  • 红色区域为容器部分, 视口高度为 400px
  • 这里假设容器滚动高度 scrollTop420px
  • 蓝色部分则是被渲染出来的数据, 这里渲染了 11 条数据, 视口高度为 400px 因为每条数据高度为 40px 所以视口能够完整放下 10 条数据, 但由于第一条只露出来一半所以就需要多渲染 1 条数据
  • 图中通过 paddingTop: 400px 将渲染的内容偏移到视口中来, paddingTop 是行高度(40px)的倍数, 你可简单理解根据 scrollTop 理论上顶部需要渲染 10 条数据, 但是用户实际是看不到的所以我们可以不渲染出来, 通过 paddingTop 进行占位

下面我们来基于第一小节的代码, 来进行硬编码, 就是不考虑滚动条滚动情况下, 我们来实现上图的一个效果:

  • 新增两个常量, ITEM_SIZE 是定义每条数据的高度, CONTENT_HEIGHT 则是列表整体高度
  • 然后为要渲染的列表外层嵌套了一层 div, 并设置了样式 height 以及 paddingTop
  • 最后使用 DATA_SOURCE.slice(10, 21).map() 渲染 11 条用户可见的数据
diff 复制代码
// Static.jsx
import React from 'react';
import scss from './index.module.scss';

+ const ITEM_SIZE = 40; // 每条数据高度
const DATA_SOURCE = Array.from({ length: 100000 }, (_, i) => `第 ${i + 1}条数据`); // 模拟数据
+ const CONTENT_HEIGHT = DATA_SOURCE.length * ITEM_SIZE; // 列表内容总高度

export default () => (
  <div className={scss.wrapper}>
+   <div
+     className={scss.list}
+     style={{ height: CONTENT_HEIGHT, paddingTop: ITEM_SIZE * 10 }}>
+     {DATA_SOURCE.slice(10, 21).map((v, index) => (
+       <div
+         key={index}
+         className={scss.item}>
+         {v}
+       </div>
+     ))}
+   </div>
  </div>
);

我们这边设置的 paddingTop 应该包含在 height 里, 所以这里还加了行样式

scss 复制代码
// static.module.scss
+ .list {
+   box-sizing: border-box;
+ }

2.2 代码实现

完整代码修改如下:

  • 从上面硬编码也能知道, 唯一需要我们动态修改的也就只有 paddingTop 和实际要渲染的列表数据, 所以我们在最开始创建了两个状态 paddingTop``、renderList
  • 因为我们还需要知道容器视口高度, 所以这里我们创建了一个 ref 也就是 containerRef 并绑定到容器上, 这样的话通过它我们就能获取到容器视口高度 clientHeight
  • 剩下我们要做的其实就是为容器绑定滚动事件, 然后根据 scrollTop 来计算 paddingToprenderList, 关于这部分逻辑我全部放在 handler 里进行处理
    • startIndex 表示要渲染的数据列表在原始数据中的开始索引, 这里直接根据 scrollTop行高 计算即可, 计算逻辑为 Math.floor(scrollTop / ITEM_SIZE)
    • renderNum 表示当前要渲染的数据量, 这里则根据容器的视口高度 clientHeight 以及 行高 进行计算, 计算逻辑为 Math.ceil(clientHeight / ITEM_SIZE); 同时我们还需要考虑如果第一条数据有一部分滚动到视口之外的情况, 这里是根据 scrollTop行高 取余的结果进行判断, 如果取余结果是 0 则说明 scrollTop 是行高的整数倍也就是说第一条刚好完整出现在视口中, 否则我们则需要多渲染一条数据
    • endIndx 则表示要渲染的数据列表在原始数据中的结束索引, 这里根据 startIndexrenderNum 计算得来(直接相加即可)
    • 最后我们根据 startIndexendIndx 即可得到要渲染的列表
    • paddingTop 的计算很简单, 直接根据 startIndex 来进行计算即可
diff 复制代码
// Static.jsx
+ import React, { useCallback, useState, useEffect, useRef } from 'react';
import scss from './index.module.scss';

const ITEM_SIZE = 40; // 每条数据高度
const DATA_SOURCE = Array.from({ length: 100000 }, (_, i) => `第 ${i + 1}条数据`); // 模拟数据
const CONTENT_HEIGHT = DATA_SOURCE.length * ITEM_SIZE; // 列表内容总高度

export default () => {
+ const containerRef = useRef();
+ const [paddingTop, setPaddingTop] = useState(0);
+ const [renderList, setRenderList] = useState([]);

+ const handler = useCallback((scrollTop) => {
+   const { clientHeight } = containerRef.current ?? {};
+   const remainder = scrollTop % ITEM_SIZE;

+   // 要渲染的列表, 在源数据中的开始索引
+   const startIndex = Math.floor(scrollTop / ITEM_SIZE);

+   // 要渲染的列表数量
+   const renderNum = Math.ceil(clientHeight / ITEM_SIZE) + (remainder !== 0 ? 1 : 0);

+   // 要渲染的列表, 在源数据中的结束索引
+   const endIndx = startIndex + renderNum;

+   setPaddingTop(startIndex * ITEM_SIZE);
+   setRenderList(DATA_SOURCE.slice(startIndex, endIndx));
+ }, []);

+ const handleScroll = useCallback((e) => {
+   handler(e.target.scrollTop);
+ }, [handler]);

+ useEffect(() => {
+   handler(0);
+ }, [handler]);

  return (
    <div
+     ref={containerRef}
      className={scss.wrapper}
+     onScroll={handleScroll}>
      <div
        className={scss.list}
+       style={{ height: CONTENT_HEIGHT, paddingTop }}>
        {renderList.map((v, index) => (
+         <div 
            key={index}
            className={scss.item}>
            {v}
          </div>
        ))}
      </div>
    </div>
  );
};

最后效果如下:

上面代码我们考虑到了一个情况就是「当视口第一条数据滚动了一部分到视口之外」这时在计算 renderNum 需要追加一条数据, 否则不追加!!!

js 复制代码
const renderNum = Math.ceil(clientHeight / ITEM_SIZE) + (remainder !== 0 ? 1 : 0);

但实际情况我们可能没必要特别精确, 我们完全可以不考虑那么多, 计算 renderNum 无脑加一即可(降低代码复杂度)

diff 复制代码
+  const renderNum = Math.ceil(clientHeight / ITEM_SIZE) + 1;

这里还有一个问题, 当滚动太快的话底部或者顶部会有白边!! 解决办法就是我们可以每次多渲染几条数据, 在顶部和底部都留出一段缓冲区域

在代码实现上也比较简单, 假设我们需要在顶部和底部都留出 2 条缓冲数据, 那么我们只需要相应的在计算 startIndex 时减 2, 在计算 renderNum 时多加 4 条数据即可

diff 复制代码
const handler = useCallback((scrollTop) => {
  const { clientHeight } = containerRef.current ?? {};
  const remainder = scrollTop % ITEM_SIZE;

  // 要渲染的列表, 在源数据中的开始索引
+ const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_SIZE) - 2);

  // 要渲染的列表数量
+ const renderNum = Math.ceil(clientHeight / ITEM_SIZE) + 5;

  // 要渲染的列表, 在源数据中的结束索引
  const endIndx = startIndex + renderNum;

  setPaddingTop(startIndex * ITEM_SIZE);
  setRenderList(DATA_SOURCE.slice(startIndex, endIndx));
}, []);

最后效果如下, 在实际渲染时会额外多渲染 4 条数据, 分别在顶部、底部多渲染了 2 条数据作为缓冲数据

最后我们看下性能, 在渲染上只花了 25 毫秒

完整源码可点击 VirtualScroll/Static 查看

三、动态(每条数据高度不固定)

上面我们每条数据在渲染时都是固定的高度 40px, 我们称之为静态的虚拟滚动; 相反如果它们的高度是不确定, 高度完全由内容自适的情况我们称之为动态的虚拟滚动

对于虚拟滚动, 我们主要就是要根据容器滚动距离 scrollTop 来计算出 startIndexendIndx, 以及 listHeight 也就是列表内容的总体高度。

对于静态虚拟滚动(行高度固定)来说计算起来比较简单, 但是呢? 对于动态的虚拟滚动(行高度不固定)在节点未渲染出来前我们是没办法确认它的高度的, 这样我们就很难准确的根据 scrollTop 来计算出 startIndexendIndx, 以及列表内容的总体高度 listHeight...

那么解决办法其实也比较简单: 对于渲染出来过的节点我们可以将其高度记录起来, 对于没有渲染过的节点我们按照一个最小高度来计算!!! 只是这样计算逻辑会比较麻烦一点点, 同时我们还需要去监听每行数据的高度变化

3.1 模拟数据修改

下面我们需要修改下相关常量、以及模拟数据:

  • 加了个 MIN_ITEM_SIZE 常量, 用于设置在不确定节点行高的情况下使用的默认最小高度
  • 模拟数据改了数据结构, 加了个 idheight, 这里的 height 基于最小高度随机生成, 注意的是这里的高度只用于渲染不作为任何计算使用, 目的就只是为了模拟动态高度情景
diff 复制代码
+ // 每条数据最小高度
+ const MIN_ITEM_SIZE = 40;

// 模拟数据
+ const DATA_SOURCE = Array.from({ length: 100000 }, (_, i) => ({
+   id: i,
+   content: `第 ${i + 1}条数据`, // 内容
+   height: Math.round(MIN_ITEM_SIZE + (Math.random() * 100)), // 当前数据高度
+ }));

- // 列表内容最小总高度
- const CONTENT_MIN_HEIGHT = DATA_SOURCE.length * MIN_ITEM_SIZE;

3.2 Row 组件

这里我们需要等节点渲染完成后, 获取到每个节点的高度, 然后将高度收集起来, 然后在计算 startIndex 等一系列值时需要用到!! 并且在节点高度变化时也需要将对应节点高度更新过来

所以这里我们可以封装一个 Row 组件, 用于渲染每行数据, 然后在每个组件内部通过 ResizeObserver 监听组件高度, ResizeObserver 回调函数将在组件 首次渲染 以及 节点高度变化 情况下被执行, 回调函数内又调用外部传传进来的 onResize 函数, 如此父组件就可以通过监听 onResize 获取到所有 Row 组件的高度信息了

js 复制代码
const Row = ({ onResize, data }) => {
  const ref = useRef(null);

  useEffect(() => {
    const observer = new ResizeObserver(onResize.bind(null, data));
    observer.observe(ref.current);

    return () => observer.disconnect();
  }, [onResize, data]);

  return (
    <div
      ref={ref}
      className={scss.item}
      style={{ height: data.height }}>
      {data.content}
    </div>
  );
};

3.3 改造

如下代码是基于静态进行修改的, 主要调整如下:

  • 新增状态 heights 用于记录所有已经渲染过的节点的高度
  • 使用 Row 组件来渲染每条数据, 并绑定 onResize 事件
  • 新增 handleRowResize 方法, 用于监听所有 Row 组件的 onResize 事件, 函数内则是调用 setHeights 方法修改 heights 状态
  • 删除了 handler 主代码(核心代码), 后面需要重写
diff 复制代码
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import scss from './static.module.scss';

const ITEM_SIZE = 40; // 每条数据高度、

// 模拟数据
const DATA_SOURCE = Array.from({ length: 100000 }, (_, i) => ({
  id: i,
  content: `第 ${i + 1}条数据`, // 内容
  height: Math.round(ITEM_SIZE + (Math.random() * 100)), // 当前数据高度
}));

export default () => {
  const containerRef = useRef();
  const [paddingTop, setPaddingTop] = useState(0);
  const [renderList, setRenderList] = useState([]);

+ const [heights, setHeights] = useState({}); // 记录已渲染过的节点高度: { [id]: 高度 }

+ const handleRowResize = useCallback((data, [resizeObserverEntry]) => {
+   setHeights((pre) => ({
+     ...pre,
+     [data.id]: resizeObserverEntry.target.offsetHeight,
+   }));
+ }, []);

  const handler = useCallback(() => {
+   // TODO
  }, [heights]);

+ useEffect(() => {
+   handler();
+ }, [handler]);

  return (
    <div
      onScroll={handler}
      ref={containerRef}
      className={scss.wrapper}>
      <div
        className={scss.list}
+       style={{ height: DATA_SOURCE.length * ITEM_SIZE , paddingTop }}>
+       {renderList.map((v) => (
+         <Row
+           data={v}
+           key={v.id}
+           onResize={handleRowResize}
+         />
+       ))}
      </div>
    </div>
  );
};

3.4 几个关键计算方法

上面我们已经能够获取到已渲染的节点高度, 那么下面我们就可以开始计算 startIndex(要渲染数据开始索引)endIndx(要渲染数据结束索引)paddingTop(渲染列表的偏移值) 以及 listHeight(列表总高度) 这几个关键数据了

js 复制代码
// 要渲染数据开始索引
const getStartIndex = ({ heights, scrollTop }) => {
  let total = 0;

  for (let i = 0; i < DATA_SOURCE.length; i += 1) {
    total += (heights[i] || ITEM_SIZE);

    if (total > scrollTop) {
      return i;
    }
  }
};

// 要渲染数据结束索引
const getEndIndex = ({ heights, startIndex, clientHeight }) => {
  let total = 0;
  let index = startIndex;

  while (total < clientHeight) {
    total += (heights[index] || ITEM_SIZE);
    index += 1;
  }

  return index;
};

// 渲染列表的偏移值
const getOffset = ({ heights, startIndex }) => {
  let total = 0;

  for (let i = 0; i < startIndex; i += 1) {
    total += (heights[i] || ITEM_SIZE);
  }

  return total;
};


// 列表总高度
const listHeight = useMemo(() => DATA_SOURCE.reduce((total, ele, index) => {
  const value = heights[index] || ITEM_SIZE;
  return total + value;
}, 0), [heights]);

3.5 逻辑处理

下面我们来完成最后一步, 就是将上面实现的方法、变量给使用起来

diff 复制代码
export default () => {
  const containerRef = useRef();
  const [paddingTop, setPaddingTop] = useState(0);
  const [renderList, setRenderList] = useState([]);

  const [heights, setHeights] = useState({}); // 记录已渲染过的节点高度: { [id]: 高度 }

  // 列表总高度
  const listHeight = useMemo(() => DATA_SOURCE.reduce((total, ele, index) => {
    const value = heights[index] || ITEM_SIZE;
    return total + value;
  }, 0), [heights]);

  const handleRowResize = useCallback((data, [resizeObserverEntry]) => {
    setHeights((pre) => ({
      ...pre,
      [data.id]: resizeObserverEntry.target.offsetHeight,
    }));
  }, []);

  const handler = useCallback(() => {
+   const { clientHeight, scrollTop = 0 } = containerRef.current ?? {};

+   // 要渲染的列表, 在源数据中的开始索引
+   const startIndex = getStartIndex({ heights, scrollTop });

+   // 要渲染的列表, 在源数据中的结束索引
+   const endIndx = getEndIndex({ heights, startIndex, clientHeight });

+   // 待渲染的列表偏移量(距离顶部的位置)
+   const paddingTop = getOffset({ heights, startIndex });

+   setPaddingTop(paddingTop);
+   setRenderList(DATA_SOURCE.slice(startIndex, endIndx));
  }, [heights]);

  useEffect(() => {
    handler();
  }, [handler]);

  return (
    <div
      onScroll={handler}
      ref={containerRef}
      className={scss.wrapper}>
      <div
        className={scss.list}
+       style={{ height: listHeight, paddingTop }}>
        {renderList.map((v) => (
          <Row
            data={v}
            key={v.id}
            onResize={handleRowResize}
          />
        ))}
      </div>
    </div>
  );
};

3.6 缓冲数据

同样的我们可以添加几条缓冲数据, 这里就更简单了, 只需要相应的调整 startIndexendIndx 的值即可

diff 复制代码
const handler = useCallback(() => {
  const { clientHeight, scrollTop = 0 } = containerRef.current ?? {};

  // 要渲染的列表, 在源数据中的开始索引
  const startIndex = getStartIndex({ heights, scrollTop });

  // 要渲染的列表, 在源数据中的结束索引
  const endIndx = getEndIndex({ heights, startIndex, clientHeight });

+ // 调整 startIndex、endIndx: 目的是在顶部和底部加 2 条缓冲数据
+ const startIndexWithBuffer = Math.max(0, startIndex - 2);
+ const endIndxWithBuffer = endIndx + 2;

+ // 待渲染的列表偏移量(距离顶部的位置)
+ const paddingTop = getOffset({
+   heights,
+   startIndex: startIndexWithBuffer,
+ });

  setPaddingTop(paddingTop);
+ setRenderList(DATA_SOURCE.slice(startIndexWithBuffer, endIndxWithBuffer));
}, [heights]);

3.7 看下效果

最后我们看下最终的一个效果图:

完整源码可点击 VirtualScroll/Dynamic 查看

四、总结

  • 何谓虚拟滚动? 就是只展示用户可见数据, 用户不可见数据不进行渲染, 而是通过样式来进行占位; 一般可用于优化大批量数据的渲染
  • 虚拟滚动的实现关键在于:
    • 通过容器滚动量 scrollTop 计算偏离量, 其实就是计算有多少条数据已经滚动到视口之外, 它们的总高度是多少
    • 通过容器滚动量 scrollTop 计算要渲染的数据在所以数据中的开始索引 startIndex
    • 通过容器视口高度 clientHeight 并配合 startIndex 计算出要渲染的数据在所以数据中的结束索引 endIndx
    • 通过 startIndexendIndx 就可以拿到我们实际要渲染的数据了, 最后渲染出来就可以了
    • 当然我们还需要计算所以列表数据的一个总高度, 目的是为了让内容区域足够大, 能够正常处理滚动事件
  • 静态虚拟滚动, 因为行高度固定, 所以所有计算都能够直接得出
  • 动态虚拟滚动, 在节点没有渲染的情况下我们是无法知道其高度的, 我们可以使用一个最小高度来进行计算, 对应已经渲染过的节点我们可以将其高度都记录下来, 在计算这些节点时就可以使用实际高度进行计算

五、参考

相关推荐
m0_748255262 小时前
前端安全——敏感信息泄露
前端·安全
鑫~阳4 小时前
html + css 淘宝网实战
前端·css·html
Catherinemin4 小时前
CSS|14 z-index
前端·css
2401_882727575 小时前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
NoneCoder5 小时前
CSS系列(36)-- Containment详解
前端·css
anyup_前端梦工厂5 小时前
初始 ShellJS:一个 Node.js 命令行工具集合
前端·javascript·node.js
5hand6 小时前
Element-ui的使用教程 基于HBuilder X
前端·javascript·vue.js·elementui
GDAL6 小时前
vue3入门教程:ref能否完全替代reactive?
前端·javascript·vue.js
六卿6 小时前
react防止页面崩溃
前端·react.js·前端框架
z千鑫6 小时前
【前端】详解前端三大主流框架:React、Vue与Angular的比较与选择
前端·vue.js·react.js