简洁、巧妙、高效的长列表,无限下拉方案

简介: 简洁、巧妙、高效的长列表,无限下拉方案

本文主旨


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

56d0d0e607e6d5ad03428338392298f5_640_wx_fmt=gif&wxfrom=5&wx_lazy=1.gif

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

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

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

  • Intersection Observer
  • padding

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


方案介绍


Intersection Observer


基本概念


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

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


兼容性


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

一些应用场景

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


padding 方案实现


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

先概览下总体思路:

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

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


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


// 观察者创建
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) {
            // 当最后一个元素进入视窗
        }
    });
};
复制代码


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


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

  • 1、最开始渲染的是数组中序号为 0 - 19 的元素,即此时对应的 firstIndex 为 0;
  • 2、当序号为 19 的元素(即上一步的 lastItem )进入视窗时,我们就会往后渲染 10 个元素,即渲染序号为 10 - 29 的元素,那么此时的 firstIndex 为 10;
  • 3、下一次就是,当序号为 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,是为了根据页面的滚动情况,知道接下来哪些数据应该被获取、渲染。


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


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

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


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


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

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

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



4d06f2419aad3f44de1d0524b37e0bbe_640_wx_fmt=other&wxfrom=5&wx_lazy=1&wx_co=1.jpg



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


fcbeb5d37966690ea13aef72c69ef975_640_wx_fmt=gif&wxfrom=5&wx_lazy=1.gifdb07f16cfcce6bd34419c4ce18f0e788_640_wx_fmt=other&wxfrom=5&wx_lazy=1&wx_co=1.jpg



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



b2eaa667b3454614a8b1f6125fe67a87_640_wx_fmt=gif&wxfrom=5&wx_lazy=1.gif6b0c970189c540c7ddd860bbd652ed8b_640_wx_fmt=other&wxfrom=5&wx_lazy=1&wx_co=1.jpg



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


延伸拓展


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


代码实现


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


参考文章


  • Intersection Observer API
  • IntersectionObserver’s Coming into View
  • Infinite Scroll’ing the right way

目录
相关文章
|
编解码 数据可视化 Java
3D模型拆分与合并展示,IVX真的可以简单实现
iVX 平台的优势和特点,包括逻辑完备性、操作流畅性、面向对象设计方法、可独立作为编程语言等方面的优势,下面来详细的介绍介绍。
128 0
|
4月前
|
前端开发 JavaScript API
解锁高效应用构建:Vuex与后端交互的前端状态同步策略,让数据流动如行云流水,紧跟前端开发的热点趋势
【8月更文挑战第27天】本文深入探讨了Vue框架下的前端状态管理库Vuex与后端服务交互时的状态同步策略。通过剖析Vuex的核心机制——状态(State)、变异(Mutation)、动作(Action)及模块(Module),文章展示了如何优雅地将后端数据加载并更新至前端状态中。特别地,借助示例代码解释了Action处理API调用、Mutation更新状态的过程,并介绍了如何通过模块化和命名空间提高状态管理的准确性和时效性。此外,还讨论了组件如何利用`mapState`和`mapActions`简化状态访问与操作的方法。遵循这些策略,开发者可以在构建复杂应用时显著提升性能与用户体验。
54 0
|
1月前
|
前端开发 JavaScript 开发者
揭秘前端高手的秘密武器:深度解析递归组件与动态组件的奥妙,让你代码效率翻倍!
【10月更文挑战第23天】在Web开发中,组件化已成为主流。本文深入探讨了递归组件与动态组件的概念、应用及实现方式。递归组件通过在组件内部调用自身,适用于处理层级结构数据,如菜单和树形控件。动态组件则根据数据变化动态切换组件显示,适用于不同业务逻辑下的组件展示。通过示例,展示了这两种组件的实现方法及其在实际开发中的应用价值。
37 1
|
4月前
|
Java 开发者
在Java编程中,if-else与switch作为核心的条件控制语句,各有千秋。if-else基于条件分支,适用于复杂逻辑;而switch则擅长处理枚举或固定选项列表,提供简洁高效的解决方案
在Java编程中,if-else与switch作为核心的条件控制语句,各有千秋。if-else基于条件分支,适用于复杂逻辑;而switch则擅长处理枚举或固定选项列表,提供简洁高效的解决方案。本文通过技术综述及示例代码,剖析两者在性能上的差异。if-else具有短路特性,但条件增多时JVM会优化提升性能;switch则利用跳转表机制,在处理大量固定选项时表现出色。通过实验对比可见,switch在重复case值处理上通常更快。尽管如此,选择时还需兼顾代码的可读性和维护性。理解这些细节有助于开发者编写出既高效又优雅的Java代码。
64 2
|
5月前
|
存储 算法 Python
“解锁Python高级数据结构新姿势:图的表示与遍历,让你的算法思维跃升新高度
【7月更文挑战第13天】Python中的图数据结构用于表示复杂关系,通过节点和边连接。常见的表示方法是邻接矩阵(适合稠密图)和邻接表(适合稀疏图)。图遍历包括DFS(深度优先搜索)和BFS(广度优先搜索):DFS深入探索分支,BFS逐层访问邻居。掌握这些技巧对优化算法和解决实际问题至关重要。**
51 1
|
7月前
|
算法 程序员 C语言
【C++ 模板和迭代器的交融】灵活多变:使用C++模板精准打造自定义迭代器
【C++ 模板和迭代器的交融】灵活多变:使用C++模板精准打造自定义迭代器
104 0
|
7月前
|
安全 编译器 程序员
C++14特性:解锁现代C++功能以获得更具表现力和更高效的代码
C++14特性:解锁现代C++功能以获得更具表现力和更高效的代码
151 0
|
7月前
|
前端开发 搜索推荐 JavaScript
探析单页应用(SPA)与多页应用(MPA):从技术角度选择合适的应用形式
随着Web应用的快速发展,单页应用(SPA)和多页应用(MPA)成为了开发者们在构建Web应用时常遇到的两种选择。本文将从技术角度出发,深入比较单页应用和多页应用的特点和优势,帮助读者更好地选择适合自己项目的应用形式。
96 0
项目实战典型案例14——代码结构混乱 逻辑边界不清晰 页面美观设计不足
项目实战典型案例14——代码结构混乱 逻辑边界不清晰 页面美观设计不足
123 0
|
算法 安全 搜索推荐
一文搞懂下拉框优化的价值和意义
一文搞懂下拉框优化的价值和意义
468 0