Vue 响应式系统的原理

大多数情况下,一个Vue组件的UI样式由它自身所维护的数据状态决定(例如,按钮上的文字等等), 当我们动态地改变组件的数据,对应的组件UI样式也随之改变。 这就是Vue组件的响应式更新,个特性使得组件的状态管理和视图变化变得更加简单。

响应式系统是贯穿Vue框架的基础,声明式渲染、输入绑定、计算属性和侦听器等各种框架特性和语法糖都是以此为基础的。

我们在深入Vue源码去了解框架细节之前,不妨思考一下如果是我们自己应该如何实现这套机制,站在更宏观的角度上理解一下响应式系统的是如何设计和运转。

Overview

我们在一个Vue组件的定义中声明了组件本身的数据和状态。 响应式系统需要在组件创建和初始化的时候,首先要收集到这个组件所有的数据状态信息,然后监听组件状态的变化, 最后根据数据状态的改变作出更新响应,渲染页面展示,保证整个过程中,数据从Model层流向View层。

从功能需求和业务场景来看,这是明显是一个观察者模式的应用场景。

模式结构很简单,在进一步完善和扩展过程中,我们还需要思考和回答以下几个问题:

  • 哪些数据和状态需要被监听和响应?
  • 需要被监听的数据是如何收集起来作为被观察者(Observable)的?
  • 观察者和被观察者是如何创建的?
  • 被观察者(Observable)如何感知到数据变化,并通知给观察者的?
  • 观察者和被观察者之间的订阅关系是如何建立起来的?(watcher被加入到watcher list中)

前面说过,Vue很多特性都是基于响应式系统来实现的,这里为了便于说明, 用一个简单的demo来讨论这些问题,先定义一个Vue组件:

const com = new Vue({
  data: {
    age: 1
  },
  watch: {
    age: function(val) {
      console.log('age is changed...')
    }
  }
})

定义一个侦听器,监听data对象中的age的变化。

被观察者的创建

对于一个Vue组件而言,它的状态主要来自于外部传入的Props(父组件状态)和自身声明的data。 毫无疑问,这些数据在整个体系中都扮演了被观察者的角色。

在组件的创建和初始化过程中,Vue会在initState中对这部分数据进行处理, 略去初始化相关操作,这里重点关注对于状态数据的data的处理:

function initData (vm: Component) {
  let data = vm.$options.data
  // data相关的初始化操作
  ...
  // observe data
  observe(data, true /* asRootData */)
}

对于组件自身状态data的相关处理会调用observe为data对象创建一个附属的Observer实例:

function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // 为value创建过Observer实例
  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
}

这里,data对象的Observer实例只有一个,缓存挂在data.__ob__上。

Observer实例在构造的过程中,会将对自己所附属的对象(data)的属性进行处理:

class Observer {
  value: any;// 目标对象
  dep: Dep; // 依赖收集器,先不用管,后面介绍
  vmCount: number; // vmCount 统计了以此target为根$data的Vue实例的数量
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)// 将Observer实例绑定到对应目标对象上
    if (Array.isArray(value)) {// 覆写对于数组的操作,大约7种
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      // 遍历并使用observe方法处理数组的每一个元素
      this.observeArray(value)
    } else {
      // 遍历目标对象的各个属性
      this.walk(value)
    }
  }
  ...
}

对于数组行为的覆写操作我们这里暂且不关心。

walk方法会遍历目标对象的每个属性,对每个属性调用defineReactive进行处理:

walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
}

defineReactive通过重写getter/setter的方法Hook了对于该属性的读写操作。

读到这里相必你对之前问题有点想法了:

被观察者(Observable)如何感知到数据变化,并通知给观察者的?

data中定义的数据毫无疑问是我们的被观察者, 当data中定义的数据发生改变(写操作),该属性的setter方法就会被执行,这样我们就有机会发消息给观察者了。

我们的被观察者虽然有了感知数据变化的能力,但还需要维护一个列表(watcher list)来存放订阅它的观察者,也就是上面Observer中出现过的Dep

我们看看defineReactive中具体是如何完成这两部分工作的:

function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 为每个属性都定义了一个watch list
  const dep = new Dep()

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

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  // 获取到该属性的`Observer`对象, 如果val不是object则为null
  let childOb = !shallow && observe(val)
  // 在目标对象上重新定义该属性,主要是重写getter setter
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      ....
      return value
    },
    set: function reactiveSetter (newVal) {
      ....
    }
  })
}

我们以demo中的data为例子,属性age经过defineReactive处理后:

dep中维护了一个数组subs,也就是我们说的观察者列表:

