React实现虚拟滚动的三种思路详解

 更新时间:2024年04月25日 08:24:03   作者:youthfighter  
在​​web​​开发的过程中,或多或少都会遇到大列表渲染的场景,为了解决大列表造成的渲染压力,便出现了虚拟滚动技术,本文主要介绍虚拟滚动的三种思路,希望对大家有所帮助

1 前言

在​​web​​开发的过程中,或多或少都会遇到大列表渲染的场景,例如全国城市列表、通讯录列表、聊天记录列表等等。当列表数据量为几百条时,依靠浏览器本身的性能基本可以支撑,一般不会出现卡顿的情况。但当列表数量级达到上千,页面渲染或操作就可能会出现卡顿,而当列表数量突破上万甚至十几万时,网页可能会出现严重卡顿甚至直接崩溃。为了解决大列表造成的渲染压力,便出现了虚拟滚动技术。本文主要介绍虚拟滚动的基本原理,以及子项定高的虚拟滚动列表的简单实现。

2 基本原理

首先来看一下直接渲染的大列表的实际表现。以有10万条子项的简单大列表为例,页面初始化时,​​FP​​时间大概在4000ms左右,大量的时间被用于执行脚本和渲染。而当快速滚动列表时,网页的​​FPS​​维持在35左右,可以明显的感觉到页面的卡顿。借助谷歌​​Lighthouse​​工具,最终网页的性能得分仅为49。通过实际访问体验和性能相关数据可以看出,直接渲染的大列表在加载操作方面体验是十分糟糕的。点击​ ​链接​​,体验实际效果。

通过以上的测试数据可以看到,在页面初始化时脚本的执行和​​DOM​​渲染占据的大部分的时间。而随着列表子项的减少,页面初始化时间会变短并且滚动时​​FPS​​可以保持在60。由此可以得出结论大量节点的渲染是页面初始化慢和操作卡顿的主要原因。

虽然大列表的数据量很大,但是设备的显示区域是有限的,也就是说在同一时间,用户看到的内容是有限的。利用这一特点,可以将大列表按需渲染。也就是只渲染某一时刻用户看的到的内容,当用户滚动页面时,再通过​​JS​​的计算重现调整视窗内的内容,这样可以把列表子项的数量级别从几万降到几十。

借助按需渲染的思想来优化大列表在实现层面可以分成三步,一是确定当前视窗在哪,二是确定当前要真实渲染哪些节点,三是把渲染的节点移动到视窗内。对于问题一,视窗的位置对于长列表来说,其开始位置为列表滚动区域的​​scrollTop​​。对于问题二,按照视窗外内容不渲染的思路,则应该渲染数组索引从​​Math.floor(scrollTop/itemHeight)​​开始共​​Math.ceil(viewHeight/itemHeight)​​个元素。对于问题三,有多种实现思路,以下将介绍几种常见虚拟滚动的实现方式。

解释:

  • scrollTop:列表滚动区域的scrollTop
  • itemHeight:子节点的高度
  • viewHeight:视窗的高度

3 实现

3.1 Transform

该方案主要是通过监听滚动区域的滚动事件,动态计算视窗内渲染节点的开始索引以及偏移量,然后重新触发渲染节点的渲染并将内容通过​​transform​​属性将该部分内容移动到视窗内。

简单代码实现如下,​ ​线上效果预览​​

