一个简洁、有趣的无限下拉方案

本文主旨

长列表渲染、无限下拉也算是前端开发老生常谈的问题之一了,本文将介绍一种简洁、巧妙、高效的方式来实现。话很少说,看下图,也许你能够发现什么?前端

无限下拉示意图

不知你是否从上面这张图中注意到了什么,好比只是渲染了可视区域的部分 DOM ,滚动过程当中只是外层容器的 padding 在改变?git

前一点很好理解,咱们考虑到性能,不可能将一个长列表(甚至是一个无限下拉列表)的全部列表元素都进行渲染;然后一点,则是本文所介绍方案的核心之一!github

不卖关子,提早告诉你该方案的要素就是两个:web

  • Intersection Observer
  • padding

说明了要素,也许你能够尝试着开始思考,看你是否能猜到具体的实现方案。npm

方案介绍

Intersection Observer

基本概念

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

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

兼容性

主要在 Safari 上兼容性较差,须要 12.2 及以上才兼容,不过还好,有 polyfill 可食用。dom

一些应用场景

  • 页面滚动时的懒加载实现。
  • 无限下拉(本文的实现)。
  • 监测某些广告元素的曝光状况来作相关数据统计。
  • 监测用户的滚动行为是否到达了目标位置来实现一些交互逻辑(好比视频元素滚动到隐藏位置时暂停播放)。

padding 方案实现

基本了解 Intersection Observer 以后,接下来就看下如何用 Intersection Observer + padding 来实现无限下拉。异步

先概览下整体思路:函数

  • 监听一个固定长度列表的首尾元素是否进入视窗;
  • 更新当前页面内渲染的第一个元素对应的序号;
  • 根据上述序号,获取目标数据元素,列表内容从新渲染成对应内容;
  • 容器 padding 调整,模拟滚动实现。

核心:利用父元素的 padding 去填充随着无限下拉而本该有的、愈来愈多的 DOM 元素,仅仅保留视窗区域上下必定数量的 DOM 元素来进行数据渲染

一、监听一个固定长度列表的首尾元素是否进入视窗

// 观察者建立
this.observer = new IntersectionObserver(callback, options);

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

咱们以在页面中渲染固定的 20 个列表元素为例,咱们对第一个元素和最后一个元素,用 Intersection Observer 进行观察,当他们其中一个从新进入视窗时,callback 函数就会触发:

const callback = (entries) => {
    entries.forEach((entry) => {
        if (entry.target.id === firstItemId) {
            // 当第一个元素进入视窗
        } else if (entry.target.id === lastItemId) {
            // 当最后一个元素进入视窗
        }
    });
};
复制代码

二、更新当前页面渲染的第一个元素对应的序号 (firstIndex)

拿具体例子来讲明,咱们用一个数组来维护须要渲染到页面中的数据。数组的长度会随着不断请求新的数据而不断变大,而渲染的始终是其中必定数量的元素,好比 20 个。 那么:

  • 一、最开始渲染的是数组中序号为 0 - 19 的元素,即此时对应的 firstIndex 为 0;

  • 二、当序号为 19 的元素(即上一步的 lastItem )进入视窗时,咱们就会日后渲染 10 个元素,即渲染序号为 10 - 29 的元素,那么此时的 firstIndex 为 10;

  • 三、下一次就是,当序号为 29 的元素进入视窗时,继续日后渲染 10个元素,即渲染序号为 20 - 39 的元素,那么此时的 firstIndex 为 20,以此类推。。。

// 咱们对原先的 firstIndex 作了缓存
const { currentIndex } = this.domDataCache;

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

let firstIndex;

if (isScrollDown) {
    // 向下滚动时序号增长
    firstIndex = currentIndex + increment;
} else {
    // 向上滚动时序号减小
    firstIndex = currentIndex - increment;
}
复制代码

整体来讲,更新 firstIndex,是为了根据页面的滚动状况,知道接下来哪些数据应该被获取、渲染。

三、根据上述序号,获取对应数据元素,列表从新渲染成新的内容

const renderFunction = (firstIndex) => {
    // offset = firstIndex, limit = 10 => getData
    // getData Done => new dataItems => render DOM
 };
复制代码

这一部分就是根据 firstIndex 查询数据,而后将目标数据渲染到页面上便可。

四、padding 调整,模拟滚动实现

既然数据的更新以及 DOM 元素的更新咱们已经实现了,那么无限下拉的效果以及滚动的体验,咱们要如何实现呢?

想象一下,抛开一切,最原始最直接最粗暴的方式无非就是咱们再又获取了 10 个新的数据元素以后,再塞 10 个新的 DOM 元素到页面中去来渲染这些数据。

但此时,对比上面这个粗暴的方案,咱们的方案是:这 10个新的数据元素,咱们用原来已有的 DOM 元素去渲染,替换掉已经离开视窗、不可见的数据元素;而本该由更多 DOM 元素进一步撑开容器高度的部分,咱们用 padding 填充来模拟实现。

img

  • 向下滚动
// 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;
    }
}
复制代码

向下滚动示意图

  • 向上滚动
// 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 设置更新以及相关缓存数据更新
// 容器padding从新设置
this.updateContainerPadding({
    newCurrentPaddingBottom,
    newCurrentPaddingTop
})

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

思考总结

方案总结:

利用 Intersection Observer 来监测相关元素的滚动位置,异步监听,尽量得减小 DOM 操做,触发回调,而后去获取新的数据来更新页面元素,而且用调整容器 padding 来替代了本该愈来愈多的 DOM 元素,最终实现列表滚动、无限下拉。

相关方案的对比

这里和较为有名的库 - iScroll 实现的无限下拉方案进行一个基本的对比,对比以前先说明下 iScroll infinite 的实现概要:

  • iScroll 经过对传统滚动事件的监听,获取滚动距离,而后:

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

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

存在的缺陷:

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

延伸拓展

  • 请你们思考一下,无限下拉有了,那么无限上拉基于这种方案要如何调整实现呢?
  • 若是将 Intersection Observer 用到 iScroll 里面去,原有方案能够怎样优化?

代码实现

参考文章

本文发布自 网易云音乐前端团队,欢迎自由转载,转载请保留出处。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们

相关文章
相关标签/搜索