为Vue组件添加非响应式数据

5,996 阅读5分钟

前言

在vue组件中内data内函数返回的对象默认是响应式的,vue的observe函数会遍历此对象所有的属性和子孙属性并转化为getter/setter, 使Vue能够追踪依赖,在属性被访问和修改时通知变更。这种响应式在被用在模板更新、watch变更、computed依赖中非常有用。但如果我们的数据并不会改变,或者只会整体改变,或者本身就不需要响应式,那上述为深度响应式做的转化、依赖以及产生的闭包、watcher空间其实是多余的,白白浪费了时间和性能。平时我们自己写的对象不会太复杂这种性能消耗并不明显,但当在引用第三方工具库,比如图表、地图、模型等,如果把多个不需要深度响应式的第三方实例或数据直接挂载到data属性上,又或者遇到大数据量列表,性能的影响就会比较明显。本文会介绍几种目前本人尽可能想到的添加非响应式数据的方式,分析其中的利弊并给出推荐。

1、避免把数据挂载到data内函数返回的对象上

显然,如果不把数据挂载到data内函数返回的对象上,就不会被observe函数处理

1.1 将数据定义在export default之外

实现如下

const bigData = {
  ...
}
export default {
  ...
}

定义在export default之外的数据,依然能被export default内的代码正常访问。定义清晰,写法简单,但存在的2点问题,首先是不能在模板内使用,其次由于实质是定义在组件这个类上面的,是类的内部变量,被所有实例对象共享,其中一个实例对象的内改变数据,另一个对象内的数据也会被改变。因此这种方式更适合不需要在模板内使用的常量、不变配置项等。然而这种情况数据一般不会很大,因此实际应用场景有限。

1.2 将数据定义在组件的自定义属性上

实现如下

export default {
  ···
  bigData: { // 自定义属性
    ....
  },
  methods: {
    doSomething() {
      return this.$options.bigData
    }
  }
}

由于挂载到实例上,通过例子中this.$options.bigData的这种方式就可以正常访问数据。弊端在于数据的定义被分在了2个地方,添加的自定义属性对不了解的人会产生误解,使用时也会增加调用链。 最后,这种写法bigData属性是非响应式的,如果数据更改,需要手动调用this.$forceUpdate()才能使模板更新。 如无特殊说明,本文中提到的bigData属性都是非响应式的。

2、利用Vue无法检测到对象属性的添加来实现

官方文档中有介绍

受现代 JavaScript 的限制 (而且 Object.observe 也已经被废弃),Vue 无法检测到对象属性的添加或删除。由于 Vue 会在初始化实例时对属性执行 getter/setter 转化,所以属性必须在 data 对象上存在才能让 Vue 将它转换为响应式的。例如:

var vm = new Vue({
  data:{
    a:1
  }
})
// `vm.a` 是响应式的
vm.b = 2
// `vm.b` 是非响应式的

因此,我们可以待实例完成初始化observe后,即created中加入

export default {
  data() {
    return {}
  },
  created() {
    this.bigData = {
      ···
    }
  }
  ···
}

这种写法简单,并且后添加是属性也可以在模板中访问。只是同样的,数据的定义被分在了2个地方。

3、剖析observe函数来寻找办法

终于来到observe内部,找到observer关键的几处代码,可以只留意几个切入口处:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}


export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  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 through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)  // 切入口2
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  // 省略
}
  /**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {  // 切入口3
    return
  }
  // 省略响应式处理代码
}
/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 */
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) &&  // 切入口1
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

vue生命周期内会调用initData,然后从函数observe开始,顺序找到其中可利用的判断点Object.isExtensible(value)、Object.keys(obj)、property.configurable === false

3.1使Object.isExtensible(value)返回为false

Object.isExtensible() 方法判断一个对象是否是可扩展的(是否可以在它上面添加新的属性)。默认情况下,对象是可扩展的:即可以为他们添加新的属性。Object.preventExtensions,Object.seal 或 Object.freeze 方法都可以标记一个对象为不可扩展。 下面以Object.preventExtensions为例

