VUE 渲染效能優化

Incimo發表於2020-12-25

VUE 渲染效能優化

專案地址

教你如何渲染 5000 個 svg圖示不卡頓

這是無意間發現的一個課題,引起了我的興趣…

事情是這樣的,有一天我發現,專案在進入路由icon 集合頁面時,頁面有概率出現短暫的頓挫感

像這樣

在這裡插入圖片描述
看麵包屑就知道卡了,淦

年輕人,這樣好嗎?這樣不好。

定位問題:

Icon 集合是從quasar中匯出的源資料,一共是material Icon1317 個 +fontawesome-v5 Icon 1601 個 = 2918 個SVG 圖示。正是因為一次渲染的svg圖示太多,所以在進入頁面時會有頓挫感。

呵,強迫症,我就是要把它搞掂

第一次優化:

既然一次載入 3000 個圖示會引起卡頓,那麼就使用分組渲染的方式來載入圖示,之後使用v-for渲染出分組後的中的圖示,程式碼如下:

initMaterialIcon () {
  // 基於 quasar-ui 的約定,需要將 icon 的名稱轉為下劃線的形式(蛇形)
  // 這一段只是單純的獲取圖示 materialIcons 下劃線格式命名集合
  for (const i in materialIconsSet) {
    this.materialIcons_key.push(this.toLowerLine(i))
  }
  // 將獲取到的 materialIcons_key 圖示名稱集合分組,其中每組 300 個,每 300 ms 渲染一組
  let i = 0
  this.timer1 = setInterval(() => {
    this.group_md = this.group_md.concat(this.materialIcons_key.slice(i, i + 300))
    i += 300
    if (i > 1320) {
      clearInterval(this.timer1)
    }
  }, 300)
}

效果如下:

在這裡插入圖片描述
發現並沒有得到很好的效果,還是會有 5 到 10 幀左右的卡頓,有不過也在意料之中。

看來渲染svg的話沒有單純的文字那麼簡單,看了應該是和迴流 / 重繪有關了,不懂的同學可以看一下這篇文章 你真的瞭解迴流和重繪嗎

去看了一下v-for的原始碼,使用的是createElement來建立節點,createElement相比createDocumentFragment來說渲染能力差一點

:::tip
當然雖說createElement渲染能力比較差,但是相比createDocumentFragment它也有自己的特點,需要了解的請自行百度哦
:::

第二次優化

確定好以迴流 / 重繪為下一次的解決方向後,接下來就是使用createDocumentFragmentrequestAnimationFrame來進行渲染操作了

:::tip
requestAnimationFrame可以暫且把它等價為setTimeout,不同的是requestAnimationFrame的執行週期是根據你當前螢幕裝置的重新整理頻率來確定的,比如你的螢幕裝置的頻率是60 HZ,那麼requestAnimationFrame的執行週期就是 16ms,等價於 setTimeout( ()=>{ }, 16ms )
:::

因為一開始不能確定渲染一個比較大的svg圖示需要多久,因此第一次寫程式碼時,就直接寫為

螢幕每重新整理一次渲染一個svg圖示,程式碼如下:

initMaterialIcon () {
  // 獲取圖示 materialIcons 下劃線格式命名集合
  for (const i in materialIconsSet) {
    this.materialIcons_key.push(this.toLowerLine(i))
  }
  this.$nextTick(() => {
    // 渲染入口
    this.RenderMDIcon(0)
  })
}

RenderMDIcon (i) {
  if (i >= 1317) {
    cancelAnimationFrame(this.timer1)
  } else {
    // 建立虛擬文件碎片
    const fragment = document.createDocumentFragment()
    // 建立虛擬節點
    const li = document.createElement('li')
    // 寫入資料
    li.innerText = this.materialIcons_key[i]
    li.setAttribute('class', 'myIcon material-icons q-icon notranslate')
    fragment.appendChild(li)
    // 將虛擬文件碎片加入正式文件流
    document.getElementById('mdtext').appendChild(fragment)
    i++  // 步長 1
    this.timer1 = requestAnimationFrame(() => {
      this.RenderMDIcon(i)
    })
  }
},

效果如下:

在這裡插入圖片描述
可以看到螢幕每重新整理一次渲染一個svg圖示還是很輕鬆的,接下來就是慢慢的更改單次渲染圖示數,找到單次渲染但又不卡的滑鼠數量作為步長即可。

(¬_¬) 狗血的是,第一頁有 1317 個圖示,使用這個方法一次渲染 1317 個圖示都不會卡頓…

程式碼如下:

