淺談 React 列表渲染

?? React 的列表渲染

key 與 Reconciliation

List and Keys - React
Reconciliation - React

React 在渲染列表時痊硕,會要求開發(fā)者為每一個列表元素指定唯一的 key懊亡,以幫助 React 識別哪些元素是新添加的,哪些元素被修改或刪除了楣黍。

通常情況下钾怔,Reconciliation 算法遞歸遍歷歷某個 DOM 節(jié)點(diǎn)的全部子節(jié)點(diǎn)轨香,以保證改變能夠正確的被應(yīng)用。這樣的方式在大多數(shù)場景下沒有問題仪或,但是對于列表來講确镊,如果在列表中添加了新元素,或者某個元素被刪除范删,可能會導(dǎo)致整個列表被重新渲染蕾域。

舉一個文檔中的例子:

List without Keys

在添加 Connecticut 節(jié)點(diǎn)時,React 會修改 Duke 和 Villanova 到旦,再添加一個 Villanova旨巷,而不是直接把 Connecticut 插入到列表頭部。

當(dāng)列表元素被指定了 key 時添忘,React 會利用 key 準(zhǔn)確的進(jìn)行 Diffing 操作采呐,而不是粗暴地逐個比較,以至于重新渲染整個列表搁骑。

使用 index 直接當(dāng) key 會帶來哪些風(fēng)險斧吐?

正因為 React 會使用 key 來識別列表元素,當(dāng)元素的 key 發(fā)生改變的時候仲器,可能會導(dǎo)致 React 的 Diffing 執(zhí)行在錯誤的元素上煤率,甚至導(dǎo)致狀態(tài)錯亂。這種情況在使用 index 的時候尤其常見乏冀。有一個很好的例子

Original State
New Item Added

可以看到蝶糯,input 的狀態(tài)發(fā)生了錯亂,"qwerty" 應(yīng)該在第二個 input 里才對煤辨。

不過裳涛, React key 的作用域僅限于當(dāng)前列表內(nèi)木张,所以我們并不需要絞盡腦汁為每一個列表的每一個元素都生成全局唯一的 key(提示:在 React 中,如果一個容器同時渲染了上下兩個列表或者多個列表端三,不需要保證列表間的 key 唯一舷礼,單個列表 key 唯一即可)。

長列表的事件綁定策略

我記得我參加校招的時候郊闯,經(jīng)常會被問到一道很經(jīng)典的面試題:如何為一個長列表綁定點(diǎn)擊事件(綁定在父元素上 / 為各列表元素單獨(dú)綁定)妻献?理由是?

在原生 DOM 列表上為列表元素綁定事件時团赁,我們通常會將事件綁定在父級元素上育拨,借助事件冒泡機(jī)制捕獲事件,以降低事件綁定開銷欢摄。React 對事件處理進(jìn)行了優(yōu)化熬丧,通常情況下,我們不需要對長列表事件綁定策略進(jìn)行優(yōu)化怀挠。

SyntheticEvent

熟悉 React 的同學(xué)都知道析蝴,React 使用 SyntheticEvent 代理了瀏覽器的原生事件,目的在于提供更通用更高效的事件策略绿淋。

SyntheticEvent 相較于原生 DOM 事件做了如下優(yōu)化:

  1. 統(tǒng)一 API
  2. 補(bǔ)充了一些瀏覽器原生沒有實現(xiàn)的事件
  3. Event Pooling

其中最重要的就是 Event Pooling闷畸。在綁定事件時,React 不會直接在對應(yīng)的 DOM 節(jié)點(diǎn)上直接綁定事件吞滞,而是以「事件委托(Event Delegation)」的方式佑菩,將事件委托到 document 上;同時裁赠,SyntheticEvent 會被復(fù)用:在每次事件回調(diào)結(jié)束后殿漠,除非用戶顯式的聲明該 Event 對象應(yīng)當(dāng)被 presist(),否則 React 會回收該對象组贺,并在下一個事件回調(diào)中進(jìn)行復(fù)用凸舵。

關(guān)于 SyntheticEvent 的實現(xiàn)機(jī)制,有兩篇文章推薦:

React 在渲染長列表時的性能開銷

React 的長列表渲染的開銷失尖,與原生 DOM 沒有太大差別啊奄;其優(yōu)勢在于借助 Reconcilication 可以得出一個很高效的 DOM 更新策略。其性能開銷主要在以下幾個方面:

