Vue.js性能優化N個技巧(值得收藏)
這篇文章主要參考瞭 Vue.js 核心成員Guillaume Chau 在 19 年美國的 Vue conf 分享的主題:9 Performance secrets revealed,分享中提到瞭九個 Vue.js 性能優化的技巧。
我看完他的分享 PPT後,也閱讀瞭相關的項目源碼,在深入瞭解它的優化原理後,把其中一些優化技巧也應用到瞭我平時的工作中,取得瞭相當不錯的效果。
這個分享可謂是非常實用瞭,但是知道和關註的人似乎並不多,到目前為止,該項目也隻有可憐的幾百個 star。雖然距大佬的分享已經有兩年時間,但是其中的優化技巧並沒有過時,為瞭讓更多的人瞭解並學習到其中的實用技巧,我決定對他的分享做二次加工,詳細闡述其中的優化原理,並做一定程度的擴展和延伸。
本文主要還是針對 Vue.js 2.x 版本,畢竟接下來一段時間,Vue.js 2.x 還是我們工作中的主流版本。
我建議你在學習這篇文章的時候可以拉取項目的源碼,並且本地運行,查看優化前後的效果差異。
Functional components
第一個技巧,函數式組件,你可以查看這個在線示例
優化前的組件代碼如下:
<template> <div class="cell"> <div v-if="value" class="on"></div> <section v-else class="off"></section> </div> </template> <script> export default { props: ['value'], } </script>
優化後的組件代碼如下:
<template functional> <div class="cell"> <div v-if="props.value" class="on"></div> <section v-else class="off"></section> </div> </template>
然後我們在父組件各渲染優化前後的組件 800 個,並在每一幀內部通過修改數據來觸發組件的更新,開啟 Chrome 的 Performance 面板記錄它們的性能,得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以看到優化前執行 script
的時間要多於優化後的,而我們知道 JS 引擎是單線程的運行機制,JS 線程會阻塞 UI 線程,所以當腳本執行時間過長,就會阻塞渲染,導致頁面卡頓。而優化後的 script
執行時間短,所以它的性能更好。
那麼,為什麼用函數式組件 JS 的執行時間就變短瞭呢?這要從函數式組件的實現原理說起瞭,你可以把它理解成一個函數,它可以根據你傳遞的上下文數據渲染生成一片 DOM。
函數式組件和普通的對象類型的組件不同,它不會被看作成一個真正的組件,我們知道在 patch
過程中,如果遇到一個節點是組件 vnode
,會遞歸執行子組件的初始化過程;而函數式組件的 render
生成的是普通的 vnode
,不會有遞歸子組件的過程,因此渲染開銷會低很多。
因此,函數式組件也不會有狀態,不會有響應式數據,生命周期鉤子函數這些東西。你可以把它當成把普通組件模板中的一部分 DOM 剝離出來,通過函數的方式渲染出來,是一種在 DOM 層面的復用。
Child component splitting
第二個技巧,子組件拆分,你可以查看這個在線示例。
優化前的組件代碼如下:
<template> <div :style="{ opacity: number / 300 }"> <div>{{ heavy() }}</div> </div> </template> <script> export default { props: ['number'], methods: { heavy () { const n = 100000 let result = 0 for (let i = 0; i < n; i++) { result += Math.sqrt(Math.cos(Math.sin(42))) } return result } } } </script>
優化後的組件代碼如下:
<template> <div :style="{ opacity: number / 300 }"> <ChildComp/> </div> </template> <script> export default { components: { ChildComp: { methods: { heavy () { const n = 100000 let result = 0 for (let i = 0; i < n; i++) { result += Math.sqrt(Math.cos(Math.sin(42))) } return result }, }, render (h) { return h('div', this.heavy()) } } }, props: ['number'] } </script>
然後我們在父組件各渲染優化前後的組件 300 個,並在每一幀內部通過修改數據來觸發組件的更新,開啟 Chrome 的 Performance 面板記錄它們的性能,得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以看到優化後執行 script
的時間要明顯少於優化前的,因此性能體驗更好。
那麼為什麼會有差異呢,我們來看優化前的組件,示例通過一個 heavy
函數模擬瞭一個耗時的任務,且這個函數在每次渲染的時候都會執行一次,所以每次組件的渲染都會消耗較長的時間執行 JavaScript。
而優化後的方式是把這個耗時任務 heavy
函數的執行邏輯用子組件 ChildComp
封裝瞭,由於 Vue 的更新是組件粒度的,雖然每一幀都通過數據修改導致瞭父組件的重新渲染,但是 ChildComp
卻不會重新渲染,因為它的內部也沒有任何響應式數據的變化。所以優化後的組件不會在每次渲染都執行耗時任務,自然執行的 JavaScript 時間就變少瞭。
不過針對這個優化的方式我提出瞭一些不同的看法,詳情可以點開這個 issue,我認為這個場景下的優化用計算屬性要比子組件拆分要好。得益於計算屬性自身緩存特性,耗時的邏輯也隻會在第一次渲染的時候執行,而且使用計算屬性也沒有額外渲染子組件的開銷。
在實際工作中,使用計算屬性是優化性能的場景會有很多,畢竟它也體現瞭一種空間換時間的優化思想。
Local variables
第三個技巧,局部變量,你可以查看這個在線示例。
優化前的組件代碼如下:
<template> <div :style="{ opacity: start / 300 }">{{ result }}</div> </template> <script> export default { props: ['start'], computed: { base () { return 42 }, result () { let result = this.start for (let i = 0; i < 1000; i++) { result += Math.sqrt(Math.cos(Math.sin(this.base))) + this.base * this.base + this.base + this.base * 2 + this.base * 3 } return result }, }, } </script>
優化後的組件代碼如下:
<template> <div :style="{ opacity: start / 300 }">{{ result }}</div> </template> <script> export default { props: ['start'], computed: { base () { return 42 }, result ({ base, start }) { let result = start for (let i = 0; i < 1000; i++) { result += Math.sqrt(Math.cos(Math.sin(base))) + base * base + base + base * 2 + base * 3 } return result }, }, } </script>
然後我們在父組件各渲染優化前後的組件 300 個,並在每一幀內部通過修改數據來觸發組件的更新,開啟 Chrome 的 Performance 面板記錄它們的性能,得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以看到優化後執行 script
的時間要明顯少於優化前的,因此性能體驗更好。
這裡主要是優化前後組件的計算屬性 result
的實現差異,優化前的組件多次在計算過程中訪問 this.base
,而優化後的組件會在計算前先用局部變量 base
緩存 this.base
,後面則直接訪問 base
變量。
那麼為啥這個差異會造成性能上的差異呢,原因是你每次訪問 this.base
的時候,由於 this.base
是一個響應式對象,所以會觸發它的 getter
,進而會執行依賴收集相關邏輯代碼。類似的邏輯執行多瞭,像示例這樣,幾百次循環更新幾百個組件,每個組件觸發 computed
重新計算,然後又多次執行依賴收集相關邏輯,性能自然就下降瞭。
從需求上來說,this.base
執行一次依賴收集就夠瞭,因此我們隻需要把它的 getter
求值結果返回給局部變量 base
,後續再次訪問 base
的時候就不會觸發 getter
,也不會走依賴收集的邏輯瞭,性能自然就得到瞭提升。
這是一個非常實用的性能優化技巧。因為很多人在開發 Vue.js 項目的時候,每當取變量的時候就習慣性直接寫 this.xxx
瞭,因為大部分人並不會註意到訪問 this.xxx
背後做的事情。在訪問次數不多的時候,性能問題並沒有凸顯,但是一旦訪問次數變多,比如在一個大循環中多次訪問,類似示例這種場景,就會產生性能問題瞭。
我之前給 ZoomUI 的 Table 組件做性能優化的時候,在 render table body
的時候就使用瞭局部變量的優化技巧,並寫瞭 benchmark 做性能對比:渲染 1000 * 10 的表格,ZoomUI Table 的更新數據重新渲染的性能要比 ElementUI 的 Table 性能提升瞭近一倍。
Reuse DOM with v-show
第四個技巧,使用 v-show
復用 DOM,你可以查看這個在線示例。
優化前的組件代碼如下:
<template functional> <div class="cell"> <div v-if="props.value" class="on"> <Heavy :n="10000"/> </div> <section v-else class="off"> <Heavy :n="10000"/> </section> </div> </template>
優化後的組件代碼如下:
<template functional> <div class="cell"> <div v-show="props.value" class="on"> <Heavy :n="10000"/> </div> <section v-show="!props.value" class="off"> <Heavy :n="10000"/> </section> </div> </template>
然後我們在父組件各渲染優化前後的組件 200 個,並在每一幀內部通過修改數據來觸發組件的更新,開啟 Chrome 的 Performance 面板記錄它們的性能,得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以看到優化後執行 script
的時間要明顯少於優化前的,因此性能體驗更好。
優化前後的主要區別是用 v-show
指令替代瞭 v-if
指令來替代組件的顯隱,雖然從表現上看,v-show
和 v-if
類似,都是控制組件的顯隱,但內部實現差距還是很大的。
v-if
指令在編譯階段就會編譯成一個三元運算符,條件渲染,比如優化前的組件模板經過編譯後生成如下渲染函數:
function render() { with(this) { return _c('div', { staticClass: "cell" }, [(props.value) ? _c('div', { staticClass: "on" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1) : _c('section', { staticClass: "off" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1)]) } }
當條件 props.value
的值變化的時候,會觸發對應的組件更新,對於 v-if
渲染的節點,由於新舊節點 vnode
不一致,在核心 diff 算法比對過程中,會移除舊的 vnode
節點,創建新的 vnode
節點,那麼就會創建新的 Heavy
組件,又會經歷 Heavy
組件自身初始化、渲染 vnode
、patch
等過程。
因此使用 v-if
每次更新組件都會創建新的 Heavy
子組件,當更新的組件多瞭,自然就會造成性能壓力。
而當我們使用 v-show
指令,優化後的組件模板經過編譯後生成如下渲染函數:
function render() { with(this) { return _c('div', { staticClass: "cell" }, [_c('div', { directives: [{ name: "show", rawName: "v-show", value: (props.value), expression: "props.value" }], staticClass: "on" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1), _c('section', { directives: [{ name: "show", rawName: "v-show", value: (!props.value), expression: "!props.value" }], staticClass: "off" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1)]) } }
當條件 props.value
的值變化的時候,會觸發對應的組件更新,對於 v-show
渲染的節點,由於新舊 vnode
一致,它們隻需要一直 patchVnode
即可,那麼它又是怎麼讓 DOM 節點顯示和隱藏的呢?
原來在 patchVnode
過程中,內部會對執行 v-show
指令對應的鉤子函數 update
,然後它會根據 v-show
指令綁定的值來設置它作用的 DOM 元素的 style.display
的值控制顯隱。
因此相比於 v-if
不斷刪除和創建函數新的 DOM,v-show
僅僅是在更新現有 DOM 的顯隱值,所以 v-show
的開銷要比 v-if
小的多,當其內部 DOM 結構越復雜,性能的差異就會越大。
但是 v-show
相比於 v-if
的性能優勢是在組件的更新階段,如果僅僅是在初始化階段,v-if
性能還要高於 v-show
,原因是在於它僅僅會渲染一個分支,而 v-show
把兩個分支都渲染瞭,通過 style.display
來控制對應 DOM 的顯隱。
在使用 v-show
的時候,所有分支內部的組件都會渲染,對應的生命周期鉤子函數都會執行,而使用 v-if
的時候,沒有命中的分支內部的組件是不會渲染的,對應的生命周期鉤子函數都不會執行。
因此你要搞清楚它們的原理以及差異,才能在不同的場景使用適合的指令。
KeepAlive
第五個技巧,使用 KeepAlive
組件緩存 DOM,你可以查看這個在線示例。
優化前的組件代碼如下:
<template> <div id="app"> <router-view/> </div> </template>
優化後的組件代碼如下:
<template> <div id="app"> <keep-alive> <router-view/> </keep-alive> </div> </template>
我們點擊按鈕在 Simple page 和 Heavy Page 之間切換,會渲染不同的視圖,其中 Heavy Page 的渲染非常耗時。我們開啟 Chrome 的 Performance 面板記錄它們的性能,然後分別在優化前後執行如上的操作,會得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以看到優化後執行 script
的時間要明顯少於優化前的,因此性能體驗更好。
在非優化場景下,我們每次點擊按鈕切換路由視圖,都會重新渲染一次組件,渲染組件就會經過組件初始化,render
、patch
等過程,如果組件比較復雜,或者嵌套較深,那麼整個渲染耗時就會很長。
而在使用 KeepAlive
後,被 KeepAlive
包裹的組件在經過第一次渲染後,的 vnode
以及 DOM 都會被緩存起來,然後再下一次再次渲染該組件的時候,直接從緩存中拿到對應的 vnode
和 DOM,然後渲染,並不需要再走一次組件初始化,render
和 patch
等一系列流程,減少瞭 script
的執行時間,性能更好。
但是使用 KeepAlive
組件並非沒有成本,因為它會占用更多的內存去做緩存,這是一種典型的空間換時間優化思想的應用。
Deferred features
第六個技巧,使用 Deferred
組件延時分批渲染組件,你可以查看這個在線示例。
優化前的組件代碼如下:
<template> <div class="deferred-off"> <VueIcon icon="fitness_center" class="gigantic"/> <h2>I'm an heavy page</h2> <Heavy v-for="n in 8" :key="n"/> <Heavy class="super-heavy" :n="9999999"/> </div> </template>
優化後的組件代碼如下:
<template> <div class="deferred-on"> <VueIcon icon="fitness_center" class="gigantic"/> <h2>I'm an heavy page</h2> <template v-if="defer(2)"> <Heavy v-for="n in 8" :key="n"/> </template> <Heavy v-if="defer(3)" class="super-heavy" :n="9999999"/> </div> </template> <script> import Defer from '@/mixins/Defer' export default { mixins: [ Defer(), ], } </script>
我們點擊按鈕在 Simple page 和 Heavy Page 之間切換,會渲染不同的視圖,其中 Heavy Page 的渲染非常耗時。我們開啟 Chrome 的 Performance 面板記錄它們的性能,然後分別在優化前後執行如上的操作,會得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以發現,優化前當我們從 Simple Page 切到 Heavy Page 的時候,在一次 Render 接近結尾的時候,頁面渲染的仍然是 Simple Page,會給人一種頁面卡頓的感覺。而優化後當我們從 Simple Page 切到 Heavy Page 的時候,在一次 Render 靠前的位置頁面就已經渲染瞭 Heavy Page 瞭,並且 Heavy Page 是漸進式渲染出來的。
優化前後的差距主要是後者使用瞭 Defer
這個 mixin
,那麼它具體是怎麼工作的,我們來一探究竟:
export default function (count = 10) { return { data () { return { displayPriority: 0 } }, mounted () { this.runDisplayPriority() }, methods: { runDisplayPriority () { const step = () => { requestAnimationFrame(() => { this.displayPriority++ if (this.displayPriority < count) { step() } }) } step() }, defer (priority) { return this.displayPriority >= priority } } } }
Defer
的主要思想就是把一個組件的一次渲染拆成多次,它內部維護瞭 displayPriority
變量,然後在通過 requestAnimationFrame
在每一幀渲染的時候自增,最多加到 count
。然後使用 Defer mixin
的組件內部就可以通過 v-if="defer(xxx)"
的方式來控制在 displayPriority
增加到 xxx
的時候渲染某些區塊瞭。
當你有渲染耗時的組件,使用 Deferred
做漸進式渲染是不錯的註意,它能避免一次 render
由於 JS 執行時間過長導致渲染卡住的現象。
Time slicing
第七個技巧,使用 Time slicing
時間片切割技術,你可以查看這個在線示例。
優化前的代碼如下:
fetchItems ({ commit }, { items }) { commit('clearItems') commit('addItems', items) }
優化後的代碼如下:
fetchItems ({ commit }, { items, splitCount }) { commit('clearItems') const queue = new JobQueue() splitArray(items, splitCount).forEach( chunk => queue.addJob(done => { // 分時間片提交數據 requestAnimationFrame(() => { commit('addItems', chunk) done() }) }) ) await queue.start() }
我們先通過點擊 Genterate items
按鈕創建 10000 條假數據,然後分別在開啟和關閉 Time-slicing
的情況下點擊 Commit items
按鈕提交數據,開啟 Chrome 的 Performance 面板記錄它們的性能,會得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以發現,優化前總的 script
執行時間要比優化後的還要少一些,但是從實際的觀感上看,優化前點擊提交按鈕,頁面會卡死 1.2 秒左右,在優化後,頁面不會完全卡死,但仍然會有渲染卡頓的感覺。
那麼為什麼在優化前頁面會卡死呢?因為一次性提交的數據過多,內部 JS 執行時間過長,阻塞瞭 UI 線程,導致頁面卡死。
優化後,頁面仍有卡頓,是因為我們拆分數據的粒度是 1000 條,這種情況下,重新渲染組件仍然有壓力,我們觀察 fps 隻有十幾,會有卡頓感。通常隻要讓頁面的 fps 達到 60,頁面就會非常流暢,如果我們把數據拆分粒度變成 100 條,基本上 fps 能達到 50 以上,雖然頁面渲染變流暢瞭,但是完成 10000 條數據總的提交時間還是變長瞭。
使用 Time slicing
技術可以避免頁面卡死,通常我們在這種耗時任務處理的時候會加一個 loading
效果,在這個示例中,我們可以開啟 loading animation
,然後提交數據。對比發現,優化前由於一次性提交數據過多,JS 一直長時間運行,阻塞 UI 線程,這個 loading
動畫是不會展示的,而優化後,由於我們拆成多個時間片去提交數據,單次 JS 運行時間變短瞭,這樣 loading
動畫就有機會展示瞭。
這裡要註意的一點,雖然我們拆時間片使用瞭
requestAnimationFrame
API,但是使用requestAnimationFrame
本身是不能保證滿幀運行的,requestAnimationFrame
保證的是在瀏覽器每一次重繪後會執行對應傳入的回調函數,想要保證滿幀,隻能讓 JS 在一個 Tick 內的運行時間不超過 17ms。
Non-reactive data
第八個技巧,使用 Non-reactive data
,你可以查看這個在線示例。
優化前代碼如下:
const data = items.map( item => ({ id: uid++, data: item, vote: 0 }) )
優化後代碼如下:
const data = items.map( item => optimizeItem(item) ) function optimizeItem (item) { const itemData = { id: uid++, vote: 0 } Object.defineProperty(itemData, 'data', { // Mark as non-reactive configurable: false, value: item }) return itemData }
還是前面的示例,我們先通過點擊 Genterate items
按鈕創建 10000 條假數據,然後分別在開啟和關閉 Partial reactivity
的情況下點擊 Commit items
按鈕提交數據,開啟 Chrome 的 Performance 面板記錄它們的性能,會得到如下結果。
優化前:
優化後:
對比這兩張圖我們可以看到優化後執行 script
的時間要明顯少於優化前的,因此性能體驗更好。
之所以有這種差異,是因為內部提交的數據的時候,會默認把新提交的數據也定義成響應式,如果數據的子屬性是對象形式,還會遞歸讓子屬性也變成響應式,因此當提交數據很多的時候,這個過程就變成瞭一個耗時過程。
而優化後我們把新提交的數據中的對象屬性 data
手動變成瞭 configurable
為 false
,這樣內部在 walk
時通過 Object.keys(obj)
獲取對象屬性數組會忽略 data
,也就不會為 data
這個屬性 defineReactive
,由於 data
指向的是一個對象,這樣也就會減少遞歸響應式的邏輯,相當於減少瞭這部分的性能損耗。數據量越大,這種優化的效果就會更明顯。
其實類似這種優化的方式還有很多,比如我們在組件中定義的一些數據,也不一定都要在 data
中定義。有些數據我們並不是用在模板中,也不需要監聽它的變化,隻是想在組件的上下文中共享這個數據,這個時候我們可以僅僅把這個數據掛載到組件實例 this
上,例如:
export default { created() { this.scroll = null }, mounted() { this.scroll = new BScroll(this.$el) } }
這樣我們就可以在組件上下文中共享 scroll
對象瞭,即使它不是一個響應式對象。
Virtual scrolling
第九個技巧,使用 Virtual scrolling
,你可以查看這個在線示例。
優化前組件的代碼如下:
<div class="items no-v"> <FetchItemViewFunctional v-for="item of items" :key="item.id" :item="item" @vote="voteItem(item)" /> </div>
優化後代碼如下:
<recycle-scroller class="items" :items="items" :item-size="24" > <template v-slot="{ item }"> <FetchItemView :item="item" @vote="voteItem(item)" /> </template> </recycle-scroller>
還是前面的示例,我們需要開啟 View list
,然後點擊 Genterate items
按鈕創建 10000 條假數據(註意,線上示例最多隻能創建 1000 條數據,實際上 1000 條數據並不能很好地體現優化的效果,所以我修改瞭源碼的限制,本地運行,創建瞭 10000 條數據),然後分別在 Unoptimized
和 RecycleScroller
的情況下點擊 Commit items
按鈕提交數據,滾動頁面,開啟 Chrome 的 Performance 面板記錄它們的性能,會得到如下結果。
優化前:
優化後:
對比這兩張圖我們發現,在非優化的情況下,10000 條數據在滾動情況下 fps 隻有個位數,在非滾動情況下也就十幾,原因是非優化場景下渲染的 DOM 太多,渲染本身的壓力很大。優化後,即使 10000 條數據,在滾動情況下的 fps 也能有 30 多,在非滾動情況下可以達到 60 滿幀。
之所以有這個差異,是因為虛擬滾動的實現方式:是隻渲染視口內的 DOM。這樣總共渲染的 DOM 數量就很少瞭,自然性能就會好很多。
虛擬滾動組件也是 Guillaume Chau 寫的,感興趣的同學可以去研究它的源碼實現。它的基本原理就是監聽滾動事件,動態更新需要顯示的 DOM 元素,計算出它們在視圖中的位移。
虛擬滾動組件也並非沒有成本,因為它需要在滾動的過程中實時去計算,所以會有一定的 script
執行的成本。因此如果列表的數據量不是很大的情況,我們使用普通的滾動就足夠瞭。
總結
通過這篇文章,我希望你能瞭解到 Vue.js 的九種性能優化技巧,並能運用到實際的開發項目中。除瞭上述技巧之外,還有懶加載圖片、懶加載組件、異步組件等等常用的性能優化手段。
在做性能優化前,我們需要分析性能的瓶頸在哪,才能因地制宜。另外,性能優化都需要數據支撐的,你在做任何性能優化前,需要先采集優化前的數據,這樣優化後才能夠通過數據對比看到優化的效果。
希望你在日後的開發過程中,不再隻滿足於實現需求,寫每一行代碼的時候,都能思考它可能產生的性能方面的影響。
參考資料
[1] vue-9-perf-secrets slidse:https://slides.com/akryum/vueconfus-2019
[2] vue-9-perf-secrets 分享演講視頻:https://www.vuemastery.com/conferences/vueconf-us-2019/9-performance-secrets-revealed/
[3] vue-9-perf-secrets 項目源碼:https://github.com/Akryum/vue-9-perf-secrets
[4] vue-9-perf-secrets 在線演示地址:https://vue-9-perf-secrets.netlify.app/
[5] vue-9-perf-secrets 討論 issue:https://github.com/Akryum/vue-9-perf-secrets/issues/1
[6] vue-virtual-scroller 項目源碼:https://github.com/Akryum/vue-virtual-scroller
到此這篇關於Vue.js九個性能優化技巧(值得收藏)的文章就介紹到這瞭,更多相關Vue.js性能優化技巧內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!