computed/watch 的响应式链路

Liu Bowen

Liu Bowen / 2020, 四月, 25

在遵循直觉的前提下,说起 "响应式" 这三个字,你会想起什么? 笔者首先的下意识反应是 观察者模式发布订阅模式。正如常人直觉,当我们设计一个响应式系统时,不外乎是在设计:

  • 类型 1. 从观察到触发观察的逻辑。

    observer --observe--> target --notify--> all observers
    
  • 类型 2. 从订阅到经过 event busmessage broker 实现向订阅者发布 topic 的逻辑。

    subscriber --subscribe--> topic --publish via message broker--> subscribers
    

Vue.js 中最令人津津乐道的响应式特性也与此多多少少有关联。本文将以解答问题的方式以最纯粹最简洁的方式回顾 vue.js 中关于响应式模块的功能设计。每提出一个问题,就在源码中寻找对应的解决方案,抛开无关逻辑,用最核心的逻辑回答问题。为什么笔者要结合 computed/watchdata 来阐述响应式设计?私以为这三者是一个整体,形成 observer --observe--> target --notify--> observer 的完整链路。只有合理高效地依赖收集,才会有有效的依赖通知。

注意:本文并不是一个覆盖所有细节的指南,而是一个着重于探究响应式设计的核心思维链路。

本文基于现有 Vue.js v2.6.11 版本。且使用 vm 表示 Vue 实例。

如何感知状态修改

TL, DR: 通过 Object.defineProperty 重写 data[dataKey]accessor 来实现 感知 data[dataKey] 的取值和赋值。

众所周知,在 vue.js v2 中,所有的组件状态由 data 选项维护,而组件派生状态由 computed 属性维护。当我们通过 Object.defineProperty 修改了原 data 对象的 assessor 时,就不难通过新的 assessor 感知任意 data[dataKey] 的取值和赋值,并且,我们可以让每个 data[dataKey] 都拥有一个 唯一且必须subscriber 队列,该队列用于存储所有依赖当前 data[dataKey]subscribers

  1. 在触发当前 data[dataKey] 的取值时,将 subscriber 加入到当前 data[dataKey]subscriber 队列中

  2. 在触发当前 data[dataKey] 的赋值时,将自身的 subscribers 队列中的 subscriber 逐一发布更新通知。

那么我们不难根据以上分析写出一个 publish/subscribe 模式下的发布触发器。

js
Object.defineProperty(vm.data, keyInDataObject, {
  get() {
    // 在此函数中触发依赖收集
  },

  set() {
    // 在此函数中触发通知依赖更新
  }
})

以上其实就是 vue.js v2 中最为核心的响应式系统的原理。那么在现实中,vue.js 又是如何在代码层面实现以上逻辑的呢?

初始化 data

通过合理利用断点调试而展示的 调用堆栈,不难得到,所有组件的 data 对象在 initData 函数中实现 accessor 修改。

ts
function initData(vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}
  // 省略代码
  const keys = Object.keys(data)
  // 省略代码
  while (i--) {
    // 省略代码
    if (props && hasOwn(props, key)) {
      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)
    }
  }
  observe(data, true /* asRootData */)
}

initData 函数中,不难看出其职责:

  1. 为了避免使用同一个引用类型(而造成一处修改触发其他引用的地方错误取值),在复用组件中,所有的 $options.data 选项必须为函数类型。并将 $options.data 函数返回的 data 对象保存在 vm._data 属性中。

  2. 迭代 $options.data 函数返回的 data 对象:

    1. 其每一个 vm[dataKey] 的值,都将通过重写 vm[dataKey]accessor 实现被代理到 vm._data[dataKey] 上。
  3. 通过 observe 函数触发重写 data[dataKey]accessor 的处理逻辑,并在 accessor 预埋 依赖收集 点和 发布更新点。

改写 accessor

ts
/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 */
export 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 类对应以下代码:

ts
/**
 * 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)
    }
  }
}

从构造函数不难看出,其核心职责在于:

  1. 在原始 value 上挂载一个 __ob__ 属性,其值保持引用为一个 Observer 类的实例。

  2. 区分数组类型和其他引用类型数据;

    1. 若为数组时,由 protoAugmentcopyAugment 函数处理,之后由 observer.observeArray 函数统一处理。

    2. 其他引用类型时,由 observer.walk 方法处理。

observer.walk 方法中,迭代每一个 dataKeydefineReactive 函数来实现 assessor 的改写。

js
export class Observer {
  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk(obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
}

当迭代改写每一个 data[dataKey]accessor 时,会因为第一轮的 defineReactive(obj, keys[i])迭代观察 data[dataKey] 的后代的值(因为此时传参 shallowundefined 值),当 data[dataKey] 的后代的值不为引用类型值时,会放弃观察。

ts
export function defineReactive(
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // ...
  let childOb = !shallow && observe(val)
}

最终,所有的 data[dataKey] 及其所有后代引用类型值都会经过 defineReactive 函数都被改写为以下的 accessor:

ts
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()
    }
    // #7981: for accessor properties without setter
    if (getter && !setter) return
    if (setter) {
      setter.call(obj, newVal)
    } else {
      val = newVal
    }
    childOb = !shallow && observe(newVal)
    dep.notify()
  }
})

那么结合最初本章节对于感知取值和赋值的 逻辑思考,不难理解上文中的 accessor 存在的核心目的:

通过改写原有引用类型值的 accessor 层,在原有 getter/setter 层的基础上新增一个 代理层,该代理层用于 感知所有的取值与赋值,以用于触发依赖收集和通知依赖更新消息。

  • 原始 accessor

    raw <---> getter/setter
    
  • 修改后的 accessor

    raw <----> collect/notify dep <----> getter/setter
    

如何在改写 accessor 后保存数据值

TL, DR: 通过调用 defineReactive 函数与 assessor 形成的闭包保存当前 data[dataKey] 的值。

在一般情况下,原始的 object 通过自身即可保持自身对应的键值,当我们借助 Object.defineProperty 改写 data[dataKey]accessor 后,我们不得不面对 data[dataKey] 的值的保存问题?这些值应该存在哪?怎么存?

当我们面临这种存在强对应关系的数据存储场景时:

  1. 一种常见的处理方式是通过 key-value 的键值对的形式存储在额外变量中,如 WeakMap 存储。

    Tips: 通过 WeakMap 存值是 vue.js v3 的实现。

  2. 另一种另辟蹊径,不太遵循直觉的方式是通过 函数闭包 形成私有作用域来保存原始值。

    形如:

    ts
    function createPrivateValue(initialValue = 0) {
      let val = initialValue
      return {
        add() {
          return ++val
        },
        current() {
          return val
        }
      }
    }
    
    const privateVal = createPrivateValue(0)
    privateVal.add() // 1
    privateVal.current() // 1
    

Vue.js v2 中,因兼容性考虑,作者使用了第二种方式来解决 accessor 被改写后的键值的存储问题。

ts
export function defineReactive(
  obj: Object,
  key: string,
  val: any
  // ...
) {
  // ...
  const property = Object.getOwnPropertyDescriptor(obj, key)
  // ...

  // 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]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    // ...
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      // ...
      return value
    },
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val
      // ...

      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // ...
    }
  })
}

defineReactive 函数体中不难看出,所有的 data 对象的键的值,都由 reactiveGetter/reactiveSetterdefineReactive 形成的 闭包中的 val 变量 保存。

如何对数组进行响应式处理

TL,DR: 通过代理可变异数组的原型方法实现数组的响应式处理。

尽管数组同样可以通过 Object.defineProperty 实现响应式处理,但是作者在 issue #8562 提及因为性能以及收益比的原因放弃对 Array 类型使用 Object.defineProperty 函数处理。

initData 函数的调用栈中,在 observe 函数中区分了 引用类型值基本类型值,而在 Observer 类的构造函数中,区分了 数组类型值和非数组类型值 以及 原型对象环境 的监听处理。如下:

js
/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment(target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}

/**
 * Augment a target Object or Array by defining
 * hidden properties.
 */
