大屏经典组件:“无限滚动” 从分析到开发

大厂技术  坚持周更  精选好文


阅读本文,你将

  1. 理解大屏 “无限滚动组件” 的开发思路

  2. 跟随作者,一步步完成一个高性能 “无限滚动组件” 的开发

  3. 收获一份该实现的粗糙源码。

一、无限滚动:事件/告警 的有力帮手

1.1 为什么需要滚动列表

大屏之所以 “炫酷” ,相比于 UI 同学出的效果图,它最大的优势就在于 它能动

哪怕平台可能没有接入 websocket,甚至数据就是静态写死的,客户依然希望数据能在屏幕上 “动起来”。

这会给人一种 “数据是实时的” 的错觉。

这种错觉,或者说故意营造出来的错觉,就是领导们 “讲故事” 的素材之一。

尤其是当业务里涉及到 “事件/告警/威胁/监控” 等元素时,涉及到的数据量很大 —— 几百或几千条,此时,会自己滚动的列表就成了非常适合场景的组件形式:

大屏经典组件:“无限滚动” 从分析到开发_第1张图片

“ 我们相关部门单据的申请和审批情况也会实时推送到系统中,可以做到实时把控。 ”

——领导如此向上介绍。

虽然大家都明白,但是谁会整天没事盯着一个深色的大屏做监管呢?这么炫酷的大屏,电脑不卡吗?

正经人都是用 "白色底+蓝色按钮" 的后台管理系统进行业务操作的。

可是汇报的时候,小小的列表就是关于 “实时监管” 的一个有力佐证。

1.2 为什么还得是 “无限滚动”?

但是普通列表有一些非常明显的弊端:

  • 它 有尽头

  • 它的滚动 没有质感

  • 它的衔接动画有 不连贯感

不理解?那我们看张图:

大屏经典组件:“无限滚动” 从分析到开发_第2张图片

你有没有发现,它存在以下问题?

  • 滚动是平缓的,没有节奏感。(相比于上面一次滚一行,然后停止若干时长后,进行下一次滚动)

  • 滚动到最后一行后,即使立刻滚动到顶部,依然会产生明显的 “不连贯感” 。

为了解决以上问题,于是有了一种更为优质的 视觉体验组件,它具备以下特性:

  • 它似乎 没有尽头
    (滚动时,第一条数据就贴合在最后一条数据的后面,依此类推)

  • 它的动画 连贯又流畅

  • 它的滚动 更有质感

它就是 无限滚动,一个常见又经典的大屏组件。

二、实现思路分析

2.1 需求分析

ok,明确了 “无限滚动” 的必要性,让我们看看,它应该具备哪些特性?

假设,你有一个长度为4的列表,长这样:

大屏经典组件:“无限滚动” 从分析到开发_第3张图片

那么它应该具备以下特性:

  1. 每次花费 N 秒滚动一单元格长度 (从A的上侧滚动到B的上侧)

  2. 每次滚动结束后停留 M 秒,方便参观者查看数据。

  3. 当 D 完全出现在视窗中之后,紧接着出现的应该是 A,然后是B,以此类推。

一个最简单的无限滚动组件,最少应该具备以上三个特性。

接下来,就是头脑风暴的时间了:

无限滚动的列表,究竟应该如何实现?

2.2 思路A:修改元素排序

大屏经典组件:“无限滚动” 从分析到开发_第4张图片

这是最直观的思路,我们只持有原列表本身,通过滚动到一定阶段,调整的顺序,来完成 “无限” 的效果。

但是很可惜,这个方案:

存在较大弊端

比如,当视窗大小只略小于列表大小时,就会出现这种情况:

大屏经典组件:“无限滚动” 从分析到开发_第5张图片

即:A元素,既要出现在顶端,但同时也要出现在尾端。

这样一来,单纯排序就无法完全满足诉求了。

2.3 思路B:不仅排序,还复制元素

为了解决上面 思路A 存在的问题,我们可以考虑通过 Node.cloneNode() 方式拷贝一个元素,手动让页面上同时存在两个A元素,一头一尾,就能补全上面那个场景的问题了。

大屏经典组件:“无限滚动” 从分析到开发_第6张图片

但是,很可惜,这一方法也存在问题:

MDN云:

克隆一个元素节点会拷贝它所有的属性以及属性值,当然也就包括了属性上绑定的事件 (比如οnclick="alert(1)"),但不会拷贝那些使用addEventListener()方法或者node.onclick = fn这种用 JavaScript 动态绑定的事件。

简单来说,事件丢了。
最核心你的一点在于,通过改变元素结构来实现无限滚动这种方式,和 ReactVue 等集成了虚拟 DOM 的框架搭配使用时,也会遇到各种各样的结构同步的问题,会急剧增加框架的复杂性。

