📖阅读本文,你将
- 理解大屏 “无限滚动组件” 的开发思路
- 跟随作者,一步步完成一个高性能 “无限滚动组件” 的开发
- 收获一份该实现的粗糙源码。
一、无限滚动:事件/告警 的有力帮手
1.1 为什么需要滚动列表
大屏之所以 “炫酷” ,相比于 UI
同学出的效果图,它最大的优势就在于 它能动。
哪怕平台可能没有接入 websocket
,甚至数据就是静态写死的,客户依然希望数据能在屏幕上 “动起来”。
这会给人一种 “数据是实时的” 的错觉。
这种错觉,或者说故意营造出来的错觉,就是领导们 “讲故事” 的素材之一。
尤其是当业务里涉及到 “事件/告警/威胁/监控” 等元素时,涉及到的数据量很大 —— 几百或几千条,此时,会自己滚动的列表就成了非常适合场景的组件形式:
“ 我们相关部门单据的申请和审批情况也会实时推送到系统中,可以做到实时把控。 ”
——领导如此向上介绍。
虽然大家都明白,但是谁会整天没事盯着一个深色的大屏做监管呢?这么炫酷的大屏,电脑不卡吗?
正经人都是用 "白色底+蓝色按钮" 的后台管理系统进行业务操作的。
可是汇报的时候,小小的列表就是关于 “实时监管” 的一个有力佐证。
1.2 为什么还得是 “无限滚动”?
但是普通列表有一些非常明显的弊端:
- 它 有尽头
- 它的滚动 没有质感
- 它的衔接动画有 不连贯感
不理解?那我们看张图:
你有没有发现,它存在以下问题?
-
滚动是平缓的,没有节奏感。(相比于上面一次滚一行,然后停止若干时长后,进行下一次滚动)
-
滚动到最后一行后,即使立刻滚动到顶部,依然会产生明显的 “不连贯感” 。
为了解决以上问题,于是有了一种更为优质的 视觉体验组件,它具备以下特性:
- 它似乎 没有尽头
(滚动时,第一条数据就贴合在最后一条数据的后面,依此类推) - 它的动画 连贯又流畅
- 它的滚动 更有质感
它就是 无限滚动,一个常见又经典的大屏组件。
二、实现思路分析
2.1 需求分析
ok,明确了 “无限滚动” 的必要性,让我们看看,它应该具备哪些特性?
假设,你有一个长度为4的列表,长这样:
那么它应该具备以下特性:
- 每次花费
N
秒滚动一单元格长度 (从A的上侧滚动到B的上侧) - 每次滚动结束后停留
M
秒,方便参观者查看数据。 - 当
D
完全出现在视窗中之后,紧接着出现的应该是 A,然后是B,以此类推。
一个最简单的无限滚动组件,最少应该具备以上三个特性。
接下来,就是头脑风暴的时间了:
无限滚动的列表,究竟应该如何实现?
2.2 思路A:修改元素排序
这是最直观的思路,我们只持有原列表本身,通过滚动到一定阶段,调整的顺序,来完成 “无限” 的效果。
但是很可惜,这个方案:
存在较大弊端。
比如,当视窗大小只略小于列表大小时,就会出现这种情况:
即:A元素,既要出现在顶端,但同时也要出现在尾端。
这样一来,单纯排序就无法完全满足诉求了。
2.3 思路B:不仅排序,还复制元素
为了解决上面 思路A 存在的问题,我们可以考虑通过 Node.cloneNode()
方式拷贝一个元素,手动让页面上同时存在两个A元素,一头一尾,就能补全上面那个场景的问题了。
但是,很可惜,这一方法也存在问题:
MDN云:
克隆一个元素节点会拷贝它所有的属性以及属性值,当然也就包括了属性上绑定的事件 (比如οnclick="alert(1)"),但不会拷贝那些使用addEventListener()方法或者node.onclick = fn这种用 JavaScript 动态绑定的事件。
简单来说,事件丢了。
最核心你的一点在于,通过改变元素结构来实现无限滚动这种方式,和 React
、Vue
等集成了虚拟 DOM
的框架搭配使用时,也会遇到各种各样的结构同步的问题,会急剧增加框架的复杂性。
那么,有没有更简单的方法呢?
2.4 方案C:双倍的快乐
众所周知:
动画是欺骗眼睛的艺术。
在帧与帧之间,画面其实是割裂的,人眼所能感知的最短时间大概是 30ms
,也就是说,如果按 30ms
作为间隔改变画面的形态,人眼就会认为画面是 连续的。
因此,很多你看到的效果,其实都是在 欺骗你的眼睛。
比如,你用两个完全相同的列表,就可以实现肉眼意义上的 无限滚动。
如上图。
思路其实是:
- 两个完全相同的列表垂直排列,从头开始向下滚动。
- 当第一个列表的下端达到视窗的上端时(此时它已经不可见了),立刻让第一个列表滚动到上端与视窗的上端重合。
- 重复第一步
之所以,这个思路可行,有两个关键点:
- 第2步改变状态前后,组件的视窗内看到的内容是一样的。
- 第2步改变状态时,因为第二步是在瞬间完成的,并没有滚动过程,因此用户不会感知到发生过状态改变。
因此,用户就能一直感觉到: “这个列表在向下无限地滚动”。
相比于 “方案A” 和 “方案B”,此方案最大的优势就在于:
- 它首先不需要改变元素的顺序
- 它也不需要去通过
cloneNode
复制单个元素
借用 props.children
(react) 或者 <slot></slot>
* 2 (vue),你就能轻易获得两份具备事件绑定的元素,逻辑简单又粗暴,不用编写复杂的代码。
综上所述,就用最轻轻松松的一笔,毁掉你所有的问题,我都选C,我都选C!
三、核心编码实现
Talk is cheap,show me your money code。
3.1 准备生产工具
首先,因为本系列都基于 vue3
,因此,有一个可运作的 vue@3.x
环境是必要的,至于是 webpack
或是 vite
并不重要。
甚至可以是一个 UI
库脚手架。(文末提供的 demo
会是这种形式的。)
{
"dependencies": {
"gsap": "latest", // 我最顺手的动画库,当然你也可以选tween.js或者纯手写。
"@vueuse/core": "latest", // vuer 必备的hooks工具库
}
}
复制代码
ok,需要依赖的外部包就这些,接下来让我们开始建造。
3.2 元素布局设计
让我们思考组件的元素布局,在我的规划中,它大概长这样:
在类名设计上,我们采用业内组件开发最常用的 BEM
规范 (参考链接),由外到内,分别是:
-
.seamless-scroll
:组件最外层元素。 -
.seamless-scroll__wrapper
:具备position: relative
和宽高100%
的元素,目的是充满父元素。之所以采用这种冗余的布局方式,是为了满足更多场景的使用,比如
.seamless-scroll
的position
不应该被限定,可以使用absolute
、fixed
、relative
等各种奇奇怪怪的布局。而.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
}
})
复制代码
以及,提供了一个默认插槽。
<slot></slot>
复制代码
在这个插槽中,使用者可以去放列表的元素,它们各有各的高度和样式,这不应该是我们 无限滚动应该接管的内容 去接管的内容, 所以通过插槽的形式暴露出去。
3.4 DOM
结构及关键 CSS
关于 DOM
结构,只需要按本文 3.2
、3.3
两个小节设计的思路,对照以下这张图就可以轻松完成构建:
<template>
<div class="seamless-scroll">
<div ref="wrapperRef" class="seamless-scroll__wrapper">
<div ref="boxRef" class="seamless-scroll__box">
<div class="seamless-scroll__box-top" ref="topRef">
<slot></slot>
</div>
<div class="seamless-scroll__box-bottom">
<slot></slot>
</div>
</div>
</div>
</div>
</template>
<script setup>
const wrapperRef = ref(null)
const boxRef = ref(null)
const topRef = ref(null)
</script>
<style lang="scss">
.seamless-scroll {
&__wrapper {
width: 100%;
height: 100%;
position: relative;
overflow: hidden; // 我们希望wrapper滚动,但不希望他露出丑陋的滚动条
}
&__box {
&-top,
&-bottom {
overflow: hidden;
}
}
}
</style>
复制代码
另外有个小 TIPS
:
关于封装组件时,
<style>
标签要不要使用scoped
,我的建议是 “不要用scoped
,要用BEM CSS
命名规范”,这样的好处在于方便其他组建对其引用样式,进行样式覆盖时,不会陷入CSS
权重竟态问题。(不得不说,vue scoped
相关机制,在这方面比react css module
更友好一点点)
3.5 让列表滚动起来
如果你有过使用 tweenjs 或者 gsap 这类动画库,你就能够明白,它们所做的最终要的一件事,就叫做 补间。
所谓 补间 的意思,就是:
你指定一个对象,从 状态A,耗费 固定时间,以 特定的方式 变化到 状态B。而之后该对象在每一帧的表现,就不再需要由你关注,相关的工具会自动计算出每帧对象的中间状态,并完成显示。
理解了这一点,我们就能很好地想到,列表的 平滑滚动,其实就是把上面漫画里的 top
改成 scrollTop
的过程。
MDN ScrollTop 相关文档在此
所以,我们让列表滚动的核心代码,如下:
import gsap from 'gsap'
onMounted(() => {
const timeLine = gsap.timeline() // 为了后续更复杂的时间线安排,我们引入了 gsap 的 timeline
timeLine.to(wrapperRef.value, { scrollTop: 200, duration: props.duration }, `+=${props.delay}`)
})
复制代码
就可以初步达到如下效果:
3.6 让列表 有质感地滚动
所谓 有质感 的滚动,其实是指 一行一行 地滚动。
所以,每一次滚动之前,我们都需要获得列表的 元素们,但我们是通过插槽形式插入的列表,应该怎么在 vue3
里获得这些元素呢?
const nodeList = topRef.value.childNodes
const nodeArr = Array.from(nodeList.values()).filter(t => t.nodeType === Node.ELEMENT_NODE)
复制代码
之所以要经过一轮 filter,是要排除掉那些空格文本(它们的
nodeType
是Node.TEXT_NODE
)
再通过维护一个 scrollingElIndex
变量作为下标,记录当前滚动元素的 index
,就能准确获得:“这一次,我应该滚多远” 这一重要信息。代码如下:
let scrollingElIndex = 0;
const currentScrollingEl = nodeArr[scrollingElIndex];
scrollingElIndex = (scrollingElIndex + 1) % nodeArr.length; // 取完记得让 `scrollingElIndex` 下标+1,但只能在元素个数之内循环
复制代码
接下来,我们需要计算元素的高度,此时,我推荐使用 getBoundingClientRect
方法,它和 clientHeight
的最大区别在于:它包含border,这会大大降低我们计算每个子元素高度的复杂度。
代码如下:
let rect = currentScrollingEl.getBoundingClientRect();
const elHeight = rect.height
const offsetTop = currentScrollingEl.offsetTop
const scrollTarget = offsetTop + elHeight;
复制代码
上面代码片段里获取到的
scrollTarget
就是此次滚动我们需要滚动到的scrollTop
的值。
使用这个思路,就可以很容易得到如下效果:
3.6 让列表无限滚动
为了让列表达成无限滚动,按照我们 2.4 方案C:双倍的快乐 这一节的思路分析,其实核心就在于:
当上半部分的列表滚动到最后一个元素后,需要立刻让其恢复到初始位置。
这里只需要判断元素下标是否为 0
即可,非常容易:
if (scrollingElIndex === 0) {
gsap.to(wrapperRef.value, {
scrollTop: 0, duration: 0, onComplete: () => {
genAnimates()// 先滚动到顶端再思考下一步动画
}
})
}
复制代码
上面的动画看似流畅,但其中已经包含了一次 偷梁换柱。在这个过程中,列表实际上就已经具备了 无限滚动的能力。
四、一些补充能力
-
当列表高度过小时,应避免滚动,这时候就不应该通过
<slot></slot>
再复制一份元素了。代码略,可参考文末源码
-
当鼠标移动到列表上之后,停止滚动,移出去后接着滚动,这对
gsap.timeline
来说小菜一碟。const onMouseOver = () => { timeLine.pause() } const onMouseOut = () => { timeLine.resume() } 复制代码
-
给元素一个 奇偶数 的状态类名
之所以需要给这个,是为了后续进行样式覆写,完成 斑马线 等效果,因为当
box-top
和box-bottom
这两个列表同时存在时,它们的子元素为奇数,以及子元素为偶数,所需要覆写样式的思路会出现偏差。
五、DEMO
& 文档
为了写这一专栏,本菜鸡专门起了一个 基于 vitepress
的 vue3
微型组件库,用来放置相关代码,以及相关文档。(特此感谢:dewfall123/ruabick 提供的脚手架 ,看库名就知道是一个老 DOTA2
玩家了)
当你需要在项目中使用到类似效果时,除了使用对你而言几乎是完全 黑盒 的开源库之外,你还可以参考本文,自行造轮子,自行沉淀组件库,并收获一个可视化开发上的小经验。
文档地址: windstorm-ui SeamlessScroll组件
源码地址:github.com/zhangshichu…