深刻浅出 Vue 系列 -- 数据劫持实现原理

1、前言

  数据双向绑定做为 Vue 核心功能之一,其实现原理主要分为两部分:数组

  • 数据劫持
  • 发布订阅模式

  本篇文章主要介绍 Vue 实现数据劫持的思路,下一篇则会介绍发布订阅模式的设计。浏览器

2、针对 Object 类型的劫持

  对于 Object 类型,主要劫持其属性的读取与设置操做。在 JavaScript 中对象的属性主要由一个字符串类型的“名称”以及一个“属性描述符”组成,属性描述符包括如下选项:app

  • value: 该属性的值;
  • writable: 仅当值为 true 时表示该属性能够被改变;
  • get: getter (读取器);
  • set: setter (设置器);
  • configurable: 仅当值为 true 时,该属性能够被删除以及属性描述符能够被改变;
  • enumerable: 仅当值为 true 时,该属性能够被枚举。

  上述 setter 和 getter 方法就是供开发者自定义属性的读取与设置操做,而设置对象属性的描述符则少不了 Object.defineProperty() 方法:函数

function defineReactive (obj, key) {
  let val = obj[key]
  Object.defineProperty(obj, key, {
    get () {
      console.log(' === 收集依赖 === ')
      console.log(' 当前值为:' + val)
      return val
    },
    set (newValue) {
      console.log(' === 通知变动 === ')
      console.log(' 当前值为:' + newValue)
      val = newValue
    }
  })
}

const student = {
  name: 'xiaoming'
}

defineReactive(student, 'name') // 劫持 name 属性的读取和设置操做

复制代码

  上述代码经过 Object.defineProperty() 方法设置属性的 setter 与 getter 方法,从而达到劫持 student 对象中的 name 属性的读取和设置操做的目的。性能

  读者能够发现,该方法每次只能设置一个属性,那么就须要遍历对象来完成其属性的配置:ui

Object.keys(student).forEach(key => defineReactive(student, key))
复制代码

  另外还必须是一个具体的属性,这也很是的致命。this

  假如后续须要扩展该对象,那么就必须手动为新属性设置 setter 和 getter 方法,**这就是为何不在 data 中声明的属性没法自动拥有双向绑定效果的缘由 **。(这时须要调用 Vue.set() 手动设置)spa

  以上即是对象劫持的核心实现,可是还有如下重要的细节须要注意:prototype

一、属性描述符 - configurable

  在 JavaScript 中,对象经过字面量建立时,其属性描述符默认以下:设计

const foo = {
  name: '123'
}
Object.getOwnPropertyDescriptor(foo, 'name') // { value: '123', writable: true, enumerable: true, configurable: true }
复制代码

  前面也提到了 configurable 的值若是为 false,则没法再修改该属性的描述符,因此在设置 setter 和 getter 方法时,须要注意 configurable 选项的取值,不然在使用 Object.defineProperty() 方法时会抛出异常:

// 部分重复代码 这里就再也不罗列了。
function defineReactive (obj, key) {
  // ...

  const desc = Object.getOwnPropertyDescriptor(obj, key)

  if (desc && desc.configurable === false) {
    return
  }

  // ...
}
复制代码

  而在 JavaScript 中,致使 configurable 值为 false 的状况仍是不少的:

  • 可能该属性在此以前已经经过 Object.defineProperty() 方法设置了 configurable 的值;
  • 经过 Object.seal() 方法设置该对象为密封对象,只能修改该属性的值而且不能删除该属性以及修改属性的描述符;
  • 经过 Object.freeze() 方法冻结该对象,相比较 Object.seal() 方法,它更为严格之处体如今不容许修改属性的值。
二、默认 getter 和 setter 方法

  另外,开发者可能已经为对象的属性设置了 getter 和 setter 方法,对于这种状况,Vue 固然不能破坏开发者定义的方法,因此 Vue 中还要保护默认的 getter 和 setter 方法:

// 部分重复代码 这里就再也不罗列了
function defineReactive (obj, key) {
  let val = obj[key]

  //....

  // 默认 getter setter
  const getter = desc && desc.get
  const setter = desc && desc.set

  Object.defineProperty(obj, key, {
    get () {
      const value = getter ? getter.call(obj) : val // 优先执行默认的 getter
      return value
    },
    set (newValue) {
      const value = getter ? getter.call(obj) : val
      // 若是值相同则不必更新 === 的坑点 NaN!!!!
      if (newValue === value || (value !== value && newValue !== newValue)) {
        return
      }

      if (getter && !setter) {
        // 用户未设置 setter
        return
      }

      if (setter) {
        // 调用默认的 setter 方法
        setter.call(obj, newValue)
      } else {
        val = newValue
      }
    }
  })
}
复制代码
三、递归属性值

  最后一种比较重要的状况就是属性的值可能也是一个对象,那么在处理对象的属性时,须要递归处理其属性值:

function defineReactive (obj, key) {
  let val = obj[key]

  // ...

  // 递归处理其属性值
  const childObj = observe(val)

  // ...
}
复制代码

  递归循环引用对象很容易出现递归爆栈问题,对于这种状况,Vue 经过定义 ob 对象记录已经被设置过 getter 和 setter 方法的对象,从而避免递归爆栈的问题。

function isObject (val) {
  const type = val
  return val !== null && (type === 'object' || type === 'function')
}

function observe (value) {
  if (!isObject(value)) {
    return
  }

  let ob
  // 避免循环引用形成的递归爆栈问题
  if (value.hasOwnProperty('__ob__') && value.__obj__ instanceof Observer) {
    ob = value.__ob__
  } else if (Object.isExtensible(value)) {
    // 后续须要定义诸如 __ob__ 这样的属性,因此须要可以扩展
    ob = new Observer(value)
  }

  return ob
}
复制代码

  上述代码中提到了对象的可扩展性,在 JavaScript 中全部对象默认都是可扩展的,但同时也提供了相应的方法容许对象不可扩展:

const obj = { name: 'xiaoming' }
Object.preventExtensions(obj)
obj.age = 20
console.log(obj.age) // undefined
复制代码

  除了上述方法,还有前面提到的 Object.seal() 和 Object.freeze() 方法。

3、针对 Array 类型的劫持

  数组是一种特殊的对象,其下标实际上就是对象的属性,因此理论上是能够采用 Object.defineProperty() 方法处理数组对象

  可是 Vue 并无采用上述方法劫持数组对象,笔者猜想主要因为如下两点:(读者有更好的看法,欢迎留言。)

一、特殊的 length 属性

  数组对象的 length 属性的描述符天生独特:

const arr = [1, 2, 3]

Object.getOwnPropertyDescriptor(arr, 'length').configurable // false
复制代码

  这就意味着没法经过 Object.defineProperty() 方法劫持 length 属性的读取和设置方法。

  相比较对象的属性,数组下标变化地相对频繁,而且改变数组长度的方法也比较灵活,一旦数组的长度发生变化,那么在没法自动感知的状况下,开发者只能手动更新新增的数组下标,这但是一个很繁琐的工做。

二、数组的操做场景

  数组主要的操做场景仍是遍历,而对于每个元素都挂载一个 get 和 set 方法,恐怕也是不小的性能负担。

三、数组方法的劫持

  最终 Vue 选择劫持一些经常使用的数组操做方法,从而知晓数组的变化状况:

const methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'sort',
  'reverse',
  'splice'
]
复制代码

  数组方法的劫持涉及到原型相关的知识,首先数组实例大部分方法都是来源于 Array.prototype 对象。

  可是这里不能直接篡改 Array.prototype 对象,这样会影响全部的数组实例,为了不这种状况,须要采用原型继承获得一个新的原型对象:

const arrayProto = Array.prototype
const injackingPrototype = Object.create(arrayProto)
复制代码

  拿到新的原型对象以后,再重写这些经常使用的操做方法:

methods.forEach(method => {
  const originArrayMethod = arrayProto[method]
  injackingPrototype[method] = function (...args) {
    const result = originArrayMethod.apply(this, args)
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) {
      // 对于新增的元素,继续劫持
      // ob.observeArray(inserted)
    }
    // 通知变化
    return result
  }
})
复制代码

  最后,更新劫持数组实例的原型,在 ES6 以前,能够经过浏览器私有属性 proto 指定原型,以后,即可以采用以下方法:

Object.setPrototypeOf(arr, injackingPrototype)
复制代码

  顺便提一下,采用 Vue.set() 方法设置数组元素时,Vue 内部其实是调用劫持后的 splice() 方法来触发更新

4、总结

  由上述内容可知,Vue 中的数据劫持分为两大部分:

  • 针对 Object 类型,采用 Object.defineProperty() 方法劫持属性的读取和设置方法
  • 针对 Array 类型,采用原型相关的知识劫持经常使用的函数,从而知晓当前数组发生变化

  而且 Object.defineProperty() 方法存在如下缺陷:

  • 每次只能设置一个具体的属性,致使须要遍历对象来设置属性,同时也致使了没法探测新增属性
  • 属性描述符 configurable 对其的影响是致命的

  而 ES6 中的 Proxy 能够完美的解决这些问题(目前兼容性是个大问题),这也是 Vue3.0 中的一个大动做,有兴趣的读者能够查阅相关的资料。

  若是本文对您有所帮助,那么点个关注,鼓励一下笔者吧。

相关文章
相关标签/搜索