前言
我們在使用 Vue 或其他框架的日常開發中,或多或少的都會遇到一些效能問題,儘管 Vue 內部已經幫助我們做了許多最佳化,但是還是有些問題是需要我們主動去避免的。我在我的日常開中,以及網上各種大佬的文章中總結了一些容易產生效能問題的場景以及針對這些問題最佳化的技巧,這篇文章就來探討下,希望對你有所幫助。
使用v-slot:slotName,而不是slot=“slotName”
v-slot
是 2。6 新增的語法,具體可檢視:Vue2。6,2。6 釋出已經是快兩年前的事情了,但是現在仍然有不少人仍然在使用
slot=“slotName”
這個語法。雖然這兩個語法都能達到相同的效果,但是內部的邏輯確實不一樣的,下面來看下這兩種方式有什麼不同之處。
我們先來看下這兩種語法分別會被編譯成什麼:
使用新的寫法,對於父元件中的以下模板:
會被編譯成:
function render() { with (this) { return _c(‘child’, { scopedSlots: _u([ { key: ‘name’, fn: function () { return [_v(_s(name))] }, proxy: true } ]) }) }}複製程式碼
使用舊的寫法,對於以下模板:
會被編譯成:
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
UserProfile 這個元件只渲染了 props 的 name,然後在 App。vue 中呼叫 500 次,統計從 beforeMount 到 mounted 的耗時,即為 500 個子元件(UserProfile)初始化的耗時。
經過我多次嘗試後,發現耗時一直在 30ms 左右,那麼現在我們再把改成 UserProfile 改成函式式元件:
此時再經過多次嘗試後,初始化的耗時一直在 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: `
這裡有一個 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 模板編譯中的一點來說下。
靜態節點
下面這個模板:
會被編譯成:
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 的模板編譯最佳化。