如果業(yè)務(wù)邏輯不是特別復(fù)雜掀潮,其開銷最大的過程通常是 DOM Rendering菇夸,而且,隨著列表越來越長仪吧,Rendering 的時間會越來越長庄新。

Profiling

?? 長列表渲染的優(yōu)化思路

使用 createDocumentFragment / innerHTML 替換 createElement

相關(guān)文檔:
DocumentFragment - Web API | MDN
createDocumentFragment - Web API | MDN

像 React 的 Virtual DOM 一樣,我們嘗試使用 DocumentFragment 替換完整的 Node,以壓縮 Scripting 的時間择诈。不過械蹋,在現(xiàn)代瀏覽器中,createElementcreateDocumentFragment 的開銷已經(jīng)沒什么差別了羞芍。

createElement
createDocumentFragment

如果不考慮生成列表元素可能包含的復(fù)雜業(yè)務(wù)邏輯哗戈,Scripting 的過程幾乎是在瞬間完成的,這種優(yōu)化實際收效甚微荷科。

懶加載

顧名思義唯咬,懶加載就是一開始不渲染太多的 DOM 節(jié)點(diǎn),隨著用戶滑動畏浆,逐步將 DOM 節(jié)點(diǎn)添加進(jìn)去胆胰。懶加載通常配合數(shù)據(jù)的分片加載一同進(jìn)行,即分步請求數(shù)據(jù) + 渲染 DOM 節(jié)點(diǎn)刻获。

Lazy Loading
Jul-26-2018 15-51-03.gif

但是蜀涨,如果一直掛載節(jié)點(diǎn),列表的 DOM 結(jié)構(gòu)會越來越大蝎毡,DOM 操作的開銷也會隨之增大勉盅,Rendering 的速度也會越來越慢。

僅渲染可視區(qū)域

既然 DOM 結(jié)構(gòu)越大顶掉,渲染速度越慢,那我們可以嘗試僅渲染用戶能夠看到的列表元素挑胸,盡可能控制 DOM 結(jié)構(gòu)的大小痒筒。

實現(xiàn)思路

  1. 獲取 container 的容器高度
  2. 計算 container 一屏能容納下的元素個數(shù)
  3. 創(chuàng)建一個 placeholder 把 container 的 scroll height 撐起來。
  4. 創(chuàng)建一個 list 放置生成的元素
  5. 滾動的時候:
    1. 獲取 scrollTop茬贵,計算 List 的 offset top簿透,保證 List 覆蓋 container 的可視區(qū)域。
    2. 通過 scrollTop 計算出當(dāng)前可視區(qū)域應(yīng)當(dāng)顯示的元素的 index解藻,根據(jù)計算結(jié)果渲染新的列表
    3. 使用 transform: translate3d(0, offsetHeight, 0) 調(diào)整 list 的位置老充,保證其在可視區(qū)域內(nèi)。
Visible Area Rendering

源碼片段

<div id="container">
    <!-- #list 是 position: absolute 的螟左,用來放置實際顯示的列表元素 -->
    <ul id="list"></ul>
    <!-- #content-placeholder 用來將整個列表區(qū)域撐開到渲染所有元素時應(yīng)有的高度 -->
    <div id="content-placeholder"></div>
</div>
// 列表元素高度
const ITEM_HEIGHT = 31
// 列表元素個數(shù)
const ITEM_COUNT = 500

window.onload = function () {
    const container = document.querySelector('#container')
    const containerHeight = container.clientHeight
    const list = document.querySelector('#list')
    // 一屏可以渲染下的元素個數(shù)
    const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT)
    const placeholder = document.querySelector('#content-placeholder')
    placeholder.style.height = ITEM_COUNT * ITEM_HEIGHT + 'px'
    // 首次渲染
    list.appendChild(renderNodes(0, visibleCount))
    container.addEventListener('scroll', function() {
        // 使用 translate3d 將可視列表調(diào)整到屏幕正中的位置
        list.style.webkitTransform = `translate3d(0, ${container.scrollTop - container.scrollTop % ITEM_HEIGHT}px, 0)`
        list.innerHTML = ''
        // 計算可視區(qū)域列表的起始元素的 index
        const firstIndex = Math.floor(container.scrollTop / ITEM_HEIGHT)
        list.appendChild(renderNodes(firstIndex, firstIndex + visibleCount))
    })
}

