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虚拟滚动内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • antd-react使用Select组件defaultValue踩的坑及解决

    antd-react使用Select组件defaultValue踩的坑及解决

    这篇文章主要介绍了antd-react使用Select组件defaultValue踩的坑及解决方案,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-05-05
  • React中的JSX  { }的使用详解

    React中的JSX  { }的使用详解

    这篇文章主要介绍了React中的JSX{ }的使用,React使用JSX来替代常规的JavaScript,JSX可以理解为的JavaScript语法扩展,它里面的标签申明要符合XML规范要求,对React JSX使用感兴趣的朋友一起看看吧
    2022-08-08
  • react实现一个优雅的图片占位模块组件详解

    react实现一个优雅的图片占位模块组件详解

    这篇文章主要给大家介绍了关于react如何实现一个还算优雅的占位模块图片组件的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧。
    2017-10-10
  • React自定义Hook的实现

    React自定义Hook的实现

    自定义Hook是一种自定义函数,它封装了React Hook的逻辑,并通过命名约定来使其可重用,本文主要介绍了React自定义Hook的实现,感兴趣的可以了解一下
    2023-11-11
  • 详解React中setState回调函数

    详解React中setState回调函数

    这篇文章主要介绍了详解React中setState回调函数,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-06-06
  • ReactNative之FlatList的具体使用方法

    ReactNative之FlatList的具体使用方法

    本篇文章主要介绍了ReactNative之FlatList的具体使用方法,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-11-11
  • React Hooks钩子中API的使用示例分析

    React Hooks钩子中API的使用示例分析

    在react类组件(class)写法中,有setState和生命周期对状态进行管理,但是在函数组件中不存在这些,故引入hooks(版本:>=16.8),使开发者在非class的情况下使用更多react特性
    2022-08-08
  • React中项目路由配置与跳转方法详解

    React中项目路由配置与跳转方法详解

    这篇文章主要为大家详细介绍了React中项目路由配置与跳转方法的相关资料,文中的示例代码讲解详细,具有一定的学习价值,感兴趣的小伙伴可以了解一下
    2023-08-08
  • ahooks解决用户多次提交方法示例

    ahooks解决用户多次提交方法示例

    这篇文章主要为大家介绍了ahooks解决用户多次提交的方法示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-07-07
  • React antd中setFieldsValu的简便使用示例代码

    React antd中setFieldsValu的简便使用示例代码

    form.setFieldsValue是antd Form组件中的一个方法,用于动态设置表单字段的值,它接受一个对象作为参数,对象的键是表单字段的名称,值是要设置的字段值,这篇文章主要介绍了React antd中setFieldsValu的简便使用,需要的朋友可以参考下
    2023-08-08

最新评论