當今天有大量資料要render出來時,舉例如果有一千筆資料要被呈現出來,但正常viewport不會長到可以顯示一千筆資料,我們會在container加上 overflow: 'scroll'
讓container可以符合使用者體驗地去顯示資料,因此我們真正會顯示給使用者的只有在container可視範圍內的資料,也代表其他不在可視範圍的DOM是沒有作用但吃資源,況且在形成DOM tree時需要花上時間去處理一千筆dom。進而導致以下幾種問題:
- 載入時被blocked時間加長(處理DOM tree以及之後的process)
- 不必要的dom佔據記憶體
- 每個DOM太吃資源的情況下會導致FPS降低
- 若每個元素裡都有掛載圖片,會花一堆時間去下載全部資源(可以用lazy load),但載進來後無非又是多更多不必要資源
- ...
在這情況下可優化的方式很多種,其中就是今天的主角: Virtualized List
原理
當滾動時,透過計算可視範圍內會有哪些元素去渲染元素,也稱作 Windowing。
以上圖為例,若List中有超過一千筆資料,而現在可視範圍內只需要 ID 1000 ~ 1005,那我們只需要這幾個元素,其他移除。
實作
在不考慮Dynamic height元素下,簡單實作一個Virtualized List會需要以下幾個條件去做計算:
- 元素數量 (constant): itemCount (from props)
- 元素高度 (constant): itemHeight (from props)
- 可視範圍高度: listHeight (from props)
- 可視範圍的scrollTop (from scroll event)
Container
我們需要兩層Container,最外層掌管scroll行為,內層為渲染list,內層的高度為 itemCount * itemHeight
。
<div className="virtualizedList__wrapper" style={{ overflow: "scroll", height: listHeight }} > <div className="virtualizedList__inner" style={{ position: "relative", height: innerHeight }} > </div> </div>
現在元素範圍
已過去的元素index:
const pastIndex = Math.max( Math.floor(scrollTop / itemHeight) - 1, -1 );
若元素index為 <= pastIndex,我們不渲染。
未來的元素index:
const futureIndex = Math.min( Math.ceil((scrollTop + listHeight) / itemHeight), itemCount );
若元素index為 >= futureIndex,我們不渲染。
渲染元素
我們需要一個prop renderComponent
讓我們知道怎麼渲染元素。
renderComponent: (index: number, style: CSSProperties) => ReactElement
在scroll event發生時,根據 pastIndex < index < futureIndex
的範圍去呼叫 renderComponent
在渲染進內層 container,同時改變元素的位移。
因為內層container被scroll,而我們只渲染可視範圍內的元素,若直接把元素放進容器而不位移,元素會從頂層渲染,這時當scrollTop不等於0時,元素會跑出可視範圍。
const temp = []; for (let i = pastIndex + 1; i < futureIndex; i++) { temp.push( renderComponent(i, { position: "absolute", transform: `translateY(${i * itemHeight}px)`, width: "100%" }) ); } setItems([...temp]); // items為state,是可視範圍內的元素
這裡使用 transform: translateY
去位移,使用GPU計算資源。
這下清楚了,直接上code。
會發現我加了一些小細節:
- buffer: 在前後提前渲染一些元素,除了提升UX,也可以預載資源
- throttle: scroll 好夥伴,避免密集運算。
TODO
- dynamic height元素的virtualized list