function renderNodes(from, to) {
    const fragment = document.createDocumentFragment()
    for (let i = from; i < to; i++) {
        const el = document.createElement('li')
        el.innerHTML = i + 1
        fragment.appendChild(el)
    }
    return fragment
}

基于「僅渲染可見區(qū)域」這個思路啡浊,我們可以進(jìn)行許多優(yōu)化。當(dāng)然胶背,也有現(xiàn)成的開源庫可供使用:Clusterized.js 和 React Virtualized巷嚣。

?? 開源的長列表渲染庫

主流開源庫實現(xiàn)長列表渲染的方式大同小異,除了決定渲染哪些元素的方式有一些不同外钳吟,其最大的區(qū)別在于如何將渲染出來的列表元素合理的放置在可視區(qū)域中廷粒。

Clusterize.js

Clusterize.js 基于 Vanilla.js (原生 JavaScript) ,用于在前端高效地展示大型數(shù)據(jù)集中的數(shù)據(jù)?。

渲染思路

Clusterize.js 將數(shù)據(jù)源劃分成了一塊一塊的「Cluster」坝茎。初次渲染時涤姊,Clusterize.js 會根據(jù)第一個 Cluster 具備的高度計算出整個列表的初始高度,并在列表下方使用 placeholder 撐開整個列表嗤放。每次渲染時思喊,Clusterize.js 會渲染足夠多的 Cluster 以覆蓋可見區(qū)域。在當(dāng)前 Cluster 離開可見區(qū)域后斤吐,該 Cluster 的高度會被添加到 .clusterize-top-space 上搔涝;同時,新渲染的 Cluster 的高度會從 .clusterize-bottom-space 上減掉和措。DOM 結(jié)構(gòu)大致如下圖:

Clusterize.js

Clusterize.js 是如何處理動態(tài)高度元素的庄呈?

Clusterize.js 強(qiáng)烈建議使用統(tǒng)一高度的數(shù)據(jù)項,但是由于其使用的渲染方式比較特別派阱,即便是高度不一的數(shù)據(jù)項也不會產(chǎn)生太大問題诬留。

React Virtualized

React Virtualized 是 React 生態(tài)鏈中最經(jīng)典、應(yīng)用最廣泛的長列表組件庫贫母。

渲染思路

React Virtualized 渲染的思路很清奇:它沒有使用 transition 對列表進(jìn)行定位文兑,而是對每一個列表元素單獨(dú)進(jìn)行定位,即每個列表元素都是 position: absolute 的腺劣,并且有自己的絕對定位坐標(biāo)绿贞。DOM 結(jié)構(gòu)大致如下圖:

React Virtualized

Aug-02-2018 12-20-49.gif

<List> 是如何處理動態(tài)高度元素的?

React Virtualized 的 <List> 支持動態(tài)高度元素的方式很暴力:它要求開發(fā)者手動傳入每一個元素的高度 / 計算元素高度的方法橘原。如果不傳入籍铁,需要配合使用 <CellMeasurer> 動態(tài)計算元素高度。親身體驗后趾断,感覺 React Virtualized 對動態(tài)元素高度的支持非常不友好拒名。

?? 進(jìn)階玩法:基于 IntersectionObserver 的長列表優(yōu)化

傳統(tǒng)長列表優(yōu)化方案的問題

  • 在長列表渲染中,我們最關(guān)心的是各節(jié)點(diǎn)與容器元素可見區(qū)域的層疊關(guān)系芋酌,scrollTop 增显、 clientHeightgetBoundingClientRect 等可以確定節(jié)點(diǎn)位置脐帝,也可以用來判斷節(jié)點(diǎn)是否應(yīng)當(dāng)被渲染同云,但是?,他們都會觸發(fā) reflow腮恩,性能開銷較大(詳見 What forces layout / reflow)梢杭。
  • 當(dāng)前的列表更新策略是基于 scroll 事件的,scroll 事件的頻繁觸發(fā)也會帶來一些性能開銷秸滴。

IntersectionObserver

相關(guān)文檔:
IntersectionObserver - Web APIs | MDN

IntersectionObserver 用來異步地觀察目標(biāo)元素與祖先元素(或視口)的相交關(guān)系武契。它的回調(diào)函數(shù)會在目標(biāo)元素與祖先元素相交的時候觸發(fā),回傳一個 IntersectionObserverEntry 數(shù)組,標(biāo)記相交時的狀態(tài)(開始相交還是開始脫離 / 目標(biāo)元素的位置信息和大小等)咒唆。