/* 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])
  }
}

export class Observer {
  value: any
  dep: Dep
  vmCount: number // number of vms that have this object as root $data

  constructor(value: any) {
    // ...
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      // ...
    }
  }
}

由代码显而易见,对于不同的原型环境有不同的响应式处理方式:

  1. 若当前 JS 运行时支持 非标准 但被广泛支持的 __proto__ 属性获取实例的隐式原型时,那么通过将数组的的隐式原型对象改为自定义的原型对象,再将自定义的原型对象的原型对象定义为数组构造函数的显式原型,即:

    原始原型链为:

    array --__proto__--> Array.prototype
    

    修改后的原型链为:

    array --__proto__--> arrayMethodsPrototype --__proto__--> Array.prototype
    

    实现数组响应式的核心在于 重写数组原型链,使得所有的可变异方法不再是原始的可变异方法,而是内含响应式处理的 arrayMethodsPrototype 对象上的可变异方法。

  2. 若当前 JS 运行时不支持 __proto__ 属性获取实例的隐式原型时,那么直接将可变异数组的自定义方法直接通过 Object.defineProperty 的方法定义在 数组实例 上,并成为不可枚举属性。

如同非数组引用类型值改写 accessor 的思路一样,数组实现响应式的核心也是将所有对值的访问和赋值都代理到一个 统一的访问代理层 来处理响应式的问题。经过以上处理,只要数组被调用了可变异数组的方法,那么就会触发自定义的可变异方法,而不再是原始的 Array.prototype 对象上的可变异数组方法,那么我们就可以处理对应的数组变异问题,并触发响应式更新。

为什么 vm[newDataKey] 的方式不能触发响应式通知

TL,DR:因 JavaScript 限制,无法感知向一个对象新增属性的行为,那么就无法 主动改写 其新增属性的 accessor 实现响应式。

Vue.js v2官方文档 中特别提到,对于 根状态对象 的新增,无法实现新增属性的响应式处理。其面临的 问题本质 是,在 JavaScript 中无法感知对象属性的新增和删除(原 Object.observe 函数已被废弃),本质上是 无法调和 新增属性时,无法探测或监听到新增 的属性(除设定了 trap methodsProxy 实例外),且无法对新增属性的 accessor 进行响应式处理的矛盾。

在面临需要对一个已经被处理过的状态对象新增属性时,我们唯一能够实现该属性响应式的操作是 主动 修改其 assessor 为响应式 assessor,如调用 Vue.set 函数。

computed 的本质

TL, DR: computed[computedKey] 的核心实现在于每一个 computed[computedKey] 都对应了一个唯一的 lazyWatcher。其 lazyWatcher.value 值即为 computed[computedKey] 的值,并且 computed[computedKey]accessor 会根据当前 lazyWatcherdirty 属性决定是使用缓存值还是重新计算。

本文称实例化 Watcher 时,传入 lazy: true 参数而返回的 watcher 实例为 lazyWatcher

在初始化 $options.computed 时,所有的 computed[computedKey] 都由 initComputed 函数处理。

ts
function initComputed(vm: Component, computed: object) {
  const watchers = (vm._computedWatchers = Object.create(null))

  // ...

  for (const key in computed) {
    // ...
    if (!isSSR) {
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      // ...
    }
  }
}

由以上函数体可见,initComputed 的主要职责在于:

  1. 所有对应的 lazyWatcher 都存储在当前 vue 实例的 _computedWatchers 容器属性中,该属性主要用于将在 computedKeyaccessor 中调用对应的 lazyWatcher 获取其 computed[computedKey] 的返回值。

  2. 对应的 computedKey 都有一个唯一的 lazyWatcher 实例,该实例的主要职责在于 计算当前 computedKey 的值

  3. 通过 defineComputedvm 实例上定义 vm[computedKey] 属性,并 定义其 accessor 中包含 对取值的判断,是使用缓存值,还是重新计算值。

computed 如何追踪 data 依赖

得益于 stack 数据结构的特点,所有的上层栈都 直接依赖 于最近的下层栈,间接依赖 于祖先下层栈。computed[computedKey] 借助了上述 stack 数据结构来实现 computed[computedKey] 的取值。

  1. 在每次取值 computed[computeKey] 的过程中,首先会将对应的 watcher 实例加入到一个名为 targetStack 栈中,并且设置栈顶为 Dep.target 变量,表示当前正在计算 lazyWatcher 对应的 computed[computedKey] 的值。

  2. 在这个过程中,若存在其他 data[dataKey] 取值时,那么在 data[dataKey] 对应的 reactiveGetter 中可从栈 targetStack 依赖了当前的 data[dataKey],将 Dep.target 对应的 lazyWatcher 加入到 data[dataKey].__ob__.dep.subs 容器中作为 data[dataKey]观察者

  3. data[dataKey] 发生赋值行为,即触发对应的 reactiveSetter 时,会触发 dep.notify 函数,进而触发 subs[i].update 函数,进而触发 lazyWatcher.dirty 标识切换为 true

    因为 subs 中存储的是 watcher 实例,故 subs[i].update 对应 watcher.update 方法。

  4. 在 3 的基础上,下一次再获取指定 computed[computedKey] 时,会再 computedGetter 中检查 lazyWatcher.dirty,并因 lazyWatcher.dirtytrue 时,调用 lazyWatcher.evaluate ,并在其内部重新调用原始的用户定义的 computed[computedKey] 函数。在得到结果后成为 lazyWatcher.value 的新值,并作为 computedGetter 的结果输出。

嵌套的 computed 的依赖追踪

注:据前文,computed[computedKey] 对应唯一 lazyWatcher 实例,renderWatcher (即在 view 中使用 computeddata 插值)同理具有以下原理。

TL, DR: 借助 computedGetter 函数体中对 Dep.target 的判断和每一个 computed[computedKeyA] 对应一个唯一的 lazyWatcherA 实例的原理,实现在 computedGetter 中触发被依赖的 lazyWatcherA.deps[i] 的所有与 data[dataKey] 相关的的 dep.depend() 依赖收集,进而触发 dep 收集当前 Dep.target 对应的 lazyWatcher

js
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/instance/state.js#L241-L254
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      // 当此语句为 true 时,那么表示当前 this 对应的 computed[computedKey] 被其他
      // computed[computedKey] 依赖了
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

首先,预设场景为 computed[computedKeyA] 依赖于 computed[computedKeyB]。如下:

js
// in the SomeComponent.vue
export default {
  computed: {
    computedKeyA() {
      return doSomeThing(this.computedKeyB)
    },
    computedKeyB() {
      // do something you like
    }
  }
}

示例代码中,在初次获取 vm[computedKeyA] 的值时,经由 computedGetter 函数触发 lazyWatcherA.get 的求值时,会首先将 lazyWatcherA 成为 Dep.target,表示当前正在获取 lazyWatchA 的值,在这个过程中会触发 vm[computedKeyB] 的求值,那么再次经由 lazyWatcherB.get 的求值时,在 computedGetter 中会触发 Dep.target 的检查:

js
function computedGetter() {
  // ...
  if (Dep.target) {
    watcher.depend()
  }
}

那么在返回 vm[computedKeyB] 之前会依次触发以下逻辑:

js
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/watcher.js#L215-L223
export default class Watcher {
  // ... 省略无关代码
  /**
   * Depend on all deps collected by this watcher.
   */
  depend() {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}
