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 界永不吃亏。
  • 实现一个需求很容易,可是要把性能作到最佳,成本可能急剧增长。