那么,有没有更简单的方法呢?

2.4 方案C:双倍的快乐

众所周知:

动画是欺骗眼睛的艺术。

在帧与帧之间,画面其实是割裂的,人眼所能感知的最短时间大概是 30ms,也就是说,如果按 30ms 作为间隔改变画面的形态,人眼就会认为画面是 连续的

因此,很多你看到的效果,其实都是在 欺骗你的眼睛

比如,你用两个完全相同的列表,就可以实现肉眼意义上的 无限滚动

大屏经典组件:“无限滚动” 从分析到开发_第7张图片

如上图。

思路其实是:

  1. 两个完全相同的列表垂直排列,从头开始向下滚动。

  2. 当第一个列表的下端达到视窗的上端时(此时它已经不可见了),立刻让第一个列表滚动到上端与视窗的上端重合。

  3. 重复第一步

之所以,这个思路可行,有两个关键点:

  • 第2步改变状态前后,组件的视窗内看到的内容是一样的。

  • 第2步改变状态时,因为第二步是在瞬间完成的,并没有滚动过程,因此用户不会感知到发生过状态改变。

因此,用户就能一直感觉到: “这个列表在向下无限地滚动”

相比于 “方案A” 和 “方案B”,此方案最大的优势就在于:

  • 它首先不需要改变元素的顺序

  • 它也不需要去通过 cloneNode 复制单个元素

借用 props.children (react) 或者  * 2 (vue),你就能轻易获得两份具备事件绑定的元素,逻辑简单又粗暴,不用编写复杂的代码。

综上所述,就用最轻轻松松的一笔,毁掉你所有的问题,我都选C,我都选C!

三、核心编码实现

Talk is cheap,show me your money code。

3.1 准备生产工具

首先,因为本系列都基于 vue3,因此,有一个可运作的 [email protected] 环境是必要的,至于是 webpack 或是 vite 并不重要。

甚至可以是一个 UI 库脚手架。(文末提供的 demo 会是这种形式的。)

{

  "dependencies": {
    "gsap": "latest", // 我最顺手的动画库,当然你也可以选tween.js或者纯手写。
    "@vueuse/core": "latest", // vuer 必备的hooks工具库
  }
}

ok,需要依赖的外部包就这些,接下来让我们开始建造。

3.2 元素布局设计

让我们思考组件的元素布局,在我的规划中,它大概长这样:

大屏经典组件:“无限滚动” 从分析到开发_第8张图片

在类名设计上,我们采用业内组件开发最常用的 BEM 规范 (参考链接),由外到内,分别是:

  • .seamless-scroll:组件最外层元素。

  • .seamless-scroll__wrapper:具备 position: relative 和 宽高100% 的元素,目的是充满父元素。

    之所以采用这种冗余的布局方式,是为了满足更多场景的使用,比如.seamless-scroll的 position 不应该被限定,可以使用 absolutefixedrelative 等各种奇奇怪怪的布局。而 .seamless-scroll__wrapper 可以保证自身永远是 relative 状态的。

  • .seamless-scroll__box: 高度不受限的控件,它会在 .seamless-scroll__wrapper 的怀抱中滚动。

  • .seamless-scroll__box-top 和 .seamless-scroll__box-bottom 就是那两份一模一样的列表的容器,它们的高度来自于列表项的撑起。

3.3 API 设计

由于本文主要以讲解为主,目标不是做一个 “可以应对各种场景的组件”,因此我们只解决单一场景,所以 API 的设计上追求极致的简单:

const props = defineProps({
   /**
    * 两次滑动之间的停顿时长
    */
  delay: {
    type: Number,
    default: 1
  },
  /**
   * 滑动单位距离需要的时间
   */
  duration: {
    type: Number,
    default: 2
  }
})

以及,提供了一个默认插槽。

在这个插槽中,使用者可以去放列表的元素,它们各有各的高度和样式,这不应该是我们 无限滚动应该接管的内容 去接管的内容, 所以通过插槽的形式暴露出去。

大屏经典组件:“无限滚动” 从分析到开发_第9张图片

3.4 DOM 结构及关键 CSS

关于 DOM 结构,只需要按本文 3.23.3 两个小节设计的思路,对照以下这张图就可以轻松完成构建:

大屏经典组件:“无限滚动” 从分析到开发_第10张图片


  const wrapperRef = ref(null)
  const boxRef = ref(null)
  const topRef = ref(null)


  .seamless-scroll {
    &__wrapper {
      width: 100%;
      height: 100%;
      position: relative;
      overflow: hidden; // 我们希望wrapper滚动,但不希望他露出丑陋的滚动条
    }
    &__box {
      &-top,
      &-bottom {
        overflow: hidden;
      }
    }
  }

另外有个小 TIPS:

关于封装组件时,