實現(xiàn)思路

大體思路和基于 scroll 事件的長列表渲染類似届垫,區(qū)別在于更新顯示區(qū)域內(nèi)容的方式不同。以下是具體步驟(React-based):

  1. 獲取 container 的容器高度
  2. 計算 container 一屏能容納下的元素個數(shù)
  3. 創(chuàng)建一個 placeholder全释,指定 min-height装处,把 container 的 scroll height 撐起來。
  4. 創(chuàng)建一個 list 保存顯示區(qū)域要渲染的數(shù)據(jù)
  5. 根據(jù)上面的 list 渲染到 placeholder 內(nèi)
  6. observer.observe(list.firstChild)
  7. 當(dāng)首部元素與 container 發(fā)生交叉(Intersection)時浸船,IntersectionObservercallback 會觸發(fā)妄迁,并傳入 IntersectionObserverEntry 實例,提供必要信息李命。
  8. 根據(jù) isIntersecting 判斷當(dāng)前元素的移動是離開了可視區(qū)域還是進(jìn)入了可視區(qū)域登淘;如果離開了可視區(qū)域,則更新 list封字,將首部元素移除黔州,在尾部添加一個新的元素(startIndex + 1),并執(zhí)行 .observe(el.nextSibling)阔籽;如果進(jìn)入了可視區(qū)域流妻,則在首部元素前再添加一個元素,執(zhí)行 .observe(el.previousSibling)笆制。
  9. placeholder 的實際顯示區(qū)域的位置绅这,使用 padding-top 來調(diào)整。
Scrolling
Reverse Scrolling

Jul-30-2018 22-20-21.gif

代碼片段

代碼寫的比較糙在辆,只是闡述一下思路君躺,實際還有不少 BUG 需要解決。

this.observer = new IntersectionObserver(entries => {
    if (this.firstExecute) {
        this.firstExecute = false
        return
    }

    const entry = entries[0]
    const {startIndex, placeHolderPadding} = this.state
    if (!entry.isIntersecting) {
        this.setState({
            startIndex: startIndex + 1,
            placeHolderPadding: placeHolderPadding + entry.boundingClientRect.height
        }, () => (this.firstExecute = true) && this.observer.observe(entry.target.nextSibling))
    } else {
        this.setState({
            startIndex: max(this.state.startIndex - 1, 0),
            placeHolderPadding: max(placeHolderPadding - entry.boundingClientRect.height, 0)
        }, () => (this.firstExecute = true) && entry.target.previousSibling && this.observer.observe(entry.target.previousSibling))
    }
})
render() {
    const {className, data, render} = this.props
    const {startIndex, visibleItemCount, placeholderHeight, placeHolderPadding} = this.state

    return (
        <div className={cx('List-Wrapper', className)}>
            <div className="List-Container" ref={el => this.containerEl = el}>
                <div className="List-Placeholder" style={{minHeight: placeholderHeight, paddingTop: placeHolderPadding}}>
                    {data.slice(startIndex, startIndex + visibleItemCount).map(render)}
                </div>
            </div>
        </div>
    )
}

? 更好的支持動態(tài)高度元素

React Virtualized 給出的方案中开缎,我們需要手動告訴 <List> 每個元素的高度,或者傳入計算方法林螃,這無疑會帶來很多限制奕删。大多數(shù)情況下,我們無法直接確定元素的實際高度疗认,只有當(dāng)元素渲染出來之后高度才能被確定下來完残。

理想狀態(tài)下,開發(fā)者無需傳入任何「高度」數(shù)據(jù)横漏,長列表組件只需根據(jù)傳入的數(shù)據(jù)源和 renderItem 方法谨设,即可自動計算出列表項高度。 嗎缎浇,長列表渲染有一個非常重要的部分扎拣,就是要保證 scroll 事件在真正到達(dá)列表尾部之前都能夠被觸發(fā)

Clusterize.js 將數(shù)據(jù)源拆分成了多個 Cluster,渲染的時候以 Cluster 為單位進(jìn)行掛載 / 解掛二蓝。這個思路很具有啟發(fā)性誉券。

實現(xiàn)思路

如果想保證 scroll 事件能一直觸發(fā),渲染「足夠多」的元素以撐開一個可滾動的區(qū)域是一個很好的主意刊愚。

