從一次性能優化看Vue的一個「feature」

使用過 Vue 的人都知道,Vue 數據驅動視圖是基於gettersetter的實現的依賴收集,實現數據變更精準更新視圖,而後修改 DOM 節點,可是實際上真的那麼「精準」嗎?vue

背景知識

首先,咱們都知道 Vue 或者 React 得以高效更新的一個核心是使用了 virtual dom(下面稱 vdom),當有數據變更的時候,經過對組件新舊 vdomdiff 操做,計算出須要實際修改的 DOM 節點而後進行增刪改操做。從這能夠知道,diff 的準確性和性能,是總體更新性能的一個關鍵環節。git

VueReact 優秀(不是指總體,單指 diff 這一塊)的緣由是 Vue 使用了 gettersetter 實現的視圖對數據依賴的精確收集,即:當數據更新時,能夠精確觸發使用了該數據的組件進行更新過程。github

可是事實真的如此嗎?chrome

業務場景

當前業務要作的是一個 Web 版的 逐字歌詞製做器,顧名思義,咱們平時在 QQ音樂 上看到的 逐字 歌詞就是使用這個工具是作出來的,大概樣子如圖:api

逐字歌詞(只要你喜歡這首歌,咱們就是好朋友)

以前不管是 QQ 音樂、酷狗仍是酷個人逐字歌詞製做器都是內嵌在桌面版客戶端中的一個工具,雖然各個工具略有差別,可是核心交互都是同樣的,以下圖:app

製做器界面

簡單來講就是咱們給歌詞的每一個字**「打標」**,經過鍵盤 上下左右 等操做控制遊標(藍色框框)的移動,給一個字標記上 開始時間持續時間dom

這裏面的實現細節不贅述,是一個有趣但異常複雜的過程。最後實現出來上線正常使用了一段時間,直到某個節目某些歌的出現,打破了這個功能已經完美實現了的「夢想」。工具

問題出現

那是一個月黑風高,雷電交加的夜晚,測試在羣裏反饋了一個問題:性能

點開發現以下面這樣,第一行第二個字開始,後面所有卡住了大概兩秒,直接跳到了第二行測試

很明顯這是一個很是嚴重的性能問題,須要緊急解決。

定位問題

找到這首歌,發現是一首長達 17 分鐘的說唱歌曲《現實 VS 夢想》(雖然這個 battle 夢想贏了,可是我卻被現實戰勝了┑( ̄Д  ̄)┍)。

普通歌曲 VS 這首說唱

咱們開啓 Vue 的 performance模式,打開 chrome 的 performance 面板看看到底瓶頸在哪。

操做 20 秒,前 10 秒每秒按一次「向右」,後十秒按住「向右」不放,看看時間耗在了哪裏?

不看不知道,一看嚇一跳

從上面能夠看出前十秒幀率波動很是大,然後面用 10 秒渲染了一幀,徹底就是卡死的節奏,對應的就是前面動圖後兩秒的效果。從面板下部分的火焰圖能夠看出來,scripting計算密密麻麻,佔用了很是多的時間。

再看按一次「向右」事件的回調耗時,一次回調的耗時就達到了 ** 243毫秒**

再看 vue-dev-tool 面板

你沒看錯,ElButton 的更新這裏顯示用了 2000 多秒,一開始我覺得這是 dev-tool 的一個統計的 bug,因此直接看 lyricMaker 這個組件(就是上面那個歌詞製做器)。從上面火焰圖能夠看出大多數的時間都花在了腳本運算,結合頁面邏輯,製做器中每一行、每一個字的展現樣式,都須要動態計算(每次前進後退都算一遍)的,因此引發性能問題的緣由定爲:

當歌詞的字很是多的時候,每次移動光標,觸發了過多的 diff 計算,致使頁面卡頓。

解決問題

既然知道了問題是字過多引發的 diff 計算耗時過多,那麼就和解決最多見的那類型問題—— DOM節點過多怎麼優化?的問題同樣了:刪除掉不在可視區域的節點

如上圖,只須要把 >±6 當前行的行歌詞都隱藏掉便可,改造後再看性能面板和 dev-tool 面板:

能夠看出事件回調的耗時已經從 243ms -> 8ms,從火焰圖中看出,方法的調用已經少了很是多,目前看來優化的成效是明顯的。

若是咱們看問題只看表面,那到此就已經撒花結束了。不過上面截圖的一個小細節,引發了個人興趣。

Dig Deep

再看 vue-del-tool 面板,細心的話咱們能夠發現 ElButton 組件的耗時從 2390310ms 減小到了 15255ms,足足減小了 99% 的耗時,其中 updateRender 佔用了 99%的耗時。

lyricMaker 組件的耗時只是減小了一半,ElButton更像是引發性能大提高的關鍵所在,從這開始我猜想一開始的幾十萬毫秒,並非一個錯誤顯示,而是真實的狀況。鑑於藍翔挖掘精神,咱們去把 dev-tool 的源碼弄下來找出這個面板顯示時間的統計邏輯,主要在這個方法:代碼

