這篇文章主要介紹Vue性能優(yōu)化之深挖數(shù)組的示例分析,文中介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們一定要看完!
成都創(chuàng)新互聯(lián)的客戶來(lái)自各行各業(yè),為了共同目標(biāo),我們?cè)诠ぷ魃厦芮信浜?,從?chuàng)業(yè)型小企業(yè)到企事業(yè)單位,感謝他們對(duì)我們的要求,感謝他們從不同領(lǐng)域給我們帶來(lái)的挑戰(zhàn),讓我們激情的團(tuán)隊(duì)有機(jī)會(huì)用頭腦與智慧不斷的給客戶帶來(lái)驚喜。專業(yè)領(lǐng)域包括成都網(wǎng)站設(shè)計(jì)、成都網(wǎng)站建設(shè)、外貿(mào)網(wǎng)站建設(shè)、電商網(wǎng)站開(kāi)發(fā)、微信營(yíng)銷、系統(tǒng)平臺(tái)開(kāi)發(fā)。
背景
最近在用 Vue 重構(gòu)一個(gè)歷史項(xiàng)目,一個(gè)考試系統(tǒng),題目量很大,所以核心組件的性能成為了關(guān)注點(diǎn)。先來(lái)兩張圖看下最核心的組件 Paper 的樣式。
從圖中來(lái)看,分為答題區(qū)與選擇面板區(qū)。
稍微對(duì)交互邏輯進(jìn)行下拆解:
答題模式與學(xué)習(xí)模式可以相互切換,控制正確答案顯隱。
單選與判斷題直接點(diǎn)擊就記錄答案正確性,多選是選擇答案之后點(diǎn)擊確定才能記錄正確性。
選擇面板則是記錄做過(guò)的題目的情況,分為六種狀態(tài)(未做過(guò)的,未做過(guò)且當(dāng)前選擇的,做錯(cuò)的,做錯(cuò)的且當(dāng)前選擇的,做對(duì)的,做對(duì)的且當(dāng)前選擇的),用不同的樣式去區(qū)別。
點(diǎn)擊選擇面板,答題區(qū)能切到對(duì)應(yīng)的題號(hào)。
基于以上考慮,我覺(jué)得我必須有三個(gè)響應(yīng)式的數(shù)據(jù):
currentIndex
: 當(dāng)前選中題目的序號(hào)。
questions
:所有題目的信息,是個(gè)數(shù)組,里面維護(hù)了每道題的問(wèn)題、選項(xiàng)、正確與否等信息。
cardData
:題目分組的信息,也是個(gè)數(shù)組,按章節(jié)名稱對(duì)不同的題目進(jìn)行了分類。
數(shù)組每一項(xiàng)數(shù)據(jù)結(jié)構(gòu)如下:
currentIndex = 0 // 用來(lái)標(biāo)記當(dāng)前選中題目的索引 questions = [{ secId: 1, // 所屬章節(jié)的 id tid: 1, // 題目 id content: '題目?jī)?nèi)容' // 題目描述 type: 1, // 題型,1 ~ 3 (單選,多選,判斷) options: ['選項(xiàng)1', '選項(xiàng)2', '選項(xiàng)3', '選項(xiàng)4',] // 每個(gè)選項(xiàng)的描述 choose: [1, 2, 4], // 多選——記錄用戶未提交前的選項(xiàng) done: true, // 標(biāo)記當(dāng)前題目是否已做 answerIsTrue: undefined // 標(biāo)記當(dāng)前題目的正確與否 }] cardData = [{ startIndex: 0, // 用來(lái)記錄循環(huán)該分組數(shù)據(jù)的起始索引,這個(gè)值等于前面數(shù)據(jù)的長(zhǎng)度累加。 secName: '章節(jié)名稱', secId: '章節(jié)id', tids: [1, 2, 3, 11] // 該章節(jié)下面的所有題目的 id }]
由于題目可以左右滑動(dòng)切換,所以我每次從 questions
取了三個(gè)數(shù)據(jù)去渲染,用的是 cube-ui 的 Slide 組件,只要自己根據(jù) this.currentIndex 結(jié)合 computed 特性去動(dòng)態(tài)的切割三個(gè)數(shù)據(jù)就行。
這一切都顯得很美好,尤其是即將結(jié)束了一個(gè)歷史項(xiàng)目的核心組件的編寫(xiě)之前,心情特別的舒暢。
然而轉(zhuǎn)折點(diǎn)出現(xiàn)在了渲染選擇面板樣式這一步
代碼邏輯很簡(jiǎn)單,但是發(fā)生了讓我懵逼的事情。
{{item.secName}}{{index + item.startIndex + 1}}
其實(shí)就是利用 cardData 去生成 DOM 元素,這是個(gè)分組數(shù)據(jù)(先是以章節(jié)為維度,章節(jié)下面還有對(duì)應(yīng)的題目),上面的代碼其實(shí)是一個(gè)循環(huán)里面嵌套了另一個(gè)循環(huán)。
但是,只要我切換題目或者點(diǎn)擊面板,抑或是觸發(fā)任意響應(yīng)式數(shù)據(jù)的改變,都會(huì)讓頁(yè)面卡死??!
探索
當(dāng)下的第一反應(yīng),肯定是 js 在某一步的執(zhí)行時(shí)間過(guò)長(zhǎng),所以利用 Chrome 自帶的 Performance 工具 追蹤了一下,發(fā)現(xiàn)問(wèn)題出在 getItemClass
這個(gè)函數(shù)調(diào)用,占據(jù)了 99% 的時(shí)間,而且時(shí)間都超過(guò) 1s 了。瞅了眼自己的代碼:
getItemClass (index) { const ret = {} // 如果是做對(duì)的題目,但并不是當(dāng)前選中 ret['item_true'] = this.questions[index]...... // 如果是做對(duì)的題目,并且是當(dāng)前選中 ret['item_true_active'] = this.questions[index]...... // 如果是做錯(cuò)的題目,但并不是當(dāng)前選中 ret['item_false'] = this.questions[index]...... // 如果是做錯(cuò)的題目,并且是當(dāng)前選中 ret['item_false_active'] = this.questions[index]...... // 如果是未做的題目,但不是當(dāng)前選中 ret['item_undo'] = this.questions[index]...... // 如果是未做的題目,并且是當(dāng)前選中 ret['item_undo_active'] = this.questions[index]...... return ret },
這個(gè)函數(shù)主要是用來(lái)計(jì)算選擇面板每一個(gè)小圓圈該有的樣式。每一步都是對(duì) questions 進(jìn)行了 getter 操作。初看,好像沒(méi)什么問(wèn)題,但是由于之前看過(guò) Vue 的源碼,細(xì)想之下,覺(jué)得不對(duì)。
首先,webpack 會(huì)將 .vue 文件的 template 轉(zhuǎn)換成 render 函數(shù),也就是實(shí)例化組件的時(shí)候,其實(shí)是對(duì)響應(yīng)式屬性求值的過(guò)程,這樣響應(yīng)式屬性就能將 renderWatcher 加入依賴當(dāng)中,所以當(dāng)響應(yīng)式屬性改變的時(shí)候,能觸發(fā)組件重新渲染。
我們先來(lái)了解下 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 }
稍微分析下流程:實(shí)例化 Vue 實(shí)例的時(shí)候會(huì)走到 options 取到由 template 編譯生成的 render 函數(shù),進(jìn)而執(zhí)行 renderWatcher 收集依賴。_render 返回的是組件的 vnode,傳入 _update 函數(shù)從而執(zhí)行組件的 patch,最終生成視圖。
其次,從我寫(xiě)的 template 來(lái)分析,為了渲染選擇面板的 DOM,是有兩層 for 循環(huán)的,內(nèi)部每次循環(huán)都會(huì)執(zhí)行 getItemClass 函數(shù),而函數(shù)的內(nèi)部又是對(duì) questions 這個(gè)響應(yīng)式數(shù)組進(jìn)行了 getter 求值,從目前來(lái)看,時(shí)間復(fù)雜度是 O(n²),如上圖所示,我們大概有 2000 多道題目,我們假設(shè)有 10 個(gè)章節(jié),每個(gè)章節(jié)有 200 道題目,getItemClass 內(nèi)部是對(duì) questions 進(jìn)行了 6 次求值,這樣一算,粗略也是 12000 左右,按 js 的執(zhí)行速度,是不可能這么慢的。
那么問(wèn)題是不是出現(xiàn)在對(duì) questions 進(jìn)行 getter 的過(guò)程中,出現(xiàn)了 O(n³) 的復(fù)雜度呢?
于是,我打開(kāi)了 Vue 的源碼,由于之前深入研究過(guò)源碼,所以輕車熟路地找到了 vue/src/core/instance/state.js
里面將 data 轉(zhuǎn)換成 getter/setter 的部分。
function initData (vm: Component) { ...... // observe data observe(data, true /* asRootData */) }
定義一個(gè)組件的 data 的響應(yīng)式,都是從 observe 函數(shù)開(kāi)始,它的定義是位于 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ù)接受對(duì)象或者數(shù)組,內(nèi)部會(huì)實(shí)例化 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) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]) } } }
Observer 的構(gòu)造函數(shù)很簡(jiǎn)單,就是聲明了 dep、value 屬性,并且將 value 的 _ ob _ 屬性指向當(dāng)前實(shí)例。舉個(gè)栗子:
// 剛開(kāi)始的 options export default { data : { msg: '消息', arr: [1], item: { text: '文本' } } } // 實(shí)例化 vm 的時(shí)候,變成了以下 data: { msg: '消息', arr: [1, __ob__: { value: ..., dep: new Dep(), vmCount: ... }], item: { text: '文本', __ob__: { value: ..., dep: new Dep(), vmCount: ... } }, __ob__: { value: ..., dep: new Dep(), vmCount: ... } }
也就是每個(gè)對(duì)象或者數(shù)組被 observe 之后,多了一個(gè) _ ob _ 屬性,它是 Observer 的實(shí)例。那么這么做的意義何在呢,稍后分析。
繼續(xù)分析 Observer 構(gòu)造函數(shù)的下面部分:
// 如果是數(shù)組,先篡改數(shù)組的一些方法(push,splice,shift等等),使其能夠支持響應(yīng)式 if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } // 數(shù)組里面的元素還是數(shù)組或者對(duì)象,遞歸地調(diào)用 observe 函數(shù),使其成為響應(yīng)式數(shù)據(jù) this.observeArray(value) } else { // 遍歷對(duì)象,使其每個(gè)鍵值也能成為響應(yīng)式數(shù)據(jù) this.walk(value) } walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { // 將對(duì)象的鍵值轉(zhuǎn)換成 getter / setter, // getter 收集依賴 // setter 通知 watcher 更新 defineReactive(obj, keys[i]) } } observeArray (items: Array) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]) } }
我們?cè)俎垡幌滤悸罚紫仍?initState 里面調(diào)用 initData,initData 得到用戶配置的 data 對(duì)象后調(diào)用了 observe,observe 函數(shù)里面會(huì)實(shí)例化 Observer 類,在其構(gòu)造函數(shù)里面,首先將對(duì)象的 _ ob _ 屬性指向 Observer 實(shí)例(這一步是為了檢測(cè)到對(duì)象添加或者刪除屬性之后,能觸發(fā)響應(yīng)式的伏筆),之后遍歷當(dāng)前對(duì)象的鍵值,調(diào)用 defineReactive 去轉(zhuǎn)換成 getter / setter。
所以,來(lái)分析下 defineReactive。
// 如果是數(shù)組,先篡改數(shù)組的一些方法(push,splice,shift等等),使其能夠支持響應(yīng)式 if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } // 數(shù)組里面的元素還是數(shù)組或者對(duì)象,遞歸地調(diào)用 observe 函數(shù),使其成為響應(yīng)式數(shù)據(jù) this.observeArray(value) } else { // 遍歷對(duì)象,使其每個(gè)鍵值也能成為響應(yīng)式數(shù)據(jù) this.walk(value) } walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { // 將對(duì)象的鍵值轉(zhuǎn)換成 getter / setter, // getter 收集依賴 // setter 通知 watcher 更新 defineReactive(obj, keys[i]) } } observeArray (items: Array) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]) } }
首先,我們從 defineReactive 可以看出,每個(gè)響應(yīng)式屬性都有一個(gè) Dep 實(shí)例,這個(gè)是用來(lái)收集 watcher 的。由于 getter 與 setter 都是函數(shù),并且引用了 dep,所以形成了閉包,dep 一直存在于內(nèi)存當(dāng)中。因此,假如在渲染組件的時(shí)候,如果使用了響應(yīng)式屬性 a,就會(huì)走到上述的語(yǔ)句1,dep 實(shí)例就會(huì)收集組件這個(gè) renderWatcher,因?yàn)樵趯?duì) a 進(jìn)行 setter 賦值操作的時(shí)候,會(huì)調(diào)用 dep.notify() 去 通知 renderWatcher 去更新,進(jìn)而觸發(fā)響應(yīng)式數(shù)據(jù)收集新一輪的 watcher。
那么語(yǔ)句2與3,到底是什么作用呢
我們舉個(gè)栗子分析
{{person}}export default { data () { return { person: { name: '張三', age: 18 } } } } this.person.gender = '男' // 組件視圖不會(huì)更新因?yàn)?Vue 是無(wú)法探測(cè)到對(duì)象增添屬性,所以也沒(méi)有一個(gè)時(shí)機(jī)去觸發(fā) renderWatcher 的更新。
為此, Vue 提供了一個(gè) API,
this.$set
,它是Vue.set
的別名。export function set (target: Array| 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ù)接受三個(gè)參數(shù),第一個(gè)參數(shù)可以是 Object 或者 Array,其余的參數(shù)分別為 key, value。如果利用這個(gè) API 給 person 增加一個(gè)屬性呢?
this.$set(this.person, 'gender', '男') // 組件視圖重新渲染為什么通過(guò) set 函數(shù)又能觸發(fā)重新渲染呢?注意到這一句,
ob.dep.notify()
,ob
怎么來(lái)的呢,那就得回到之前的 observe 函數(shù)了,其實(shí) data 經(jīng)過(guò) observe 處理之后變成下面這樣。{ person: { name: '張三', age: 18, __ob__: { value: ..., dep: new Dep() } }, __ob__: { value: ..., dep: new Dep() } } // 只要是對(duì)象,都定義了 __ob__ 屬性,它是 Observer 類的實(shí)例從 template 來(lái)看,視圖依賴了 person 這個(gè)屬性值,renderWatcher 被收集到了 person 屬性的 Dep 實(shí)例當(dāng)中,對(duì)應(yīng)
defineReactive
函數(shù)定義的 語(yǔ)句1,同時(shí), 語(yǔ)句2的作用就是將 renderWatcher 收集到 person._ ob _.dep 當(dāng)中去,因此在給 person 增加屬性的時(shí)候,調(diào)用 set 方法才能獲取到 person._ ob _.dep,進(jìn)而觸發(fā) renderWatcher 更新。那么得出結(jié)論,語(yǔ)句2的作用是為了能夠探測(cè)到響應(yīng)式數(shù)據(jù)是對(duì)象的情況下增刪屬性而引發(fā)重新渲染的。
再舉個(gè)栗子解釋下 語(yǔ)句3的作用。
{{books}}export default { data () { return { books: [ { id: 1, name: 'js' } ] } } }因?yàn)榻M件對(duì) books 進(jìn)行求值,而它是一個(gè)數(shù)組,所以會(huì)走到語(yǔ)句3的邏輯。
if (Array.isArray(value)) { // 語(yǔ)句3 dependArray(value) } function dependArray (value: Array) { 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) } } } 從邏輯上來(lái)看,就是循環(huán) books 的每一項(xiàng) item,如果 item 是一個(gè)數(shù)組或者對(duì)象,就會(huì)獲取到 item._ ob _.dep,并且將當(dāng)前 renderWatcher 收集到 dep 當(dāng)中去。
如果沒(méi)有這一句,會(huì)發(fā)生什么情況?考慮下如下的情況:
this.$set(this.books[0], 'comment', '棒極了') // 并不會(huì)觸發(fā)組件更新如果理解成 renderWatch 并沒(méi)有對(duì) this.books[0] 進(jìn)行求值,所以改變它并不需要造成組件更新,那么這個(gè)理解是有誤的。正確的是因?yàn)閿?shù)組是元素的集合,內(nèi)部的任何修改是需要反映出來(lái)的,所以語(yǔ)句3就是為了在 renderWatcher 對(duì)數(shù)組求值的時(shí)候,將 renderWatcher 收集到數(shù)組內(nèi)部每一項(xiàng) item._ ob _.dep 當(dāng)中去,這樣只要內(nèi)部發(fā)生變化,就能通過(guò) dep 獲取到 renderWatcher,通知它更新。
那么結(jié)合我的業(yè)務(wù)代碼,就分析出來(lái)問(wèn)題出現(xiàn)在語(yǔ)句3當(dāng)中。
{{item.secName}}{{index + item.startIndex + 1}}getItemClass (index) { const ret = {} // 如果是做對(duì)的題目,但并不是當(dāng)前選中 ret['item_true'] = this.questions[index]...... // 如果是做對(duì)的題目,并且是當(dāng)前選中 ret['item_true_active'] = this.questions[index]...... // 如果是做錯(cuò)的題目,但并不是當(dāng)前選中 ret['item_false'] = this.questions[index]...... // 如果是做錯(cuò)的題目,并且是當(dāng)前選中 ret['item_false_active'] = this.questions[index]...... // 如果是未做的題目,但不是當(dāng)前選中 ret['item_undo'] = this.questions[index]...... // 如果是未做的題目,并且是當(dāng)前選中 ret['item_undo_active'] = this.questions[index]...... return ret },首先
cardData
是一個(gè)分組數(shù)據(jù),循環(huán)里面套循環(huán),假設(shè)有 10 個(gè)章節(jié), 每個(gè)章節(jié)有 200 道題目,那么其實(shí)會(huì)執(zhí)行 2000 次 getItemClass 函數(shù),getItemClass 內(nèi)部會(huì)有 6 次對(duì) questions 進(jìn)行求值,每次都會(huì)走到 dependArray,每次執(zhí)行 dependArray 都會(huì)循環(huán) 2000 次,所以粗略估計(jì) 2000 * 6 * 2000 = 2400 萬(wàn)次,如果假設(shè)一次執(zhí)行的語(yǔ)句是 4 條,那么也會(huì)執(zhí)行接近一億次的語(yǔ)句,性能自然是原地爆炸!既然從源頭分析出了原因,那么就要找出方法從源頭上去解決。
拆分組件
很多人理解拆分組件是為了復(fù)用,當(dāng)然作用不止是這些,拆分組件更多的是為了可維護(hù)性,可以更語(yǔ)義化,在同事看到你的組件名的時(shí)候,大概能猜出里面的功能。而我這里拆分組件,是為了隔離無(wú)關(guān)的響應(yīng)式數(shù)據(jù)造成的組件渲染。從上圖可以看出,只要任何一個(gè)響應(yīng)式數(shù)據(jù)改變,Paper 都會(huì)重新渲染,比如我點(diǎn)擊收藏按鈕,Paper 組件會(huì)重新渲染,按道理只要收藏按鈕這個(gè) DOM 重新渲染即可。
在嵌套循環(huán)中,不要用函數(shù)
性能出現(xiàn)問(wèn)題的原因是在于我用了 getItemClass 去計(jì)算每一個(gè)小圓圈的樣式,而且在函數(shù)里面還對(duì) questions 進(jìn)行了求值,這樣時(shí)間復(fù)雜度從 O(n²) 變成了 O(n³)(由于源碼的 dependArray也會(huì)循環(huán))。最后的解決方案,我是棄用了 getItemClass 這個(gè)函數(shù),直接更改了 cardData 的 tids 的數(shù)據(jù)結(jié)構(gòu),變成了 tInfo,也就是在構(gòu)造數(shù)據(jù)的時(shí)候,計(jì)算好樣式。
this.cardData = [{ startIndex: 0, secName: '章節(jié)名稱', secId: '章節(jié)id', tInfo: [ { id: 1, klass: 'item_false' }, { id: 2, klass: 'item_false_active' }] }]如此一來(lái),就不會(huì)出現(xiàn) O(n³) 時(shí)間復(fù)雜度的問(wèn)題了。
善用緩存
我發(fā)現(xiàn) getItemClass 里面自己寫(xiě)的很不好,其實(shí)應(yīng)該用個(gè)變量去緩存 quesions,這樣就不會(huì)造成對(duì) questions 多次求值,進(jìn)而多次走到源碼的 dependArray 當(dāng)中去。
const questions = this.questions // good // bad // questions[0] this.questions[0] // questions[1] this.questions[1] // questions[2] this.questions[2] ...... // 前者只會(huì)對(duì) this.questions 一次求值,后者會(huì)三次求值以上是“Vue性能優(yōu)化之深挖數(shù)組的示例分析”這篇文章的所有內(nèi)容,感謝各位的閱讀!希望分享的內(nèi)容對(duì)大家有幫助,更多相關(guān)知識(shí),歡迎關(guān)注創(chuàng)新互聯(lián)行業(yè)資訊頻道!
網(wǎng)頁(yè)名稱:Vue性能優(yōu)化之深挖數(shù)組的示例分析
當(dāng)前地址:http://weahome.cn/article/jcheho.html其他資訊