摘要:虛擬列表的實現有多種方案,本文以組件為基礎進行分析。常見的無限滾動便是延遲渲染的一種實現,而虛擬列表則是按需渲染的一種實現。接下來,本文會簡單介紹虛擬列表的一種實現方案。實現本章節將會創建一個組件,并結合代碼,慢慢梳理虛擬列表的實現。
在 列表數據的展示優化 一文中,提到了對于列表形態的數據展示的按需渲染。這種方式是指根據容器元素的高度以及列表項元素的高度來顯示長列表數據中的某一個部分,而不是去完整地渲染長列表,以提高無限滾動的性能。而按需顯示方案的實現就是本文標題中說的虛擬列表。
虛擬列表的實現有多種方案,本文以 react-virtual-list 組件為基礎進行分析。原文鏈接:https://github.com/dwqs/blog/...什么是虛擬列表?
在正文之前,先對虛擬列表做個簡單的定義。
根據上文,虛擬列表是按需顯示思路的一種實現,即虛擬列表是一種根據滾動容器元素的可視區域來渲染長列表數據中某一個部分數據的技術。
簡而言之,虛擬列表指的就是「可視區域渲染」的列表。有三個概念需要了解一下:
滾動容器元素:一般情況下,滾動容器元素是 window 對象。然而,我們可以通過布局的方式,在某個頁面中任意指定一個或者多個滾動容器元素。只要某個元素能在內部產生橫向或者縱向的滾動,那這個元素就是滾動容器元素考慮每個列表項只是渲染一些純文本。在本文中,只討論元素的縱向滾動。
可滾動區域:滾動容器元素的內部內容區域。假設有 100 條數據,每個列表項的高度是 50,那么可滾動的區域的高度就是 100 * 50。可滾動區域當前的具體高度值一般可以通過(滾動容器)元素的 scrollHeight 屬性獲取。用戶可以通過滾動來改變列表在可視區域的顯示部分。
可視區域:滾動容器元素的視覺可見區域。如果容器元素是 window 對象,可視區域就是瀏覽器的視口大小(即視覺視口);如果容器元素是某個 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("" + i + "") } this.setState({ simpleDOMs: array }) } render () { return () } }Creat large of DOMs:
當點擊 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(``) } this.setState({ complexDOMs: array }) } render () { return (#${i} eligendi voluptatem quisquam
Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.
) } }Creat large of DOMs:
當點擊 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 () } }{ // render list }
在虛擬列表上的實現上,也分為兩種情形:列表項是固定高度的和列表項是動態高度的。
列表項是固定高度的既然列表項是固定高度的,那約定沒個列表項的高度為 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 ({ this.node = node }}>) } // ...#${index} eligendi voluptatem quisquam
Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.
緩存了錨點元素和已渲染元素的位置信息之后,接下來就可以處理用戶的滾動行為了。以用戶向下滾動(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 ({ this.node = node }}>) } // ...#${index} {item.words}
{item.paragraphs}
此時,列表項的高度已經是動態的了,根據渲染的實際情況,我們給的預估高度是 80:
完整的代碼在可以戳:動態高度的虛擬列表實現
那如果列表項渲染的不是純文本呢?比如渲染的是圖文,那在 Item 組件的 componentDidMount 去調用 cachePosition 方法時,能拿到對應節點的正確高度嗎?在渲染圖文的情況下,因為圖片會發起網絡請求,此時并不能保證在列表項組件掛載(執行 componentDidMount)的時候圖片渲染好了,那此時對應節點的高度就是不準確的,因而在用戶滾動改變可見區域渲染的數據時,就可能出現元素相互重疊的情況:
在這種情況下,如果我們能監聽 Item 組件節點的大小變化就能獲取其正確的高度了。ResizeObserver 或許就可以滿足我們的需求,其提供了監聽 DOM 元素大小變化的能力,但在撰寫本文時,僅 Chrome 67 及以上版本支持,其它主流瀏覽器均為提供支持。以下是我搜集的一些資料,供你參考(自備梯子):
ResizeObserver: It’s Like document.onresize for Elements
ResizeObserver
caniuse#resizeobserver
總結在本文中,首先對虛擬列表進行了簡單的定義,然后從長列表的角度分析了為什么需要虛擬列表,最后就列表項固高和不固高兩個場景下以一個簡單的 demo 詳細講述了虛擬列表的實現思路。
在列表項是動態高度的場景下,分析了渲染純文本和圖文混合的場景。前者給出了一個具體的 demo,針對后者對于怎么監聽元素大小的變化提供了參考的 ResizeObserver 方案。基于 ResizeObserver 的方案呢,我也實現了一個支持渲染圖文混合(當然也支持純文本)的虛擬列表組件 react-virtual-list,供你參考。
當然,這并不是唯一一種實現虛擬列表的方案。在組件 react-virtual-list 的實現過程中,也閱讀了不同虛擬列表組件的源碼,如: react-tiny-virtual-list、react-window、react-virtualized 等,后續的系列文章我會從源碼的角度逐一分析。
原文:https://github.com/dwqs/blog/...
參考Complexities of an Infinite Scroller
Infinite List and React
聊聊前端開發中的長列表
再談前端虛擬列表的實現
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/98491.html
摘要:應用常見安全漏洞一覽注入注入就是通過給應用接口傳入一些特殊字符,達到欺騙服務器執行惡意的命令。此外,適當的權限控制不曝露必要的安全信息和日志也有助于預防注入漏洞。 web 應用常見安全漏洞一覽 1. SQL 注入 SQL 注入就是通過給 web 應用接口傳入一些特殊字符,達到欺騙服務器執行惡意的 SQL 命令。 SQL 注入漏洞屬于后端的范疇,但前端也可做體驗上的優化。 原因 當使用外...
摘要:應用常見安全漏洞一覽注入注入就是通過給應用接口傳入一些特殊字符,達到欺騙服務器執行惡意的命令。此外,適當的權限控制不曝露必要的安全信息和日志也有助于預防注入漏洞。 web 應用常見安全漏洞一覽 1. SQL 注入 SQL 注入就是通過給 web 應用接口傳入一些特殊字符,達到欺騙服務器執行惡意的 SQL 命令。 SQL 注入漏洞屬于后端的范疇,但前端也可做體驗上的優化。 原因 當使用外...
摘要:合理的優化長列表,可以提升用戶體驗。這樣保證了無論如何滾動,真實渲染出的節點只有可視區內的列表元素。具體效果如下圖所示對于比無優化的情況,優化后的虛擬列表渲染速度提升很明顯。是基于來實現的,但是是一個維的列表,而不是網狀。 ??對于較長的列表,比如1000個數組的數據結構,如果想要同時渲染這1000個數據,生成相應的1000個原生dom,我們知道原生的dom元素是很復雜的,如果長列表...
摘要:合理的優化長列表,可以提升用戶體驗。這樣保證了無論如何滾動,真實渲染出的節點只有可視區內的列表元素。具體效果如下圖所示對于比無優化的情況,優化后的虛擬列表渲染速度提升很明顯。是基于來實現的,但是是一個維的列表,而不是網狀。 ??對于較長的列表,比如1000個數組的數據結構,如果想要同時渲染這1000個數據,生成相應的1000個原生dom,我們知道原生的dom元素是很復雜的,如果長列表...
閱讀 2464·2021-11-23 09:51
閱讀 523·2019-08-30 13:59
閱讀 1829·2019-08-29 11:20
閱讀 2534·2019-08-26 13:41
閱讀 3244·2019-08-26 12:16
閱讀 733·2019-08-26 10:59
閱讀 3327·2019-08-26 10:14
閱讀 603·2019-08-23 17:21