无限滚动加载解决方案之虚拟滚动(上)

作者|安笺

无限滚动加载解决方案之虚拟滚动(上)

大量的多媒体内容被用户消费,传统的拼dom元素的滚动方案会导致页面滚动的卡顿,有什么好的方案让用户浏览海量数据?



前言


做前端开发,难以避免的要和无限滚动加载这类交互打交道,简单的滚动加载大家都知道,记录某个元素的位置,在该元素即将到达视口时触发去请求加载下一屏数据的行为。在当前vue/react大行其道的时代,数据驱动视图更新,修改数据来新增dom节点到列表元素中就可以实现,而且这种行为在大部分产品或场景中都没多大问题,但当代随着社交媒体的流行,大量的视频、图片、文字等数据被用户消费,传统的拼dom元素的滚动方案在性能上就存在瑕疵,海量的数据造就大量的元素节点产生,从而会导致页面滚动的卡顿,那么有什么好的方案让用户浏览海量数据?


谷歌LightHouse开发推荐


chrome影响页面性能的因素:


  1. 总共有超过 1,500 个节点。 
  2. 具有大于 32 个节点的深度。 
  3. 有一个超过 60 个子节点的父节点。


效果对比


内容社交消耗是目前网上用户消费最多,而且加载数据最多的一种类型,下方是在数据大概2000条左右常规加载和虚拟滚动实现的效果对比

常规方案在数据量小,dom元素少的情况下,也是非常流畅,但是在数据量达到一定程度,dom元素量过大时,渲染时间就会急剧增多,滚动将变得滞后,灵敏度下降。


滚动方式介绍


原理:用固定个数的元素来模拟无线滚动加载,通过位置的布局来达到滚动后的效果。


由于无限滚动的列表元素高度是和产品设计的场景有关,有定高的,也有不定高的。


定高:滚动列表中子元素高度相等。

不定高:滚动列表中子元素高度都随机且不相等。


  元素定高方案


  • 实现原理


无限滚动加载解决方案之虚拟滚动(上)

如图所示,我们只渲染固定个数的dom元素,一个视口高度是固定的,子元素的高度也是固定的,我们可以推算出一个视口最多可以看到多少个元素

domNum = window.screen.height / itemHeight 


在这个基础上上下增加3~5个元素即可,例如一共可以显示4个元素,上下视口溢出各加3,一共需要10个dom元素来实现虚拟滚动。滚动后,每个元素距离父类都会有个偏移高度,默认的高度就是这个元素之上所有的兄弟元素的高度之和,我们这里只采用固定个数的元素,则视口内的元素上面并没有那么多的兄弟,但有需要该元素距离视口有那么多的偏移高度,则通过transform或者top值来把该元素的位置钉住,来模拟滚动后自己需要处在的位置上。


  • 滚动效果

无限滚动加载解决方案之虚拟滚动(上)

  • 实现方式


忽略业务相关的任何东西,我们单纯来模拟打造这一套虚拟滚动。我们先通过数组模拟定义2万条数据,并创建一个对象currentArr来表示当前已经获取到的数据。我们模拟一页10条数据,刚进来的时候“后端”放回20条数据,即第一次请求了2页数据(每页10条数据)


...
const arr = [];
for (let i = 0; i < 20000; i++) {
  arr.push(i);
}

const currentArr = arr.slice(0, 19) // 默认取前2页(1页10条数据)
...

我们采用3级深度的dom结构


  • 第一层是100vh高度的容器,允许滚动
  • 第二层是所有元素组成高度,可以理解成是一个空有高度的空白元素,这个高度是当前已经获取的所有元素的总高度
  • 第三层是固定元素渲染层


可滚动的元素高度我们需要先撑开。

我们可以动态计算当前元素一共需要多少高度的空间, itemHeight是每一个列表元素的高度,也可以让后端直接返回给我们一共有多少个元素,然后直接全部撑开,  那么height则为total * itemHeight


...  
<div className="main" ref={slider} onScroll={throttle(scroll, 200)}>
  <div
    className="wrap"
    style={{
      height: currentArr.length * itemHeight * 2, // 这里默认是2倍屏
    }}
  >
    {currentArr.slice(startInex, endIndex).map((item, index) => {
      return (
        <div 
          className="item" 
          key={index}
          style={{
            position: 'absolute',
            left: 0,
            top: 0,
            transform: `translateY(${(startInex + index) * itemHeight}px)`,
          }}
        >
          {item}
        </div>);}
       )
     }
  </div>
