Vue 性能優化之深挖數組

做者:嵇智html

背景

最近在用 Vue 重構一個歷史項目,一個考試系統,題目量很大,因此核心組件的性能成爲了關注點。先來兩張圖看下最核心的組件 Paper 的樣式。vue

從圖中來看,分爲答題區與選擇面板區。node

稍微對交互邏輯進行下拆解:react

  • 答題模式與學習模式能夠相互切換,控制正確答案顯隱。
  • 單選與判斷題直接點擊就記錄答案正確性,多選是選擇答案以後點擊肯定才能記錄正確性。
  • 選擇面板則是記錄作過的題目的狀況,分爲六種狀態(未作過的,未作過且當前選擇的,作錯的,作錯的且當前選擇的,作對的,作對的且當前選擇的),用不一樣的樣式去區別。
  • 點擊選擇面板,答題區能切到對應的題號。

基於以上考慮,我以爲我必須有三個響應式的數據:webpack

  • currentIndex: 當前選中題目的序號。
  • questions:全部題目的信息,是個數組,裏面維護了每道題的問題、選項、正確與否等信息。
  • cardData:題目分組的信息,也是個數組,按章節名稱對不一樣的題目進行了分類。

數組每一項數據結構以下:git

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 特性去動態的切割三個數據就行。github

這一切都顯得很美好,尤爲是即將結束了一個歷史項目的核心組件的編寫以前,心情特別的舒暢。web

然而轉折點出如今了渲染選擇面板樣式這一步數組

代碼邏輯很簡單,可是發生了讓我懵逼的事情。緩存

<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 實例的時候會走到 mount,即走到上述的 new Watcher,這個就是 renderWatcher,以後走到 updateComponent 函數,也就是會執行 _render,函數內部會經過 vm.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。

export function defineReactive ( obj: Object, key: string, val: any, customSetter?: ?Function, shallow?: boolean ) {
  // 每一個屬性收集 watcher 的管理器 
  const dep = new Dep()
  ......    
  // 遞歸地去將屬性值變成響應式 
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        // 當前屬性收集 watcher
        dep.depend() // 語句1
        if (childOb) {
          // 若是當前屬性對應的屬性值是對象,將當前 watcher 加入 val.__ob__.dep當中去,爲何要這麼作呢?先思考一下
          childOb.dep.depend() // 語句2
          // 若是當前屬性對應的屬性值是數組,遞歸地將當前 watcher 加入數組每一項,item.__ob__.dep當中去,爲何要這麼作呢?
          if (Array.isArray(value)) { // 語句3
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      .....    
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

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)
    }
  }
}
複製代碼

首先,咱們從 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 條,那麼也會執行接近一億次的語句,性能天然是原地爆炸!

既然從源頭分析出了緣由,那麼就要找出方法從源頭上去解決。

  1. 拆分組件

    不少人理解拆分組件是爲了複用,固然做用不止是這些,拆分組件更多的是爲了可維護性,能夠更語義化,在同事看到你的組件名的時候,大概能猜出裏面的功能。而我這裏拆分組件,是爲了隔離無關的響應式數據形成的組件渲染。從上圖能夠看出,只要任何一個響應式數據改變,Paper 都會從新渲染,好比我點擊收藏按鈕,Paper 組件會從新渲染,按道理只要收藏按鈕這個 DOM 從新渲染便可。

  2. 在嵌套循環中,不要用函數

    性能出現問題的緣由是在於我用了 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³) 時間複雜度的問題了。

  3. 善用緩存

    我發現 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 一次求值,後者會三次求值
    複製代碼

後感

從此次教訓,本身也學到了也不少。

  • 遇到問題的時候,要利用現有工具去分析問題的緣由,好比 Chrome 自帶的 Performance。
  • 對於本身所用的技術,要追根究底,慶幸本身以前深刻研究過 Vue 的源碼,這樣才能遊刃有餘地去解決問題,不然如今估計還一頭霧水,若是有想深刻理解 Vue 的小夥伴,能夠參考Vue.js 技術揭祕,看過 GitHub 上面不少源碼分析,這個應該是寫的最全最好的,我本身也對該源碼分析提過 PR。若是自學吃力的狀況下,能夠考慮配套視頻,畢竟用知識武裝本身,在 IT 界永不吃虧。
  • 實現一個需求很容易,可是要把性能作到最佳,成本可能急劇增長。
相關文章
相關標籤/搜索