// materialIcons 圖示集合初始化
initMaterial () {
  // 獲取圖示 materialIcons 下劃線格式命名集合
  for (const i in materialIconsSet) {
    this.materialIcons_key.push(this.toLowerLine(i))
  }
  this.$nextTick(() => {
    this.RenderMDIcon(0)
  })
},
// 渲染圖示
RenderMDIcon (i) {
  if (i >= 1317) {
    cancelAnimationFrame(this.timer1)
  } else {
    const fragment = document.createDocumentFragment()
    for (let j = i; j < i + 1317; j++) {
      const li = document.createElement('li')
      li.innerText = this.materialIcons_key[j]
      li.setAttribute('class', 'myIcon material-icons q-icon notranslate')
      li.setAttribute('onclick', 'window.copyIcon(' + "'" + this.materialIcons_key[j] + "'" + ')')
      fragment.appendChild(li)
      i += 1317   // 步長 1317
    }
    document.getElementById('mdtext').appendChild(fragment)
      this.timer1 = requestAnimationFrame(() => {
      this.RenderMDIcon(i)
    })
  }
},

效果如下:

在這裡插入圖片描述
確認過眼神,是我想要的效果

:::tip
題外話:經過測試,這個方式單次渲染超過 6000 個圖示時,才會開始卡頓。如果還想繼續載入,可以使用IntersectionObserver這個 API 進行埋點,來載入接下來的資料。感興趣請自行百度哦。
:::

接下來說一下其中的原理

有的同學可能會認為,是因為requestAnimationFrame的執行頻率緊貼著螢幕的重新整理頻率,因此渲染的速度是最佳的速度,所以看不出來卡頓。其實這是不正確的。

關鍵點在於createDocumentFragment

createDocumentFragment會建立一個虛擬的文件碎片節點

它將需要渲染的操作統一集中在虛擬的文件碎片節點裡,之後再將虛擬的文件碎片節點插入真實的文件流,這樣的話就能保證只進行一次迴流操作。

渲染時如下圖所示,只進行了 1 次迴流:
在這裡插入圖片描述
(圖片來自:頁面優化,DocumentFragment物件詳解

而如果按照普通的方式向文件流插入節點,每插入一次新節點就會執行一次迴流操作,這樣開銷非常大。

渲染時如下圖所示,就進行了 5 次迴流:
在這裡插入圖片描述

第三種方法:虛擬滾動

網路上對於虛擬滾動的介紹目前已經比較多了,不知道的請先自行百度

它有很多好處,比如僅渲染可見項,在任何給定時間點DOM樹中的節點數量最少,並且記憶體消耗保持在最低水平,不用擔心記憶體洩漏的問題等。

對於圖示的渲染我也使用過虛擬滾動,用的是 quasar 內建的虛擬滾動元件,效果如下:

在這裡插入圖片描述
我還模擬了 1w 個 icon 的資料,使用虛擬滾動,模擬使用者操作 90 s 頁面時的效能分析:

可以看到在記憶體到達一定的閾值之後會被立即釋放掉

在這裡插入圖片描述
不足之處就是每次渲染將要出現的資料時,會有些許卡頓

圖中 FPS 欄上面一段一段紅色幀就是渲染新 icon 時出現的卡頓

為什麼最後沒有使用虛擬滾動?有下面幾個因素:

  1. 虛擬滾動更適合於列表型的資料,即每一行為一項,這樣能更好的計算出滾動區域的高度。但是當前這一頁是一個 icon 集合頁,每個圖示作為一項,此時想實現每一行為一項,需要通過計算將 icon 根據自身寬度以及滾動區域寬度進行分行操作,比如將 15 個 icon 作為一行,之後根據行高和行數生成虛擬滾動。然而這樣做的話又需要監聽當前螢幕的變化去重新計算每一行需要多少個 icno,來完成其他的響應式操作,對於只顯示 3000 個圖示的頁面來說,我覺得開銷有點大…

  2. quasar內建的虛擬滾動元件在渲染即將出現的 icon 時,會有短暫卡頓,應該也是與迴流有關。關鍵是,卡頓這一下,我就有些受不了了 ( ̄(00) ̄) 這個才是主要原因…

  3. 百度會發現很多寫著高效能的虛線滾動元件,如果想要自己完成一個虛擬滾動還是需要在其中下不少功夫 ( ̄(00) ̄)

最後還順便解決了一個記憶體洩漏問題

這是無意間在效能分析皮膚看到的,從圖中可以看到當前頁是一個靜態頁面,然後分析 5 s,可以看到堆疊資訊一直在那跳來跳去,不知道執行了啥 js 語句用了 9786ms 。

年輕人,一看就知道這樣不好,要講武德。

在這裡插入圖片描述
開啟Bottom-up皮膚就能看到,有幾個指令碼一直在執行是什麼鬼。

在這裡插入圖片描述

好傢伙,馬上定位問題:

原因是封裝的 lottie 元件中新增了事件監聽,並且沒有在 lottie 被銷燬時移除該監聽

不要問我為什麼知道,因為程式碼是我寫的, ( ̄(00) ̄)

this.lottie.addEventListener('data_ready', handlerFinish)

如何解決:

在元件被銷燬的同時銷燬物件即可

beforeDestroy () {
  this.lottie.destroy()
  this.lottie = null
}

看一下效果,是不是爽多了:

在這裡插入圖片描述

到這裡這一次效能優化就又結束了,希望能成為你成功路上的絆腳石…

相關文章