阅读 305

从源码分析Vue的响应式原理

响应式原理

据Vue的官方介绍,Vue的响应式系统是非侵入性的,那Vue是如何做到将不同类型的数据(基本类型、普通对象、数组等)转换为可检测的呢?在了解Vue的具体实现之前,我们先了解一下为什么要将数据变为可检测的。

因为Vue是MVVM框架,即数据可以驱动视图的,在传统的开发中(非数据驱动视图),我们需要去操作DOM来实现视图的更新,但在Vue中,视图是通过数据进行驱动的,也就是说我们只需要操作数据,Vue内部会为我们进行DOM的操作。这样一来,我们的开发工作就会变得比较便捷,我们只需要维护数据,而不需要进行DOM的操作。那数据驱动视图一个核心就是检测数据的变化,当数据发生变化时,需要对视图进行更新。

接下来,我们回到第一个问题:Vue是如何做到将不同类型的数据(基本类型、普通对象、数组等)转换为可检测的呢?

响应式对象

在进行Vue的初始化时,会执行initState方法,它定义在文件中:src/core/instance/state.js

function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}
复制代码

initState主要是进行初始化props、methods、data、computed、watch等,先看看是如何初始化props的。

initProps

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // 缓存prop的键,以便将来的prop更新时可以使用Array而不是动态对象键枚举进行迭代。
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  // 根实例的props应该被转换
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      // ...
    } else {
      defineReactive(props, key, value)
    }
    // 在Vue.extend()期间,静态props已经代理在组件的原型上
    // 我们只需要代理定义在此处的实例化。
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}
复制代码

initProps主要是遍历propsOptions(即是我们在Vue组件中定义的props属性),接着做两件事情:defineReactive和proxy。

initData

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
    )
  }
  // 在实例上代理data
  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') {
      // 检查methods上是否有同名键
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      // 检查props上是否有同名键
      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)
    }
  }
  // 对data进行observe
  observe(data, true /* asRootData */)
}
复制代码

initData主要做的事情是:

  • data可以是函数,如果是,则将函数的返回值当成data
  • 遍历data上的键
    • 检查每个键是否已经在methods和props上定义过,因为data/methods/props上的键都会代理到vm上,所以不能同名
    • 将data的键代理到vm上
    • 将data进行observe

初始化props和data都会使用proxy方法和observe方法,那这两个方法究竟是做什么的呢?我们一起来看看。

proxy

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
复制代码

proxy的逻辑比较简单,即是将target上的sourceKey中的key直接代理到target上,即是通过defineProperty,将通过sourceKey[key]获取和设置的方式变为通过target[key]直接获取和设置的方式。proxy的核心目的是将props/data等数据直接代理到vm实例上,这样,我们就可以在Vue组件中直接通过vm.xxx/this.xxx的方式直接获取到props/data上的数据了。

defineReactive

defineReactive 方法定义在文件中:src/core/observer/index.js

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) {
    return
  }

  // 迎合预定义的getter/setter
  const getter = property && property.get
  if (!getter && arguments.length === 2) {
    val = obj[key]
  }
  const setter = property && property.set

  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) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}
复制代码

defineReactive是将数据转化为响应式的关键,主要是使用Object.defineProperty方法,如果对这个方法不熟悉的可以查看MDN文档

defineReactive主要做的事情是为对象的某个键重新定义getter和setter,在原有的getter的基础上,新增收集依赖的操作,在原有的setter的基础上,新增派发更新的操作。这样,当我们为获取数据的值时,就会触发其getter,然后将使用到这个值的订阅者收集起来;当我们为某一个值重新赋值的时候,就会触发其setter,这时可以通知之前收集到的订阅者数据发生改变,这样一来,通过对数据劫持的方式就可以把对象上的某个键变成响应式。

在此之前,还执行了let childOb = !shallow && observe(val),那observe方法是干什么的呢?

observe

observe方法定义在文件中:src/core/observer/index.js

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实例,并将其返回,那类Observer是什么呢?其定义在文件中:src/core/observer/index.js

