阅读 4579

刺破 Vue 的心脏之——响应式源码分析

之前发刺破 vue 的心脏之——详解 render function code的时候,承诺过会对 Vue 的核心过程的各个部分通过源码解析的方式进行抽丝剥茧的探索,今天就来进入第二部分响应式原理部分的源码解析,承诺兑现得有些晚,求轻拍

一、先分析工作原理

还是之前的套路,在读源码之前,先分析原理


上图来自 Vue 官网深入响应式原理,建议先看看,这里主要说说我的理解:在初始化的时候,首先通过 Object.defineProperty 改写 getter/setter 为 Data 注入观察者能力,在数据被调用的时候,getter 函数触发,调用方(会为调用方创建一个 Watcher)将会被加入到数据的订阅者序列,当数据被改写的时候,setter 函数触发,变更将会通知到订阅者(Watcher)序列中,并由 Watcher 触发 re-render,后续的事情就是通过 render function code 生成虚拟 dom,进行 diff 比对,将不同反应到真实的 dom 中

二、源码分析

记住一个实例

读源码是一件枯燥的事情,带着问题去找答案,要更容易读得进去

<template>
...
</template>
<script>
  export default {
    data () {
      return {
        name: 'hello'
      }
    },
    computed: {
      cname: function () {
        return this.name + 'world'
      }
    }
  }
</script>
<style>
...
</style>复制代码

为了减少干扰,例子已经剥离得只剩下两个关键的要素,数据属性 name,以及调用了该属性的计算属性 cname,这其中 cname 跟 name 就是订阅者跟被订阅者的关系。我们现在需要带着这样的疑问去阅读源码,cname 是如何成为 name 的订阅者的,并且当 name 发生了变更的时候,如何通知到 cname 更新自己的数据

初始化数据,注入观察者能力

响应式处理的源码在 src/core/observer 目录下,见名之意,这使用了观察者模式,先不用着急进入这个目录,在 Vue 实例初始化的时候,会执行到 src/core/instance/state.js 中相关的状态初始化逻辑,先到这个文件来看看:

export function initState (vm: Component) {
  ...
  if (opts.data) {
    // 初始化数据
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  // 初始化计算属性
  if (opts.computed) initComputed(vm, opts.computed)
  ...
}复制代码

我们所关注的初始化数据和初始化计算属性在这里都会被执行到,先来分析下 initData, 沿着方法跟下去,发现最终 initData 要做的事情是:

  // observe data
  observe(data, true /* asRootData */)复制代码

调用 observe 方法为 data 注入观察者能力,这个时候我们可以正式进入 observer/index.js 文件了,在这个文件我们可以找到 observe 方法的定义,跟着方法读下去,找到下一步的关键信号:

ob = new Observer(value)复制代码

这一步通过传入的 data,创建一个 Observer 实例,再跟到 Observer 的构造函数中会发现,构造函数会为 data 的各个元素(当 data 为数组的时候)或者各个属性(当 data 为对象的时候)递归的创建 Observer 对象,最终起作用的方法是 defineReactive

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: Function
) {
  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

  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // 当前的 Watcher
      if (Dep.target) {
        // 将当前的 watcher 加入到该数据的订阅者序列
        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 = observe(newVal)
      // 当数据发生变更的时候,通知订阅方进行数据变更
      dep.notify()
    }
  })
}复制代码

代码贴得有点多,但着实不是为了凑字数,因为在这里部分省略可能会带来一些疑惑,就没有进行缩减,见谅。这里主要是通过 Object.defineProperty 方法,重写数据的 set 和 get 方法,当数据被调用时,set 方法会将当前的 Watcher Dep.target 也就是当前的调用方加入到该数据的订阅者序列中,当数据变更,set 方法发通知到所有订阅者,让大家重新计算。这其中定义在 observer/dep.js 文件中的 Dep 定义了数据订阅者的订阅、取消订阅等行为,在这里就不贴代码了。

回忆一下我们实例中的 name 和计算属性 cname,当 cname 的方法执行的时候,name 被调用,就会触发它的 get 方法,这个时候 cname 所对应的 watcher (computed 初始化的时候会为每个计算属性创建一个 watcher)。当 name 发生了变更,set 方法被触发,cname 所对应的 watcher 作为订阅者就会被通知到,从而重新计算 cname 的值

初始化计算属性,创建 Watcher

回到 src/core/instance/state.js 文件的计算属性初始化逻辑 initComputed,这个方法不负众望的为计算属性创建了 Watcher 对象

  // create internal watcher for the computed property.
  watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions)复制代码

于是乎我们的视线需要转移到 observer/watcher.js 中,Watcher 的构造函数中最为关键的是,this.get 方法的调用

this.value = this.lazy
      ? undefined
      : this.get()复制代码

在 this.get 方法中有两步尤为关键(对于计算属性来说,会进行延迟计算,这就是 this.lazy 标志的意义所在):

 get () {
    // 将当前的调用者 watcher 置为 Dep.target
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 调用方法,计算依赖方的值
      value = this.getter.call(vm, vm)
    } catch (e) {
      ...
    } finally {
      ...
    }
    return value
  }复制代码
  1. pushTarget(this) 将 watcher 置为 Dep.target,当所依赖的数据的 get 方法被调用的时候,就可以根据 Dep.target 把当前的 watcher 加入到订阅者序列中。这么做的目的是,当 watcher 依赖于多个数据的时候,可以共享 Dep.target
  2. 执行 this.getter.call(vm, vm) 方法计算值,例如计算属性 cname 的 getter 就是它的定义函数function(){this.name + 'world'}。此时依赖方的 get 方法被触发,整个流程就能串起来,说得通了

对于计算属性,还有一个细节,需要将视线再转移到 initComputed 中:

export function defineComputed (target: any, key: string, userDef: Object | Function) {
  ...
  Object.defineProperty(target, key, sharedPropertyDefinition)
}复制代码

它所调用的 defineComputed 方法会为计算属性在当前的组件实例中创建一个同名的属性,这也就是为何计算属性的定义上是方法,但是在实际的使用当中却是属性的原因。只有在它所依赖的数据更新的时候,数据通过 set 方法通知到它,它才会重新计算并把值赋给这个新建的代理属性。计算属性高效就高效在这里

三、总结

写源码分析难以覆盖到方方面面,毕竟不能一直贴代码,如何在贴最少量代码的情况下把问题说清楚,这仍然还是努力的方向。在达到这个目标之前,只能通过提问的方式了,有问题欢迎评论,尽力解答

此文还在公众号菲麦前端中发布:

文章分类
前端
文章标签