function VirtualList(props) {
  const { list, itemHeight } = props;
  const [start, setStart] = useState(0);
  const [count, setCount] = useState(0);
  const scrollRef = useRef(null);
  const contentRef = useRef(null);
  const totalHeight = useMemo(() => itemHeight * list.length, [list.length]);
  useEffect(() => {
    setCount(Math.ceil(scrollRef.current.clientHeight / itemHeight));
  }, []);
  const scrollHandle = () => {
    const { scrollTop } = scrollRef.current;
    const newStart = Math.floor(scrollTop / itemHeight);
    setStart(newStart);
    contentRef.current.style.transform = `translate3d(0, ${
      newStart * itemHeight
    }px, 0)`;
  };
  const subList = list.slice(start, start + count);
  return (
    <div className="virtual-list" onScroll={scrollHandle} ref={scrollRef}>
      <div style={{ height: totalHeight + "px" }}>
        <div className="content" ref={contentRef}>
          {subList.map(({ idx }) => (
            <div
              key={idx}
              className="item"
              style={{ height: itemHeight + "px" }}
            >
              {idx}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

类似思想实现的开源项目:​ ​react-list​​

3.2 Absolute

该方案与​​transform​​方案类似,都是通过监听滚动区域的滚动事件,动态的计算要显示的内容。但​​transform​​方案显示内容的偏量是动态计算并赋值的,而该方案则是利用​​absolute​​属性直接将待渲染的节点定位到其该出现的位置。例如,索引为0的元素,其必定在​​top = 0 * itemHeight​​的位置,索引为​​start​​的元素必定在​​top = start * itemHeight​​的位置,这与视窗位置无关。视窗只决定了要渲染那些子节点,不影响子节点的相对位置。

简单代码实现如下,​ ​线上效果预览​​。

function VirtualList(props) {
  const { list, itemHeight } = props;
  const [start, setStart] = useState(0);
  const [count, setCount] = useState(0);
  const scrollRef = useRef(null);
  const totalHeight = useMemo(() => itemHeight * list.length, [list.length]);
  useEffect(() => {
    setCount(Math.ceil(scrollRef.current.clientHeight / itemHeight));
  }, []);
  const scrollHandle = () => {
    const { scrollTop } = scrollRef.current;
    const newStart = Math.floor(scrollTop / itemHeight);
    setStart(newStart);
  };
  const subList = list.slice(start, start + count);
  return (
    <div className="virtual-list" onScroll={scrollHandle} ref={scrollRef}>
      <div style={{ height: `${totalHeight}px` }}>
        {subList.map(({ idx }) => (
          <div
            key={idx}
            className="item"
            style={{
              position: "absolute",
              width: "100%",
              height: itemHeight + "px",
              top: `${(idx - 1) * itemHeight}px`,
            }}
          >
            {idx}
          </div>
        ))}
      </div>
    </div>
  );
}

类似思想实现的开源项目:​ ​react-virtualized​​

3.3 Padding

该方案与以上两种方案有较大的差别,主要体现在以下两点:一是列表高度撑起的方式不同,以上两种方案的高度是通过设置​​height = list.length * itemHeight​​​的方式撑起来的,而该方案则是通过​​paddingTop + paddingBottom + renderHeight​​​的方式来撑起来的。二是列表的重新渲染时机不同,以上两种方案会在​​Math.floor(scrollTop / itemHeight)​​值变化时重新渲染,而该方案则是在渲染节点"不够"在视窗内显示时触发。

举个例子,假定视窗一次可以显示10个,同时配置虚拟滚动组件一次渲染50节点,那么当屏幕滚动到第11个时并不需要渲染,因为此时显示的是11-20个节点,而将要显示的21-50已经渲染好了。只有当滚动到第41个的时候才需要重新渲染,因为屏幕外已经没有渲染好的节点了,再滚动就要显示白屏了。根据以上例子进一步的分析临界条件,当前渲染位置为​​[itemHeight * start, itemHeight * (start + count)]​​​,视窗显示的位置为​​[scrollTop, scrollTop + clientHeight]​​。

当​​scrollTop + clientHeight >= itemHeight * (start + count)​​时,说明视窗显示位置超过了渲染的最大位置,重新触发渲染调整渲染位置,避免底部白屏。
当​​scrollTop <= itemHeight * start​​时,说明视窗显示位置不足渲染的最小位置,重新触发渲染调整渲染位置,避免顶部白屏。

简单代码实现如下,​ ​线上效果预览​​。

function VirtualList(props) {
  // 注意该count是外部传入的
  const { list, itemHeight, count } = props;
  const totalHeight = useMemo(() => itemHeight * list.length, [list.length]);
  const currentHeight = useMemo(() => itemHeight * count, [itemHeight, count]);
  const [start, setStart] = useState(0);
  const scrollRef = useRef(null);
  const paddingTop = useMemo(() => itemHeight * start, [start]);
  const paddingBottom = useMemo(
    () => totalHeight - itemHeight * start - currentHeight,
    [start]
  );
  const scrollHandle = () => {
    const { scrollTop, clientHeight } = scrollRef.current;
    if (
      scrollTop + clientHeight >= itemHeight * (start + count) ||
      scrollTop <= itemHeight * start
    ) {
      const newStart = Math.floor(scrollTop / itemHeight);
      setStart(Math.min(list.length - count, newStart));
    }
  };
  const subList = list.slice(start, start + count);
  return (
    <div className="virtual-list" onScroll={scrollHandle} ref={scrollRef}>
      <div
        style={{
          paddingTop: `${paddingTop}px`,
          paddingBottom: `${paddingBottom}px`,
        }}
      >
        {subList.map(({ idx }) => (
          <div key={idx} className="item" style={{ height: itemHeight + "px" }}>
            {idx}
          </div>
        ))}
      </div>
    </div>
  );
}

类似思想实现的开源项目:​ ​vue-virtual-scroll-list​​

4 性能

使用以上三种方案分别测试页面加载速度和滚动时的​​FPS​​发现,三者之间的性能数据无明显差别。页面初始化时,​​FP​​时间提前到450ms左右,快速滚动时的​​FPS​​基本稳定在60左右,网站的谷歌​​Lighthouse​​性能跑分提高到95左右。实际访问体验和性能相关数据都得到了较大的提升。

5 总结

本文主要是介绍了虚拟滚动的基本原理,并根据常见虚拟滚动开源库的实现思路使用​​react​​进行了简单的实现。通过简单的实现可以帮助我们更好的理解虚拟滚动原理,不过在实际开发过程中,还是建议大家使用成熟的开源库。

到此这篇关于React实现虚拟滚动的三种思路详解的文章就介绍到这了,更多相关React虚拟滚动内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • React手写一个手风琴组件示例

    React手写一个手风琴组件示例

    这篇文章主要为大家介绍了React手写一个手风琴组件示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-07-07
  • React组件useReducer的讲解与使用

    React组件useReducer的讲解与使用

    在React函数式组件中,我们可以通过useState()来创建state,这种state创建方式会给我们返回两个东西state和setState()。
    2023-04-04
  • react-router6.x路由配置及导航详解

    react-router6.x路由配置及导航详解

    这篇文章主要介绍了react-router6.x路由配置及导航,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2023-10-10
  • 前端面试题必会之前端react面试题

    前端面试题必会之前端react面试题

    在前端面试过程中经常会问到一些面试题,今天小编抽空给大家讲解前端面试题之必会react面试题,需要的朋友可以参考下
    2023-03-03
  • react批量引入svg图标的方法

    react批量引入svg图标的方法

    这篇文章主要介绍了react批量引入svg图标的方法,在批量引入之前,我们需要安装一个包并配置到typescript.json文件中,需要的朋友可以参考下
    2024-03-03
  • ReactDOM.render在react源码中执行原理

    ReactDOM.render在react源码中执行原理

    这篇文章主要为大家介绍了ReactDOM.render在react源码中执行原理解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-12-12
  • react-diagram 序列化Json解读案例分析

    react-diagram 序列化Json解读案例分析

    今天带来大家学习react-diagram 序列化Json解读的相关知识,本文通过多种案例给大家分析序列化知识,通过图文并茂的形式给大家介绍的非常详细,感兴趣的朋友一起看看吧
    2021-05-05
  • react中的forwardRef 和memo的区别解析

    react中的forwardRef 和memo的区别解析

    forwardRef和memo是React中用于性能优化和组件复用的两个高阶函数,本文给大家介绍react中的forwardRef 和memo的区别及适用场景,感兴趣的朋友跟随小编一起看看吧
    2023-10-10
  • React团队测试并发特性详解

    React团队测试并发特性详解

    这篇文章主要为大家介绍了React团队测试并发特性详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-08-08
  • React可定制黑暗模式切换开关组件

    React可定制黑暗模式切换开关组件

    这篇文章主要为大家介绍了React可定制黑暗模式切换开关组件示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-10-10

最新评论