Jacleklm's Blog

渲染方案

2020/05/27

长列表 / 无限下拉列表 渲染

考虑到性能,我们不可能将一个长列表(甚至是一个无限下拉列表)的所有列表元素都进行渲染,应该是只渲染部分数据并随着下拉渲染新数据

方案一:Intersection Observer + padding

该方案来自云音乐-一个简洁、有趣的无限下拉方案

Intersection Observer

详见MDN,建议全都读完

一直以来,检测元素的可视状态或者两个元素的相对可视状态都不是件容易事。传统的各种方案不但复杂,而且性能成本很高,比如需要监听滚动事件,然后查询 DOM , 获取元素高度、位置,计算距离视窗高度等等。

这就是 Intersection Observer 要解决的问题。它为开发人员提供一种便捷的新方法来异步查询元素相对于其他元素或视窗的位置,消除了昂贵的 DOM 查询和样式读取成本

它不能告诉你的一件事情是 (两个元素的)重叠部分的准确像素个数或者重叠的像素属于哪一个元素。然而这个API覆盖最广的最常用的使用方式是 如果两个元素发生的交集部分在N%左右,我需要做处理一些事情(执行回调)

在 Safari 上兼容性较差,需要 12.2 及以上才兼容,否则需配合 polyfill 使用

一些应用场景:

  • 当页面滚动时,懒加载图片或其他内容。
  • 实现“可无限滚动”网站,也就是当用户滚动网页时直接加载更多内容,无需翻页。
  • 为计算广告收益,检测其广告元素的曝光情况。
  • 根据用户是否已滚动到相应区域来灵活开始执行任务或动画

具体用法

  1. 创建一个 IntersectionObserver对象。
  • root必须是目标元素的父级元素;如果未指定或者为null,则默认为浏览器视窗
  • rootMargin是root元素的外边距。类似于css中的 margin 属性
  • threshold。目标(target)元素与根(root)元素之间的交叉度是交叉比(intersection ratio)。这是目标(target)元素相对于根(root)的交集百分比的表示,它的取值在0.0和1.0之间。阈值为1.0意味着目标元素完全出现在root选项指定的元素中可见时,回调函数将会被执行。可以是单一的number也可以是number数组
    1
    2
    3
    4
    5
    6
    var options = {
    root: document.querySelector('#scrollArea'),
    rootMargin: '0px',
    threshold: 1.0
    }
    var observer = new IntersectionObserver(callback, options);
  1. 每个观察者配置一个目标。每当目标满足该IntersectionObserver指定的threshold值,回调被调用
    1
    2
    var target = document.querySelector('#listItem');
    observer.observe(target);
  2. 回调函数。回调接收 IntersectionObserverEntry对象和观察者的列表:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var callback = function(entries, observer) { 
    entries.forEach(entry => {
    // 这里的 entry 是 IntersectionObserverEntry 对象,entry.target 是目标元素
    // entry.boundingClientRect
    // entry.intersectionRatio
    // entry.intersectionRect
    // entry.isIntersecting
    // entry.rootBounds
    // entry.target
    // entry.time
    });
    };
    entry的属性大概是这样:

    详见MDN-IntersectionObserverEntry

实现思路