class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;// 观察者列表

  constructor () {
    this.id = uid++
    this.subs = []
  }
}

数据属性的Observer已经创建完毕,那么观察者是如何创建的呢?

观察者的创建

我们在demo中定义了一个侦听器,用来监听age的变化,并回调指定函数完成操作。 对于组件中所定义的侦听器,同样也是在initState中来进行初始化:

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}
function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

基本上是兼容侦听器的各种姿势的语法糖,最后调用挂载Vue上的$watch方法:

  Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    ...
    const watcher = new Watcher(vm, 
    expOrFn,// age
    cb, // 回调函数
    options)
    ...

    return function unwatchFn () {
      watcher.teardown()
    }
  }

这里直接new了一个Watcher实例出来,来看下我们的观察者在创建阶段做了哪些工作:

constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm

    vm._watchers.push(this)

    this.cb = cb // 回调函数
    this.id = ++uid // uid for batching

    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()

    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      // 这里解析出了之前所声明观察对象的age的getter方法
      this.getter = parsePath(expOrFn)
    }
    this.value = this.lazy
      ? undefined
      : this.get()
}

当我们创建一个观察者来响应数据变化,首先要知道要观察什么数据(age),例如组件的侦听器声明了自己要监听age的变化。

另外一点需要注意的是,组件中所声明的数据在初始化过程中,被挂在了组件实例上,也就是说可以通过vm.age获取到对应的值。

观察者创建完毕后,此时还无法接收到被观察者的消息,两者之间需要建立连接关系。 这里通常会有两种设计选择:

  • 被观察者维护一个观察者注册表,被观察者数据变化,主动推送数据给观察者;
  • 观察者当需要获取数据的时候,主动去找被观察者拉取数据

我们来看下Vue是如何构建和维护两者之间的关系的。

建立订阅关系

Watcher对象的构造函数中,除了完成各种初始化工作,还在末尾调用了get方法,需要把自己注册给被观察者。 末尾调用了watcher的get方法:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    // 调用age的getter方法
    value = this.getter.call(vm, vm)
  } catch (e) {
    ...
  } finally {
    ...
    popTarget()
    this.cleanupDeps()
  }
  return value
}

调用get方法除了让观察者主动拉取被观察者的数据之外,还会将自己注册到被观察者的注册表中。

这里实质上就是调用了age属性的getter方法, 也就是之前在defineReactive中被hook为reactiveGetter

get: function reactiveGetter () {
  // 原始数据值
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()// 收集依赖
    if (childOb) {// 递归处理属性值为object类型的属性
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

这里我们可能会有一个疑问:Dep.target是干啥的?

从上面代码中我们不难看出,age和侦听器之间的订阅关系是在reactiveGetter中建立的; 但是,age的值又不仅仅是只被一个侦听器所使用,只要我们对age进行读操作都会调用reactiveGetter方法。

所以Dep就维护了一个全局的标识位target,在watcher调用的时候才设置:

 pushTarget(this)
 ...
 popTarget()

被观察者也只有在Dep.target不为空的时候执行depend方法建立订阅关系:

// dep.depend
depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}
// watcher.addDep
addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

这里的订阅关系建立其实是双向的:watcher对象本身也维护了一个数组存放被观察者,也就是说一个观察者可以订阅多个被观察者对象。 这里完成的操作就是互相把自己注册给对方。

更新响应

当被观察者的数据发生变化,一定会调用对应数据的setter方法:

set: function reactiveSetter (newVal) {
  // 原始值
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  if (process.env.NODE_ENV !== 'production' && customSetter) {
    customSetter()
  }
  // #7981: for accessor properties without setter
  // 只定义了getter
  if (getter && !setter) return
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  // 属性的子属性需要重新被observe
  childOb = !shallow && observe(newVal)
  // 通知观察者数据改变
  dep.notify()
}

除了对数据进行赋值的写操作外,还调用了dep.notify()来通知观察者们执行数据更新后的回调函数。

其他

在Vue组件中,我们声明了所要观察的数据状态,这些数据状态都会在组件初始化过程中被observe递归转换为被观察者对象(descriptor被Hook)。

但是并不是所有的被观察者都会被订阅,由于Vue把两者订阅关系的建立放在了具体观察者的构建过程中完成,这样就保证了只有那些真正被使用到的数据状态才会被监听。

总结

上面我们讨论了Vue响应式系统构建的主要流程以及这个过程中的相关知识,其中也忽略掉了很多细节上的处理。 在学习源码的过程中,我们需要理解这背后的设计思想和原因,而不是一味地拿着观察者模式去生搬硬套。

其他

  • ES6 Proxy
  • 观察者模式
  • 代理模式