</div>
...


这里需获取要展示数组数据里的起始位置和结束位置

第一个元素位置 = 滚动距离/列表元素的高度。

最后一个元素位置 = 第一个元素位置 + 视口内最多展示元素的个数。

拿到起始位置和结束位置来切割数据数组,每次就取固定个数的元素来进行重绘渲染


const scrollTop = slider.current.scrollTop; // 滚动距离
let currentStartIndex = Math.floor(scrollTop / itemHeight); // 起始索引
let currentEndIndex = currentStartIndex + Math.ceil(screenH / itemHeight); // 终点索引

计算元素偏移位置

通过第一个元素的的索引值 + 当前元素数组索引值 可以计算距离父元素顶部的高度, 作用在元素的transform属性上,使其定位在固定的高度偏移量上,这样就大功告成了。


<div 
  className="item" 
  key={index}
  style={{
    position: 'absolute',
    left: 0,
    top: 0,
    transform: `translateY(${(startInex + index) * itemHeight}px)`,
  }}
>

整体代码


// index.less

.main {
  width: 100vw;js
  height: 100vh;
  overflow: scroll;
  position: relative;
}
.item {
  width: 100vw;
  height: 180rpx;
  border-bottom: 2rpx solid black;
}
// index.js
import { createElement, useRef, useState } from "rax";
import "./index.less";
const arr = [];
// 模拟一共有2万条数据
for (let i = 0; i < 20000; i++) {
  arr.push(i);
}
// 默认第一屏取2页数据
const currentArr = arr.slice(0, 19), screenH = window.screen.height;
let i = 2, isReqeust = false;
function throttle(func, delay){
  var timer = null;
  return function(){
    var context = this;
    var args = arguments;
    if(!timer){
      timer = setTimeout(function(){
        func.apply(context, args);
        timer = null;
      }, delay);
    }
  }
}

function Index(props) {
  const { itemHeight = 90 } = props;
  const [startInex, setStartIndex] = useState(0);
  const [endIndex, setEndIndex] = useState(9);
  const [forceUpdate, setForceUpdate] = useState(false);
  const slider = useRef(null);

  const scroll = () => {
    const scrollTop = slider.current.scrollTop; // 滚动距离
    if (currentArr.length * itemHeight - scrollTop - screenH < 400 && !isReqeust ) {
      isReqeust = true;
      // 加载下一页数据
      setTimeout(() => {
        currentArr.push(...arr.slice(i* 10, i*10 + 9));
        i++;
        scroll();
        isReqeust = false;
        setForceUpdate(!forceUpdate)
      }, 500)
      return;
    }

    let currentStartIndex = Math.floor(scrollTop / itemHeight); // 起始索引
    let currentEndIndex = currentStartIndex + Math.ceil(screenH / itemHeight); // 终点索引
    if (currentStartIndex === startInex && currentEndIndex === endIndex) return
    requestAnimationFrame(() => {
      setStartIndex(currentStartIndex)
      setEndIndex(currentEndIndex)
    });
  }

  return (
    <div className="main" ref={slider} onScroll={throttle(scroll, 200)}>
      <div
        className="wrap"
        style={{
          height: currentArr.length * itemHeight * 2, // 这里默认是2倍屏
        }}
      >
        {currentArr.slice(startInex, endIndex).map((item, index) => {
          return (
            <div 
              className="item" 
              key={index}
              style={{
                position: 'absolute',
                left: 0,
                top: 0,
                transform: `translateY(${(startInex + index) * itemHeight}px)`,
              }}
            >
              {item}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default Index;

总结


列表元素等高的方案相对比较容易实现,而且方案很多,有作用在父元素上整体使用transform的,也有作用在每个单一元素上使用transform的,甚至通过top、paddingTop等等各种位置布局属性方案来实现的。


固定元素在渲染上能占到非常大的优势,通过key绑定保障元素性能甚至可以在20个固定元素的场景下渲染做到3ms,但是应用场景往往是多变的,我们的元素等高的场景只是一种,往往还存在非等高的场景,例如微薄、Twitter等等,也例如我们上面的对比图,里面的列表元素卡片分非常多种类,这种再操作上就需要换个思路的,不过换汤不换药,对于非等高的列表元素,可以参考下一篇文档。


上一篇:深入理解Spark:核心思想与源码分析. 3.6 创建任务调度器TaskScheduler


下一篇:如何选择阿里云服务器相关配置