总体思路:利用 Intersection Observer 来监测相关元素的滚动位置,异步监听,尽可能得减少 DOM 操作,触发回调,然后去获取新的数据来更新页面元素,并且用调整容器 padding 来替代了本该越来越多的 DOM 元素,最终实现列表滚动、无限下拉。具体如下:

  1. 监听一个固定长度列表的首尾元素是否进入视窗
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 观察者创建
    this.observer = new IntersectionObserver(callback, options);

    // 观察列表第一个以及最后一个元素
    this.observer.observe(this.firstItem);
    this.observer.observe(this.lastItem);

    const callback = (entries) => {
    entries.forEach((entry) => {
    if (entry.target.id === firstItemId) {
    // 当第一个元素进入视窗
    } else if (entry.target.id === lastItemId) {
    // 当最后一个元素进入视窗
    }
    });
    };
  2. 更新当前页面内渲染的第一个元素对应的序号(firstIndex)。我们用一个数组来维护需要渲染到页面中的数据。数组的长度会随着不断请求新的数据而不断变大,而渲染的始终是其中一定数量的元素,比如 20 个。那么:
  • 最开始渲染的是数组中序号为 0 - 19 的元素,即此时对应的 firstIndex 为 0;
  • 当序号为 19 的元素(即上一步的 lastItem )进入视窗时,我们就会往后渲染 10 个元素,即渲染序号为 10 - 29 的元素,那么此时的 firstIndex 为 10;
  • 下一次就是,当序号为 29 的元素进入视窗时,继续往后渲染 10个元素,即渲染序号为 20 - 39 的元素,那么此时的 firstIndex 为 20,以此类推。。。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 我们对原先的 firstIndex 做了缓存
    const { currentIndex } = this.domDataCache;

    // 以全部容器内所有元素的一半作为每一次渲染的增量
    const increment = Math.floor(this.listSize / 2);

    let firstIndex;

    if (isScrollDown) {
    // 向下滚动时序号增加
    firstIndex = currentIndex + increment;
    } else {
    // 向上滚动时序号减少
    firstIndex = currentIndex - increment;
    }
  1. 根据上述序号,获取目标数据元素,列表内容重新渲染成对应内容
    1
    2
    3
    4
    const renderFunction = (firstIndex) => {
    // offset = firstIndex, limit = 10 => getData
    // getData Done => new dataItems => render DOM
    };
  2. 容器 padding 调整,模拟滚动实现
    其实不用这一步也可以,直接获取了 10 个新的数据元素之后,再塞 10 个新的 DOM 元素到页面中去来渲染这些数据。但是 DOM 会越来越多,页面DOM太多可能会有性能问题。所以这里的方案是:用原来已有的 DOM 元素去渲染,替换掉已经离开视窗、不可见的数据元素;而本该由更多 DOM 元素进一步撑开容器高度的部分,我们用 padding 填充来模拟实现。
  • 向下滚动
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // padding的增量 = 每一个item的高度 x 新的数据项的数目
    const remPaddingsVal = itemHeight * (Math.floor(this.listSize / 2));

    if (isScrollDown) {
    // paddingTop新增,填充顶部位置
    newCurrentPaddingTop = currentPaddingTop + remPaddingsVal;

    if (currentPaddingBottom === 0) {
    newCurrentPaddingBottom = 0;
    } else {
    // 如果原来有paddingBottom则减去,会有滚动到底部的元素进行替代
    newCurrentPaddingBottom = currentPaddingBottom - remPaddingsVal;
    }
    }
  • 向上滚动
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // padding的增量 = 每一个item的高度 x 新的数据项的数目
    const remPaddingsVal = itemHeight * (Math.floor(this.listSize / 2));

    if (!isScrollDown) {
    // paddingBottom新增,填充底部位置
    newCurrentPaddingBottom = currentPaddingBottom + remPaddingsVal;

    if (currentPaddingTop === 0) {
    newCurrentPaddingTop = 0;
    } else {
    // 如果原来有paddingTop则减去,会有滚动到顶部的元素进行替代
    newCurrentPaddingTop = currentPaddingTop - remPaddingsVal;
    }
    }
  • 最后是 padding 设置更新以及相关缓存数据更新
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 容器padding重新设置
    this.updateContainerPadding({
    newCurrentPaddingBottom,
    newCurrentPaddingTop
    })

    // DOM元素相关数据缓存更新
    this.updateDomDataCache({
    currentPaddingTop: newCurrentPaddingTop,
    currentPaddingBottom: newCurrentPaddingBottom
    });

完整代码实现参考https://github.com/Guohjia/listScroll

缺陷

  • padding 的计算依赖列表项固定的高度
  • 这是一个同步渲染的方案,也就是目前容器 padding 的计算调整,无法计算异步获取的数据,只跟用户的滚动行为有关。解决思路:
    • 思路1:利用 Skeleton Screen Loading 来同步渲染数据元素,不受数据异步获取的影响。即在数据请求还未完成时,先使用一些图片进行占位,待内容加载完成之后再进行替换。
    • 思路2:滚动到目标位置,阻塞容器 padding 的设置(即无限下拉的发生)直至数据请求完毕,用 loading gif 提示用户加载状态,但这个方案相对复杂,你需要全面考虑用户难以预测的滚动行为来设置容器的 padding。

方案二:较为有名的库 - iScroll

思路:
iScroll 通过对传统滚动事件的监听,获取滚动距离,然后:

  1. 设置父元素的 translate 来实现整体内容的上移(下移);
  2. 再基于这个滚动距离进行相应计算,得知相应子元素已经被滚动到视窗外,并且判断是否应该将这些离开视窗的子元素移动到末尾,从而再对它们进行 translate 的设置来移动到末尾。这就像是一个循环队列一样,随着滚动的进行,顶部元素先出视窗,但又将移动到末尾,从而实现无限下拉

方案一和二对比

  • 实现对比:一个是 Intersection Observer 的监听,来通知子元素离开视窗,只要定量设置父元素 padding 就行;另一个是对传统滚动事件的监听,滚动距离的获取,再进行一系列计算,去设置父元素以及子元素的 translate。显而易见,前者看起来更加简洁明了一些
  • 性能对比:我知道说到对比,你脑海中肯定一下子会想到性能问题。其实性能对比的关键就是 Intersection Observer。因为单就 padding 设置还是 translate 设置,性能方面的差距是甚小的,只是个人感觉 padding 会简洁些?而 Intersection Observer 其实抽离了所有滚动层面的相关逻辑,你不再需要对滚动距离等相应 DOM 属性进行获取,也不再需要进行一系列滚动距离相关的复杂计算,并且同步的滚动事件触发变成异步的,你也不再需要另外去做防抖之类的逻辑,这在性能方面还是有所提升的

tree组件的渲染优化

高性能渲染10万个DOM

见之前的博客性能优化-如何插入几万个 DOM 并不卡住界面

参考资料
云音乐-一个简洁、有趣的无限下拉方案
前端tree组件,10000个树节点,从14.65s到0.49s

CATALOG
  1. 1. 长列表 / 无限下拉列表 渲染
    1. 1.1. 方案一:Intersection Observer + padding
      1. 1.1.1. Intersection Observer
        1. 1.1.1.1. 具体用法
      2. 1.1.2. 实现思路
      3. 1.1.3. 缺陷
    2. 1.2. 方案二:较为有名的库 - iScroll
    3. 1.3. 方案一和二对比
  2. 2. tree组件的渲染优化
  3. 3. 高性能渲染10万个DOM