天天看點

大屏經典元件:“無限滾動” 從分析到開發

📖閱讀本文,你将

  1. 了解大屏 “無限滾動元件” 的開發思路
  2. 跟随作者,一步步完成一個高性能 “無限滾動元件” 的開發
  3. 收獲一份該實作的粗糙源碼。

一、無限滾動:事件/告警 的有力幫手

1.1 為什麼需要滾動清單

大屏之是以 “炫酷” ,相比于 ​

​UI​

​ 同學出的效果圖,它最大的優勢就在于 它能動。

哪怕平台可能沒有接入 ​

​websocket​

​,甚至資料就是靜态寫死的,客戶依然希望資料能在螢幕上 “動起來”。

這會給人一種 “資料是實時的” 的錯覺。

這種錯覺,或者說故意營造出來的錯覺,就是上司們 “講故事” 的素材之一。

尤其是當業務裡涉及到 “事件/告警/威脅/監控” 等元素時,涉及到的資料量很大 —— 幾百或幾千條,此時,會自己滾動的清單就成了非常适合場景的元件形式:

大屏經典元件:“無限滾動” 從分析到開發

“ 我們相關部門單據的申請和審批情況也會實時推送到系統中,可以做到實時把控。 ”

——上司如此向上介紹。

雖然大家都明白,但是誰會整天沒事盯着一個深色的大屏做監管呢?這麼炫酷的大屏,電腦不卡嗎?

正經人都是用 "白色底+藍色按鈕" 的背景管理系統進行業務操作的。

可是彙報的時候,小小的清單就是關于 “實時監管” 的一個有力佐證。

1.2 為什麼還得是 “無限滾動”?

但是普通清單有一些非常明顯的弊端:

  • 它 有盡頭
  • 它的滾動 沒有質感
  • 它的銜接動畫有 不連貫感

不了解?那我們看張圖:

大屏經典元件:“無限滾動” 從分析到開發

你有沒有發現,它存在以下問題?

  • 滾動是平緩的,沒有節奏感。(相比于上面一次滾一行,然後停止若幹時長後,進行下一次滾動)
  • 滾動到最後一行後,即使立刻滾動到頂部,依然會産生明顯的 “不連貫感” 。

為了解決以上問題,于是有了一種更為優質的 視覺體驗元件,它具備以下特性:

  • 它似乎 沒有盡頭

    (滾動時,第一條資料就貼合在最後一條資料的後面,依此類推)

  • 它的動畫 連貫又流暢
  • 它的滾動 更有質感

它就是 無限滾動,一個常見又經典的大屏元件。

二、實作思路分析

2.1 需求分析

ok,明确了 “無限滾動” 的必要性,讓我們看看,它應該具備哪些特性?

假設,你有一個長度為4的清單,長這樣:

大屏經典元件:“無限滾動” 從分析到開發

那麼它應該具備以下特性:

  1. 每次花費 ​

    ​N​

    ​ 秒滾動一單元格長度 (從A的上側滾動到B的上側)
  2. 每次滾動結束後停留 ​

    ​M​

    ​ 秒,友善參觀者檢視資料。
  3. 當 ​

    ​D​

    ​ 完全出現在視窗中之後,緊接着出現的應該是 A,然後是B,以此類推。

一個最簡單的無限滾動元件,最少應該具備以上三個特性。

接下來,就是頭腦風暴的時間了:

無限滾動的清單,究竟應該如何實作?

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​

​ 作為間隔改變畫面的形态,人眼就會認為畫面是 連續的。

是以,很多你看到的效果,其實都是在 欺騙你的眼睛。

比如,你用兩個完全相同的清單,就可以實作肉眼意義上的 無限滾動。

大屏經典元件:“無限滾動” 從分析到開發

如上圖。

思路其實是:

  1. 兩個完全相同的清單垂直排列,從頭開始向下滾動。
  2. 當第一個清單的下端達到視窗的上端時(此時它已經不可見了),立刻讓第一個清單滾動到上端與視窗的上端重合。
  3. 重複第一步

之是以,這個思路可行,有兩個關鍵點:

  • 第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​

​​,是以,有一個可運作的 ​

[email protected]

​​ 環境是必要的,至于是 ​

​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()
}      
  • 給元素一個 奇偶數 的狀态類名

五、​

​DEMO​

​ & 文檔

繼續閱讀