js
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/dep.js#L31-L35
/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
export default class Dep {
  // ...省略无关代码
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}
js
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/watcher.js#L125-L137
export default class Watcher {
  /**
   * Add a dependency to this directive.
   */
  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)
      }
    }
  }
}
js
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/dep.js#L23-L25
export default class Dep {
  // ...
  addSub(sub: Watcher) {
    this.subs.push(sub)
  }
}

根据以上调用路径不难发现,这与前文 computed 如何追踪 data 依赖 章节表述的追踪逻辑一致,那么我们不难得到以下结论:

在嵌套的 computed[computedKeyA] 中,被嵌套的 computed[computedKeyB] 会在 computed[computedKeyA] 的取值过程中 获取所有的 computed[computedKeyB] 的依赖,并与它们逐一通过 lazyWatcherA.deps 进行连接。本质上在嵌套的 computed[computedA] 中的依赖收集是一个 完整的依赖关系全量复制 的过程。

另外我们也不难发现,在 watcher 实例中,watcher.dep 的主要作用就是用于主动找到那些当前 watcher 订阅的依赖。

更深层次地思考,为什么要进行依赖关系的全量复制?

本质上是为了解决 computed 的值及时更新问题,若没有以上依赖关系复制,那么仅有当 computed[computedKeyB] 的值发生变化时,computed[computedKeyA] 才会重新计算。而 computed[computedKeyB] 的值是惰性的,那么即使 computed[computedKeyB] 的依赖发生变化,也仅有在取值时,才会重新计算,进而才会导致此时的 computed[computedKeyA] 在取值时重新计算。若此时 computed[computedKeyB] 没有被其他视图或其他 computed 依赖的话,那么 computed[computedKeyB] 永远都不会触发 computedGetter 中的 dirty 检查进而不会触发重新求值,进而 computed[computedKeyA] 始终都不会转变为 dirty 状态,进而永远不会重新计算,即使这时 computed[computedKeyB] 的依赖已经更新。

