Vue.set 源码解析

Vue.set 源码解析

用过 Vue 的同学应该都知道 Vue.set 这个 api ,在 Vue2.x 组件实例初始化之后,动态给 data 选项添加属性是不会触发响应的,如果希望动态添加的属性也能触发响应式机制,这个时候就可以用 Vue.set 这个 api 。了解过原理的同学应该不多,但面试经常会问到,因此这边分析一下 Vue.set 源码实现。

这个 api 是做什么的

首先看源码之前,推荐看一下官方文档的描述,这样更容易理解源码。

Vue.set 源码解析_第1张图片

注意文档中描述的几个重点:向响应式对象中添加属性,确保新添加的属性也是响应式的,且触发视图更新,传入的对象不能是 Vue 实例,或者 Vue 实例的根数据对象。

从文档上来看,set 方法可以用于对象,也可用于数组:

// 在对象上添加一个属性,同时触发响应
this.$set(this.someObj, 'a', 1);
// 使用下标修改数组,同时触发响应
this.$set(this.arr, 1, 2333);

这边需要注意下,set 方法可以在两个地方访问到,一种是作为 Vue 构造器的静态方法,另一种是实例方法,两个方法是完全一致的:

// Vue 构造器的静态方法
Vue.set();
// 作为实例方法,这是全局 Vue.set 的别名
vm.$set();

在哪里看源码

由于 set 方法是挂载在 Vue 构造器上的静态方法,那么 set 必然是在 Vue 构造器初始化过程中挂载上去的,因此我们可以去看一下 global-api/index.js ,在第 44 行进行 set 的挂载:

Vue.set = set

然后就顺藤摸瓜,找一下 set 方法从哪里引入的:

import { set, del } from '../observer/index'

这会应该很清楚了,从 observer/index 引入,而且还是命名导出,set 的源码在:

node_modules/vue/src/core/observer/index.js:201

首先先看下这个模块都做了那些事情。这个模块主要用于将普通 JS 对象转换为响应式对象,定义了一个类和若干方法:

// 用于将观察对象的属性进行 getter/setter 转换,实现依赖收集 (collect dependencies) 和派发更新 (dispatch updates)
export class Observer {}
// 用于创建 Observer 实例,如果已经是 Observer 实例就不再重复创建
export function observe()
// 在对象中定义响应式属性
export function defineReactive()
// 在对象中设置一个属性,如果属性不存在,则触发更改通知 (change notification)
export function set()
// 删除一个对象属性并触发通知
export function del()

其中 Observer 类、observe 方法和 defineReactive 方法的调用关系是:

  • 调用 observe 方法,传入需要观察的值;
  • observe 方法中创建 Observer 实例,对传入的值进行观察;
  • Observer 类的构造器中,调用 walk 实例方法将对象的每个属性进行遍历,然后调用 defineReactive 方法将对象属性进行 getter/setter 转换;
总结一下, observer 方法可以观察一个对象,而 defineReactive 是观察对象的一个属性

响应式机制分析

首先在 observer 方法中判断传入的值是否为 JavaScript 对象,如果是,创建 Observer 实例进行观察,简化后的代码如下:

export function observe(value) {
  if (!isObject(value) || value instanceof VNode) {
    // 不是对象,或者是 VNode 的实例,不进行观察
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    // 如果是响应式对象就不再观察
    ob = value.__ob__
  } else {
    // 非响应式对象,创建 Observer 实例进行观察
    ob = new Observer(value)
  }
  return ob
}

Observer 类中,判断传入的是对象还是数组,如果是数组,则需要重写数组的变更方法,然后观测数组,如果是对象,直接观测对象。简化后的代码如下:

export class Observer {
  constructor (value) {
    this.value = value
    // 将 __ob__ 属性添加到传入的对象上,用于将对象标记为响应式对象
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      // 如果是数组,则重写数组的变更方法
      protoAugment(value, arrayMethods)
      // 观测数组
      this.observeArray(value)
    } else {
      // 观测对象
      this.walk(value)
    }
  }

  walk (obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      // 使用 defineReactive 将对象的每个属性进行 getter/setter 转换
      defineReactive(obj, keys[i])
    }
  }

  observeArray (items) {
    for (let i = 0, l = items.length; i < l; i++) {
      // 调用 observe 观察对象的每个元素(如果是对象就观察,基本类型不观察)
      observe(items[i])
    }
  }
}

defineReactive 方法中,给对象的属性添加 getset 方法。简化后的代码如下:

export function defineReactive(obj, key, val) {
  const dep = new Dep()
  // 如果对象的 configurable: false 则不进行观测
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }
  // 深度观测子属性
  let childOb = observe(val)
  // 使用 Object.defineProperty 将对象属性进行 getter/setter 转换
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      // 访问对象属性的时候,触发 get 方法
      return val
    },
    set: function reactiveSetter (newVal) {
      // 设置属性的时候触发 set 方法
      if (val === newVal) {
        // 新值和旧值一样,不触发响应
        return
      }
      // 设置新值
      val = newVal
      // 对新值进行观测(如果是对象)
      childOb = observe(newVal)
      // 触发通知更新视图
      dep.notify()
    }
  })
}

Vue.set 源码分析

Vue.set 完整代码如下:

export function set (target: Array | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: 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
}

首先判断传入 target 的类型,如果是 undefinednull 或者原始类型,打印警告信息:

if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}

接着如果是数组,就调用数组的 splice 方法添加元素(Vue 重写了数组变更方法,调用 splice 会触发响应,下面会进行分析):

