前端多資料渲染優化

Grewer發表於2022-04-01

前言

在前一段時間做一個需求的時候, 碰到一個自定義列表的功能, 他的所有資料顯示都是通過 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/snow...

進一步優化

現在 IntersectionObserver 已經實現了類似虛擬列表的功能了, 但是頻繁的新增監聽和解除, 怎麼都看起來會有隱患, 所以我打算採取擴大化的方案:

大概的思路:

當前列表以 10 個為一隊,當前列表總共渲染 30 個, 當滾動到第 20 個時, 觸發事件, 載入第 30-40 個, 同時刪除0-10 個, 後面依次觸發

這樣的話觸發次數和監聽次數會呈倍數下降, 當然代價就是同事渲染的 dom 數量增加, 後續我們再度增加每一隊的數量, 可以維持一個
dom 數和監聽較為平衡的狀態

相容

關於 IntersectionObserver 的相容, 通過 polyfill, 可獲得大多瀏覽器的相容, 最低支援 IE7, 具體可檢視: https://github.com/w3c/Inters...

總結下優缺點:

  • 優點: 利用原生 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/angr...

還需要注意的一點是, 大量資料在 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/agit...

這就是一個最小巧的虛擬列表例項, 他主要分為 2 部分

  1. 需要有容器包裹, 並且使用 CSS 撐大高度, 實際渲染的 item 需要使用 transform 來顯示到正確的位置
  2. 監聽外部容器的滾動, 在滾動時, 動態切片原來的資料來源, 同時替換需要顯示的列表

來檢視下他的效能:

基本沒有阻塞, 偶爾會有一點點失幀

這個 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/virtu...
Chrome 關於 virtual-scroller 的介紹: https://chromestatus.com/feat...

content-visibility

這就是之後開發的新 CSS 屬性

Chromium 85 開始有了 content-visibility 屬性,這可能是對於提升頁面載入效能提升最有效的CSS屬性,content-visibility 讓使用者代理正常情況下跳過元素渲染工作(包括 layout 和 painting ),除非需要的時候進行渲染工作。如果頁面有大量離屏(off-screen)的內容,藉助 content-visibility 屬性可以跳過離屏內容的渲染,加快使用者首屏渲染時間,可以做到減少的頁面可互動的等待時間

具體介紹: https://web.dev/content-visib...

使用方式是直接新增 CSS 屬性

#content {
  content-visibility: auto;
}

有點遺憾的是, 他的效果是增強渲染效能, 但是在大量資料初始化的時候, 依舊會卡頓, 沒有虛擬列表來的那麼直接有效

但是在我們減小首屏渲染時間的時候可以考慮利用起來

總結

在多資料下的效能優化, 有很多中解決方案

  • requestAnimationFrame
  • canvas
  • IntersectionObserver
  • startTransition
  • 虛擬列表
  • content-visibility

總的來說虛擬列表是最有效的, 同時也可以使用最簡單 demo 級別來臨時優化程式碼

相關文章