export default {
  data() {
    return {
      bigData: Object.preventExtensions({
        ···
      })
    }
  }
}

虽然上述的三种方法都可以实现,但也要注意区别,Object.seal会额外使其所有属性都不可配置且因此不可删除,Object.freeze更会使所有属性都不可更改。Object.seal和Object.freeze都会遍历一次对象的所有属性,性能上比Object.preventExtensions略差。不过由于是浅层遍历,实践上影响不大,而且对于数组,浏览器实现时做了优化,几乎没有性能损耗。综合看推荐使用Object.preventExtensions和Object.seal。 另外三者都有一个共同的注意点,当bigData值改变时,需要重新调用一次,即

  updateBigData (newBigData) {
    this.bigData = Object.preventExtensions(newBigData)
  }

最后,这种写法bigData属性是响应式的,值改变后模板会自动更新;当然如果是bigData某个属性改变,仍然需要手动调用this.$forceUpdate()

ps: 1.思考,为什么检测到不可添加新的数据,vue就不做响应式处理了? 2.第三方工具可能会需要在原数据上添加属性,这会限制此方法的适用范围。

3.2使挂载的数据key不在Object.keys(obj)返回的数组中

Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组。

3.2.1 把属性改成不可枚举

显然如果我们把属性改成不可枚举,就实现了 代码如下

export default {
  data() {
    const data = {
      bigData: {
        ···
      }
      ··· // 其他属性
    }
    Object.defineProperty(data, 'bigData', {
      enumerable: false
    })
    return data
  }
}

要注意的是,如果像例子这样bigData是在根属性上,会跳过initData方法里把bigData代理到vue实例的根属性上的过程,访问时需要使用this.$data.bigData,无法避免作用域链加一级的情况。

3.2.2 使用Symble类型作为属性名称

利用Object.keys()无法获取Symbol类型属性名称的方式,实现如下

export default {
  data() {
    let bigData = Symbol.for('bigData')
    return {
      [bigData]: {
        ···
      },
      bigData,
      ··· // 其他属性
    }
  },
}

这种方法获取数据就更麻烦了,如果bigData是在根属性上,需要使用this.$data[this.bigData]或者this.$data[Symbol.for('bigData')]来获取。

3.3 使property.configurable === false

这个和3.2.1写法类似

export default {
  data() {
    const data = {
      bigData: {
        ···
      }
      ··· // 其他属性
    }
    Object.defineProperty(data, 'bigData', {
      configurable: false
    })
    return data
    }
  }
}

和3.2.1比好处是不会存在bigData代理被跳过的情况,使用this.bigData可以直接访问。

整体对比

方式和响应式数据一起定义模板自动更新便捷程度其他说明推荐指数
在export default之外定义简单不可在模板内使用;不可被改变
定义在自定义属性上复杂★★
在created钩子中加入简单不需要在模板内使用时★★★★★
使用Object.preventExtensions处理较简单适用广泛★★★★
使用Object.seal处理较简单方法名称短★★★★
使用Object.freeze处理较简单处理后的数据属性值不可更改★★★
设置属性不可枚举复杂
使用Symble类型作为属性名称复杂难得可以使用Symble类型的场景,加一星★★
设置属性描述符不可更改一般★★★

由上表可见,本人比较推荐的是在created钩子中加入使用Object.preventExtensions处理,在数据对象属性不是超级多时也推荐使用Object.seal处理,毕竟字数较少。

简单性能对比

这里选取Object.preventExtensions、Object.seal来和正常数据做个简单的性能对比。数据采用随机生成包含一万属性的对象,并加入不加对象数据做对照组。

const getTestData = function (length = 1e4) {
  return Array.from({ length }).reduce((pre, curr, index) => {
    pre['attr' + index] = Math.random()
    return pre
  }, {})
}

结果如下

方式结果
不加数据0.04ms
正常响应式13ms
使用Object.preventExtensions处理0.05ms
使用Object.seal处理0.13ms

可见性能提升明显,另外这里只测试了初始化响应式数据花费的时间,实际项目中对运行速度、内存占用都有明显改善。

参考链接