當(dāng)然踊跟,元素太多就失去了長列表優(yōu)化的作用。我們可以使用「走馬燈」的思路鸥诽,循環(huán)渲染元素商玫,以達(dá)到復(fù)用可視區(qū)域、還原 Scroll Bar 位置 & 循環(huán)滾動的目的牡借。

實現(xiàn)思路

  1. 根據(jù)開發(fā)者設(shè)定的粒度拳昌,將傳入的 data 分為若干個 slice
  2. 從第 0 個 slice 開始,每次渲染三個 slice
  3. 監(jiān)?聽 scroll 事件蓖捶,當(dāng) slice[1] 的首部元素處于可視區(qū)域底部下方時地回,丟棄 slice[2],在 slice[0] 前插入新的 slice俊鱼;當(dāng) slice[1] 的尾部元素處于可視區(qū)域頂部上方時刻像,丟棄 slice[0],在 slice[2] 下方渲染新的 slice并闲。效果如圖:
Slices

可以看到细睡,DOM Tree 的更新頻率非常低,而且列表元素的高度也不需要提前計算帝火。不過這個方案的問題也很明顯:Scroll Bar 會上下跳溜徙。

為了解決 Scroll Bar 的問題,我們在 slice[0] 被丟棄之前犀填,將 slice[0] 的高度 pushtopSpaces 數(shù)組中蠢壹,將數(shù)組中數(shù)字的和作為 padding-top 設(shè)定在列表元素上。

代碼片段

// 正在切換 slice
processing = false
handleScroll = () => {
    if (this.processing) {
        return
    }

    if (!this.topBoundary || !this.bottomBoundary) {
        return
    }

    const topBoundaryLoc = this.topBoundary.getBoundingClientRect().top
    const bottomBoundaryLoc = this.bottomBoundary.getBoundingClientRect().top
    if (
        bottomBoundaryLoc < containerTop + sliceThreshold &&
        currentSliceIndex + 3 < slices.length
    ) {
        this.processing = true
        // 用 slice[0] 首部元素的坐標(biāo)和 slice[1] 首部元素的坐標(biāo)差確定 slice 的高度
        const startY = this.listEl.firstChild.getBoundingClientRect().top
        const topSpace = topBoundaryLoc - startY
        this.setState(
            {
                currentSliceIndex: currentSliceIndex + 1,
                topSpaces: topSpaces.concat(topSpace),
            },
            () => {
                this.bindBoundaryEls()
                this.processing = false
            }
        )
        return
    }
    
    const containerTop = this.containerEl.getBoundingClientRect().top
    const containerHeight = this.containerEl.clientHeight
    const {sliceThreshold} = this.props
    const {slices, currentSliceIndex, topSpaces} = this.state

    if (
        topBoundaryLoc > containerTop + containerHeight - sliceThreshold &&
        currentSliceIndex > 0
    ) {
        this.processing = true
        this.setState(
            {
                currentSliceIndex: currentSliceIndex - 1,
                topSpaces: topSpaces.slice(0, topSpaces.length - 1),
            },
            () => {
                this.bindBoundaryEls()
                this.processing = false
            }
        )
    }
}
get visibleData() {
    const {slices, currentSliceIndex} = this.state
    const visibleSlices = slices.slice(
        currentSliceIndex,
        currentSliceIndex + 3
    )
    const startIndex = visibleSlices[0].startIndex
    const amount = visibleSlices.reduce(
        (amount, slice) => slice.amount + amount,
        0
    )
    return data.slice(startIndex, startIndex + amount)
}

render() {
    const {className, placeholders, isDrained} = this.props
    const {topSpaces} = this.state
    return (
        <div
            className={cx(css['InfiniteLoader'], className)}
            ref={el => (this.rootEl = el)}
        >
            <div
                ref={el => (this.listEl = el)}
                style={{
                    paddingTop: `${topSpaces.reduce(
                        (total, curr) => curr + total,
                        0
                    )}px`,
                }}
            >
                {this.visibleData.map(this.renderItem)}
            </div>
        </div>
    )
}

?? 自帶長列表優(yōu)化的 InfiLoader

需要解決的問題

  • 數(shù)據(jù)源數(shù)據(jù)量遞增變化
  • 加載下一片段時九巡,隨著 Loading Spinner 的出現(xiàn)可視區(qū)域的高度會被壓縮
  • 列表元素高度在被渲染之前難以計算

