📖閱讀本文,你将
- 了解大屏 “無限滾動元件” 的開發思路
- 跟随作者,一步步完成一個高性能 “無限滾動元件” 的開發
- 收獲一份該實作的粗糙源碼。
一、無限滾動:事件/告警 的有力幫手
1.1 為什麼需要滾動清單
大屏之是以 “炫酷” ,相比于
UI
同學出的效果圖,它最大的優勢就在于 它能動。
哪怕平台可能沒有接入
websocket
,甚至資料就是靜态寫死的,客戶依然希望資料能在螢幕上 “動起來”。
這會給人一種 “資料是實時的” 的錯覺。
這種錯覺,或者說故意營造出來的錯覺,就是上司們 “講故事” 的素材之一。
尤其是當業務裡涉及到 “事件/告警/威脅/監控” 等元素時,涉及到的資料量很大 —— 幾百或幾千條,此時,會自己滾動的清單就成了非常适合場景的元件形式:
“ 我們相關部門單據的申請和審批情況也會實時推送到系統中,可以做到實時把控。 ”
——上司如此向上介紹。
雖然大家都明白,但是誰會整天沒事盯着一個深色的大屏做監管呢?這麼炫酷的大屏,電腦不卡嗎?
正經人都是用 "白色底+藍色按鈕" 的背景管理系統進行業務操作的。
可是彙報的時候,小小的清單就是關于 “實時監管” 的一個有力佐證。
1.2 為什麼還得是 “無限滾動”?
但是普通清單有一些非常明顯的弊端:
- 它 有盡頭
- 它的滾動 沒有質感
- 它的銜接動畫有 不連貫感
不了解?那我們看張圖:
你有沒有發現,它存在以下問題?
- 滾動是平緩的,沒有節奏感。(相比于上面一次滾一行,然後停止若幹時長後,進行下一次滾動)
- 滾動到最後一行後,即使立刻滾動到頂部,依然會産生明顯的 “不連貫感” 。
為了解決以上問題,于是有了一種更為優質的 視覺體驗元件,它具備以下特性:
-
它似乎 沒有盡頭
(滾動時,第一條資料就貼合在最後一條資料的後面,依此類推)
- 它的動畫 連貫又流暢
- 它的滾動 更有質感
它就是 無限滾動,一個常見又經典的大屏元件。
二、實作思路分析
2.1 需求分析
ok,明确了 “無限滾動” 的必要性,讓我們看看,它應該具備哪些特性?
假設,你有一個長度為4的清單,長這樣:
那麼它應該具備以下特性:
- 每次花費
秒滾動一單元格長度 (從A的上側滾動到B的上側)N
- 每次滾動結束後停留
秒,友善參觀者檢視資料。M
- 當
完全出現在視窗中之後,緊接着出現的應該是 A,然後是B,以此類推。D
一個最簡單的無限滾動元件,最少應該具備以上三個特性。
接下來,就是頭腦風暴的時間了:
無限滾動的清單,究竟應該如何實作?
2.2 思路A:修改元素排序
這是最直覺的思路,我們隻持有原清單本身,通過滾動到一定階段,調整的順序,來完成 “無限” 的效果。
但是很可惜,這個方案:
存在較大弊端。
比如,當視窗大小隻略小于清單大小時,就會出現這種情況:
即:A元素,既要出現在頂端,但同時也要出現在尾端。
這樣一來,單純排序就無法完全滿足訴求了。
2.3 思路B:不僅排序,還複制元素
為了解決上面 思路A 存在的問題,我們可以考慮通過
Node.cloneNode()
方式拷貝一個元素,手動讓頁面上同時存在兩個A元素,一頭一尾,就能補全上面那個場景的問題了。
但是,很可惜,這一方法也存在問題:
MDN雲:
克隆一個元素節點會拷貝它所有的屬性以及屬性值,當然也就包括了屬性上綁定的事件 (比如notallow="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
,是以,有一個可運作的
環境是必要的,至于是
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
由于本文主要以講解為主,目标不是做一個 “可以應對各種場景的元件”,是以我們隻解決單一場景,是以
API
的設計上追求極緻的簡單:
const props = defineProps({
/**
* 兩次滑動之間的停頓時長
*/
delay: {
type: Number,
default: 1
},
/**
* 滑動機關距離需要的時間
*/
duration: {
type: Number,
default: 2
}
})
以及,提供了一個預設插槽。
<slot></slot>
在這個插槽中,使用者可以去放清單的元素,它們各有各的高度和樣式,這不應該是我們 無限滾動應該接管的内容 去接管的内容, 是以通過插槽的形式暴露出去。
3.4 DOM
結構及關鍵 CSS
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()
}
- 給元素一個 奇偶數 的狀态類名
五、 DEMO
& 文檔
DEMO