const COMPONENT_HOOKS = [
  'beforeCreate',
  'created',
  'beforeMount',
  'mounted',
  'beforeUpdate',
  'updated',
  'beforeDestroyed',
  'destroyed'
]

const RENDER_HOOKS = {
  beforeMount: { after: 'mountRender' },
  mounted: { before: 'mountRender' },
  beforeUpdate: { after: 'updateRender' },
  updated: { before: 'updateRender' }
}
function applyHooks (vm) {
  if (vm.$options.$_devtoolsPerfHooks) return
  vm.$options.$_devtoolsPerfHooks = true

  const renderMetrics = {}

  COMPONENT_HOOKS.forEach(hook => {
    const renderHook = RENDER_HOOKS[hook]

    const handler = function () {
      if (SharedData.recordPerf) {
        // Before
        const time = performance.now()
        if (renderHook && renderHook.before) {
          // Render hook ends before one hook
          const metric = renderMetrics[renderHook.before]
          if (metric) {
            metric.end = time
            addComponentMetric(vm.$options, renderHook.before, metric.start, metric.end)
          }
        }

        // After
        this.$once(`hook:${hook}`, () => {
          const newTime = performance.now()
          addComponentMetric(vm.$options, hook, time, newTime)
          if (renderHook && renderHook.after) {
            // Render hook starts after one hook
            renderMetrics[renderHook.after] = {
              start: newTime,
              end: 0
            }
          }
        })
      }
    }
    const currentValue = vm.$options[hook]
    if (Array.isArray(currentValue)) {
      vm.$options[hook] = [handler, ...currentValue]
    } else if (typeof currentValue === 'function') {
      vm.$options[hook] = [handler, currentValue]
    } else {
      vm.$options[hook] = [handler]
    }
  })
}
複製代碼

從上面代碼看出(COMPONENT_HOOKS.forEach開始),updateRender 這個耗時是從 beforeUpdate 開始到 updated 結束這段時間,是每一個組件都會算到統計中,好比當次數據變化有 1 千個 button 更新,共花了 1 秒,那麼這個面板統計 ElButton 的 updateRender 時間是 1秒 * 1000,也就是 10000毫秒。

因此說上面咱們的 ElButton updateRender 用了 2390 秒是真實存在的,只不過還須要除以一個組件個數,並非顯示錯誤!

咱們先來看一個 Demo,點擊「點我」按鈕,更新 i 的值,能夠看到控制檯輸出 3 btn updated

Wait~ 思考 3 秒,是否是有哪裏怪怪的?

1 秒。

2 秒。。

3 秒。。。

button 組件爲何會更新?!和文章一開始說的高效更新機制是否是有點衝突了?咱們從代碼能夠看出 button 並無使用到 i 變量,那麼在 i 變化先後應該是不會觸發更新的。而逐字製做器中的 button 寫法也是如此:

<div class="tool">
    <el-button type="info" @click="handleLineModify(lineIndex)">修改</el-button> <el-button type="danger" @click="handleLineDelete(lineIndex)">刪除</el-button> </div> 複製代碼

一樣由於不相關的狀態數據更新,引發了這兩個按鈕的更新。這一切的一切究竟是人性的扭曲仍是道德的淪喪,敬請關注今晚23點 59分。。。咳咳,串場了抱歉...

When you have eliminated the impossible, whatever remains,however improbable,must be the truth.
----Sherlock·Holmes

既然事已至此,咱們去深刻研究下 Vue 的更新流程是怎樣的。(這裏省略一萬字漫長的研究 Vue 源碼的過程)

最終能夠定位到,在 ElButton 被觸發更新前,是由於 lyricMaker 組件在 diff 過程當中當遍歷到 ElButton 這個元素的時候,強制執行了 ElButton$forceUpadte 方法,從而引發的性能雪崩。

完整源碼在這:代碼

精簡版代碼

從源碼能夠看出,有兩種狀況致使觸發強制更新:

  • 第一個是:hasDynamicScopedSlot 爲 true,至於這個值什麼時候爲真,又是一個能夠寫一篇文章的故事,這裏暫且不表,如今主要看第二個。

  • 第二個是 當組件擁有子元素(靜態的、動態的)的時候,每次 diff 都會強制更新,這是 Vue 的一個 Feature!。咱們看看 Vue 的做者尤大是怎麼說的:github.com/vuejs/vue/p…

野生翻譯菌:包含靜態 slot 的組件,由於有可能在父組件狀態更新以後,slot 已經發生了變化,因此須要強制更新一次此子組件。

可是如 PR 所說,這個問題在 Vue3.0中就不會存在了,全部 slot 都會統一爲 scope slot 處理。當前咱們也有一個不太優雅的解決方案是手動把全部靜態內容都設置爲 scope slot。能夠看這個 Demo

思考題

  • 上面的第一個 Demo,若是把 <!-- updateCount: {{updateCount}} --> 這個註釋放開,會發生什麼事?

總結

  1. 有時候咱們解決問題了,也可能只是歪打正着而已。

  2. 有時候咱們代碼出問題了,並非 bug,是 Feature!


版權聲明:原創文章,如需轉載,請註明出處「本文首發於xlaoyu.info

相關文章
相關標籤/搜索