若有了依赖关系复制,即表示 computed[computedKeyA] 直接依赖于 computed[computedKeyB]依赖。那么 computed[computedKeyA] 是否更新并不直接依赖于 computed[computedKeyB] 的取值,而是在 computed[computedKeyB] 的依赖发生变化时,就会主动在下一次 computed[computedKeyA] 的取值时,重新计算 computed[computedKeyA]

computed 如何实现缓存

TL,DR:在 initComputed 中实例化对应的 computedKeywatcher 实例时,传递额外的实例化选项 { lazy: true },在设置 lazytrue 时,那么仅当获取 computed[computedKey] 时的值时,才会触发 watcher 求值,并返回 watcher.value 作为 computed[computedKey] 的返回值。

核心在于通过 defineComputed 函数改写的 computed[computedKey]accessorgetter 中对 watcher.dirty 的判断。在 watcher.dirtyfalse 时,那么永远返回当前 watcher.value 的值。反之,调用 watcher.evaluate 方法求值并返回。

有如下场景:当 data[dateKey] 触发 setter 函数时,那么会触发所有 data[dataKey].__ob__.dep.subs 中所有的 watcher.update 方法,该方法会将所有的 lazy watcher(,即 computed[computedKey] 唯一对应的 watcher 实例)的 dirty 修改为 true。那么在下一次 computed[computedKey] 取值时,就会首先触发 watcher.evaluate 求值,在求值结束后返回新的 wathcer.value 而不再是直接返回 watcher.value

  • 初次调用

    computedGetter --> lazyWatcher.dirty = lazyWatcher.lazy = true --> lazyWatcher.evaluate --> lazyWatcher.get --> lazyWatcher.value
    
  • 返回缓存值

    computedGetter --lazyWatcher.dirty: false--> lazyWatcher.value
    
  • 依赖更新

    reactiveSetter --> dep.notify --> subs[i].update --> lazyWatcher.dirty = true --> queueWatcher
    

    注意:在依赖更新时,并不会立即重新计算 computed[computedKey] 的值,该值的重新计算 仅仅 依赖于在触发 computedGetter 时,当前 lazyWatcher.dirty 是否为 true

    computedGetter -- lazyWatcher.dirty: true --> lazyWatcher.evaluate --> lazyWatcher.get, lazyWatcher.dirty = false
    

如何触发 computed 重新计算

TL,DR: 在触发 data[dataKey] 名为 reactiveSetteraccessor 时,会触发 dep.notify 方法,该方法对应会触发调用 data[dataKey].__ob__.dep.subs 中所有的订阅者,以实现通知当前它们依赖的数据已经更新。