// 附加到每个被观察对象的观察者类。
// 附加后,观察者将目标对象的属性键转换为收集依赖和派发更新的getter/setter。
class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // 将此对象作为根$data的vm数量

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  // 遍历每个属性并将它们转换为getter/setter。仅当值类型为“对象”时才应调用此方法。
  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,接着执行def(value, '__ob__', this),最后如果value是数组,则将其每一项进行observe,如果是对象,则将其所有键进行defineReactive。

function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}
复制代码

def是把Observer实例挂载在value的__ob__属性上。

其实observe也是将数据变为响应式的,在observe和defineReactive中,也都有调用对方,那observe和defineReactive有什么区别呢?observe是将一个对象或数组转化为响应式,而defineReactive是对一个键值对转化为响应式,,两者在内部都会调用对方,在 defineReactive 中,如果属性的值为对象,则会通过 observe 将其值转换为响应式; 在 observe 中,会遍历对象的每一个属性,通过 defineReactive 将每个属性都转换为响应式。我们举一个例子:

data(){
  return {
    time: "2020-02-22 15:01:30",
    person: {
      name: 'haha',
      age: 18,
    },
    list: ['1', '2']
  }
}
复制代码

Vue先通过initData进行初始化,会对data返回的整个对象进行observe,因为data是一个对象,所以会通过walk遍历对象的所有键值对,进行defineReactive,也就是将time/person/list这三个键转化为响应式,当改变这三个键的值时,就会通知对应的订阅者;defineReactive过程中,因为person/list的值不是基本类型,所以会对其值进行observe,通过这种递归的方式,无论一个对象嵌套多少层,其子属性是什么类型,都会将整个对象变成一个响应式的对象。

检测变化的注意事项

Vue 在以下的情况下是无法检测到数据变化的:

  • Vue 不能检测到对象属性的添加或删除
  • Vue 不能检测以下变动的数组:
    • 当利用索引直接设置一个项时,例如:vm.items[index] = newValue
    • 当修改数组的长度时,例如:vm.items.length = newLength

所以,Vue通过一些处理,让操作对象和数据也是可以变成响应式的。

Vue.set

set方法定义在文件中:src/core/observer/index.js

// 在对象上设置属性。 添加新属性,如果该属性尚不存在,则触发更改通知。
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 方法的主要逻辑是:

  • 如果target是数组,则设置数组的长度,并且使用splice进行替换元素
  • 如果target是对象,若key存在其自身,则直接赋值,因为此key已经是响应式的了;若key不存在且ob不存在,则直接赋值并返回,因为说明该对象不是响应式;若key不存在且ob存在,则通过defineReactive将此key设置为响应式,并且手动进行派发更新,这样一来,新设置的属性也转化为响应式的。

那为什么数组直接通过splice,就可以通知视图更新呢?

数组

这是因为Vue针对数组的原生方法进行修改,在其中触发派发更新的操作。

class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      // ...
    }
  }
}
复制代码

在Observer中,若值是数组,会执行augmentthis.observeArray

// 通过使用__proto__截取原型链来增强目标对象或数组
function protoAugment (target, src: Object, keys: any) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}

// 通过定义隐藏属性来增强目标对象或数组。
/* istanbul ignore next */
function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}
复制代码

所以,augment的主要作用是将 value 的原型指向了 arrayMethods。arrayMethods定义在文件中:src/core/observer/array.js

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

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

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

// 拦截修改方法并触发事件
methodsToPatch.forEach(function (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
  })
})
复制代码

arrayMethods本质是Array.prototype,并在其上针对数组的方法进行重写。这些方法除了执行原本的逻辑之外,还对数组新增的元素进行observe,最后还手动触发派发更新,这样就实现了数组通过数组方法,也能直接触发视图更新了。

那observe的dep是什么时候收集依赖的呢?答案是在defineReactive中

function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // ...
  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) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    // ...
  })
}
复制代码

当存在childOb时,执行childOb.dep.depend()就会进行依赖的收集,所以当触发ob.dep.notify()时,就会进行派发更新。如果 value 是个数组,那么就通过 dependArray把数组每个元素也进行依赖收集。