淺說虛擬列表的實現原理
在 ofollow,noindex" target="_blank">列表資料的展示優化 一文中,提到了對於列表形態的資料展示的按需渲染。這種方式是指根據容器元素的高度以及列表項元素的高度來顯示長列表資料中的某一個部分,而不是去完整地渲染長列表,以提高無限滾動的效能。而按需顯示方案的實現就是本文標題中說的虛擬列表。
虛擬列表的實現有多種方案,本文以 react-virtual-list 元件為基礎進行分析
什麼是虛擬列表?
在正文之前,先對虛擬列表做個簡單的定義。
根據上文,虛擬列表是按需顯示思路的一種實現,即 虛擬列表是一種根據滾動容器元素的可視區域來渲染長列表資料中某一個部分資料的技術。
簡而言之,虛擬列表指的就是「可視區域渲染」的列表。有三個概念需要了解一下:
- 滾動容器元素 :一般情況下,滾動容器元素是
window
物件。然而,我們可以通過佈局的方式,在某個頁面中任意指定一個或者多個滾動容器元素。只要某個元素能在內部產生橫向或者縱向的滾動,那這個元素就是滾動容器元素考慮每個列表項只是渲染一些純文字。在本文中,只討論元素的縱向滾動。 - 可滾動區域 :滾動容器元素的內部內容區域。假設有 100 條資料,每個列表項的高度是 50,那麼可滾動的區域的高度就是 100 * 50。可滾動區域當前的具體高度值一般可以通過(滾動容器)元素的
scrollHeight
屬性獲取。使用者可以通過滾動來改變列表在可視區域的顯示部分。 - 可視區域 :滾動容器元素的視覺可見區域。如果容器元素是
window
物件,可視區域就是瀏覽器的視口大小(即 3609-26e0d164-933b-11e8-85e5-1ec21d5ba398.png" rel="nofollow,noindex" target="_blank">視覺視口 );如果容器元素是某個div
元素,其高度是 300,右側有縱向滾動條可以滾動,那麼視覺可見的區域就是可視區域。
實現虛擬列表就是在處理使用者滾動時,要改變列表在可視區域的渲染部分,其具體步驟如下:
- 計算當前可見區域起始資料的 startIndex
- 計算當前可見區域結束資料的 endIndex
- 計算當前可見區域的資料,並渲染到頁面中
- 計算 startIndex 對應的資料在整個列表中的偏移位置 startOffset,並設定到列表上
- 計算 endIndex 對應的資料相對於可滾動區域最底部的偏移位置 endOffset,並設定到列表上
建議參考下圖理解一下上面的步驟:
元素 L 代指當前列表中的最後一個元素
從上圖可以看出, startOffset
和 endOffset
會撐開容器元素的內容高度,讓其可持續的滾動;此外,還能保持滾動條處於一個正確的位置。
為什麼需要虛擬列表?
虛擬列表是對長列表的一種優化方案。在前端開發中,會碰到一些不能使用分頁方式來載入列表資料的業務形態,我們稱這種列表叫做長列表。比如,在一些外匯交易系統中,前端會準實時的展示使用者的持倉情況(收益、虧損、手數等),此時對於使用者的持倉列表一般是不能分頁的。
在本篇文章中,我們把長列表定義成資料長度大於 999,並且不能使用分頁的形式來展示的列表。
如果對長列表不作優化,完整地渲染一個長列表,到底需要多長時間呢?接下來會寫一個簡單的 demo 來測試以下。
本文 demo 的測試環境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1
在 demo 中,我們先測一下瀏覽器渲染 10000 個簡單的節點需要多長時間:
import React from 'react' const count = 10000 function createMarkup (doms) { return doms.length ? { __html: doms.join(' ') } : { __html: '' } } export default class DOM extends React.Component { constructor (props) { super(props) this.state = { simpleDOMs: [] } this.onCreateSimpleDOMs = this.onCreateSimpleDOMs.bind(this) } onCreateSimpleDOMs () { const array = [] for (var i = 0; i < count; i++) { array.push('<div>' + i + '</div>') } this.setState({ simpleDOMs: array }) } render () { return ( <div style={{ marginLeft: '10px' }}> <h3>Creat large of DOMs:</h3> <button onClick={this.onCreateSimpleDOMs}>Create Simple DOMs</button> <div dangerouslySetInnerHTML={createMarkup(this.state.simpleDOMs)} /> </div> ) } }
當點選 Button 時,會呼叫 onCreateSimpleDOMs
建立 10000 個簡單節點。從 Chrome 的 Performance 標籤頁看到的資料如下:
從上圖可以看到,從 Event Click 到 Paint,總共用了大約 693ms,渲染時的主要時間消耗情況如下:
- Recalculate Style:40.80ms
- Layout:518.55ms
- Update Layer Tree:11.84ms
在 Recalculate Style 和 Layout 階段,ReactDOM 呼叫了 setInnerHTML
方法,其內部主要通過 innerHTML
方法,將建立好的 html 片段新增到對應節點
然後,我們建立 10000 個稍微複雜點的節點。修改元件如下:
import React from 'react' function createMarkup (doms) { return doms.length ? { __html: doms.join(' ') } : { __html: '' } } export default class DOM extends React.Component { constructor (props) { super(props) this.state = { complexDOMs: [] } this.onCreateComplexDOMs = this.onCreateComplexDOMs.bind(this) } onCreateComplexDOMs () { const array = [] for (var i = 0; i < 5000; i++) { array.push(` <div class='list-item'> <p>#${i} eligendi voluptatem quisquam</p> <p>Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.</p> </div> `) } this.setState({ complexDOMs: array }) } render () { return ( <div style={{ marginLeft: '10px' }}> <h3>Creat large of DOMs:</h3> <button onClick={this.onCreateComplexDOMs}>Create Complex DOMs</button> <div dangerouslySetInnerHTML={createMarkup(this.state.complexDOMs)} /> </div> ) } }
當點選 Button 時,會呼叫 onCreateComplexDOMs
。從 Chrome 的 Performance 標籤頁看到的資料如下:
從上圖可以看到,從 Event Click 到 Paint,總共用了大約 964.2ms,渲染時的主要時間消耗情況如下:
- Recalculate Style:117.07ms
- Layout:538.00ms
- Update Layer Tree:31.15ms
對於上述測試各進行 5 次,然後取各指標的平均值,統計結果如下:
- | Recalculate Style | Layout | Update Layer Tree | Total |
---|---|---|---|---|
渲染簡單節點 | 199.66ms | 523.72ms | 12.572ms | 735.952ms |
渲染複雜節點 | 114.684ms | 806.05ms | 31.328ms | 952.512ms |
- Total = Recalculate Style + Layout + Update Layer Tree
- demo 的測試程式碼: test code
從上面的測試結果中可以看到,渲染 10000 個節點就需要 700ms+,實際業務中的列表每個節點都需要 20 個左右的節點,佈局也會複雜很多,在 Recalculate Style 和 Layout 階段也會耗費更長的時間。那麼,700ms 也僅能渲染 300 ~ 500 個左右的列表項,所以完整的長列表渲染基本上很難達到業務上的要求的。而非完整的長列表渲染一般有兩種方式:按需渲染和延遲渲染(即懶渲染)。常見的無限滾動便是延遲渲染的一種實現,而虛擬列表則是按需渲染的一種實現。
延遲渲染不在本文討論範圍。接下來,本文會簡單介紹虛擬列表的一種實現方案。
實現
本章節將會建立一個 VirtualizedList
元件,並結合程式碼,慢慢梳理虛擬列表的實現。
為了簡化,我們設定 window
為滾動容器元素,給 html
和 body
元素均新增樣式規則 height: 100%
,設定可視區域為瀏覽器的視窗大小。 VirtualizedList
在 DOM 元素的佈局上將參考 Twitter 的移動端 :
class VirtualizedList extends Component { constructor (props) { super(props) this.state = { startOffset: 0, endOffset: 0, visibleData: [] } this.data = new Array(1000).fill(true) this.startIndex = 0 this.endIndex = 0 this.scrollTop = 0 } render () { const {startOffset, endOffset} = this.state return ( <div className='wrapper'> <div style={{ paddingTop: `${startOffset}px`, paddingBottom: `${endOffset}px` }}> { // render list } </div> </div> ) } }
在虛擬列表上的實現上,也分為兩種情形:列表項是固定高度的和列表項是動態高度的。
列表項是固定高度的
既然列表項是固定高度的,那約定沒個列表項的高度為 60,列表資料的長度為 1000。
首先,我們根據可視區域的高度估算可視區域能渲染的元素個數:
const height = 60 const bufferSize = 5 // ... this.visibleCount = Math.ceil(window.clientHeight / height)
然後,計算 startIndex
和 endIndex
,並先初始化初次需要渲染的資料:
// ... updateVisibleData (scrollTop) { const visibleData = this.data.slice(this.startIndex, this.endIndex) const endOffset = (this.data.length - this.endIndex) * height this.setState({ startOffset: 0, endOffset, visibleData }) } componentDidMount () { // 計算可渲染的元素個數 this.visibleCount = Math.ceil(window.innerHeight / height) + bufferSize this.endIndex = this.startIndex + this.visibleCount this.updateVisibleData() }
如上文所說, endOffset
是計算 endIndex
對應的資料相對於可滾動區域底部的偏移位置。在本 demo 中,可滾動區域的高度就是 1000 * 60,因而 endIndex
對應的資料相距底部的偏移就是 (1000 - endIndex) * 60。
由於是初始化初次需要渲染的資料,因而 startOffset
的初始值是 0。
根據上述程式碼,可以得知,要計算可見區域需要渲染的資料,只要計算出 startIndex
就行,因為 visibleCount
是一個定值, bufferSize
是一個緩衝值,用來增加一定的快取區域,讓正常滑動速度的時候不會顯得那麼突兀。而 endIndex
的值就等於 startIndex
加上 visibleCount
;同時,當用戶滾動改變可見區域的資料時,還需要計算 startOffset
的值,以保證新的資料會出現在使用者瀏覽器的視口中:
如果不計算 startOffset
的值,那本應該渲染在可視區域內的元素會渲染到可視區域之外。從上圖可以看到, startOffset
的值就是元素8的上邊框 (可視區域內最上面一個元素) 到元素1的上邊框的偏移量。元素8稱為 錨點元素,即可視區域內的第一個元素。 因而,我們需要定義一個變數來快取錨點元素的一些位置資訊,同時也要快取已渲染的元素的位置資訊:
// ... // 快取已渲染元素的位置資訊 this.cache = [] // 快取錨點元素的位置資訊 this.anchorItem = { index: 0, // 錨點元素的索引值 top: 0, // 錨點元素的頂部距離第一個元素的頂部的偏移量(即 startOffset) bottom: 0 // 錨點元素的底部距離第一個元素的頂部的偏移量 } // ... cachePosition (node, index) { const rect = node.getBoundingClientRect() const top = rect.top + window.pageYOffset this.cache.push({ index, top, bottom: top + height }) } // ...
方法 cachePosition
會在每個列表項元件渲染完後( componentDidMount
)進行呼叫, node
是對應的列表項節點元素, index
是節點的索引值:
// Item.jsx // ... componentDidMount () { this.props.cachePosition(this.node, this.props.index) } render () { /* eslint-disable-next-line */ const {index} = this.props return ( <div className='list-item' ref={node => { this.node = node }}> <p>#${index} eligendi voluptatem quisquam</p> <p>Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.</p> </div> ) } // ...
快取了錨點元素和已渲染元素的位置資訊之後,接下來就可以處理使用者的滾動行為了。以使用者向下滾動( scrollTop
值增大的方向)為例:
// ... // 計算 startIndex 和 endIndex updateBoundaryIndex (scrollTop) { scrollTop = scrollTop || 0 //使用者正常滾動下,根據 scrollTop 找到新的錨點元素位置 const anchorItem = this.cache.find(item => item.bottom >= scrollTop) this.anchorItem = { ...anchorItem } this.startIndex = this.anchorItem.index this.endIndex = this.startIndex + this.visibleCount } // 滾動事件處理函式 handleScroll (e) { if (!this.doc) { // 相容 iOS Safari/Webview this.doc = window.document.body.scrollTop ? window.document.body : window.document.documentElement } const scrollTop = this.doc.scrollTop if (scrollTop > this.scrollTop) { if (scrollTop > this.anchorItem.bottom) { this.updateBoundaryIndex(scrollTop) this.updateVisibleData() } } else if (scrollTop < this.scrollTop) { // 向上滾動(`scrollTop` 值減小的方向) } this.scrollTop = scrollTop } // ...
在滾動事件處理函式中,會去更新 startIndex
、 endIndex
以及新的錨點元素的位置資訊(即更新 startOffset
),然後就可以動態的去更新可視區域的渲染資料了:
完整的程式碼在可以戳: 固定高度的虛擬列表實現
列表項是動態高度的
這種情形下,實現的思路和列表項固高大同小異。而小異之處就在於快取列表項的位置資訊時,怎麼拿到列表項的精確高度?首先要更改 cachePosition
的部分邏輯:
// ... cachePosition (node, index) { const rect = node.getBoundingClientRect() const top = rect.top + window.pageYOffset this.cache.push({ index, top, bottom: top + rect.height // 將 height 更為 rect.height }) } // ...
由於列表項的高度不固定,那要怎麼計算 visibleCount
呢?我們先 考慮每個列表項只是渲染一些純文字 。在實際專案中,有的列表項可能只有一行文字,有的列表項可能有多行文字,此時,我們要基於專案的實際情況,給列表項一個 預估的高度 : estimatedItemHeight
。
比如,有一個長列表要渲染使用者的文章摘要,並規定摘要顯示不超過三行,那麼我們取列表的前 10 個列表項的高度平均值作為預估高度。當然,為了預估高度更精確,我們是可以擴大取樣樣本的。
既然有了預估高度,那麼將原先程式碼中的 height
替換成 estimatedItemHeight
,就可以計算出 visibleCount
了:
// ... const estimatedItemHeight = 80 // ... // 計算可渲染的元素個數 this.visibleCount = Math.ceil(window.innerHeight / estimatedItemHeight) + bufferSize // ...
我們通過 faker.js 來建立一些隨機資料,並賦值給 data
:
// ... function fakerData () { const a = [] for (let i = 0; i < 1000; i++) { a.push({ id: i, words: faker.lorem.words(), paragraphs: faker.lorem.sentences() }) } return a } // ... this.data = fakerData() // ...
修改一下列表項的 render
邏輯,其它不變:
// Item.jsx // ... render () { /* eslint-disable-next-line */ const {index, item} = this.props return ( <div className='list-item' style={{ height: 'auto' }} ref={node => { this.node = node }}> <p>#${index} {item.words}</p> <p>{item.paragraphs}</p> </div> ) } // ...
此時,列表項的高度已經是動態的了,根據渲染的實際情況,我們給的預估高度是 80:
完整的程式碼在可以戳: 動態高度的虛擬列表實現
那如果列表項渲染的不是純文字呢?比如渲染的是圖文,那在 Item 元件的 componentDidMount
去呼叫 cachePosition
方法時,能拿到對應節點的正確高度嗎?在渲染圖文的情況下,因為圖片會發起網路請求,此時並不能保證在列表項元件掛載(執行 componentDidMount
)的時候圖片渲染好了,那此時對應節點的高度就是不準確的,因而在使用者滾動改變可見區域渲染的資料時,就可能出現元素相互重疊的情況:
在這種情況下,如果我們能監聽 Item 元件節點的大小變化就能獲取其正確的高度了。ResizeObserver 或許就可以滿足我們的需求,其提供了監聽 DOM 元素大小變化的能力,但在撰寫本文時,僅 Chrome 67 及以上版本支援,其它主流瀏覽器均為提供支援。以下是我搜集的一些資料,供你參考(自備梯子):
總結
在本文中,首先對虛擬列表進行了簡單的定義,然後從長列表的角度分析了為什麼需要虛擬列表,最後就列表項固高和不固高兩個場景下以一個簡單的 demo 詳細講述了虛擬列表的實現思路。
在列表項是動態高度的場景下,分析了渲染純文字和圖文混合的場景。前者給出了一個具體的 demo,針對後者對於怎麼監聽元素大小的變化提供了參考的 ResizeObserver 方案。基於 ResizeObserver 的方案呢,我也實現了一個支援渲染圖文混合(當然也支援純文字)的虛擬列表元件 react-virtual-list ,供你參考。
當然,這並不是唯一一種實現虛擬列表的方案。在元件 react-virtual-list 的實現過程中,也閱讀了不同虛擬列表元件的原始碼,如: react-tiny-virtual-list、react-window、react-virtualized 等,後續的系列文章我會從原始碼的角度逐一分析。