Vue 效能最佳化方式及原理

前言

我們在使用 Vue 或其他框架的日常開發中,或多或少的都會遇到一些效能問題,儘管 Vue 內部已經幫助我們做了許多最佳化,但是還是有些問題是需要我們主動去避免的。我在我的日常開中,以及網上各種大佬的文章中總結了一些容易產生效能問題的場景以及針對這些問題最佳化的技巧,這篇文章就來探討下,希望對你有所幫助。

使用v-slot:slotName,而不是slot=“slotName”

v-slot

是 2。6 新增的語法,具體可檢視:Vue2。6,2。6 釋出已經是快兩年前的事情了,但是現在仍然有不少人仍然在使用

slot=“slotName”

這個語法。雖然這兩個語法都能達到相同的效果,但是內部的邏輯確實不一樣的,下面來看下這兩種方式有什麼不同之處。

我們先來看下這兩種語法分別會被編譯成什麼:

使用新的寫法,對於父元件中的以下模板:

  {{name}}複製程式碼

會被編譯成:

function render() {  with (this) {    return _c(‘child’, {      scopedSlots: _u([        {          key: ‘name’,          fn: function () {            return [_v(_s(name))]          },          proxy: true        }      ])    })  }}複製程式碼

使用舊的寫法,對於以下模板:

  {{name}}複製程式碼

會被編譯成:

function render() {  with (this) {    return _c(      ‘child’,      [        _c(          ‘template’,          {            slot: ‘name’          },          [_v(_s(name))]        )      ],    )  }}複製程式碼

透過編譯後的程式碼可以發現,

舊的寫法是將插槽內容作為 children 渲染的,會在父元件的渲染函式中建立,插槽內容的依賴會被父元件收集(name 的 dep 收集到父元件的渲染 watcher),而新的寫法將插槽內容放在了 scopedSlots 中,會在子元件的渲染函式中呼叫,插槽內容的依賴會被子元件收集(name 的 dep 收集到子元件的渲染 watcher)

,最終導致的結果就是:當我們修改 name 這個屬性時,舊的寫法是呼叫父元件的更新(呼叫父元件的渲染 watcher),然後在父元件更新過程中呼叫子元件更新(prePatch => updateChildComponent),而新的寫法則是直接呼叫子元件的更新(呼叫子元件的渲染 watcher)。

這樣一來,舊的寫法在更新時就多了一個父元件更新的過程,而新的寫法由於直接更新子元件,就會更加高效,效能更好,所以推薦始終使用

v-slot:slotName

語法。

使用計算屬性

這一點已經被提及很多次了,計算屬性最大的一個特點就是它是可以被快取的,這個快取指的是隻要它的依賴的不發生改變,它就不會被重新求值,再次訪問時會直接拿到快取的值,在做一些複雜的計算時,可以極大提升效能。可以看以下程式碼:

複製程式碼

這個例子中,在 created、mounted 以及模板中都訪問了 superCount 屬性,這三次訪問中,實際上只有第一次即

created

時才會對 superCount 求值,由於 count 屬性並未改變,其餘兩次都是直接返回快取的 value,對於計算屬性更加詳細的介紹可以看我之前寫的文章:Vue computed 是如何實現的?。

使用函式式元件

對於某些元件,如果我們只是用來顯示一些資料,不需要管理狀態,監聽資料等,那麼就可以用函式式元件。函式式元件是無狀態的,無例項的,在初始化時不需要初始化狀態,不需要建立例項,也不需要去處理生命週期等,相比有狀態元件,會更加輕量,同時效能也更好。具體的函式式元件使用方式可參考官方文件:函式式元件

我們可以寫一個簡單的 demo 來驗證下這個最佳化:

// UserProfile。vue// App。vue複製程式碼

UserProfile 這個元件只渲染了 props 的 name,然後在 App。vue 中呼叫 500 次,統計從 beforeMount 到 mounted 的耗時,即為 500 個子元件(UserProfile)初始化的耗時。

經過我多次嘗試後,發現耗時一直在 30ms 左右,那麼現在我們再把改成 UserProfile 改成函式式元件:

  {{ props。name }}

複製程式碼

