前言
在前一段時間做一個需求的時候, 碰到一個自定義列表的功能, 他的所有數據顯示都是通過 jSON 字符串來存儲,使用也是通過 JSON 解析 起先他是有數據上限的, 但是后面提高上限后就出現了卡頓等問題,
所以本文就是介紹一些方案來解決前端大量數據的渲染問題
方案
innerHTML
首先是在很久很久之前的渲染方案 innerHTML
插入, 他是官方的 API, 性能較好
這是一個簡單的 HTML 渲染例子(在試驗時數據取10w級別, 擴大差異, 實際中基本會小於這個級別)
const items = new Array(100000).fill(0).map((it, index) => {
return `<div>item ${index}</div>`
}).join('')
content.innerHTML = items
來自谷歌的性能分析:
在 10 秒內進行了頁面的刷新和滾動, 可以看到 dom 的渲染阻塞了頁面 1300 ms
在性能檢測中, 總阻塞時間控制在300毫秒以內才是一個合格的狀態, 這個時間還會受電腦硬件的影響
總結下這個方法的優缺點:
- 優點: 性能相對可以接受, 但數據較多時也同樣有阻塞
- 缺點:
- 有注入的危險, 和框架的搭配較差
- 在 dom 過多時並沒有解決滾動的性能問題
批量插入
通過分片來插入, 假如有 10W 條數據, 我們就分成 10 次, 每次 1w 條循環插入
[...new Array(10)].forEach((_, i) => {
requestAnimationFrame(() => {
[...new Array(10000)].forEach((_, index) => {
const item = document.createElement("div")
item.textContent = `item ${i}${index}`
content.append(item)
})
})
})
經過谷歌分析:
這里也是包括的頁面刷新和滾動的性能分析, 可以看到阻塞時間為 1800 毫秒, 相較 innerHTML 來說會差一點, 這是在 10w 的這個數量級, 數量越小, 時間的差距也會越小
關於 requestAnimationFrame
其中 requestAnimationFrame
的作用: 此方法會告訴瀏覽器希望執行動畫並請求瀏覽器在下一次重繪之前調用回調函數來更新動畫。
執行方式: 當執行 requestAnimationFrame(callback)的時候,不會立即調用 callback 回調函數,會將其放入回調函數隊列,
當頁面可見並且動畫幀請求callback回調函數列表不為空時,瀏覽器會定期將這些回調函數加入到瀏覽器 UI 線程的隊列中(由系統來決定回調函數的執行時機)
總的來說就是不會阻塞其他代碼的執行, 但是總的執行時間和 innerHTML 方案差不太多
總結下優缺點:
- 優點: 不會阻塞代碼的運行
- 缺點:
- 插入所花費的總時間仍舊和 innerHTML 差不太多
- 同樣地, 在 dom 過多時也沒有解決滾動的性能問題
其他原生方式
canvas
canvas 是專門用來繪制的一個工具, 可以用於動畫、游戲畫面、數據可視化、圖片編輯以及實時視頻處理等方面。
最近在著名框架 Flutter 的 Web 中就是使用 canvas 來渲染頁面的
同樣我們也可以使用 canvas 來渲染大量的數據
<div style="max-height: 256px;max-width:256px;overflow: scroll;">
<canvas id="canvas"></canvas>
</div>
let ctx = canvas.getContext('2d');
[...new Array(100000)].map((it, index) => {
ctx.fillText(`item ${index}`, 0, index * 30)
})
經過實際的嘗試, canvas 他是有限制的,最大到 6w 左右的高度就不能再繼續放大了, 也就是說在大量數據下, canvas 還是被限制住了
進一步優化
這里提供一個優化思路, 監聽外層 DOM 的滾動, 根據高度來動態渲染 canvas 的顯示, 能達到最終的效果, 但是這樣成本還是太高了
- 優點: 在渲染數量上性能很好
- 缺點:
- 想要實現虛擬列表一樣的渲染, 不可控(在其他場景下是一種比較好的方案, 比如動畫,地圖等)
- 在 canvas 中的樣式難以把控
IntersectionObserver
IntersectionObserver 提供了一種異步觀察目標元素與視口的交叉狀態,簡單地說就是能監聽到某個元素是否會被我們看到,當我們看到這個元素時,可以執行一些回調函數來處理某些事務。
注意:
IntersectionObserver的實現,應該采用requestIdleCallback(),即只有線程空閑下來,才會執行觀察器。這意味着,這個觀察器的優先級非常低,只在其他任務執行完,瀏覽器有了空閑才會執行。
通過這個 api 我們可以做一些嘗試, 來實現類似虛擬列表的方案
這里我實現了往下滑動的一個虛擬列表 demo, 主要思路是監聽列表中所有的 dom, 當他消失的時候, 移除並去除監聽, 然后添加新的 DOM和監聽
核心代碼:
const intersectionObserver = new IntersectionObserver(function (entries) {
entries.forEach(item => {
// 0 表示消失
if (item.intersectionRatio === 0) {
// 最后末尾添加
intersectionObserver.unobserve(item.target)
item.target.remove()
addDom()
}
})
});
谷歌的性能分析(首次進入頁面和持續滾動 1000 個 item):
可以看到基本是沒有阻塞的, 此方案是可行的, 在初始渲染和滾動之間都沒問題
詳情點擊可以查看, demo只實現了往下滾動方案:
https://codesandbox.io/s/snowy-glade-w3i9fh?file=/index.html
進一步優化
現在 IntersectionObserver 已經實現了類似虛擬列表的功能了, 但是頻繁的添加監聽和解除, 怎么都看起來會有隱患, 所以我打算采取擴大化的方案:
大概的思路:
當前列表以 10 個為一隊,當前列表總共渲染 30 個, 當滾動到第 20 個時, 觸發事件, 加載第 30-40 個, 同時刪除0-10 個, 后面依次觸發
這樣的話觸發次數和監聽次數會呈倍數下降, 當然代價就是同事渲染的 dom 數量增加, 后續我們再度增加每一隊的數量, 可以維持一個
dom 數和監聽較為平衡的狀態
兼容
關於 IntersectionObserver 的兼容, 通過 polyfill, 可獲得大多瀏覽器的兼容, 最低支持 IE7, 具體可查看: https://github.com/w3c/IntersectionObserver/tree/main/polyfill
總結下優缺點:
- 優點: 利用原生 API 實現的一種虛擬列表方案, 沒有數據瓶頸
- 缺點:
- 生產中的框架的適配性不夠高, 實現較為復雜
- 在無限滾動中頻繁觸發監聽和解除, 可能存在某些問題
框架
前面說了那么多方法, 都是在非框架中的實現, 這里我們來看一下在 react 中列表的表現
react
這是一個長度為 1萬 的列表渲染
function App() {
const [list, setList] = useState([]);
useEffect(() => {
setList([...new Array(50000)]);
}, []);
return (
<div className="App">
{list.map((item, index) => {
return <div key={index}>item {index}</div>;
})}
</div>
);
}
在 demo 運行的時候可以明顯地感知到頁面地卡頓了
通過谷歌分析, 在 5 萬的數量級下, 重新刷新之后, 10 秒仍然沒有渲染完畢
當然框架中的性能肯定是沒有原生強的, 這個結論是在意料之內的
在線 demo 地址: https://codesandbox.io/s/angry-roentgen-25vipz
還需要注意的一點是, 大量數據在 template 中的傳輸問題:
// 這個 list 的數量級是幾千甚至上萬的, 會導致卡頓成倍的增加,
<Foo list={list}/>
這個結論不管是在 vue 中還是 react 都是適用的, 所以大量數據的傳遞, 就得在內存中賦值,獲取, 而不是通過模塊,render 等常規方式
如果數量級在是 100 的, 我們也可以考慮優化, 可以積少成多
startTransition
在 react18 中還會有新的 API startTransition
:
startTransition(() => {
setList([...new Array(10000)]);
})
這個 API 的作用, 和我上面所說的 requestAnimationFrame
大同小異, 他並不能增強性能, 但是可以避免卡頓, 優先渲染其他的組件, 避免白屏
虛擬列表
這里正式引入虛擬列表的概念
儲存所有列表元素的位置,只渲染可視區 (viewport)內的列表元素,當可視區滾動時,根據滾動的 offset 大小以及所有列表元素的位置,計算在可視區應該渲染哪些元素。
一張動圖看懂原理:
最小實現方案
這里我們嘗試下自己實現一個最小的虛擬列表方案:
// 這是一個 react demo, 在 vue 項目中, 原理類似, 除了數據源的設置外基本沒什么變化
// 數據源以及配置屬性
const totalData = [...new Array(10000)].map((item, index)=>({
index
}))
const total = totalData.length
const itemSize = 30
const maxHeight = 300
function App() {
const [list, setList] = useState(() => totalData.slice(0, 20));
const onScroll = (ev) => {
const scrollTop = ev.target.scrollTop
const startIndex = Math.max(Math.floor(scrollTop / itemSize) -5, 0);
const endIndex = Math.min(startIndex + (maxHeight/itemSize) + 5, total);
setList(totalData.slice(startIndex, endIndex))
}
return (
<div onScroll={onScroll} style={{height: maxHeight, overflow: 'auto',}}>
<div style={{height: total * itemSize, width: '100%', position: 'relative',}}>
{list.map((item) => {
return <div style={{
position: "absolute",
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.index *itemSize}px)`,
}} key={item.index}>item {item.index}</div>;
})}
</div>
</div>
);
}
可查看在線 demo: https://codesandbox.io/s/agitated-shtern-phcg6z?file=/src/App.js
這就是一個最小巧的虛擬列表實例, 他主要分為 2 部分
- 需要有容器包裹, 並且使用 CSS 撐大高度, 實際渲染的 item 需要使用 transform 來顯示到正確的位置
- 監聽外部容器的滾動, 在滾動時, 動態切片原來的數據源, 同時替換需要顯示的列表
來查看下他的性能:
基本沒有阻塞, 偶爾會有一點點失幀
這個 demo 並不是一個最終的形態, 他還有很多地方可以優化
比如緩存, 邏輯的提取, CSS再度簡化, 控制下滾動的觸發頻率, 滾動的方向控制等等, 有很多可以優化的點
其他庫
- react-virtualized 很多庫推薦的虛擬列表解決方案, 大而全
- react-window react-virtualized 推薦的庫, 更加輕量級替代方案。
- react-virtual 虛擬列表的 hooks 形式, 類似於我的 demo 中的邏輯封裝
chrome 官方的支持
virtual-scroller
在 Chrome dev summit 2018 上,谷歌工程經理 Gray Norton 向我們介紹 virtual-scroller,一個 Web 滾動組件,未來它可能會成為 Web 高層級 API(Layered
API)的一部分。它的目標是解決長列表的性能問題,消除離屏渲染。
但是, 開發了部分之后, 經過內部討論, 還是先終止此 API, 轉向 CSS 的開發
鏈接: https://github.com/WICG/virtual-scroller/issues/201
Chrome 關於 virtual-scroller 的介紹: https://chromestatus.com/feature/5673195159945216
content-visibility
這就是之后開發的新 CSS 屬性
Chromium 85 開始有了 content-visibility 屬性,這可能是對於提升頁面加載性能提升最有效的CSS屬性,content-visibility 讓用戶代理正常情況下跳過元素渲染工作(包括 layout 和 painting ),除非需要的時候進行渲染工作。如果頁面有大量離屏(off-screen)的內容,借助 content-visibility 屬性可以跳過離屏內容的渲染,加快用戶首屏渲染時間,可以做到減少的頁面可交互的等待時間
具體介紹: https://web.dev/content-visibility/
使用方式是直接添加 CSS 屬性
#content {
content-visibility: auto;
}
有點遺憾的是, 他的效果是增強渲染性能, 但是在大量數據初始化的時候, 依舊會卡頓, 沒有虛擬列表來的那么直接有效
但是在我們減小首屏渲染時間的時候可以考慮利用起來
總結
在多數據下的性能優化, 有很多中解決方案
- requestAnimationFrame
- canvas
- IntersectionObserver
- startTransition
- 虛擬列表
- content-visibility
總的來說虛擬列表是最有效的, 同時也可以使用最簡單 demo 級別來臨時優化代碼