if (Array.isArray(target) && isValidArrayIndex(key)) {
  // 这行的作用是,如果传入下标大于数组长度,例如 [0, 1], 2, 2
  // 那么会将数组长度改为 3 ,得到 [0, 1, empty]
  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
}
这个时候文档中说的 target 必须是响应式就发挥作用了,如果不是响应式,只能修改属性,不能触发视图更新了

那么后面对应的逻辑应该是传入的属性不存在于对象中,接下来首先判断的是传入对象是否为 Vue 实例或者 Vue 实例的根数据对象:

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 方法将这个属性进行 getter/setter 转换,并且触发通知更新视图:

defineReactive(ob.value, key, val)
ob.dep.notify()
return val

Vue 2.x 怎么监听数组变化

JS 的数组操作方法有一些会修改原数组:

  • push
  • pop
  • shift
  • unshift
  • splice
  • sort
  • reverse

这些方法被称为变更方法。相比之下,还有一些是非变更方法,例如 mapfilterconcatslice ,这些方法不会修改原数组,而是返回一个新数组。

使用非变更方法,即新数组替换旧数组,可以触发响应从而更新视图,变更方法由于直接修改原数组,在 Vue 2.x 的响应式机制下监听不到数组变化,因此理论上是不能触发响应的,但实际上我们使用变更方法都可以触发视图更新。

在 Vue 2.x 官方文档中提到,Vue 对数组的变更方法进行了包裹,所以也能触发视图更新。这里的包裹,可以理解为一种封装,那么 Vue 究竟是怎么封装了。

我们来看一下 observer/index.js 中的 Observer 类,简化后的代码如下:

export class Observer {
  constructor(value) {
    this.value = value;
    if (Array.isArray(value)) {
      // 处理数组
      protoAugment(value, arrayMethods);
      // 观察数组
      this.observeArray(value);
    } else {
      // 观察对象
      this.walk(value);
    }
  }
}

这里的 protoAugment 方法实际上就是一个工具函数,定义如下:

function protoAugment(target, src: Object) {
  target.__proto__ = src;
}

那么再结合上面的用法,protoAugment 实际上就是把传入数组实例的 __proto__ 指针指向的内容给换掉了,换成了 arrayMethods

不得不说 Vue 这个处理还是非常棒的,没有全局污染 Array.prototype 原型链,只是对需要响应式转换的数组实例进行处理。

// 只是对某个实例产生影响,没有污染原型链,推荐
[].__proto__ = arrayMethods;

// 污染原型链,不推荐
Array.prototype = arrayMethods;

然后接下来看 arrayMethods 是怎么处理的,这部分源码在:

node_modules/vue/src/core/observer/array.js

首先利用原型式继承,将 Array.prototype 作为原型创建空对象:

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

然后在 arrayMethods 上面重写数组方法,对数组的变更方法进行拦截操作,同时触发通知更新视图:

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  const original = arrayProto[method]
  // 在 arrayMethods 上面重写数组方法覆盖原来的方法
  def(arrayMethods, method, function mutator (...args) {
    // 首先调用数组原来的方法获取执行结果
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    // 判断数组是否进行插入操作
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // 如果进行插入操作,观察插入的元素
    if (inserted) ob.observeArray(inserted)
    // 触发通知更新视图
    ob.dep.notify()
    // 返回数组原来方法执行的结果
    return result
  })
})

再次称赞一下,Vue 采用的是原型式继承 + 重写,没有直接修改原型链,思路值得借鉴。

// 原型式继承,没有修改原型链,推荐
const arrayMethods = Object.create(Array.prototype);
arrayMethods[methodsToOverride] = function() {};

// 修改原型链了,不推荐
Array,prototype[methodsToOverride] = function() {};

这边用到了一个 dep 方法,作用就是在对象上添加属性:

export function def (obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

总结一下,Vue 使用了原型式继承,重写了数组方法(这里重写指的是面向对象编程中的重写),对数组变更方法进行了拦截操作,同时触发通知更新视图。

为什么 Object.freeze() 可以提升渲染性能

Vue 官方推荐对不需要响应式的数据使用 Object.freeze() 进行处理,这样可以提高渲染性能,但是为什么 Object.freeze() 可以提高性能呢?

在下面代码中,使用 Object.getOwnPropertyDescriptor 打印一下对象冻结前和冻结后的变化:

let obj = { a: 1 }
Object.getOwnPropertyDescriptor(obj, 'a') 
/**
 * {
 *     "value": 1,
 *     "writable": true,
 *     "enumerable": true,
 *     "configurable": true
 * }
 */
Object.freeze(obj)
Object.getOwnPropertyDescriptor(obj, 'a')
/**
 * {
 *    "value": 1,
 *    "writable": false,
 *    "enumerable": true,
 *    "configurable": false
 * }
 */

我们看到,在对象冻结之后,writableconfigurable 都变为 false ,也就是说这个时候对象的属性值无法再修改,也无法通过 Object.defineProperty 修改对象属性的 descriptor 。如果尝试用 Object.defineProperty 修改对象属性的 descriptor ,则会报如下错误:

Uncaught TypeError: Cannot redefine property: xxx

那么在 defineReactive 方法中,有这样一段代码:

const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
    return
}

这段代码,表面上是一个校验,如果对象中存在 configurable: false 的属性,则不进行 getter/setter 转换。但实际上,这段代码也是逃生舱,如果不希望对象进行响应式转换,只需 Object.freeze() 冻结对象即可。由于省略了响应式转换,无需再对对象进行深度遍历和递归处理,以及 getter/setter 转换,因此可以在一定程度上提升渲染性能。

参考

Vue.set - Vue 官方文档

我似乎发现了vue的一个bug

你可能感兴趣的:(Vue.set 源码解析)