只要列表不關(guān)心整體高度图贸,或者不需要規(guī)劃可視區(qū)域,后兩個問題就不攻自破了冕广。對于第一個問題疏日,可以嘗試?yán)?getDerivedStateFromProps 解決。

實現(xiàn)過程

利用 IntersectionObserver 實現(xiàn) InfiLoader

我們可以利用 IntersectionObserver 監(jiān)聽 Loading Spinner 和容器元素的相交事件撒汉,以觸發(fā) Load More 動作沟优。核心代碼如下:

startObserve = () => {
    if (!this.placeholderEl) return
    // 銷毀已經(jīng)存在的 Observer
    this.stopObserve()

    this.observer = new IntersectionObserver(this.handleObserve)
    this.observer.observe(this.placeholderEl)
}

stopObserve = () => {
    if (this.observer) {
        this.observer.disconnect()
        this.observer = undefined
    }
}

handleObserve = ([entry]) => {
    if (!entry.isIntersecting) return
    
    const {isLoading, isDrained, onLoad} = this.props
    if (isLoading || isDrained) return
    
    onLoad()
}

InfiLoader 上添加基于 slice 的長列表渲染

只有當(dāng)數(shù)據(jù)量到達(dá)一定閾值,才應(yīng)當(dāng)使用分片渲染的方式渲染視圖睬辐。閾值為「能夠被劃分成三個 slice 」:

get shouldOptimize() {
    const {slices} = this.state
    return slices.length > 3
}

getDerivedStateFromProps 周期時挠阁,根據(jù)傳入的 data 生成 slice

static getDerivedStateFromProps(props, state) {
    const {prevProps} = state
    const {data, sliceSize} = props
    const {data: prevData} = prevProps

    const slices = getSlices(data, sliceSize)

    if (data.length < prevData.length) {
        return {
            slices,
            currentSliceIndex: 0,
            topSpaces: [],
            prevProps: {
                data,
            },
        }
    }

    return {
        slices,
        prevProps: {
            data,
        },
    }
}

計算生成 slice 的函數(shù)如下:

const getSlices = (data, sliceSize) => {
    const slices = []
    // 按照傳入的 sliceSize 將 data 劈開
    for (let i = 0, amount = data.length; amount >= 0; i++, amount -= sliceSize) {
        slices.push({
            startIndex: sliceSize * i,
            amount: amount > sliceSize ? sliceSize : amount,
        })
    }
    return slices
}

其他的實現(xiàn)與上邊「基于 slice 的長列表渲染」大同小異宾肺。

完整的 InfiniteLoader 代碼如下:

import React, {Component} from 'react'
import PropTypes from 'prop-types'
import {subscribe} from 'subscribe-ui-event'

const VISIBLE_SLICE_COUNT = 3

const getSlices = (data, sliceSize) => {
  const slices = []
  for (let i = 0, amount = data.length; amount >= 0; i++, amount -= sliceSize) {
    slices.push({
      startIndex: sliceSize * i,
      amount: amount > sliceSize ? sliceSize : amount,
    })
  }
  return slices
}

export default class InfiniteLoader extends Component {
  static propTypes = {
    template: PropTypes.func.isRequired,
    data: PropTypes.array.isRequired,
    keyProp: PropTypes.string,
    onLoad: PropTypes.func.isRequired,
    isLoading: PropTypes.bool,
    isDrained: PropTypes.bool,
    placeholders: PropTypes.shape({
      loading: PropTypes.element,
      drained: PropTypes.element,
    }),
    getContainer: PropTypes.func,
    // sliced list
    // slice 的粒度
    sliceSize: PropTypes.number,
    // slice 切換的邊界條件(距離 containerEL ${sliceThreshold}px)
    sliceThreshold: PropTypes.number,
  }

  static defaultProps = {
    keyProp: 'id',
    placeholders: {},
    sliceSize: 30,
    sliceThreshold: 30,
  }

  state = {
    prevProps: {
      data: [],
    },
    slices: [],
    currentSliceIndex: 0,
    topSpaces: [],
  }

  static getDerivedStateFromProps(props, state) {
    const {prevProps} = state
    const {data, sliceSize} = props
    const {data: prevData} = prevProps

    const slices = getSlices(data, sliceSize)

    // 數(shù)據(jù)源沒有變化
    if (prevData === data) {
      return null
    }

    // 數(shù)據(jù)源切換或者被裁減了
    if (
      (prevData[0] && data[0] && prevData[0] !== data[0]) ||
      data.length < prevData.length
    ) {
      return {
        slices,
        currentSliceIndex: 0,
        topSpaces: [],
        prevProps: {
          data,
        },
      }
    }

    // 記錄數(shù)據(jù)源
    return {
      slices,
      prevProps: {
        data,
      },
    }
  }