此時再經過多次嘗試後,初始化的耗時一直在 10-15ms,這些足以說明函式式元件比有狀態元件有著更好的效能。

結合場景使用 v-show 和 v-if

以下是兩個使用 v-show 和 v-if 的模板

複製程式碼

複製程式碼

這兩者的作用都是用來控制某些元件或 DOM 的顯示 / 隱藏,在討論它們的效能差異之前,先來分析下這兩者有何不同。其中,v-if 的模板會被編譯成:

function render() {  with (this) {    return _c(      ‘div’,      [        visible          ? _c(‘UserProfile’, {              attrs: {                user: user1              }            })          : _e(),        _c(          ‘button’,          {            on: {              click: function ($event) {                visible = !visible              }            }          },          [_v(‘toggle’)]        )      ],    )  }}複製程式碼

可以看到,v-if 的部分被轉換成了一個三元表示式,visible 為 true 時,建立一個 UserProfile 的 vnode,否則建立一個空 vnode,在 patch 的時候,新舊節點不一樣,就會移除舊的節點或建立新的節點,這樣的話

UserProfile

也會跟著建立 / 銷燬。如果

UserProfile

元件裡有很多 DOM,或者要執行很多初始化 / 銷燬邏輯,那麼隨著 visible 的切換,勢必會浪費掉很多效能。這個時候就可以用 v-show 進行最佳化,我們來看下 v-show 編譯後的程式碼:

function render() {  with (this) {    return _c(      ‘div’,      [        _c(‘UserProfile’, {          directives: [            {              name: ‘show’,              rawName: ‘v-show’,              value: visible,              expression: ‘visible’            }          ],          attrs: {            user: user1          }        }),        _c(          ‘button’,          {            on: {              click: function ($event) {                visible = !visible              }            }          },          [_v(‘toggle’)]        )      ],    )  }}複製程式碼

v-show

被編譯成了

directives

,實際上,v-show 是一個 Vue 內部的指令,在這個指令的程式碼中,主要執行了以下邏輯:

el。style。display = value ? el。__vOriginalDisplay : ‘none’複製程式碼

它其實是透過切換元素的 display 屬性來控制的,和 v-if 相比,不需要在 patch 階段建立 / 移除節點,只是根據

v-show

上繫結的值來控制 DOM 元素的

style。display

屬性,在頻繁切換的場景下就可以節省很多效能。

但是並不是說

v-show

可以在任何情況下都替換

v-if

,如果初始值是

false

時,

v-if

並不會建立隱藏的節點,但是

v-show

會建立,並透過設定

style。display=‘none’

來隱藏,雖然外表看上去這個 DOM 都是被隱藏的,但是

v-show

已經完整的走了一遍建立的流程,造成了效能的浪費。

所以,

v-if

的優勢體現在初始化時,

v-show

體現在更新時,當然並不是要求你絕對按照這個方式來,比如某些元件初始化時會請求資料,而你想先隱藏元件,然後在顯示時能立刻看到資料,這時候就可以用

v-show

,又或者你想每次顯示這個元件時都是最新的資料,那麼你就可以用

v-if

,所以我們要結合具體業務場景去選一個合適的方式。

使用 keep-alive

在動態元件的場景下:

複製程式碼

這個時候有多個元件來回切換,

currentComponent

每變一次,相關的元件就會銷燬 / 建立一次,如果這些元件比較複雜的話,就會造成一定的效能壓力,其實我們可以使用 keep-alive 將這些元件快取起來:

複製程式碼

keep-alive

的作用就是將它包裹的元件在第一次渲染後就快取起來,下次需要時就直接從快取裡面取,避免了不必要的效能浪費,在討論上個問題時,說的是

v-show

初始時效能壓力大,因為它要建立所有的元件,其實可以用

keep-alive

最佳化下:

複製程式碼

這樣的話,初始化時不會渲染

UserProfileB

元件,當切換

visible

時,才會渲染

UserProfileB

元件,同時被

keep-alive

快取下來,頻繁切換時,由於是直接從快取中取,所以會節省很多效能,所以這種方式在初始化和更新時都有較好的效能。

但是

keep-alive

