淺談Vue 性能優(yōu)化之深挖數(shù)組
背景
最近在用 Vue 重構(gòu)一個歷史項目,一個考試系統(tǒng),題目量很大,所以核心組件的性能成為了關注點。先來兩張圖看下最核心的組件 Paper 的樣式。
從圖中來看,分為答題區(qū)與選擇面板區(qū)。
稍微對交互邏輯進行下拆解:
- 答題模式與學習模式可以相互切換,控制正確答案顯隱。
- 單選與判斷題直接點擊就記錄答案正確性,多選是選擇答案之后點擊確定才能記錄正確性。
- 選擇面板則是記錄做過的題目的情況,分為六種狀態(tài)(未做過的,未做過且當前選擇的,做錯的,做錯的且當前選擇的,做對的,做對的且當前選擇的),用不同的樣式去區(qū)別。
- 點擊選擇面板,答題區(qū)能切到對應的題號。
基于以上考慮,我覺得我必須有三個響應式的數(shù)據(jù):
currentIndex
: 當前選中題目的序號。questions
:所有題目的信息,是個數(shù)組,里面維護了每道題的問題、選項、正確與否等信息。cardData
:題目分組的信息,也是個數(shù)組,按章節(jié)名稱對不同的題目進行了分類。
數(shù)組每一項數(shù)據(jù)結(jié)構(gòu)如下:
currentIndex = 0 // 用來標記當前選中題目的索引 questions = [{ secId: 1, // 所屬章節(jié)的 id tid: 1, // 題目 id content: '題目內(nèi)容' // 題目描述 type: 1, // 題型,1 ~ 3 (單選,多選,判斷) options: ['選項1', '選項2', '選項3', '選項4',] // 每個選項的描述 choose: [1, 2, 4], // 多選——記錄用戶未提交前的選項 done: true, // 標記當前題目是否已做 answerIsTrue: undefined // 標記當前題目的正確與否 }] cardData = [{ startIndex: 0, // 用來記錄循環(huán)該分組數(shù)據(jù)的起始索引,這個值等于前面數(shù)據(jù)的長度累加。 secName: '章節(jié)名稱', secId: '章節(jié)id', tids: [1, 2, 3, 11] // 該章節(jié)下面的所有題目的 id }]
由于題目可以左右滑動切換,所以我每次從 questions
取了三個數(shù)據(jù)去渲染,用的是 cube-ui 的 Slide 組件,只要自己根據(jù) this.currentIndex 結(jié)合 computed 特性去動態(tài)的切割三個數(shù)據(jù)就行。
這一切都顯得很美好,尤其是即將結(jié)束了一個歷史項目的核心組件的編寫之前,心情特別的舒暢。
然而轉(zhuǎn)折點出現(xiàn)在了渲染選擇面板樣式這一步
代碼邏輯很簡單,但是發(fā)生了讓我懵逼的事情。
<div class="card-content"> <div class="block" v-for="item in cardData" :key="item.secName"> <div class="sub-title">{{item.secName}}</div> <div class="group"> <span @click="cardClick(index + item.startIndex)" class="item" :class="getItemClass(index + item.startIndex)" v-for="(subItem, index) in item.secTids" :key="subItem">{{index + item.startIndex + 1}}</span> </div> </div> </div>
其實就是利用 cardData 去生成 DOM 元素,這是個分組數(shù)據(jù)(先是以章節(jié)為維度,章節(jié)下面還有對應的題目),上面的代碼其實是一個循環(huán)里面嵌套了另一個循環(huán)。
但是,只要我切換題目或者點擊面板,抑或是觸發(fā)任意響應式數(shù)據(jù)的改變,都會讓頁面卡死?。?
探索
當下的第一反應,肯定是 js 在某一步的執(zhí)行時間過長,所以利用 Chrome 自帶的 Performance 工具 追蹤了一下,發(fā)現(xiàn)問題出在 getItemClass
這個函數(shù)調(diào)用,占據(jù)了 99% 的時間,而且時間都超過 1s 了。瞅了眼自己的代碼:
getItemClass (index) { const ret = {} // 如果是做對的題目,但并不是當前選中 ret['item_true'] = this.questions[index]...... // 如果是做對的題目,并且是當前選中 ret['item_true_active'] = this.questions[index]...... // 如果是做錯的題目,但并不是當前選中 ret['item_false'] = this.questions[index]...... // 如果是做錯的題目,并且是當前選中 ret['item_false_active'] = this.questions[index]...... // 如果是未做的題目,但不是當前選中 ret['item_undo'] = this.questions[index]...... // 如果是未做的題目,并且是當前選中 ret['item_undo_active'] = this.questions[index]...... return ret },
這個函數(shù)主要是用來計算選擇面板每一個小圓圈該有的樣式。每一步都是對 questions 進行了 getter 操作。初看,好像沒什么問題,但是由于之前看過 Vue 的源碼,細想之下,覺得不對。
首先,webpack 會將 .vue 文件的 template 轉(zhuǎn)換成 render 函數(shù),也就是實例化組件的時候,其實是對響應式屬性求值的過程,這樣響應式屬性就能將 renderWatcher 加入依賴當中,所以當響應式屬性改變的時候,能觸發(fā)組件重新渲染。
我們先來了解下 renderWatcher 是什么概念,首先在 Vue 的源碼里面是有三種 watcher 的。我們只看 renderWatcher 的定義。
// 位于 vue/src/core/instance/lifecycle.js new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) updateComponent = () => { vm._update(vm._render(), hydrating) } // 位于 vue/src/core/instance/render.js Vue.prototype._render = function (): VNode { ...... const { render, _parentVnode } = vm.$options try { vnode = render.call(vm._renderProxy, vm.$createElement) } catch (e) { ...... } return vnode }
稍微分析下流程:實例化 Vue 實例的時候會走到 options 取到由 template 編譯生成的 render 函數(shù),進而執(zhí)行 renderWatcher 收集依賴。_render 返回的是組件的 vnode,傳入 _update 函數(shù)從而執(zhí)行組件的 patch,最終生成視圖。
其次,從我寫的 template 來分析,為了渲染選擇面板的 DOM,是有兩層 for 循環(huán)的,內(nèi)部每次循環(huán)都會執(zhí)行 getItemClass 函數(shù),而函數(shù)的內(nèi)部又是對 questions 這個響應式數(shù)組進行了 getter 求值,從目前來看,時間復雜度是 O(n²),如上圖所示,我們大概有 2000 多道題目,我們假設有 10 個章節(jié),每個章節(jié)有 200 道題目,getItemClass 內(nèi)部是對 questions 進行了 6 次求值,這樣一算,粗略也是 12000 左右,按 js 的執(zhí)行速度,是不可能這么慢的。
那么問題是不是出現(xiàn)在對 questions 進行 getter 的過程中,出現(xiàn)了 O(n³) 的復雜度呢?
于是,我打開了 Vue 的源碼,由于之前深入研究過源碼,所以輕車熟路地找到了 vue/src/core/instance/state.js
里面將 data 轉(zhuǎn)換成 getter/setter 的部分。
function initData (vm: Component) { ...... // observe data observe(data, true /* asRootData */) }
定義一個組件的 data 的響應式,都是從 observe 函數(shù)開始,它的定義是位于 vue/src/core/observer/index.js
。
export function observe (value: any, asRootData: ?boolean): Observer | void { if (!isObject(value) || value instanceof VNode) { return } let ob: Observer | void if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { ob = value.__ob__ } else if ( shouldObserve && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue ) { ob = new Observer(value) } if (asRootData && ob) { ob.vmCount++ } return ob }
observe 函數(shù)接受對象或者數(shù)組,內(nèi)部會實例化 Observer 類。
export class Observer { value: any; dep: Dep; vmCount: number; constructor (value: any) { this.value = value this.dep = new Dep() this.vmCount = 0 def(value, '__ob__', this) if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } this.observeArray(value) } else { this.walk(value) } } walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { defineReactive(obj, keys[i]) } } observeArray (items: Array<any>) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]) } } }
Observer 的構(gòu)造函數(shù)很簡單,就是聲明了 dep、value 屬性,并且將 value 的 _ ob _ 屬性指向當前實例。舉個栗子:
// 剛開始的 options export default { data : { msg: '消息', arr: [1], item: { text: '文本' } } } // 實例化 vm 的時候,變成了以下 data: { msg: '消息', arr: [1, __ob__: { value: ..., dep: new Dep(), vmCount: ... }], item: { text: '文本', __ob__: { value: ..., dep: new Dep(), vmCount: ... } }, __ob__: { value: ..., dep: new Dep(), vmCount: ... } }
也就是每個對象或者數(shù)組被 observe 之后,多了一個 _ ob _ 屬性,它是 Observer 的實例。那么這么做的意義何在呢,稍后分析。
繼續(xù)分析 Observer 構(gòu)造函數(shù)的下面部分:
// 如果是數(shù)組,先篡改數(shù)組的一些方法(push,splice,shift等等),使其能夠支持響應式 if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } // 數(shù)組里面的元素還是數(shù)組或者對象,遞歸地調(diào)用 observe 函數(shù),使其成為響應式數(shù)據(jù) this.observeArray(value) } else { // 遍歷對象,使其每個鍵值也能成為響應式數(shù)據(jù) this.walk(value) } walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { // 將對象的鍵值轉(zhuǎn)換成 getter / setter, // getter 收集依賴 // setter 通知 watcher 更新 defineReactive(obj, keys[i]) } } observeArray (items: Array<any>) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]) } }
我們再捋一下思路,首先在 initState 里面調(diào)用 initData,initData 得到用戶配置的 data 對象后調(diào)用了 observe,observe 函數(shù)里面會實例化 Observer 類,在其構(gòu)造函數(shù)里面,首先將對象的 _ ob _ 屬性指向 Observer 實例(這一步是為了檢測到對象添加或者刪除屬性之后,能觸發(fā)響應式的伏筆),之后遍歷當前對象的鍵值,調(diào)用 defineReactive 去轉(zhuǎn)換成 getter / setter。
所以,來分析下 defineReactive。
// 如果是數(shù)組,先篡改數(shù)組的一些方法(push,splice,shift等等),使其能夠支持響應式 if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } // 數(shù)組里面的元素還是數(shù)組或者對象,遞歸地調(diào)用 observe 函數(shù),使其成為響應式數(shù)據(jù) this.observeArray(value) } else { // 遍歷對象,使其每個鍵值也能成為響應式數(shù)據(jù) this.walk(value) } walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { // 將對象的鍵值轉(zhuǎn)換成 getter / setter, // getter 收集依賴 // setter 通知 watcher 更新 defineReactive(obj, keys[i]) } } observeArray (items: Array<any>) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]) } }
首先,我們從 defineReactive 可以看出,每個響應式屬性都有一個 Dep 實例,這個是用來收集 watcher 的。由于 getter 與 setter 都是函數(shù),并且引用了 dep,所以形成了閉包,dep 一直存在于內(nèi)存當中。因此,假如在渲染組件的時候,如果使用了響應式屬性 a,就會走到上述的語句1,dep 實例就會收集組件這個 renderWatcher,因為在對 a 進行 setter 賦值操作的時候,會調(diào)用 dep.notify() 去 通知 renderWatcher 去更新,進而觸發(fā)響應式數(shù)據(jù)收集新一輪的 watcher。
那么語句2與3,到底是什么作用呢
我們舉個栗子分析
<div>{{person}}<div>
export default { data () { return { person: { name: '張三', age: 18 } } } } this.person.gender = '男' // 組件視圖不會更新
因為 Vue 是無法探測到對象增添屬性,所以也沒有一個時機去觸發(fā) renderWatcher 的更新。
為此, Vue 提供了一個 API, this.$set
,它是 Vue.set
的別名。
export function set (target: Array<any> | Object, key: any, val: any): any { if (Array.isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key) target.splice(key, 1, val) return val } if (key in target && !(key in Object.prototype)) { target[key] = val return val } const ob = (target: any).__ob__ if (target._isVue || (ob && ob.vmCount)) { process.env.NODE_ENV !== 'production' && warn( 'Avoid adding reactive properties to a Vue instance or its root $data ' + 'at runtime - declare it upfront in the data option.' ) return val } if (!ob) { target[key] = val return val } defineReactive(ob.value, key, val) ob.dep.notify() return val }
set 函數(shù)接受三個參數(shù),第一個參數(shù)可以是 Object 或者 Array,其余的參數(shù)分別為 key, value。如果利用這個 API 給 person 增加一個屬性呢?
this.$set(this.person, 'gender', '男') // 組件視圖重新渲染
為什么通過 set 函數(shù)又能觸發(fā)重新渲染呢?注意到這一句, ob.dep.notify()
, ob
怎么來的呢,那就得回到之前的 observe 函數(shù)了,其實 data 經(jīng)過 observe 處理之后變成下面這樣。
{ person: { name: '張三', age: 18, __ob__: { value: ..., dep: new Dep() } }, __ob__: { value: ..., dep: new Dep() } } // 只要是對象,都定義了 __ob__ 屬性,它是 Observer 類的實例
從 template 來看,視圖依賴了 person 這個屬性值,renderWatcher 被收集到了 person 屬性的 Dep 實例當中,對應 defineReactive
函數(shù)定義的 語句1 ,同時, 語句2 的作用就是將 renderWatcher 收集到 person._ ob _.dep 當中去,因此在給 person 增加屬性的時候,調(diào)用 set 方法才能獲取到 person._ ob _.dep,進而觸發(fā) renderWatcher 更新。
那么得出結(jié)論,語句2的作用是為了能夠探測到響應式數(shù)據(jù)是對象的情況下增刪屬性而引發(fā)重新渲染的。
再舉個栗子解釋下 語句3 的作用。
<div>{{books}}<div>
export default { data () { return { books: [ { id: 1, name: 'js' } ] } } }
因為組件對 books 進行求值,而它是一個數(shù)組,所以會走到語句3的邏輯。
if (Array.isArray(value)) { // 語句3 dependArray(value) } function dependArray (value: Array<any>) { for (let e, i = 0, l = value.length; i < l; i++) { e = value[i] e && e.__ob__ && e.__ob__.dep.depend() if (Array.isArray(e)) { dependArray(e) } } }
從邏輯上來看,就是循環(huán) books 的每一項 item,如果 item 是一個數(shù)組或者對象,就會獲取到 item._ ob _.dep,并且將當前 renderWatcher 收集到 dep 當中去。
如果沒有這一句,會發(fā)生什么情況?考慮下如下的情況:
this.$set(this.books[0], 'comment', '棒極了') // 并不會觸發(fā)組件更新
如果理解成 renderWatch 并沒有對 this.books[0] 進行求值,所以改變它并不需要造成組件更新,那么這個理解是有誤的。正確的是因為數(shù)組是元素的集合,內(nèi)部的任何修改是需要反映出來的,所以語句3就是為了在 renderWatcher 對數(shù)組求值的時候,將 renderWatcher 收集到數(shù)組內(nèi)部每一項 item._ ob _.dep 當中去,這樣只要內(nèi)部發(fā)生變化,就能通過 dep 獲取到 renderWatcher,通知它更新。
那么結(jié)合我的業(yè)務代碼,就分析出來問題出現(xiàn)在語句3當中。
<div class="card-content"> <div class="block" v-for="item in cardData" :key="item.secName"> <div class="sub-title">{{item.secName}}</div> <div class="group"> <span @click="cardClick(index + item.startIndex)" class="item" :class="getItemClass(index + item.startIndex)" v-for="(subItem, index) in item.secTids" :key="subItem">{{index + item.startIndex + 1}}</span> </div> </div> </div>
getItemClass (index) { const ret = {} // 如果是做對的題目,但并不是當前選中 ret['item_true'] = this.questions[index]...... // 如果是做對的題目,并且是當前選中 ret['item_true_active'] = this.questions[index]...... // 如果是做錯的題目,但并不是當前選中 ret['item_false'] = this.questions[index]...... // 如果是做錯的題目,并且是當前選中 ret['item_false_active'] = this.questions[index]...... // 如果是未做的題目,但不是當前選中 ret['item_undo'] = this.questions[index]...... // 如果是未做的題目,并且是當前選中 ret['item_undo_active'] = this.questions[index]...... return ret },
首先 cardData
是一個分組數(shù)據(jù),循環(huán)里面套循環(huán),假設有 10 個章節(jié), 每個章節(jié)有 200 道題目,那么其實會執(zhí)行 2000 次 getItemClass 函數(shù),getItemClass 內(nèi)部會有 6 次對 questions 進行求值,每次都會走到 dependArray,每次執(zhí)行 dependArray 都會循環(huán) 2000 次,所以粗略估計 2000 * 6 * 2000 = 2400 萬次,如果假設一次執(zhí)行的語句是 4 條,那么也會執(zhí)行接近一億次的語句,性能自然是原地爆炸!
既然從源頭分析出了原因,那么就要找出方法從源頭上去解決。
拆分組件
很多人理解拆分組件是為了復用,當然作用不止是這些,拆分組件更多的是為了可維護性,可以更語義化,在同事看到你的組件名的時候,大概能猜出里面的功能。而我這里拆分組件,是為了隔離無關的響應式數(shù)據(jù)造成的組件渲染。從上圖可以看出,只要任何一個響應式數(shù)據(jù)改變,Paper 都會重新渲染,比如我點擊收藏按鈕,Paper 組件會重新渲染,按道理只要收藏按鈕這個 DOM 重新渲染即可。
在嵌套循環(huán)中,不要用函數(shù)
性能出現(xiàn)問題的原因是在于我用了 getItemClass 去計算每一個小圓圈的樣式,而且在函數(shù)里面還對 questions 進行了求值,這樣時間復雜度從 O(n²) 變成了 O(n³)(由于源碼的 dependArray也會循環(huán))。最后的解決方案,我是棄用了 getItemClass 這個函數(shù),直接更改了 cardData 的 tids 的數(shù)據(jù)結(jié)構(gòu),變成了 tInfo,也就是在構(gòu)造數(shù)據(jù)的時候,計算好樣式。
this.cardData = [{ startIndex: 0, secName: '章節(jié)名稱', secId: '章節(jié)id', tInfo: [ { id: 1, klass: 'item_false' }, { id: 2, klass: 'item_false_active' }] }]
如此一來,就不會出現(xiàn) O(n³) 時間復雜度的問題了。
善用緩存
我發(fā)現(xiàn) getItemClass 里面自己寫的很不好,其實應該用個變量去緩存 quesions,這樣就不會造成對 questions 多次求值,進而多次走到源碼的 dependArray 當中去。
const questions = this.questions // good // bad // questions[0] this.questions[0] // questions[1] this.questions[1] // questions[2] this.questions[2] ...... // 前者只會對 this.questions 一次求值,后者會三次求值
后感
從這次教訓,自己也學到了也很多。
遇到問題的時候,要利用現(xiàn)有工具去分析問題的原因,比如 Chrome 自帶的 Performance。
對于自己所用的技術,要追根究底,慶幸自己之前深入研究過 Vue 的源碼,這樣才能游刃有余地去解決問題,否則現(xiàn)在估計還一頭霧水,如果有想深入理解 Vue 的小伙伴,可以參考Vue.js 技術揭秘,看過 GitHub 上面很多源碼分析,這個應該是寫的最全最好的,我自己也對該源碼分析提過 PR。
實現(xiàn)一個需求很容易,但是要把性能做到最佳,成本可能急劇增加。
以上就是本文的全部內(nèi)容,希望對大家的學習有所幫助,也希望大家多多支持腳本之家。
相關文章
vue2 d3實現(xiàn)企查查股權(quán)穿透圖股權(quán)結(jié)構(gòu)圖效果詳解
這篇文章主要為大家介紹了vue2 d3實現(xiàn)企查查股權(quán)穿透圖股權(quán)結(jié)構(gòu)圖效果詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-01-01Vue-Router實現(xiàn)頁面正在加載特效方法示例
這篇文章主要給大家介紹了利用Vue-Router實現(xiàn)頁面正在加載特效方法示例,文中給出了詳細的示例代碼,相信對大家具有一定的參考價值,有需要的朋友們下面來一起看看吧。2017-02-02vue3的setup語法如何自定義v-model為公用hooks
這篇文章主要介紹了vue3的setup語法如何自定義v-model為公用hooks,文章分為兩個部分介紹,簡單介紹vue3的setup語法如何自定義v-model和如何提取v-model語法作為一個公用hooks2022-07-07Vue打包程序部署到Nginx 點擊跳轉(zhuǎn)404問題
這篇文章主要介紹了Vue打包程序部署到Nginx 點擊跳轉(zhuǎn)404問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-02-02