  componentDidMount() {
    const {isDrained} = this.props

    this.bindScrollHandler()

    if (this.shouldOptimize) {
      this.bindBoundaryEls()
    }

    if (isDrained) return

    this.startObserve()
  }

  componentDidUpdate(prevProps) {
    const {data: oldData, isDrained: wasDrained} = prevProps
    const {isLoading, isDrained, data} = this.props

    if (oldData.length > data.length) {
      this.containerEl.scrollTop = 0
    }

    if (this.shouldOptimize) {
      this.bindBoundaryEls()
    } else {
      this.unbindBoundaryEls()
    }

    if (isLoading) return

    if (isDrained) {
      this.stopObserve()
      return
    }

    if (wasDrained && !isDrained) {
      this.startObserve()
      return
    }

    if (oldData.length < data.length) {
      this.mayLoadMore()
    }
  }

  componentWillUnmount() {
    this.stopObserve()
    this.unbindBoundaryEls()
    this.unbindScrollHandler()
  }

  get shouldOptimize() {
    const {slices} = this.state
    return slices.length > VISIBLE_SLICE_COUNT
  }

  get visibleData() {
    const {data} = this.props
    if (!this.shouldOptimize) {
      return data
    }

    if (this.shouldOptimize) {
      const {slices, currentSliceIndex} = this.state
      const visibleSlices = slices.slice(
        currentSliceIndex,
        currentSliceIndex + VISIBLE_SLICE_COUNT
      )
      const startIndex = visibleSlices[0].startIndex
      const amount = visibleSlices.reduce(
        (amount, slice) => slice.amount + amount,
        0
      )
      return data.slice(startIndex, startIndex + amount)
    }
  }

  get containerEl() {
    const {getContainer} = this.props
    return (getContainer && getContainer(this.rootEl)) || document.body
  }

  topBoundary = null
  bottomBoundary = null

  bindBoundaryEls = () => {
    const {slices, currentSliceIndex} = this.state
    const nodeList = this.listEl.childNodes
    this.topBoundary = nodeList[slices[currentSliceIndex].amount]
    this.bottomBoundary =
      nodeList[
        slices[currentSliceIndex].amount +
          slices[currentSliceIndex + 1].amount -
          1
      ]
  }

  unbindBoundaryEls = () => {
    this.topBoundary = null
    this.bottomBoundary = null
  }

  bindScrollHandler = () => {
    this.subscriber = subscribe('scroll', this.handleScroll, {
      useRAF: true,
      target: this.containerEl,
    })
  }

  unbindScrollHandler = () => {
    if (this.subscriber) {
      this.subscriber.unsubscribe()
    }
  }

  processing = false

  handleScroll = () => {
    if (!this.shouldOptimize || this.processing) {
      return
    }

    if (!this.topBoundary || !this.bottomBoundary) {
      return
    }

    const {sliceThreshold} = this.props
    const {slices, currentSliceIndex, topSpaces} = this.state

    const topBoundaryLoc = this.topBoundary.getBoundingClientRect().top
    const bottomBoundaryLoc = this.bottomBoundary.getBoundingClientRect().top

    const containerTop = this.containerEl.getBoundingClientRect().top

    if (
      bottomBoundaryLoc < containerTop + sliceThreshold &&
      currentSliceIndex + VISIBLE_SLICE_COUNT < slices.length
    ) {
      this.processing = true
      const startY = this.listEl.firstChild.getBoundingClientRect().top
      const topSpace = topBoundaryLoc - startY
      this.setState(
        {
          currentSliceIndex: currentSliceIndex + 1,
          topSpaces: topSpaces.concat(topSpace),
        },
        () => {
          this.bindBoundaryEls()
          this.processing = false
        }
      )
      return
    }

    const containerHeight = this.containerEl.clientHeight

    if (
      topBoundaryLoc > containerTop + containerHeight - sliceThreshold &&
      currentSliceIndex > 0
    ) {
      this.processing = true
      this.setState(
        {
          currentSliceIndex: currentSliceIndex - 1,
          topSpaces: topSpaces.slice(0, topSpaces.length - 1),
        },
        () => {
          this.bindBoundaryEls()
          this.processing = false
        }
      )
    }
  }