正如前文 如何感知状态修改 的阐述,在初始化 data[dataKey] 时,会对每一个 data 中键值的 getter 进行以下 accessor 改造:

js
Object.defineProperty(target, key, {
  // ...
  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()
    }
    // #7981: for accessor properties without setter
    if (getter && !setter) return
    if (setter) {
      setter.call(obj, newVal)
    } else {
      val = newVal
    }
    childOb = !shallow && observe(newVal)
    dep.notify()
  }
})

data[dataKey] 被赋值时,即数据更新时,会触发 reactiveSetter 函数调用,在设定新值后,最后会调用 dep.notify 方法向所有的订阅者发送数据更新通知,如下:

ts
export class Dep {
  // ...
  notify() {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

其中 subs[i].update() 对应 watcher.update 方法:

js
export default class Watcher {
  // ...
  update() {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

前文已经说明 computed[computedKey] 本质上是通过 lazyWatcher 实现计算输出值,那么在调用以上方法后,对应的 lazyWatcherwatcher.dirty 属性由 false 转为 true,表示当前 watcher.value 不再有效,在下一次获取 computed[computedKey] 的值,即调用内部的 computedGetter 时将触发重新计算。注意,此处的 computed[computedValue] 依然是 仅在 取值时会发生重新计算。

从源码角度看 computed 与 watch 的差异

TL,DR: 核心差异在于 computed[computedKey] 所对应的 lazyWatcher 是通过运行时追踪依赖,那么在运行时中可能存在 零或多个 依赖;而 watch[watchKey] 是通过 watchKey 的声明方式声明依赖,这里声明式是指在实例化 normalWatcher 时,始终返回一个 在运行时期间只依赖vm[watchKey]watcher 实例,即 normalWatcher 始终 仅有唯一 的依赖。

每一个 watch[watchKey] 都要通过 initWatch 实现初始化:

ts
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)
}

initWatch 函数的职责在于:迭代 $options.watch 的键名,分别对其进行依赖处理,并创建 normalWatcher

watch 初始化本质上是调用了对外暴露的 API —— vm.$watch 函数。而 vm.$watch 本质上是调用的原型方法 Vue.prototype.$watch,如下:

ts
export function stateMixin(Vue: Class<Component>) {
  // ...
  Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value)
      } catch (error) {
        handleError(
          error,
          vm,
          `callback for immediate watcher "${watcher.expression}"`
        )
      }
    }
    return function unwatchFn() {
      watcher.teardown()
    }
  }
}

由上我们可以对比 lazyWatcher 实例化的差异:

  1. computed 的本质 章节中 lazyWatcher 实例化:

    ts
    watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions)
    
  2. $options.watch[watchKey] 实例化:

    ts
    const watcher = new Watcher(vm, expOrFn, cb, options)
    

