您好,登錄后才能下訂單哦!
這篇文章主要介紹Vue性能優化之深挖數組的示例分析,文中介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們一定要看完!
背景
最近在用 Vue 重構一個歷史項目,一個考試系統,題目量很大,所以核心組件的性能成為了關注點。先來兩張圖看下最核心的組件 Paper 的樣式。
從圖中來看,分為答題區與選擇面板區。
稍微對交互邏輯進行下拆解:
答題模式與學習模式可以相互切換,控制正確答案顯隱。
單選與判斷題直接點擊就記錄答案正確性,多選是選擇答案之后點擊確定才能記錄正確性。
選擇面板則是記錄做過的題目的情況,分為六種狀態(未做過的,未做過且當前選擇的,做錯的,做錯的且當前選擇的,做對的,做對的且當前選擇的),用不同的樣式去區別。
點擊選擇面板,答題區能切到對應的題號。
基于以上考慮,我覺得我必須有三個響應式的數據:
currentIndex
: 當前選中題目的序號。
questions
:所有題目的信息,是個數組,里面維護了每道題的問題、選項、正確與否等信息。
cardData
:題目分組的信息,也是個數組,按章節名稱對不同的題目進行了分類。
數組每一項數據結構如下:
currentIndex = 0 // 用來標記當前選中題目的索引 questions = [{ secId: 1, // 所屬章節的 id tid: 1, // 題目 id content: '題目內容' // 題目描述 type: 1, // 題型,1 ~ 3 (單選,多選,判斷) options: ['選項1', '選項2', '選項3', '選項4',] // 每個選項的描述 choose: [1, 2, 4], // 多選——記錄用戶未提交前的選項 done: true, // 標記當前題目是否已做 answerIsTrue: undefined // 標記當前題目的正確與否 }] cardData = [{ startIndex: 0, // 用來記錄循環該分組數據的起始索引,這個值等于前面數據的長度累加。 secName: '章節名稱', secId: '章節id', tids: [1, 2, 3, 11] // 該章節下面的所有題目的 id }]
由于題目可以左右滑動切換,所以我每次從 questions
取了三個數據去渲染,用的是 cube-ui 的 Slide 組件,只要自己根據 this.currentIndex 結合 computed 特性去動態的切割三個數據就行。
這一切都顯得很美好,尤其是即將結束了一個歷史項目的核心組件的編寫之前,心情特別的舒暢。
然而轉折點出現在了渲染選擇面板樣式這一步
代碼邏輯很簡單,但是發生了讓我懵逼的事情。
<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 元素,這是個分組數據(先是以章節為維度,章節下面還有對應的題目),上面的代碼其實是一個循環里面嵌套了另一個循環。
但是,只要我切換題目或者點擊面板,抑或是觸發任意響應式數據的改變,都會讓頁面卡死!!
探索
當下的第一反應,肯定是 js 在某一步的執行時間過長,所以利用 Chrome 自帶的 Performance 工具 追蹤了一下,發現問題出在 getItemClass
這個函數調用,占據了 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 },
這個函數主要是用來計算選擇面板每一個小圓圈該有的樣式。每一步都是對 questions 進行了 getter 操作。初看,好像沒什么問題,但是由于之前看過 Vue 的源碼,細想之下,覺得不對。
首先,webpack 會將 .vue 文件的 template 轉換成 render 函數,也就是實例化組件的時候,其實是對響應式屬性求值的過程,這樣響應式屬性就能將 renderWatcher 加入依賴當中,所以當響應式屬性改變的時候,能觸發組件重新渲染。
我們先來了解下 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 函數,進而執行 renderWatcher 收集依賴。_render 返回的是組件的 vnode,傳入 _update 函數從而執行組件的 patch,最終生成視圖。
其次,從我寫的 template 來分析,為了渲染選擇面板的 DOM,是有兩層 for 循環的,內部每次循環都會執行 getItemClass 函數,而函數的內部又是對 questions 這個響應式數組進行了 getter 求值,從目前來看,時間復雜度是 O(n²),如上圖所示,我們大概有 2000 多道題目,我們假設有 10 個章節,每個章節有 200 道題目,getItemClass 內部是對 questions 進行了 6 次求值,這樣一算,粗略也是 12000 左右,按 js 的執行速度,是不可能這么慢的。
那么問題是不是出現在對 questions 進行 getter 的過程中,出現了 O(n³) 的復雜度呢?
于是,我打開了 Vue 的源碼,由于之前深入研究過源碼,所以輕車熟路地找到了 vue/src/core/instance/state.js
里面將 data 轉換成 getter/setter 的部分。
function initData (vm: Component) { ...... // observe data observe(data, true /* asRootData */) }
定義一個組件的 data 的響應式,都是從 observe 函數開始,它的定義是位于 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 函數接受對象或者數組,內部會實例化 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 的構造函數很簡單,就是聲明了 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: ... } }
也就是每個對象或者數組被 observe 之后,多了一個 _ ob _ 屬性,它是 Observer 的實例。那么這么做的意義何在呢,稍后分析。
繼續分析 Observer 構造函數的下面部分:
// 如果是數組,先篡改數組的一些方法(push,splice,shift等等),使其能夠支持響應式 if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } // 數組里面的元素還是數組或者對象,遞歸地調用 observe 函數,使其成為響應式數據 this.observeArray(value) } else { // 遍歷對象,使其每個鍵值也能成為響應式數據 this.walk(value) } walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { // 將對象的鍵值轉換成 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 里面調用 initData,initData 得到用戶配置的 data 對象后調用了 observe,observe 函數里面會實例化 Observer 類,在其構造函數里面,首先將對象的 _ ob _ 屬性指向 Observer 實例(這一步是為了檢測到對象添加或者刪除屬性之后,能觸發響應式的伏筆),之后遍歷當前對象的鍵值,調用 defineReactive 去轉換成 getter / setter。
所以,來分析下 defineReactive。
// 如果是數組,先篡改數組的一些方法(push,splice,shift等等),使其能夠支持響應式 if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } // 數組里面的元素還是數組或者對象,遞歸地調用 observe 函數,使其成為響應式數據 this.observeArray(value) } else { // 遍歷對象,使其每個鍵值也能成為響應式數據 this.walk(value) } walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { // 將對象的鍵值轉換成 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 都是函數,并且引用了 dep,所以形成了閉包,dep 一直存在于內存當中。因此,假如在渲染組件的時候,如果使用了響應式屬性 a,就會走到上述的語句1,dep 實例就會收集組件這個 renderWatcher,因為在對 a 進行 setter 賦值操作的時候,會調用 dep.notify() 去 通知 renderWatcher 去更新,進而觸發響應式數據收集新一輪的 watcher。
那么語句2與3,到底是什么作用呢
我們舉個栗子分析
<div>{{person}}<div>
export default { data () { return { person: { name: '張三', age: 18 } } } } this.person.gender = '男' // 組件視圖不會更新
因為 Vue 是無法探測到對象增添屬性,所以也沒有一個時機去觸發 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 函數接受三個參數,第一個參數可以是 Object 或者 Array,其余的參數分別為 key, value。如果利用這個 API 給 person 增加一個屬性呢?
this.$set(this.person, 'gender', '男') // 組件視圖重新渲染
為什么通過 set 函數又能觸發重新渲染呢?注意到這一句, ob.dep.notify()
, ob
怎么來的呢,那就得回到之前的 observe 函數了,其實 data 經過 observe 處理之后變成下面這樣。
{ person: { name: '張三', age: 18, __ob__: { value: ..., dep: new Dep() } }, __ob__: { value: ..., dep: new Dep() } } // 只要是對象,都定義了 __ob__ 屬性,它是 Observer 類的實例
從 template 來看,視圖依賴了 person 這個屬性值,renderWatcher 被收集到了 person 屬性的 Dep 實例當中,對應 defineReactive
函數定義的 語句1 ,同時, 語句2 的作用就是將 renderWatcher 收集到 person._ ob _.dep 當中去,因此在給 person 增加屬性的時候,調用 set 方法才能獲取到 person._ ob _.dep,進而觸發 renderWatcher 更新。
那么得出結論,語句2的作用是為了能夠探測到響應式數據是對象的情況下增刪屬性而引發重新渲染的。
再舉個栗子解釋下 語句3 的作用。
<div>{{books}}<div>
export default { data () { return { books: [ { id: 1, name: 'js' } ] } } }
因為組件對 books 進行求值,而它是一個數組,所以會走到語句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) } } }
從邏輯上來看,就是循環 books 的每一項 item,如果 item 是一個數組或者對象,就會獲取到 item._ ob _.dep,并且將當前 renderWatcher 收集到 dep 當中去。
如果沒有這一句,會發生什么情況?考慮下如下的情況:
this.$set(this.books[0], 'comment', '棒極了') // 并不會觸發組件更新
如果理解成 renderWatch 并沒有對 this.books[0] 進行求值,所以改變它并不需要造成組件更新,那么這個理解是有誤的。正確的是因為數組是元素的集合,內部的任何修改是需要反映出來的,所以語句3就是為了在 renderWatcher 對數組求值的時候,將 renderWatcher 收集到數組內部每一項 item._ ob _.dep 當中去,這樣只要內部發生變化,就能通過 dep 獲取到 renderWatcher,通知它更新。
那么結合我的業務代碼,就分析出來問題出現在語句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
是一個分組數據,循環里面套循環,假設有 10 個章節, 每個章節有 200 道題目,那么其實會執行 2000 次 getItemClass 函數,getItemClass 內部會有 6 次對 questions 進行求值,每次都會走到 dependArray,每次執行 dependArray 都會循環 2000 次,所以粗略估計 2000 * 6 * 2000 = 2400 萬次,如果假設一次執行的語句是 4 條,那么也會執行接近一億次的語句,性能自然是原地爆炸!
既然從源頭分析出了原因,那么就要找出方法從源頭上去解決。
拆分組件
很多人理解拆分組件是為了復用,當然作用不止是這些,拆分組件更多的是為了可維護性,可以更語義化,在同事看到你的組件名的時候,大概能猜出里面的功能。而我這里拆分組件,是為了隔離無關的響應式數據造成的組件渲染。從上圖可以看出,只要任何一個響應式數據改變,Paper 都會重新渲染,比如我點擊收藏按鈕,Paper 組件會重新渲染,按道理只要收藏按鈕這個 DOM 重新渲染即可。
在嵌套循環中,不要用函數
性能出現問題的原因是在于我用了 getItemClass 去計算每一個小圓圈的樣式,而且在函數里面還對 questions 進行了求值,這樣時間復雜度從 O(n²) 變成了 O(n³)(由于源碼的 dependArray也會循環)。最后的解決方案,我是棄用了 getItemClass 這個函數,直接更改了 cardData 的 tids 的數據結構,變成了 tInfo,也就是在構造數據的時候,計算好樣式。
this.cardData = [{ startIndex: 0, secName: '章節名稱', secId: '章節id', tInfo: [ { id: 1, klass: 'item_false' }, { id: 2, klass: 'item_false_active' }] }]
如此一來,就不會出現 O(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 一次求值,后者會三次求值
以上是“Vue性能優化之深挖數組的示例分析”這篇文章的所有內容,感謝各位的閱讀!希望分享的內容對大家有幫助,更多相關知識,歡迎關注億速云行業資訊頻道!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。