?? React 的列表渲染
key
與 Reconciliation
React 在渲染列表時痊硕,會要求開發(fā)者為每一個列表元素指定唯一的 key
懊亡,以幫助 React 識別哪些元素是新添加的,哪些元素被修改或刪除了楣黍。
通常情況下钾怔,Reconciliation 算法遞歸遍歷歷某個 DOM 節(jié)點(diǎn)的全部子節(jié)點(diǎn)轨香,以保證改變能夠正確的被應(yīng)用。這樣的方式在大多數(shù)場景下沒有問題仪或,但是對于列表來講确镊,如果在列表中添加了新元素,或者某個元素被刪除范删,可能會導(dǎo)致整個列表被重新渲染蕾域。
舉一個文檔中的例子:
在添加 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
的時候尤其常見乏冀。有一個很好的例子:
可以看到蝶糯,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)化:
- 統(tǒng)一 API
- 補(bǔ)充了一些瀏覽器原生沒有實現(xiàn)的事件
- 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 更新策略。其性能開銷主要在以下幾個方面:
- 調(diào)用
render
前需要執(zhí)行的業(yè)務(wù)邏輯 - Reconciliation
- DOM Rendering & Painting
如果業(yè)務(wù)邏輯不是特別復(fù)雜掀潮,其開銷最大的過程通常是 DOM Rendering菇夸,而且,隨著列表越來越長仪吧,Rendering 的時間會越來越長庄新。
?? 長列表渲染的優(yōu)化思路
使用 createDocumentFragment
/ innerHTML
替換 createElement
相關(guān)文檔:
DocumentFragment
- Web API | MDN
createDocumentFragment
- Web API | MDN
像 React 的 Virtual DOM 一樣,我們嘗試使用 DocumentFragment
替換完整的 Node
,以壓縮 Scripting 的時間择诈。不過械蹋,在現(xiàn)代瀏覽器中,createElement
和 createDocumentFragment
的開銷已經(jīng)沒什么差別了羞芍。
如果不考慮生成列表元素可能包含的復(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)刻获。
但是蜀涨,如果一直掛載節(jié)點(diǎn),列表的 DOM 結(jié)構(gòu)會越來越大蝎毡,DOM 操作的開銷也會隨之增大勉盅,Rendering 的速度也會越來越慢。
僅渲染可視區(qū)域
既然 DOM 結(jié)構(gòu)越大顶掉,渲染速度越慢,那我們可以嘗試僅渲染用戶能夠看到的列表元素挑胸,盡可能控制 DOM 結(jié)構(gòu)的大小痒筒。
實現(xiàn)思路
- 獲取 container 的容器高度
- 計算 container 一屏能容納下的元素個數(shù)
- 創(chuàng)建一個 placeholder 把 container 的 scroll height 撐起來。
- 創(chuàng)建一個 list 放置生成的元素
- 滾動的時候:
- 獲取 scrollTop茬贵,計算 List 的 offset top簿透,保證 List 覆蓋 container 的可視區(qū)域。
- 通過 scrollTop 計算出當(dāng)前可視區(qū)域應(yīng)當(dāng)顯示的元素的 index解藻,根據(jù)計算結(jié)果渲染新的列表
- 使用
transform: translate3d(0, offsetHeight, 0)
調(diào)整list
的位置老充,保證其在可視區(qū)域內(nèi)。
源碼片段
<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 是如何處理動態(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)大致如下圖:
<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
增显、clientHeight
、getBoundingClientRect
等可以確定節(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):
- 獲取 container 的容器高度
- 計算 container 一屏能容納下的元素個數(shù)
- 創(chuàng)建一個 placeholder全释,指定
min-height
装处,把 container 的 scroll height 撐起來。 - 創(chuàng)建一個 list 保存顯示區(qū)域要渲染的數(shù)據(jù)
- 根據(jù)上面的 list 渲染到 placeholder 內(nèi)
observer.observe(list.firstChild)
- 當(dāng)首部元素與
container
發(fā)生交叉(Intersection)時浸船,IntersectionObserver
的callback
會觸發(fā)妄迁,并傳入IntersectionObserverEntry
實例,提供必要信息李命。 - 根據(jù)
isIntersecting
判斷當(dāng)前元素的移動是離開了可視區(qū)域還是進(jìn)入了可視區(qū)域登淘;如果離開了可視區(qū)域,則更新 list封字,將首部元素移除黔州,在尾部添加一個新的元素(startIndex + 1
),并執(zhí)行.observe(el.nextSibling)
阔籽;如果進(jìn)入了可視區(qū)域流妻,則在首部元素前再添加一個元素,執(zhí)行.observe(el.previousSibling)
笆制。 - placeholder 的實際顯示區(qū)域的位置绅这,使用
padding-top
來調(diào)整。
代碼片段
代碼寫的比較糙在辆,只是闡述一下思路君躺,實際還有不少 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)思路
- 根據(jù)開發(fā)者設(shè)定的粒度拳昌,將傳入的
data
分為若干個slice
- 從第 0 個
slice
開始,每次渲染三個slice
- 監(jiān)?聽 scroll 事件蓖捶,當(dāng)
slice[1]
的首部元素處于可視區(qū)域底部下方時地回,丟棄slice[2]
,在slice[0]
前插入新的slice
俊鱼;當(dāng)slice[1]
的尾部元素處于可視區(qū)域頂部上方時刻像,丟棄slice[0]
,在slice[2]
下方渲染新的slice
并闲。效果如圖:
可以看到细睡,DOM Tree 的更新頻率非常低,而且列表元素的高度也不需要提前計算帝火。不過這個方案的問題也很明顯:Scroll Bar 會上下跳溜徙。
為了解決 Scroll Bar 的問題,我們在 slice[0]
被丟棄之前犀填,將 slice[0]
的高度 push
到 topSpaces
數(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>
)
}
}