从实例化传参,我们发现:

  1. lazyWatcher 是没有 cb 回调函数的。lazyWatchercb 回调函数为空函数 noop。因为结合前文对所有 watcher 的更新方式阐述,其更新是依赖于 watcher.update,而当 watcherlazy 类型时,那么永远不会触发 watcher.run 函数,就更加不会触发 watcher.cb 函数,那么 lazyWatcher 是没有必要有 cb 函数的。

  2. lazyWatcherlazyWatcher.getter 方法对应的类型为函数类型即 getter || noop。而 watch[watchKey]normalWatcher.getter 传入时为字符串。该字符串在实例化 normalWatcher 时会经历以下流程创建一个针对于当前 vm 实例的 取值函数

    ts
    export default class Watcher {
      // ...
      constructor(/* omit parameters */) {
        // ...
        // parse expression for getter
     if (typeof expOrFn === 'function') {
       this.getter = expOrFn
     } else {
       this.getter = parsePath(expOrFn)
       if (!this.getter) {
         this.getter = noop
         process.env.NODE_ENV !== 'production' && warn(
           `Failed watching path: "${expOrFn}" ` +
           'Watcher only accepts simple dot-delimited paths. ' +
           'For full control, use a function instead.',
           vm
         )
       }
      }
    }
    

    所有的 watch[watchKey]parsePath 函数转化并处理 vm 属性的监听。

    ts
    /**
     * Parse simple path.
     */
    const bailRE = new RegExp(`[^${unicodeLetters}.$_\\d]`)
    export function parsePath(path: string): any {
      if (bailRE.test(path)) {
        return
      }
      const segments = path.split('.')
    
      // 返回一个 vm[watchKey] 取值函数
      return function (obj) {
        for (let i = 0; i < segments.length; i++) {
          if (!obj) return
          obj = obj[segments[i]]
        }
        return obj
      }
    }
    

    parsePath 本质上是一个 高阶工具函数,用于迭代递归处理传入的对象路径取值。那么 watch[watchKey] 对应的 normalWatcher.getter 同样是一个取值函数,该函数同样仅接受 vm 实例作为参数。最终实现了 watchKey 所对应的 normalWatcher 实例仅仅依赖于 vm[watchKey] 的值。

    注意,当 watchKey 为包含 “.” 分隔符的字符串时,如 dataKey.subDataKey.nestedDataKey,将通过 parsePath 分步 取值,而不是一次性直接获取 vm[watchKey]。这里是因为直接取值是取的名为 dataKey.subDataKey.nestedDataKey 的键名的值,而不是取得 vmdataKey 属性下的 subDataKey 下的 nestedDataKey 的值。

  3. 由前文的分析可知,computed[computedKey]watch[watchKey] 在核心实现上都依赖于 Watcher 实例。computed[computedKey] 对应的是 lazyWatcher,而 watch[watchKey] 对应的是 normalWatcher。它们在实例化上的区别对应了 computed[computedKey] 函数和 watch[watchKey] 对应的回调函数的区别。

    在实例化时,lazyWatcher 为了实现懒计算,那么永远不会主动调用 lazyWatcher.getter,直到等到获取 computed[computedKey] 的新值时,才会触发 lazyWatcher 的计算(细节见前文 computed 如何实现缓存 章节)。与之相反的 normalWatcher 会在实例化时,通过 normalWatcher.get 方法,首次调用 normalWatcher.getter 函数。这里的首次调用同样是为了订阅对应的依赖,将当前的 normalWatcher 实例加入到对应依赖的 subs 订阅者队列中。

watcher 如何触发更新

TL,DR:如同 computed[computedKey] 的依赖追踪核心一样。在触发 watcher.getter 时,会将当前 watcher 实例加入到前文已经分析过的 targetStack 中,不同于 computed[computedKey] 的是,此时的 watcher 永远只会拥有 唯一一个 vm[depKey] 依赖。

在初始化 normalWatcher 时,会根据 watch[watchKey] 的类型赋值 normalWatcher.getter 函数。在调用 normalWatcher.getter 期间,同样会加入到前文已经着重分析过的 targetStack 栈中,表示当前正在追踪并订阅当前 normalWatcher 的唯一依赖。在唯一的依赖触发更新时,将触发订阅了该依赖的 normalWatcherrun 函数,进而触发传入的 normalWatcher.cb 回调函数。

  watcher in stack
        +
        |
    subscribe
        |
        v
  data[dataKey]
        +
        |
      notify
        |
        v
    dep.notify
        +
        |
        v
dep.subs[i].update
        +
        |
        v
    watcher.run
        +
        |
        v
    watcher.cb

回顾

Vue.js v2 响应式模块中的精妙之处之一,以及核心原理是引入了一个 访问代理层 来实现所有数据的取值和赋值的感知行为,借助观察者模式的变体实现通知对应数据依赖的观察者实现响应式行为。

  1. 借助 accessor 实现依赖收集触发和依赖更新触发;

  2. 借助 stack 结构实现订阅追踪数据依赖;

  3. 借助特定标识 dirty 来解决数据缓存的问题,在满足特定条件下,始终使用缓存值以实现节约计算力的目的;

  4. 在基于依赖收集和更新通知的前提下,实现观察者模式中的观察者 observer 和主题 subject 之间的异步回调函数的调用关系。

References