  mayLoadMore = () => {
    const {top: containerY} = this.containerEl.getBoundingClientRect()
    const containerHeight = this.containerEl.clientHeight
    const {top: placeholderY} = this.placeholderEl.getBoundingClientRect()
    if (placeholderY <= containerHeight + containerY) {
      const {onLoad} = this.props
      onLoad()
    }
  }

  handleObserve = ([entry]) => {
    if (!entry.isIntersecting) return

    const {isLoading, isDrained, onLoad} = this.props
    if (isLoading || isDrained) return

    onLoad()
  }

  startObserve = () => {
    if (!this.placeholderEl) return
    // 銷毀已經(jīng)存在的 Observer
    this.stopObserve()

    this.observer = new IntersectionObserver(this.handleObserve)
    this.observer.observe(this.placeholderEl)
  }

  stopObserve = () => {
    if (this.observer) {
      this.observer.disconnect()
      this.observer = undefined
    }
  }

  renderItem = (data, index) => {
    const {template: Template, keyProp} = this.props
    return <Template data={data} index={index} key={data[keyProp]} />
  }

  render() {
    const {className, placeholders, isDrained} = this.props
    const {topSpaces} = this.state
    return (
      <div className={className} ref={el => (this.rootEl = el)}>
        <div
          ref={el => (this.listEl = el)}
          style={{
            paddingTop: `${topSpaces.reduce(
              (total, curr) => curr + total,
              0
            )}px`,
          }}
        >
          {this.visibleData.map(this.renderItem)}
        </div>
        {!isDrained && (
          <div ref={el => (this.placeholderEl = el)}>
            {placeholders.loading}
          </div>
        )}
        {isDrained && placeholders.drained}
      </div>
    )
  }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市鹃唯,隨后出現(xiàn)的幾起案子爱榕,更是在濱河造成了極大的恐慌,老刑警劉巖坡慌,帶你破解...
    沈念sama閱讀 219,490評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件黔酥,死亡現(xiàn)場離奇詭異,居然都是意外死亡洪橘,警方通過查閱死者的電腦和手機(jī)跪者,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來熄求,“玉大人渣玲,你說我怎么就攤上這事〉芡恚” “怎么了忘衍?”我有些...
    開封第一講書人閱讀 165,830評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長卿城。 經(jīng)常有香客問我枚钓,道長,這世上最難降的妖魔是什么瑟押? 我笑而不...
    開封第一講書人閱讀 58,957評論 1 295
  • 正文 為了忘掉前任搀捷,我火速辦了婚禮,結(jié)果婚禮上多望,老公的妹妹穿的比我還像新娘嫩舟。我一直安慰自己,他們只是感情好怀偷,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,974評論 6 393
  • 文/花漫 我一把揭開白布家厌。 她就那樣靜靜地躺著,像睡著了一般椎工。 火紅的嫁衣襯著肌膚如雪像街。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,754評論 1 307
  • 那天晋渺,我揣著相機(jī)與錄音,去河邊找鬼脓斩。 笑死木西,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的随静。 我是一名探鬼主播八千,決...
    沈念sama閱讀 40,464評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼吗讶,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了恋捆?” 一聲冷哼從身側(cè)響起照皆,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎沸停,沒想到半個月后膜毁,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,847評論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡愤钾,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,995評論 3 338
  • 正文 我和宋清朗相戀三年瘟滨,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片能颁。...
    茶點(diǎn)故事閱讀 40,137評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡杂瘸,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出伙菊,到底是詐尸還是另有隱情败玉,我是刑警寧澤,帶...
    沈念sama閱讀 35,819評論 5 346
  • 正文 年R本政府宣布镜硕,位于F島的核電站运翼,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏谦疾。R本人自食惡果不足惜南蹂,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,482評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望念恍。 院中可真熱鬧六剥,春花似錦、人聲如沸峰伙。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,023評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽瞳氓。三九已至策彤,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間匣摘,已是汗流浹背店诗。 一陣腳步聲響...
    開封第一講書人閱讀 33,149評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留音榜,地道東北人庞瘸。 一個月前我還...
    沈念sama閱讀 48,409評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像赠叼,于是被迫代替她去往敵國和親擦囊。 傳聞我的和親對象是個殘疾皇子违霞,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,086評論 2 355