一個簡潔、有趣的無限下拉方案

本文主旨

長列表渲染、無限下拉也算是前端開發老生常談的問題之一了,本文將介紹一種簡潔、巧妙、高效的方式來實現。話很少說,看下圖,也許你能夠發現什麼?前端

無限下拉示意圖

不知你是否從上面這張圖中注意到了什麼,好比只是渲染了可視區域的部分 DOM ,滾動過程當中只是外層容器的 padding 在改變?git

前一點很好理解,咱們考慮到性能,不可能將一個長列表(甚至是一個無限下拉列表)的全部列表元素都進行渲染;然後一點,則是本文所介紹方案的核心之一!github

不賣關子,提早告訴你該方案的要素就是兩個:web

  • Intersection Observer
  • padding

說明了要素,也許你能夠嘗試着開始思考,看你是否能猜到具體的實現方案。npm

方案介紹

Intersection Observer

基本概念

一直以來,檢測元素的可視狀態或者兩個元素的相對可視狀態都不是件容易事。傳統的各類方案不但複雜,並且性能成本很高,好比須要監聽滾動事件,而後查詢 DOM , 獲取元素高度、位置,計算距離視窗高度等等。數組

這就是 Intersection Observer 要解決的問題。它爲開發人員提供一種便捷的新方法來異步查詢元素相對於其餘元素或視窗的位置,消除了昂貴的 DOM 查詢和樣式讀取成本。緩存

兼容性

主要在 Safari 上兼容性較差,須要 12.2 及以上才兼容,不過還好,有 polyfill 可食用。dom

一些應用場景

  • 頁面滾動時的懶加載實現。
  • 無限下拉(本文的實現)。
  • 監測某些廣告元素的曝光狀況來作相關數據統計。
  • 監測用戶的滾動行爲是否到達了目標位置來實現一些交互邏輯(好比視頻元素滾動到隱藏位置時暫停播放)。

padding 方案實現

基本瞭解 Intersection Observer 以後,接下來就看下如何用 Intersection Observer + padding 來實現無限下拉。異步

先概覽下整體思路:函數

  • 監聽一個固定長度列表的首尾元素是否進入視窗;
  • 更新當前頁面內渲染的第一個元素對應的序號;
  • 根據上述序號,獲取目標數據元素,列表內容從新渲染成對應內容;
  • 容器 padding 調整,模擬滾動實現。

核心:利用父元素的 padding 去填充隨着無限下拉而本該有的、愈來愈多的 DOM 元素,僅僅保留視窗區域上下必定數量的 DOM 元素來進行數據渲染

一、監聽一個固定長度列表的首尾元素是否進入視窗

// 觀察者建立
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) {
            // 當最後一個元素進入視窗
        }
    });
};

二、更新當前頁面渲染的第一個元素對應的序號 (firstIndex)

拿具體例子來講明,咱們用一個數組來維護須要渲染到頁面中的數據。數組的長度會隨着不斷請求新的數據而不斷變大,而渲染的始終是其中必定數量的元素,好比 20 個。
那麼:

  • 一、最開始渲染的是數組中序號爲 0 - 19 的元素,即此時對應的 firstIndex 爲 0;
  • 二、當序號爲 19 的元素(即上一步的 lastItem )進入視窗時,咱們就會日後渲染 10 個元素,即渲染序號爲 10 - 29 的元素,那麼此時的 firstIndex 爲 10;
  • 三、下一次就是,當序號爲 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,是爲了根據頁面的滾動狀況,知道接下來哪些數據應該被獲取、渲染。

三、根據上述序號,獲取對應數據元素,列表從新渲染成新的內容

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

這一部分就是根據 firstIndex 查詢數據,而後將目標數據渲染到頁面上便可。

四、padding 調整,模擬滾動實現

既然數據的更新以及 DOM 元素的更新咱們已經實現了,那麼無限下拉的效果以及滾動的體驗,咱們要如何實現呢?

想象一下,拋開一切,最原始最直接最粗暴的方式無非就是咱們再又獲取了 10 個新的數據元素以後,再塞 10 個新的 DOM 元素到頁面中去來渲染這些數據。

但此時,對比上面這個粗暴的方案,咱們的方案是:這 10個新的數據元素,咱們用原來已有的 DOM 元素去渲染,替換掉已經離開視窗、不可見的數據元素;而本該由更多 DOM 元素進一步撐開容器高度的部分,咱們用 padding 填充來模擬實現。

img

  • 向下滾動
// 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;
    }
}

向下滾動示意圖

  • 向上滾動
// 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;
    }
}

向上滾動示意圖

  • 最後是 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 的計算調整,沒法計算異步獲取的數據,只跟用戶的滾動行爲有關。這看起來與實際業務場景有些不符。解決思路:

    • 思路 一、利用 Skeleton Screen Loading 來同步渲染數據元素,不受數據異步獲取的影響。即在數據請求還未完成時,先使用一些圖片進行佔位,待內容加載完成以後再進行替換。
    • 思路 二、滾動到目標位置,阻塞容器 padding 的設置(即無限下拉的發生)直至數據請求完畢,用 loading gif 提示用戶加載狀態,但這個方案相對複雜,你須要全面考慮用戶難以預測的滾動行爲來設置容器的 padding。

延伸拓展

  • 請你們思考一下,無限下拉有了,那麼無限上拉基於這種方案要如何調整實現呢?
  • 若是將 Intersection Observer 用到 iScroll 裏面去,原有方案能夠怎樣優化?

代碼實現

參考文章

本文發佈自 網易雲音樂前端團隊,歡迎自由轉載,轉載請保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索