並不是沒有缺點,元件被快取時會佔用記憶體,屬於空間和時間上的取捨,在實際開發中要根據場景選擇合適的方式。

避免 v-for 和 v-if 同時使用

這一點是 Vue 官方的風格指南中明確指出的一點:Vue 風格指南

如以下模板:

          {{ user。name }}  
複製程式碼

會被編譯成:

// 簡化版function render() {  return _c(    ‘ul’,    this。users。map((user) => {      return user。isActive        ? _c(            ‘li’,            {              key: user。id            },            [_v(_s(user。name))]          )        : _e()    }),  )}複製程式碼

可以看到,這裡是先遍歷(v-for),再判斷(v-if),這裡有個問題就是:如果你有一萬條資料,其中只有 100 條是

isActive

狀態的,你只希望顯示這 100 條,但是實際在渲染時,每一次渲染,這一萬條資料都會被遍歷一遍。比如你在這個元件內的其他地方改變了某個響應式資料時,會觸發重新渲染,呼叫渲染函式,呼叫渲染函式時,就會執行到上面的程式碼,從而將這一萬條資料遍歷一遍,即使你的

users

沒有發生任何改變。

為了避免這個問題,在此場景下你可以用計算屬性代替:

複製程式碼

這樣只會在

users

發生改變時才會執行這段遍歷的邏輯,和之前相比,避免了不必要的效能浪費。

始終為 v-for 新增 key,並且不要將 index 作為的 key

這一點是 Vue 風格指南中明確指出的一點,同時也是面試時常問的一點,很多人都習慣的將 index 作為 key,這樣其實是不太好的,index 作為 key 時,將會讓 diff 演算法產生錯誤的判斷,從而帶來一些效能問題,你可以看下 ssh 大佬的文章,深入分析下,為什麼 Vue 中不要用 index 作為 key。在這裡我也透過一個例子來簡單說明下當 index 作為 key 時是如何影響效能的。

看下這個例子:

const Item = {  name: ‘Item’,  props: [‘message’, ‘color’],  render(h) {    debugger    console。log(‘執行了Item的render’)    return h(‘div’, { style: { color: this。color } }, [this。message])  }}new Vue({  name: ‘Parent’,  template: `        

`,  components: { Item },  data() {    return {      list: [        { id: ‘a’, color: ‘#f00’, message: ‘a’ },        { id: ‘b’, color: ‘#0f0’, message: ‘b’ }      ]    }  },  methods: {    reverse() {      this。list。reverse()    }  }})。$mount(‘#app’)複製程式碼

這裡有一個 list,會渲染出來

a b

,點選後會執行

reverse

方法將這個 list 顛倒下順序,你可以將這個例子複製下來,在自己的電腦上看下效果。

我們先來分析用

id

作為 key 時,點選時會發生什麼,

由於 list 發生了改變,會觸發

Parent

元件的重新渲染,拿到新的

vnode

,和舊的

vnode

去執行

patch

,我們主要關心的就是

patch

過程中的

updateChildren

邏輯,

updateChildren

就是對新舊兩個

children

執行

diff

演算法,使盡可能地對節點進行復用,對於我們這個例子而言,此時

舊的children

是:

;[  {    tag: ‘Item’,    key: ‘a’,    propsData: {      color: ‘#f00’,      message: ‘紅色’    }  },  {    tag: ‘Item’,    key: ‘b’,    propsData: {      color: ‘#0f0’,      message: ‘綠色’    }  }]複製程式碼

執行

reverse

後的

新的children

是:

;[  {    tag: ‘Item’,    key: ‘b’,    propsData: {      color: ‘#0f0’,      message: ‘綠色’    }  },  {    tag: ‘Item’,    key: ‘a’,    propsData: {      color: ‘#f00’,      message: ‘紅色’    }  }]複製程式碼

此時執行

updateChildren

updateChildren

會對新舊兩組 children 節點的迴圈進行對比:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  if (isUndef(oldStartVnode)) {    oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left  } else if (isUndef(oldEndVnode)) {    oldEndVnode = oldCh[——oldEndIdx]  } else if (sameVnode(oldStartVnode, newStartVnode)) {    // 對新舊節點執行patchVnode    // 移動指標  } else if (sameVnode(oldEndVnode, newEndVnode)) {    // 對新舊節點執行patchVnode    // 移動指標  } else if (sameVnode(oldStartVnode, newEndVnode)) {    // 對新舊節點執行patchVnode    // 移動oldStartVnode節點    // 移動指標  } else if (sameVnode(oldEndVnode, newStartVnode)) {    // 對新舊節點執行patchVnode    // 移動oldEndVnode節點    // 移動指標  } else {    //。。。  }}複製程式碼

透過

sameVnode

判斷兩個節點是相同節點的話,就會執行相應的邏輯:

function sameVnode(a, b) {  return (    a。key === b。key &&    ((a。tag === b。tag &&      a。isComment === b。isComment &&      isDef(a。data) === isDef(b。data) &&      sameInputType(a, b)) ||      (isTrue(a。isAsyncPlaceholder) &&        a。asyncFactory === b。asyncFactory &&        isUndef(b。asyncFactory。error)))  )}複製程式碼

sameVnode

主要就是透過 key 去判斷,由於我們顛倒了 list 的順序,所以第一輪對比中:

sameVnode(oldStartVnode, newEndVnode)

成立,即舊的首節點和新的尾節點是同一個節點,此時會執行

patchVnode

邏輯,

patchVnode

中會執行

prePatch

prePatch

中會更新 props,此時我們的兩個節點的

propsData

是相同的,都為

{color: ‘#0f0’,message: ‘綠色’}

,這樣的話

Item

元件的 props 就不會更新,

Item

也不會重新渲染。再回到

updateChildren

中,會繼續執行

“移動oldStartVnode節點”

的操作,將 DOM 元素。移動到正確位置,其他節點對比也是同樣的流程。

可以發現,在整個流程中,

只是移動了節點,並沒有觸發 Item 元件的重新渲染

,這樣實現了節點的複用。

我們再來看下使用

index

作為 key 的情況,使用

index

時,

舊的children

是:

;[  {    tag: ‘Item’,    key: 0,    propsData: {      color: ‘#f00’,      message: ‘紅色’    }  },  {    tag: ‘Item’,    key: 1,    propsData: {      color: ‘#0f0’,      message: ‘綠色’    }  }]複製程式碼

執行

reverse

後的

新的children

是:

;[  {    tag: ‘Item’,    key: 0,    propsData: {      color: ‘#0f0’,      message: ‘綠色’    }  },  {    tag: ‘Item’,    key: 1,    propsData: {      color: ‘#f00’,      message: ‘紅色’    }  }]複製程式碼

這裡和

id

作為 key 時的節點就有所不同了,雖然我們把 list 順序顛倒了,但是 key 的順序卻沒變,在

updateChildren

sameVnode(oldStartVnode, newStartVnode)

將會成立,即舊的首節點和新的首節點相同,此時執行

patchVnode -> prePatch -> 更新props

,這個時候舊的 propsData 是

{color: ‘#f00’,message: ‘紅色’}

,新的 propsData 是

{color: ‘#0f0’,message: ‘綠色’}

,更新過後,Item 的 props 將會發生改變,

會觸發 Item 元件的重新渲染

這就是 index 作為 key 和 id 作為 key 時的區別,

id 作為 key 時,僅僅是移動了節點,並沒有觸發 Item 的重新渲染。index 作為 key 時,觸發了 Item 的重新渲染

,可想而知,當 Item 是一個複雜的元件時,必然會引起效能問題。

上面的流程比較複雜,涉及的也比較多,可以拆開寫好幾篇文章,有些地方我只是簡略的說了一下,如果你不是很明白的話,你可以把上面的例子複製下來,在自己的電腦上調式,我在 Item 的渲染函式中加了列印日誌和 debugger,你可以分別用 id 和 index 作為 key 嘗試下,你會發現 id 作為 key 時,Item 的渲染函式沒有執行,但是 index 作為 key 時,Item 的渲染函式執行了,這就是這兩種方式的區別。

延遲渲染

延遲渲染就是分批渲染,假設我們某個頁面裡有一些元件在初始化時需要執行復雜的邏輯:

複製程式碼

這將會佔用很長時間,導致幀數下降、卡頓,其實可以使用分批渲染的方式來進行最佳化,就是先渲染一部分,再渲染另一部分:

參考黃軼老師揭秘 Vue。js 九個效能最佳化技巧中的程式碼:

複製程式碼

其實原理很簡單,主要是維護

displayPriority

變數,透過

requestAnimationFrame

在每一幀渲染時自增,然後我們就可以在元件上透過

v-if=“defer(n)”

使

displayPriority

增加到某一值時再渲染,這樣就可以避免 js 執行時間過長導致的卡頓問題了。

使用非響應式資料

在 Vue 元件初始化資料時,會遞迴遍歷在 data 中定義的每一條資料,透過

Object。defineProperty

將資料改成響應式,這就意味著如果 data 中的資料量很大的話,在初始化時將會使用很長的時間去執行

Object。defineProperty

, 也就會帶來效能問題,這個時候我們可以強制使資料變為非響應式,從而節省時間,看下這個例子:

複製程式碼

heavyData

中有一萬條資料,這裡統計了下從

beforeCreate

created

經歷的時間,對於這個例子而言,這個時間基本上就是初始化資料的時間。

我在我個人的電腦上多次測試,這個時間一直在

40-50ms

,然後我們透過

Object。freeze()

方法,將

heavyData

變為非響應式的再試下:

//。。。data() {  return {    heavyData: Object。freeze(heavyData)  }}//。。。複製程式碼

改完之後再試下,初始化資料的時間變成了

0-1ms

,快了有

40ms

,這

40ms

都是遞迴遍歷

heavyData

執行

Object。defineProperty

的時間。

那麼,為什麼

Object。freeze()

會有這樣的效果呢?對某一物件使用

Object。freeze()

後,將不能向這個物件新增新的屬性,不能刪除已有屬性,不能修改該物件已有屬性的可列舉性、可配置性、可寫性,以及不能修改已有屬性的值。

而 Vue 在將資料改造成響應式之前有個判斷:

export function observe(value, asRootData) {  // 。。。省略其他邏輯  if (    shouldObserve &&    !isServerRendering() &&    (Array。isArray(value) || isPlainObject(value)) &&    Object。isExtensible(value) &&    !value。_isVue  ) {    ob = new Observer(value)  }  // 。。。省略其他邏輯}複製程式碼

這個判斷條件中有一個

Object。isExtensible(value)

,這個方法是判斷一個物件是否是可擴充套件的,由於我們使用了

Object。freeze()

,這裡肯定就返回了

false

,所以就跳過了下面的過程,自然就省了很多時間。

實際上,不止初始化資料時有影響,你可以用上面的例子統計下從

created

mounted

所用的時間,在我的電腦上不使用

Object。freeze()

時,這個時間是

60-70ms

,使用

Object。freeze()

後降到了

40-50ms

,這是因為在渲染函式中讀取

heavyData

中的資料時,會執行到透過

Object。defineProperty

定義的

getter

方法,Vue 在這裡做了一些收集依賴的處理,肯定就會佔用一些時間,由於使用了

Object。freeze()

後的資料是非響應式的,沒有了收集依賴的過程,自然也就節省了效能。

由於訪問響應式資料會走到自定義 getter 中並收集依賴,所以平時使用時要避免頻繁訪問響應式資料,比如在遍歷之前先將這個資料存在區域性變數中,尤其是在計算屬性、渲染函式中使用,關於這一點更具體的說明,你可以看黃奕老師的這篇文章:Local variables

但是這樣做也不是沒有任何問題的,這樣會導致

heavyData

下的資料都不是響應式資料,你對這些資料使用

computed

watch

等都不會產生效果,不過通常來說這種大量的資料都是展示用的,如果你有特殊的需求,你可以只對這種資料的某一層使用

Object。freeze()

,同時配合使用上文中的延遲渲染、函式式元件等,可以極大提升效能。

模板編譯和渲染函式、JSX 的效能差異

Vue 專案不僅可以使用 SFC 的方式開發,也可以使用渲染函式或 JSX 開發,很多人認為僅僅是隻是開發方式不同,卻不知這些開發方式之間也有效能差異,甚至差異很大,這一節我就找些例子來說明下,希望你以後在選擇開發方式時有更多衡量的標準。

其實 Vue2 模板編譯中的效能最佳化不多,Vue3 中有很多,Vue3 透過編譯和執行時結合的方式提升了很大的效能,但是由於本篇文章講的是 Vue2 的效能最佳化,並且 Vue2 現在還是有很多人在使用,所以我就挑 Vue2 模板編譯中的一點來說下。

靜態節點

下面這個模板:

你好! Hello
複製程式碼

會被編譯成:

function render() {  with (this) {    return _m(0)  }}複製程式碼

可以看到和普通的渲染函式是有些不一樣的,下面我們來看下為什麼會編譯成這樣的程式碼。

Vue 的編譯會經過

optimize

過程,這個過程中會標記靜態節點,具體內容可以看黃奕老師寫的這個文件:Vue2 編譯 - optimize 標記靜態節點。

codegen

階段判斷到靜態節點的標記會走到

genStatic

的分支:

function genStatic(el, state) {  el。staticProcessed = true  const originalPreState = state。pre  if (el。pre) {    state。pre = el。pre  }  state。staticRenderFns。push(`with(this){return ${genElement(el, state)}}`)  state。pre = originalPreState  return `_m(${state。staticRenderFns。length - 1}${    el。staticInFor ? ‘,true’ : ‘’  })`}複製程式碼

這裡就是生成程式碼的關鍵邏輯,這裡會把渲染函式儲存在

staticRenderFns

裡,然後拿到當前值的下標生成

_m

函式,這就是為什麼我們會得到

_m(0)

這個

_m

其實是

renderStatic

的縮寫:

export function renderStatic(index, isInFor) {  const cached = this。_staticTrees || (this。_staticTrees = [])  let tree = cached[index]  if (tree && !isInFor) {    return tree  }  tree = cached[index] = this。$options。staticRenderFns[index]。call(    this。_renderProxy,    null,    this  )  markStatic(tree, `__static__${index}`, false)  return tree}function markStatic(tree, key) {  if (Array。isArray(tree)) {    for (let i = 0; i < tree。length; i++) {      if (tree[i] && typeof tree[i] !== ‘string’) {        markStaticNode(tree[i], `${key}_${i}`, isOnce)      }    }  } else {    markStaticNode(tree, key, isOnce)  }}function markStaticNode(node, key, isOnce) {  node。isStatic = true  node。key = key  node。isOnce = isOnce}複製程式碼

renderStatic

的內部實現比較簡單,先是獲取到元件例項的

_staticTrees

,如果沒有就建立一個,然後嘗試從

_staticTrees

上獲取之前快取的節點,獲取到的話就直接返回,否則就從

staticRenderFns

上獲取到對應的渲染函式執行並將結果快取到

_staticTrees

上,這樣下次再進入這個函式時就會直接從快取上返回結果。

拿到節點後還會透過

markStatic

將節點打上

isStatic

等標記,標記為

isStatic

的節點會直接跳過

patchVnode

階段,因為靜態節點是不會變的,所以也沒必要 patch,跳過 patch 可以節省效能。

透過編譯和執行時結合的方式,可以幫助我們很好的提升應用效能,這是渲染函式 / JSX 很難達到的,當然不是說不能用 JSX,相比於模板,JSX 更加靈活,兩者有各自的使用場景。在這裡寫這些是希望能給你提供一些技術選型的標準。

Vue2 的編譯最佳化除了靜態節點,還有插槽,createElement 等。

Vue3 的模板編譯最佳化

相比於 Vue2,Vue3 中的模板編譯最佳化更加突出,效能提升的更多,由於涉及的比較多,本篇文章寫不下,如果你感興趣的話你可以看看這些文章:Vue3 Compiler 最佳化細節,如何手寫高效能渲染函式,聊聊 Vue。js 3。0 的模板編譯最佳化,以及尤雨溪的解讀影片:Vue 之父尤雨溪深度解讀 Vue3。0 的開發思路,以後我也會單獨寫一些文章分析 Vue3 的模板編譯最佳化。

相關推薦