vue2从数据变化到视图变化:Vue.$set(this.$set)原理分析

1,082 阅读4分钟

理论上当vue中的数据发生变化时视图会进行渲染,但是,有些情况下数组变化和对象变化的时候,视图没有进行变化,这个时候就需要vue提供的方法进行处理。

一、对象

const app = new Vue({
  el: "#app",
  data() {
    return {
      obj: {
        name: "name-1"
      }
    };
  },
  template: `<div @click="change">{{obj.name}}的年龄是{{obj.age}}</div>`,
  methods: {
    change() {
      this.obj.name = 'name-2';
      this.obj.age = 30;
    }
  }
});

以上例子执行的结果是:
name-1的年龄是
当点击后依然是:
name-2的年龄是
可以看出点击后,objname属性变化得到了视图更新,而age属性并未进行变化。

1、对象属性增加时未检测到变化的原因

vue初始化过程中会对定义在data中的数据通过递归的方式进行响应式的处理(响应式分析请移步juejin.cn/post/713099… name属性响应式的过程中锁定了一个发布者dep,在当前视图渲染时在发布者depsubs中做了记录,一旦其发生改变,就会触发set方法中的dep.notify,继而执行视图的重新渲染。然而,age属性并未进行响应式的处理,当其改变时就不能进行视图渲染。

2、手动响应式处理的方法

change() {
  this.obj.name = "name-2";
  this.$set(this.obj, 'age', 30);
}

通过this.$set处理,视图可以进行重新渲染:

3、Vue.$set源码解析

在文件src/core/instance/index.js文件中:

// ...
import { stateMixin } from './state'
import { warn } from '../util/index'

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}
// ...
stateMixin(Vue)
// ...
export default Vue

再看stateMixin(Vue)方法:

import {set} from '../observer/index'
export function stateMixin (Vue: Class<Component>) {
    // ...
    Vue.prototype.$set = set
    // ...
}

Vue的原型上挂载$setset方法:

/**
 * Set a property on an object. Adds the new property and
 * triggers change notification if the property doesn't
 * already exist.
 */
export function set (target: Array<any> | 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
}

$set的过程中有多处判断:

  • (isUndef(target) || isPrimitive(target))表示如果当前被添加属性的目标值不存在或者是基本类型,控制台进行警告提示并返回
  • Array.isArray(target) && isValidArrayIndex(key)表示如果是数组并且是合法的索引值,则执行target.splice(key, 1, val)并返回,这里的splice方法是数组splice原始方法执行和数据响应式处理的结合体
  • key in target && !(key in Object.prototype)表示如果当前属性在目标上,则直接赋值并返回
  • target._isVue || (ob && ob.vmCount)表示如果当前目标是vue实例则控制台进行警告提示并返回
  • !ob如果当前不是响应式对象,则赋值并返回

最后通过defineReactive(ob.value, key, val)对当前目标ob.value的属性key绑定val,并进行响应式的处理,完成以后通过ob.dep.notify()通知进行视图渲染。

二、数组

const app = new Vue({
  el: "#app",
  data() {
    return {
      hobbies: ["hobby-1", "hobby-2"]
    };
  },
  template: `<div @click="change">我的爱好是{{hobbies}}</div>`,
  methods: {
    change() {
      this.hobbies[this.hobbies.length] = "hobby-3";
    }
  }
});

以上例子执行的结果是:
我的爱好是[ "hobby-1", "hobby-2" ]
当点击后依然是:
我的爱好是[ "hobby-1", "hobby-2" ]
可以看出点击后,通过给数组的下一个索引赋值的方式,不能引起视图的变化。

1、数组改变视图变化的方式

  • this.hobbies[2] = "hobby-3"后通过this.hobbies = JSON.parse(JSON.stringify(this.hobbies))进行深拷贝。
  • this.hobbies = [...this.hobbies, "hobby-3"]进行浅拷贝
  • this.$set(this.hobbies, 2, "hobby-3"),这个方法最终执行了数组的target.splice(key, 1, val)方法
  • this.hobbies.push("hobby-3")通过push推入的方式触发视图更新
  • this.hobbies.splice(2, 0, "hobby-3")通过splice替换指定元素的方式触发视图更新

前两种方式不管是JSON.parse(JSON.stringify(this.hobbies))还是[...this.hobbies, "hobby-3"]都会对this.hobbies重新赋值,this.hobbies是响应式对象,所以重新赋值是可以触发重新渲染的。但是,this.hobbies[this.hobbies.length] = "hobby-3"不能触发视图渲染,splicepush却能,这是什么原因?

2、splicepush触发视图原理分析

从数据变化到视图渲染流程比较长(需要了解请移步juejin.cn/post/713099… 这里我们只看构造函数Observer

/**
 * Observer class that is attached to each observed
 * object. Once attached, the observer converts the target
 * object's property keys into getter/setters that
 * collect dependencies and dispatch updates.
 */
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)
    }
  }
  // ...
}

如果需要响应式处理的数据满足Array.isArray(value),则可protoAugment或者copyAugment的处理。hasProto的定义是const hasProto = '__proto__' in {},在浏览器中显然是true,进而执行protoAugment(value, arrayMethods)protoAugment方法是:

/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object) {
  target.__proto__ = src
}

这里修改目标的__proto__ 指向为srcprotoAugment(value, arrayMethods)执行的含义就是修改数组的原型指向为arrayMethodsarrayMethods相关的代码在文件src/core/observer/array.js中:

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */

import { def } from '../util/index'

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

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

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  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)
    // notify change
    ob.dep.notify()
    return result
  })
})

通过const arrayProto = Array.prototype的方式缓存Array的原型,通过const arrayMethods = Object.create(arrayProto)原型继承的方式让arrayMethods上继承了Array原型上的所有方法。这里有定义了包含pushsplice等方法的数组methodsToPatch,循环遍历methodsToPatch数组并执行def方法:

/**
 * Define a property.
 */
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

这里的目的是当访问到methodsToPatch中的方法method的时候,先const result = original.apply(this, args)执行的原始方法,获取到方法的执行结果result,然后switch (method) 获取到pushunshift中的参数作为插入的数据insertedsplice的时候则从第三位参数开始作为插入的数据inserted。最后,通过ob.observeArray(inserted)的方式将插入的数据进行响应式处理,完成以后通过ob.dep.notify()通知进行视图渲染。

小结:

对象和数组的响应式都可以通过深拷贝或者浅拷贝的方式让数据重新进行响应式处理,但是要从根数据处重新递归处理,成本比较高。vue中提供的Vue.$set以及重写的数组方法可以在当前属性开始进行递归处理,成本比从根数据处开始较小。所以比较推荐Vue.$set和数组重写的方法'push', 'pop', 'shift', 'unshift', 'splice', 'sort'和 'reverse'进行对象属性或者数组元素的添加。