vue源码分析(十九) 数据响应系统 —— Watcher

10/19/2018 vue源码分析面试

# 1. 概述

​ 我们在 vue源码分析(十八) 数据响应系统 —— Observe (opens new window) 一章中有提到过,正是因为 watcher 对表达式的求值,触发了数据属性的 get 拦截器函数,从而收集到了依赖,当数据变化时能够触发响应。 Watcher 观察者实例将对 updateComponent 函数求值,我们知道 updateComponent 函数的执行会间接触发渲染函数(vm.$options.render)的执行,而渲染函数的执行则会触发数据属性的 get 拦截器函数,从而将依赖(观察者)收集,当数据变化时将重新执行 updateComponent 函数,这就完成了重新渲染。同时我们把实例化的观察者对象称为 渲染函数的观察者

watcher 入口代码如下:

​ 源码目录:scr/core/instance/lifecycle.js

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) { // 如果已经挂载了,并且没有销毁
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
1
2
3
4
5
6
7

​ 在分析 Watcher 之前我们还是列举一个案例来说明,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>深入响应式原理-响应式对象</title>
  <script src="../../dist/vue.js"></script>
</head>
<body>
  <div id="app"></div>
    <script>
      new Vue({
        el: '#app',
        template: `<div> {{ name }} </div>`,
        data: {
          name: 'robin',
          children: {
            name: 'child'
          }
        },
        watch: {
          'children.name': function(newVal, old) {
            console.log(newVal, old)
          }
        }
      })
    </script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 2. Watcher

​ 接下来我们就以渲染函数的观察者对象为例,分析 Watcher 类,Watcher 类定义如下:

​ 源码目录:scr/core/observer/watchere.js

/**
 * A watcher parses an expression, collects dependencies,
 * and fires callback when the expression value changes.
 * This is used for both the $watch() api and directives.
 */
export default class Watcher {
  vm: Component;
  expression: string;
  cb: Function;
  id: number;
  deep: boolean;
  user: boolean;
  lazy: boolean;
  sync: boolean;
  dirty: boolean;
  active: boolean;
  deps: Array<Dep>;
  newDeps: Array<Dep>;
  depIds: SimpleSet;
  newDepIds: SimpleSet;
  before: ?Function;
  getter: Function;
  value: any;

  constructor (
    vm: Component, // vm dom
    expOrFn: string | Function, // 获取值的函数,或者是更新viwe试图函数
    cb: Function, // 回调函数,回调值给回调函数
    options?: ?Object, // 参数
    isRenderWatcher?: boolean // 是否渲染过得观察者
  ) {
    // 省略...
  }

  /**
   * Evaluate the getter, and re-collect dependencies.
   */
  get () {
    // 省略...
  }

  /**
   * Add a dependency to this directive.
   */
  addDep (dep: Dep) {
    // 省略...
  }

  /**
   * Clean up for dependency collection.
   */
  cleanupDeps () {
    // 省略...
  }

  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    // 省略...
  }

  /**
   * Scheduler job interface.
   * Will be called by the scheduler.
   */
  run () {
    // 省略...
  }

  /**
   * Evaluate the value of the watcher.
   * This only gets called for lazy watchers.
   */
  /**
   * 为计算watcher量身定制的
   */
  evaluate () {
    // 省略...
  }

  /**
   * Depend on all deps collected by this watcher.
   */
  depend () {
    // 省略...
  }

  /**
   * Remove self from all dependencies' subscriber list.
   */
  teardown () {
    // 省略...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96

​ 我们先把 Watcher 类精简一下,来看看它的主要属性,通过 Watcher 类的 constructor 方法可以知道在创建 Watcher 实例时可以传递五个参数,分别是:组件实例对象 vm、要观察的表达式 expOrFn、当被观察的表达式的值变化时的回调函数 cb、一些传递给当前观察者对象的选项 options 以及一个布尔值 isRenderWatcher 用来标识该观察者实例是否是渲染函数的观察者。

​ 知道了 Watcher 五个参数的含义,接下来我们看看在首次渲染时 Watcher 的实例化,如下:

​ 源码目录:scr/core/instance/lifecycle.js

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) { // 如果已经挂载了,并且没有销毁
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
1
2
3
4
5
6
7

​ 可以看到在创建渲染函数观察者实例对象时传递了全部的五个参数,第一个参数 vm 很显然就是当前组件实例对象;第二个参数 updateComponent 就是被观察的目标,它是一个函数;第三个参数 noop 是一个空函数;第四个参数是一个包含 before 函数的对象,这个对象将作为传递给该观察者的选项;第五个参数为 true,我们知道这个参数标识着该观察者实例对象是否是渲染函数的观察者,很显然上面的代码是在为渲染函数创建观察者对象,所以第五个参数自然为 true

​ 这里有几个问题需要注意,首先被观察的表达式是一个函数,即 updateComponent 函数,我们知道 Watcher 的原理是通过对“被观测目标”的求值,触发数据属性的 get 拦截器函数从而收集依赖,至于“被观测目标”到底是表达式还是函数或者是其他形式的内容都不重要,重要的是“被观测目标”能否触发数据属性的 get 拦截器函数,很显然函数是具备这个能力的。另外一个我们需要注意的是传递给 Watcher 构造函数的第三个参数 noop 是一个空函数,它什么事情都不会做,有的同学可能会有疑问:“不是说好了当数据变化时重新渲染吗,现在怎么什么都不做了?”,实际上数据的变化不仅仅会执行回调,还会重新对“被观察目标”求值,也就是说 updateComponent 也会被调用,所以不需要通过执行回调去重新渲染。说到这里大家或许又产生了一个疑问:“再次执行 updateComponent 函数难道不会导致再次触发数据属性的 get 拦截器函数导致重复收集依赖吗?”,这是个好问题,不过不用担心,因为 Vue 已经实现了避免收集重复依赖的处理,我们后面会讲到的。

# 2.1 constructor

​ 接下来我们就从 constructor 函数开始,看一下创建渲染函数观察者实例对象的过程,进一步了解一个观察者,如下是 constructor 函数开头的一段代码:

​ 源码目录:scr/core/observer/watchere.js

constructor (
    vm: Component, 
    expOrFn: string | Function, 
    cb: Function, 
    options?: ?Object, 
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
  	// 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 首先将当前组件实例对象 vm 赋值给该观察者实例的 this.vm 属性,也就是说每一个观察者实例对象都有一个 vm 实例属性,该属性指明了这个观察者是属于哪一个组件的。接着使用 if 条件语句判断 isRenderWatcher 是否为真,前面说过 isRenderWatcher 标识着是否是渲染函数的观察者,只有在 mountComponent 函数中创建渲染函数观察者时这个参数为真,如果 isRenderWatcher 为真那么则会将当前观察者实例赋值给 vm._watcher 属性,也就是说组件实例的 _watcher 属性的值引用着该组件的渲染函数观察者。大家还记得 _watcher 属性是在哪里初始化的吗?是在 initLifecycle 函数中被初始化的,其初始值为 null。在 if 语句块的后面将当前观察者实例对象 pushvm._watchers 数组中,也就是说属于该组件实例的观察者都会被添加到该组件实例对象的 vm._watchers 数组中,包括渲染函数的观察者和非渲染函数的观察者。另外组件实例的 vm._watchers 属性是在 initState 函数中初始化的,其初始值是一个空数组。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/watchere.js

constructor (
    vm: Component, 
    expOrFn: string | Function, 
    cb: Function, 
    options?: ?Object, 
    isRenderWatcher?: boolean
  ) {
  	// 省略...
    // options
    if (options) {
      this.deep = !!options.deep 
      this.user = !!options.user 
      this.lazy = !!options.lazy 
      this.sync = !!options.sync 
      this.before = options.before
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
  	// 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

​ 上面这段代码是一个 if...else 语句,首先判断 options 参数是否存在,如果存在则执行 if 语句块,如果不存在则执行 else 语句块。if 语句块使用 options 对象中同名属性值的真假来初始化,else 语句块内将当前观察者实例对象的四个属性 this.deepthis.userthis.lazy 以及 this.sync 全部初始化为 false

​ 通过上面代码我们可以知道实例化 Watcher 时传递的 options 有五个属性,分别为:

  • options.deep,用来告诉当前观察者实例对象是否是深度观测

​ 我们平时在使用 Vuewatch 选项或者 vm.$watch 函数去观测某个数据时,可以通过设置 deep 选项的值为 true 来深度观测该数据。

  • options.user,用来标识当前观察者实例对象是 开发者定义的 还是 内部定义的

​ 实际上无论是 Vuewatch 选项还是 vm.$watch 函数,他们的实现都是通过实例化 Watcher 类完成的,等到我们讲解 Vuewatch 选项和 vm.$watch 的具体实现时大家会看到,除了内部定义的观察者(如:渲染函数的观察者、计算属性的观察者等)之外,所有观察者都被认为是开发者定义的,这时 options.user 会自动被设置为 true

  • options.lazy,用来标识当前观察者实例对象是否是计算属性的观察者

​ 这里需要明确的是,计算属性的观察者并不是指一个观察某个计算属性变化的观察者,而是指 Vue 内部在实现计算属性这个功能时为计算属性创建的观察者。等到我们讲解计算属性的实现时再详细说明。

  • options.sync,用来告诉观察者当数据变化时是否同步求值并执行回调

​ 默认情况下当数据变化时不会同步求值并执行回调,而是将需要重新求值并执行回调的观察者放到一个异步队列中,当所有数据的变化结束之后统一求值并执行回调,这么做的好处有很多,我们后面会详细讲解。

  • options.before,可以理解为 Watcher 实例的钩子,当数据变化之后,触发更新之前,调用在创建渲染函数的观察者实例对象时传递的 before 选项。如下代码:

​ 源码目录:scr/core/instance/lifecycle.js

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) { // 如果已经挂载了,并且没有销毁
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
1
2
3
4
5
6
7

​ 可以看到当数据变化之后,触发更新之前,如果 vm._isMounted 属性的值为真,则会调用 beforeUpdate 生命周期钩子。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/watchere.js

constructor (
    vm: Component, 
    expOrFn: string | Function, 
    cb: Function, 
    options?: ?Object, 
    isRenderWatcher?: boolean
  ) {
  	// 省略...
    this.cb = cb 
    this.id = ++uid // uid for batching 
    this.active = true
    this.dirty = this.lazy // for lazy watchers 
  	// 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 这段代码的作用是给当前实例添加 cbidactivedirty 属性,并赋值。首先定义了 this.cb 属性,它的值为 cb 回调函数。接着定义了 this.id 属性,它是观察者实例对象的唯一标识。又定义了 this.active 属性,它标识着该观察者实例对象是否是激活状态,默认值为 true 代表激活。最后定义了 this.dirty 属性,该属性的值与 this.lazy 属性的值相同,也就是说只有计算属性的观察者实例对象的 this.dirty 属性的值才会为真,因为计算属性是惰性求值。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/watchere.js

constructor (
    vm: Component, 
    expOrFn: string | Function, 
    cb: Function, 
    options?: ?Object, 
    isRenderWatcher?: boolean
  ) {
  	// 省略...
    this.deps = [] 
    this.newDeps = [] 
    this.depIds = new Set()
    this.newDepIds = new Set()
  	// 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 这四个属性两两一组,this.depsthis.depIds 为一组,this.newDepsthis.newDepIds 为一组。那么这两组属性的作用是什么呢?其实它们就是用来实现避免收集重复依赖,且移除无用依赖的功能也依赖于它们,后面我们会详细讲解,现在大家注意一下这四个属性的数据结构,其中 this.depsthis.newDeps 被初始化为空数组,而 this.depIdsthis.newDepIds 被初始化为 Set 实例对象。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/watchere.js

constructor (
    vm: Component, 
    expOrFn: string | Function, 
    cb: Function, 
    options?: ?Object, 
    isRenderWatcher?: boolean
  ) {
  	// 省略...
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''
  	// 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13

​ 这段代码定义了 this.expression 属性,在非生产环境下该属性的值为表达式(expOrFn)的字符串表示,在生产环境下其值为空字符串。所以可想而知 this.expression 属性肯定是在非生产环境下使用的。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/watchere.js

constructor (
    vm: Component, 
    expOrFn: string | Function, 
    cb: Function, 
    options?: ?Object, 
    isRenderWatcher?: boolean
  ) {
  	// 省略...
    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
        )
      }
    }
  	// 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

​ 这段代码检测了 expOrFn 的类型,如果 expOrFn 是函数,那么直接使用 expOrFn 作为 this.getter 属性的值。如果 expOrFn 不是函数,那么将 expOrFn 传给 parsePath 函数,并以 parsePath 函数的返回值作为 this.getter 属性的值。

​ 观察者实例对象的 this.getter 函数终将会是一个函数,如果不是函数,此时只有一种可能,那就是 parsePath 函数在解析表达式的时候失败了,那么这时在非生产环境会打印警告信息,告诉开发者:Watcher 只接受简单的点(.)分隔路径,如果你要用全部的 js 语法特性直接观察一个函数即可

说明:关于 parsePath 函数我们在下一小节详细分析。

​ 我们继续看constructor 中的最后一段代码,代码如下:

​ 源码目录:scr/core/observer/watchere.js

constructor (
    vm: Component, 
    expOrFn: string | Function, 
    cb: Function, 
    options?: ?Object, 
    isRenderWatcher?: boolean
  ) {
  	// 省略...
    this.value = this.lazy
      ? undefined
      : this.get()
}
1
2
3
4
5
6
7
8
9
10
11
12

​ 通过这段代码我们可以发现,计算属性的观察者和其他观察者实例对象的处理方式是不同的,对于计算属性的观察者我们会在讲解计算属性时详细说明。除计算属性的观察者之外的所有观察者实例对象都将执行如上代码的 else 分支语句,即调用 this.get() 方法。

# 2.2 parsePath

​ 我们先来看一下 parsePath 函数定义,源码如下:

​ 源码目录: src/core/util/lang.js

/**
 * unicode letters used for parsing html tags, component names and property paths.
 * using https://www.w3.org/TR/html53/semantics-scripting.html#potentialcustomelementname
 * skipping \u10000-\uEFFFF due to it freezing up PhantomJS
 */
// unicode代表:·À-ÖØ-öø-ͽͿ-῿‌-‍‿-⁀⁰-↏Ⰰ-⿯、-퟿豈-﷏ﷰ-�
export const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/

/**
 * Parse simple path.
 */
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) { //返回一个函数,参数是一个对象
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

​ 首先我们需要知道 parsePath 函数接收的参数是什么,如下是平时我们使用 $watch 函数的例子:

// 函数
const expOrFn = function () {
  return this.obj.a
}
this.$watch(expOrFn, function () { /* 回调 */ })

// 表达式
const expOrFn = 'obj.a'
this.$watch(expOrFn, function () { /* 回调 */ })
1
2
3
4
5
6
7
8
9

​ 以上两种用法实际上是等价的,当 expOrFn 不是函数时,比如上例中的 'obj.a' 是一个字符串,这时便会将该字符串传递给 parsePath 函数,其实我们可以看到 parsePath 函数的返回值是另一个函数,那么返回的新函数的作用是什么呢?很显然其作用是触发 'obj.a'get 拦截器函数,同时新函数会将 'obj.a' 的值返回。

​ 接下来我们具体看一下 parsePath 函数的具体实现,首先来看一下在 parsePath 函数之前定义的 bailRE 正则,这个正则将匹配一个位置,该位置满足三个条件:

  • 不是 unicodeRegExp 中的字符,也就是说这个位置不能是 字母数字下划线一些特殊符号
  • 不是字符 .
  • 不是字符 $

​ 举几个例子如 obj~aobj/aobj*aobj+a 等,这些字符串中的 ~/* 以及 + 字符都能成功匹配正则 bailRE,这时 parsePath 函数将返回 undefined,也就是解析失败。实际上这些字符串在 javascript 中不是一个合法的访问对象属性的语法,按照 bailRE 正则只有如下这几种形式的字符串才能解析成功:obj.athis.$watch 等,看到这里你也应该知道为什么 bailRE 正则中包含字符 .$

​ 在 parsePath 首先判断如果参数 path 不满足正则 bailRE,则直接返回,如果满足,则继续执行后面的代码。接下来定义 segments 常量,它的值是通过字符 . 分割 path 字符串产生的数组,随后 parsePath 函数将返回值一个函数,该函数的作用是遍历 segments 数组循环访问 path 指定的属性值。这样就触发了数据属性的 get 拦截器函数。但要注意 parsePath 返回的新函数将作为 this.getter 的值,只有当 this.getter 被调用的时候,这个函数才会执行。

# 2.3 收集依赖

get 的作用就只 求值,求值的目的有两个,第一个是能够触发访问器属性的 get 拦截器函数,第二个是能够获得被观察目标的值。而且能够触发访问器属性的 get 拦截器函数是依赖被收集的关键,下面我们具体查看一下 this.get() 方法的内容:

​ 源码目录:scr/core/observer/watchere.js

/**
 * Evaluate the getter, and re-collect dependencies.
 */
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

get 首先调用 pushTarget 函数,参数为当前 Watcher 实例,我们先来看看 pushTarget 的定义:

​ 源码目录:scr/core/observer/dep.js

// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}
1
2
3
4
5
6
7
8
9
10
11

​ 在上一章节中我们讲过每个响应式数据的属性都通过闭包引用着一个用来收集属于自身依赖的“筐”,实际上那个“筐”就是 Dep 类的实例对象。在上面这段代码中我们可以看到 Dep 类拥有一个静态属性,即 Dep.target 属性,该属性的初始值为 null,其实 pushTarget 函数的作用就是用来为 Dep.target 属性赋值的,pushTarget 函数会将接收到的参数赋值给 Dep.target 属性,我们知道传递给 pushTarget 函数的参数就是调用该函数的观察者对象,所以 Dep.target 保存着一个观察者对象,其实这个观察者对象就是即将要收集的目标。

​ 我们回到 get 继续往下看,接下来是定义一个 value 变量,该变量的值为 this.getter 函数的返回值,我们知道观察者对象的 this.getter 属性是一个函数,这个函数的执行就意味着对被观察目标的求值,并将得到的值赋值给 value 变量,而且我们可以看到 this.get 方法的最后将 value 返回。

​ 以我们本章的案例为例,即渲染函数的观察者为例,生成的 render 函数为:

function anonymous() {
	with(this){
    return _c(
      'div',
      [_v(" "+_s(name)+" ")]
    )
  }
}
1
2
3
4
5
6
7
8

​ 最终生成的渲染函数的执行会读取数据属性 name 的值,这将会触发 name 属性的 get 拦截器函数,在上一章我们分析过数据的响应式是通过 defineReactive 来实现的,源码如下:

​ 源码目录:scr/core/observer/index.js

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      // 当obj的某个属性被访问的时候,就会调用getter方法
      const value = getter ? getter.call(obj) : val
      // 当Dep.target不为空时,调用dep.depend 和 childOb.dep.depend方法做依赖收集
      if (Dep.target) {
        // 通过dep对象, 收集依赖关系
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          // 如果访问的是一个数组, 则会遍历这个数组, 收集数组元素的依赖
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    // 省略...
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

​ 这段代码是数据属性的 get 拦截器函数,由于渲染函数读取了 name 属性的值,所以 name 属性的 get 拦截器函数将被执行,上面代码中,首先判断了 Dep.target 是否存在,如果存在则调用 dep.depend 方法收集依赖。这就是为什么 pushTarget 函数要在调用 this.getter 函数之前被调用的原因。既然 dep.depend 方法被执行,那么我们就找到 dep.depend 方法,代码如下:

​ 源码目录:scr/core/observer/dep.js

let uid = 0

/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
export default class Dep {
  // 省略...
  // 收集依赖关系
  depend () {
    // 把当前Dep对象实例添加到当前正在计算的Watcher的依赖中
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
	// 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

​ 在 dep.depend 方法内部又判断了一次 Dep.target 是否有值,有的同学可能会有疑问,这不是多此一举吗?其实这么做并不多余,因为 dep.depend 方法除了在属性的 get 拦截器函数内被调用之外还在其他地方被调用了,这时候就需要对 Dep.target 做判断,至于在哪里调用的我们后面会讲到。另外我们发现在 depend 方法内部其实并没有真正的执行收集依赖的动作,而是调用了观察者实例对象的 addDep 方法:Dep.target.addDep(this),并以当前 Dep 实例对象作为参数。

​ 我们再来看看 WatcheraddDep 方法的定义,如下:

​ 源码目录:scr/core/observer/watchere.js

/**
 * 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)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

​ 可以看到 addDep 方法接收一个参数,这个参数是一个 Dep 对象,在 addDep 方法内部首先定义了常量 id,它的值是 Dep 实例对象的唯一 id 值。接着是一段 if 语句块,该 if 语句块的代码很关键,因为它的作用就是用来 避免收集重复依赖 的,既然是用来避免收集重复的依赖,那么就不得不用到我们前面提到过的两组属性,即 newDepIdsnewDeps 以及 depIdsdeps

​ 为了让大家更好地理解,我们思考一下可不可以把 addDep 方法修改成如下这样:

addDep (dep: Dep) {
  dep.addSub(this)
}
1
2
3

​ 首先解释一下 dep.addSub 方法,它的源码如下:

​ 源码目录:scr/core/observer/dep.js

addSub (sub: Watcher) {
  this.subs.push(sub)
}
1
2
3

addSub 方法接收观察者对象作为参数,并将接收到的观察者添加到 Dep 实例对象的 subs 数组中,其实 addSub 方法才是真正用来收集观察者的方法,并且收集到的观察者都会被添加到 subs 数组中存起来。

​ 了解了 addSub 方法之后,我们再回到如下这段代码:

addDep (dep: Dep) {
  dep.addSub(this)
}
1
2
3

​ 我们修改了 addDep 方法,直接在 addDep 方法内调用 dep.addSub 方法,并将当前观察者对象作为参数传递。这不是很好吗?难道有什么问题吗?当然有问题,假如我们有如下模板:

<div> {{ name }} {{ name }}</div>
1

​ 这段模板的不同之处在于我们使用了两次 name 数据,那么相应的渲染函数也将变为如下这样:

function anonymous() {
	with(this){
    return _c(
      'div',
      [_v(" "+_s(name)+_s(name)+" ")]
    )
  }
}
1
2
3
4
5
6
7
8

​ 可以看到,渲染函数的执行将读取两次数据对象 name 属性的值,这必然会触发两次 name 属性的 get 拦截器函数,同样的道理,dep.depend 也将被触发两次,最后导致 dep.addSub 方法被执行了两次,且参数一模一样,这样就产生了同一个观察者被收集多次的问题。所以我们不能像如上那样修改 addDep 函数的代码。

​ 我们再回到 addDep,在 addDep 内部并不是直接调用 dep.addSub 收集观察者,而是先根据 dep.id 属性检测该 Dep 实例对象是否已经存在于 newDepIds 中,如果存在那么说明已经收集过依赖了,什么都不会做。如果不存在才会继续执行 if 语句块的代码,同时将 dep.id 属性和 Dep 实例对象本身分别添加到 newDepIdsnewDeps 属性中,这样无论一个数据属性被读取了多少次,对于同一个观察者它只会收集一次。

​ 这里的判断条件 !this.depIds.has(id) 是什么意思呢?我们知道 newDepIds 属性用来避免在 一次求值 的过程中收集重复的依赖,其实 depIds 属性是用来在 多次求值 中避免收集重复依赖的。什么是多次求值,其实所谓多次求值是指当数据变化时重新求值的过程。大家可能会疑惑,难道重新求值的时候不能用 newDepIds 属性来避免收集重复的依赖吗?不能,原因在于每一次求值之后 newDepIds 属性都会被清空,也就是说每次重新求值的时候对于观察者实例对象来讲 newDepIds 属性始终是全新的。虽然每次求值之后会清空 newDepIds 属性的值,但在清空之前会把 newDepIds 属性的值以及 newDeps 属性的值赋值给 depIds 属性和 deps 属性,这样重新求值的时候 depIds 属性和 deps 属性将会保存着上一次求值中 newDepIds 属性以及 newDeps 属性的值。

​ 我们继续回到 get() 方法往下看,可以看到在 finally 语句块内调用了观察者对象的 cleanupDeps 方法,这个方法的作用正如我们前面所说的那样,每次求值完毕后都会使用 depIds 属性和 deps 属性保存 newDepIds 属性和 newDeps 属性的值,然后再清空 newDepIds 属性和 newDeps 属性的值。

​ 我们再来看看 WatchercleanupDeps 方法的定义,如下:

​ 源码目录:scr/core/observer/watchere.js

/**
 * Clean up for dependency collection.
 */
cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) {
      dep.removeSub(this) //清除 sub
    }
  }
  let tmp = this.depIds // 获取depids
  this.depIds = this.newDepIds // 获取新的depids
  this.newDepIds = tmp // 旧的覆盖新的
  this.newDepIds.clear() //清空对象
  // 互换值
  tmp = this.deps
  this.deps = this.newDeps
  this.newDeps = tmp
  this.newDeps.length = 0
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

​ 在 cleanupDeps 方法内部,首先是一个 while 循环,我们暂且不关心这个循环的作用,我们看循环下面的代码,这段代码是典型的引用类型变量交换值的过程,最终的结果就是 newDepIds 属性和 newDeps 属性被清空,并且在被清空之前把值分别赋给了 depIds 属性和 deps 属性,这两个属性将会用在下一次求值时避免依赖的重复收集。

​ 现在我们可以做几点总结:

  • 1、newDepIds 属性用来在一次求值中避免收集重复的观察者
  • 2、每次求值并收集观察者完成之后会清空 newDepIdsnewDeps 这两个属性的值,并且在被清空之前把值分别赋给了 depIds 属性和 deps 属性
  • 3、depIds 属性用来避免重复求值时收集重复的观察者

​ 通过以上三点内容我们可以总结出一个结论,即 newDepIdsnewDeps 这两个属性的值所存储的总是当次求值所收集到的 Dep 实例对象,而 depIdsdeps 这两个属性的值所存储的总是上一次求值过程中所收集到的 Dep 实例对象。

​ 除了以上三点之外,其实 deps 属性还能够用来移除废弃的观察者,cleanupDeps 方法中开头的那段 while 循环就是用来实现这个功能的。 while 循环就是对 deps 数组进行遍历,也就是对上一次求值所收集到的 Dep 对象进行遍历,然后在循环内部检查上一次求值所收集到的 Dep 实例对象是否存在于当前这次求值所收集到的 Dep 实例对象中,如果不存在则说明该 Dep 实例对象已经和该观察者不存在依赖关系了,这时就会调用 dep.removeSub(this) 方法并以该观察者实例对象作为参数传递,从而将该观察者对象从 Dep 实例对象中移除。

​ 我们再来看看 Dep 类的 removeSub 方法的定义,如下:

​ 源码目录:scr/core/observer/dep.js

removeSub (sub: Watcher) {
  remove(this.subs, sub)
}
1
2
3

removeSub 方法接收一个要被移除的观察者作为参数,然后使用 remove 工具函数,将该观察者从 this.subs 数组中移除。

​ 说明:关于 traverse 我们将在深度观测的小节具体分析。

# 2.4 触发依赖

​ 在上一小节中我们提到了,每次求值并收集完观察者之后,会将当次求值所收集到的观察者保存到另外一组属性中,即 depIdsdeps,并将存有当次求值所收集到的观察者的属性清空,即清空 newDepIdsnewDeps。我们当时也说过了,这么做的目的是为了对比当次求值与上一次求值所收集到的观察者的变化情况,并做出合理的矫正工作,比如移除那些已经没有关联关系的观察者等。本节我们将以数据属性的变化为切入点,讲解重新求值的过程。

​ 在们当前案例中模板将会被编译成渲染函数,接着创建一个渲染函数的观察者,从而对渲染函数求值,在求值的过程中会触发数据对象 name 属性的 get 拦截器函数,进而将该观察者收集到 name 属性通过闭包引用的“筐”中,即收集到 Dep 实例对象中。这个 Dep 实例对象是属于 name 属性自身所拥有的,这样当我们尝试修改数据对象 name 属性的值时就会触发 name 属性的 set 拦截器函数,这样就有机会调用 Dep 实例对象的 notify 方法,从而触发了响应。

​ 在上一章我们分析过数据的响应式是通过 defineReactive 来实现的,当属性值变化时确实通过 set 拦截器函数调用了 Dep 实例对象的 notify 方法,这个方法就是用来通知变化的,源码如下:

​ 源码目录:scr/core/observer/index.js

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    set: function reactiveSetter (newVal) {
      // 当改变obj的属性是,就会调用setter方法。这是就会调用dep.notify方法进行通知
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      // 新旧值相等 或 新旧值都等于NaN时
      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对象通知依赖的vue实例进行更新
      dep.notify()
    }
    // 省略...
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

​ 我们再来看看 Dep 类的 notify 方法的定义,如下:

​ 源码目录:scr/core/observer/dep.js

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    // dep对象的id
    this.id = uid++
    // 数组,用来存储依赖响应式属性的Observer
    this.subs = []
  }

	// 省略...

	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)
    }
    // 遍历所有的订阅Watcher,然后调用他们的update方法
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

notify 首先通过 slice 方法获取到所有的观察者对象并保存在 subs 变量中,接下来的 if 语句的作用是判断在同步执行的观察者时,在执行观察者对象的 update 更新方法之前就对观察者进行排序,从而保证正确的更新顺序。最后遍历所有的观察者对象,逐个调用观察者对象的 update 方法,这就是触发响应的实现机制。

​ 接下来我们回到 watcher 类,再来看看 update 方法的定义,如下:

​ 源码目录:scr/core/observer/watcher.js

/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 在 update 方法中代码被拆分成了三部分,即 if...else if...else 语句块。首先 if 语句块的代码会在判断条件 this.lazy 为真的情况下执行,我们说过 this.lazy 属性是用来判断该观察者是不是计算属性的观察者。也就是说渲染函数的观察者肯定是不会执行 if 语句块中的代码的,此时会继续判断 else...if 语句的条件 this.sync 是否为真,我们知道 this.sync 属性的值就是创建观察者实例对象时传递的第三个选项参数中的 sync 属性的值,这个值的真假代表了当变化发生时是否同步更新变化。对于渲染函数的观察者来讲,它并不是同步更新变化的,而是将变化放到一个异步更新队列中,也就是 else 语句块中代码所做的事情,即 queueWatcher 会将当前观察者对象放到一个异步更新队列,这个队列会在调用栈被清空之后按照一定的顺序执行。无论是同步更新变化还是将更新变化的操作放到异步更新队列,真正的更新变化操作都是通过调用观察者实例对象的 run 方法完成的。

​ 说明:关于异步更新队列和计算属性我们将在后面小节具体分析。

​ 我们再来看看 Dep 类的 run 方法的定义,如下:

​ 源码目录:scr/core/observer/dep.js

/**
 * Scheduler job interface.
 * Will be called by the scheduler.
 */
run () {
  if (this.active) {
    const value = this.get() // 获取新值
    if (
      value !== this.value || // 新值和旧值不相等
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) || // 新值是对象
      this.deep // deep为true
    ) {
      // set new value
      const oldValue = this.value
      this.value = value
      if (this.user) { // 如果是个用户 watcher
        try {
          // 执行这个回调函数 vm作为上下文 参数1为新值 参数2为旧值,
          // 也就是最后我们自己定义的function(newval,val){ console.log(newval,val) }函数
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

run 方法首先判断了当前观察者实例的 this.active 属性是否为真,其中 this.active 属性用来标识一个观察者是否处于激活状态,或者可用状态。如果观察者处于激活状态那么 this.active 的值为真,则会执行 if 语句块。if 语句块首先调用this.get 方法,这意味着重新求值。对于渲染函数的观察者来讲,重新求值其实等价于重新执行渲染函数,最终结果就是重新生成了虚拟 DOM 并更新真实 DOM,这样就完成了重新渲染的过程。

​ 在重新调用 this.get 方法之后是一个 if 语句块,实际上对于渲染函数的观察者来讲并不会执行这个 if 语句块,因为 this.get 方法的返回值其实就等价于 updateComponent 函数的返回值,这个值将永远都是 undefined。实际上 if 语句块内的代码是为非渲染函数类型的观察者准备的,它用来对比新旧两次求值的结果,当值不相等的时候会调用通过参数传递进来的回调。

​ 首先对比新值 value 和旧值 this.value 是否相等,只有在不相等的情况下才需要执行回调,或者检测第二个条件是否成立,即 isObject(value),判断新值的类型是否是对象,如果是对象的话即使值不变也需要执行回调,注意这里的“不变”指的是引用不变。

​ 如果判断条件为真则执行 if 语句块,即执行观察者的回调函数。首先定义了 oldValue 常量,它的值是旧值,紧接着使用新值更新了 this.value 的值。

​ 在调用回调函数的时候,如果观察者对象的 this.user 为真意味着这个观察者是开发者定义的,所谓开发者定义的是指那些通过 watch 选项或 $watch 函数定义的观察者,这些观察者的特点是回调函数是由开发者编写的,所以这些回调函数在执行的过程中其行为是不可预知的,很可能出现错误,这时候将其放到一个 try...catch 语句块中,这样当错误发生时我们就能够给开发者一个友好的提示。并且我们注意到在提示信息中包含了 this.expression 属性,我们前面说过该属性是被观察目标(expOrFn)的字符串表示,这样开发者就能清楚的知道是哪里发生了错误。

​ 最后调用回调函数 this.cb ,当变化发生时会触发,但是对于渲染函数的观察者来讲,this.cb 属性的值为 noop,即什么都不做。我们可以看到不管是 if 语句块还是 else 语句块都会执行回调函数,即将回调函数的作用域修改为当前 Vue 组件对象,然后传递了两个参数,分别是新值和旧值。

# 2.5 异步更新

# 2.5.1 JS运行机制

​ 为了方便大家理解,我先简单介绍 一下JS 的运行机制 (opens new window)

JS 执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:

  1. 所有同步任务都在主线程上执行,形成一个执行栈 (execution context stack)
  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

js运行机制

​ 主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度被调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro taskmicro task,并且每个 macro task 结束后,都要清空所有的 micro task

​ 关于 macro task 和 micro task 的概念参考这里 (opens new window),简单通过一段代码演示他们的执行顺序:

for (macroTask of macroTaskQueue) { 
  // 1. Handle current MACRO-TASK 
  handleMacroTask();
	// 2. Handle all MICRO-TASK
  for (microTask of microTaskQueue) {
     handleMicroTask(microTask);
	} 
}
1
2
3
4
5
6
7
8

​ 在浏览器环境中,常⻅的 macro tasksetTimeoutMessageChannelpostMessagesetImmediate;常 ⻅的 micro taskMutationObseverPromise.then

​ 我们对 JS 的运行机制讲解清楚,下面我们我们再来看看同步更新异步更新

# 2.5.2 同步更新

​ 我们以本章的案例为基础,稍微修改一下我们案例,如下:

new Vue({
  el: '#app',
  template: `<div> {{ name }} </div>`,
  data: {
    name: 'robin',
    children: {
      name: 'child',
      age: 18
    }
  },
  mounted () {
    this.name = 'hcy'
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 我们在模板中使用了数据对象的 name 属性,这意味着 name 属性将会收集渲染函数的观察者作为依赖,接着我们在 mounted 钩子中修改了 name 属性的值,这样就会触发响应:渲染函数的观察者会重新求值,完成重渲染

​ 这个过程从属性值的变化到完成重新渲染,这是一个同步更新的过程,大家思考一下“同步更新”会导致什么问题?很显然这会导致每次属性值的变化都会引发一次重新渲染,假设我们要修改两个属性的值,那么同步更新将导致两次的重渲染。

​ 有时候这是致命的缺陷,想象一下复杂业务场景,你可能会同时修改很多属性的值,如果每次属性值的变化都要重新渲染,就会导致严重的性能问题,而异步更新队列就是用来解决这个问题的。

# 2.5.3 异步更新

​ 与同步更新的不同之处在于,每次修改属性的值之后并没有立即重新求值,而是将需要执行更新操作的观察者放入一个队列中。当我们修改 name 属性值时,由于 name 属性收集了渲染函数的观察者作为依赖,所以此时渲染函数的观察者 会被添加到队列中,接着我们修改了 age 属性的值,由于 age 属性也收集了渲染函数的观察者作为依赖,所以此时也会尝试将渲染函数的观察者添加到队列中,但是由于渲染函数的观察者已经存在于队列中了,所以并不会重复添加,这样队列中将只会存在一个渲染函数的观察者。当所有的突变完成之后,再一次性的执行队列中所有观察者的更新方法,同时清空队列,这样就达到了优化的目的。

​ 接下来我们就从具体代码入手,看一看其具体实现,我们知道当修改一个属性的值时,会通过执行该属性所收集的所有观察者对象的 update 方法进行更新,那么我们就找到观察者对象的 update 方法,如下:

​ 源码目录:scr/core/observer/watcher.js

/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 在 update 如果没有指定这个观察者是同步更新(this.sync 为真),那么这个观察者的更新机制就是异步的,这时当调用观察者对象的 update 方法时,在 update 方法内部会调用 queueWatcher 函数,并将当前观察者对象作为参数传递,queueWatcher 函数的作用就是它将观察者放到一个队列中等待所有突变完成之后统一执行更新。

​ 我们再来看看 queueWatcher 方法的定义,如下:

​ 源码目录:src/core/observer/scheduler.js

const queue: Array<Watcher> = []
let has: { [key: number]: ?true } = {}
let waiting = false
let flushing = false

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

queueWatcher 函数接收观察者对象作为参数,首先定义了 id 常量,它的值是观察者对象的唯一 id,然后执行 if 判断语句。其中变量 has 定义在 scheduler.js 文件头部,它是一个空对象。当 queueWatcher 函数被调用之后,会尝试将该观察者放入队列中,并将该观察者的 id 值登记到 has 对象上作为 has 对象的属性同时将该属性值设置为 true。该 if 语句以及变量 has 的作用就是用来避免将相同的观察者重复入队的。

​ 在该 if 语句块内执行了真正的入队操作,在入队之前有一个对变量 flushing 的判断,它的初始值是 falseflushing 变量是一个标志,我们知道放入队列 queue 中的所有观察者将会在突变完成之后统一执行更新,当更新开始时会将 flushing 变量的值设置为 true,代表着此时正在执行更新,所以根据判断条件 if (!flushing) 可知只有当队列没有执行更新时才会简单地将观察者追加到队列的尾部,即 queue.push(watcher),这里需要强调的是在队列执行更新的过程中还会有观察者入队的操作,典型的例子就是计算属性,比如队列执行更新时经常会执行渲染函数观察者的更新,渲染函数中很可能有计算属性的存在,由于计算属性在实现方式上与普通响应式属性有所不同,所以当触发计算属性的 get 拦截器函数时会有观察者入队的行为,这个时候我们需要特殊处理,也就是 else 分支的代码。

​ 当变量 flushing 为真时,说明队列正在执行更新,这时如果有观察者入队则会执行 else 分支中的代码,这段代码的作用是为了保证观察者的执行顺序,现在大家只需要知道观察者会被放入 queue 队列中即可,我们后面会详细讨论。

​ 接下来又是一个 if 语句,其中变量 waiting 同样是一个标志,初始值为 false,在 if 语句块内先将 waiting 的值设置为 true,这意味着无论调用多少次 queueWatcher 函数,该 if 语句块的代码只会执行一次。接着调用 nextTick 并以 flushSchedulerQueue 函数作为参数,其中 flushSchedulerQueue 函数的作用之一就是用来将队列中的观察者统一执行更新的。对于 nextTick 相信大家已经很熟悉了,其实最好理解的方式就是把 nextTick 看做 setTimeout(fn, 0)

# 2.5.4 nextTick

vm.$nextTick 方法是在 renderMixin 函数中挂载到 Vue 原型上的,可以看到 $nextTick 函数体只有一句话即调用 nextTick 函数,这说明 $nextTick 确实是对 nextTick 函数的简单包装。

​ 源码目录:src/core/instance/render.js

export function renderMixin (Vue: Class<Component>) {
  // 省略...
  
  // 给 Vue 原型上添加 $nextTick API
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }
  
  // 省略...
}
1
2
3
4
5
6
7
8
9
10

​ 同理,Vue.nextTick 方法是在 initGlobalAPI 函数中挂载到 Vue 构造函数上的,可以看到 nextTicknextTick 函数的引用。

​ 源码目录:src/core/global-api/index.js

export function initGlobalAPI (Vue: GlobalAPI) {
  // 省略...
  Vue.nextTick = nextTick
  // 省略...
}
1
2
3
4
5

​ 我们知道任务队列并非只有一个队列,在 node 中更为复杂,但总的来说我们可以将其分为 microtask(macro)task,并且这两个队列的行为还要依据不同浏览器的具体实现去讨论,这里我们只讨论被广泛认同和接受的队列执行行为。当调用栈空闲后每次事件循环只会从 (macro)task 中读取一个任务并执行,而在同一次事件循环内会将 microtask 队列中所有的任务全部执行完毕,且要先于 (macro)task。另外 (macro)task 中两个不同的任务之间可能穿插着UI的重渲染,那么我们只需要在 microtask 中把所有在UI重渲染之前需要更新的数据全部更新,这样只需要一次重渲染就能得到最新的DOM了。恰好 Vue 是一个数据驱动的框架,如果能在UI重渲染之前更新所有数据状态,这对性能的提升是一个很大的帮助,所有要优先选用 microtask 去更新数据状态而不是 (macro)task,这就是为什么不使用 setTimeout 的原因,因为 setTimeout 会将回调放到 (macro)task 队列中而不是 microtask 队列,所以理论上最优的选择是使用 Promise,当浏览器不支持 Promise 时再降级为 setTimeout

​ 下面我们看看,nextTick 的实现,如下:

​ 源码目录:src/core/util/next-tick.js

export let isUsingMicroTask = false

// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
let timerFunc
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 首先我们可以从注释知道next-tick.js 2.5以后,删除了 microTimerFuncmacroTimerFunc ,统一使用 micro task 。2.5 版本则是 macrotask 结合 microtask。然而,在重绘之前状态改变时会有小问题(如 #6813)。此外,在事件处理程序中使用 macrotask 会导致一些无法规避的奇怪行为(如#7109,#7153,#7546,#7834,#8109)。所以 2.6 版本现在改用 microtask 了,microtask 在某些情况下也是会有问题的,因为 microtask 优先级比较高,事件会在顺序事件(如#4521,#6690 有变通方法)之间甚至在同一事件的冒泡过程中触发(#6566)。

​ 源码首先是定义一个变量 timerFunc ,保存核心的异步延迟函数,用于异步延迟调用 flushCallbacks 函数。

​ 我们继续往下看,代码如下:

​ 源码目录:src/core/util/next-tick.js

// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} 

// 省略...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

timerFunc 优先使用原生Promise,首先检测当前宿主环境是否支持原生的 Promise,如果支持则优先使用 Promise 注册 microtask,做法很简单,首先定义常量 p 它的值是一个立即 resolvePromise 实例对象,接着将变量 timerFunc 定义为一个函数,这个函数的执行将会把 flushCallbacks 函数注册为 microtask

​ 接下来是对 iOS 兼容性的处理,从注释我们知道原本 MutationObserver 支持更广,但在iOS >= 9.3.3UIWebView 中,触摸事件处理程序中触发会产生严重错误,即 microtask 没有被刷新。IOSUIWebViewPromise.then 回调被推入microtask 队列但是队列可能不会如期执行。 因此,添加一个空计时器“强制”执行 microtask 队列。

​ 我们继续往下看,代码如下:

​ 源码目录:src/core/util/next-tick.js

// 省略...
else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
}
// 省略...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

​ 我们从注释可以知道当原生 Promise 不可用时,timerFunc 使用原生 MutationObserver,如 PhantomJS,iOS7,Android 4.4issue #6466 MutationObserverIE11 并不可靠,所以这里排除了 IE

​ 我们在看分析代码, else-if 语句首先判断在非IE的环境下,并且支持原生 MutationObserver的情况下执行,首先定义一个变量 counter 初始值为 1,然后再创建一个 MutationObserver 实例 observer 并以 flushCallbacks 作为回调函数,接着是创建了一个文本节点,作用是通过改变文本节点的内容来触发变动,接下来通过 observe 对目标节点进行观测,接着将变量 timerFunc 定义为一个函数,这个函数的执行将会文本节点的内容,所以,加了这样一个变动监听,用一个文本节点的变动触发监听,等所有 dom 渲染完后,执行函数,达到延迟的效果。

​ 说明:关于 MutationObserver 更多内容请移步 这里 (opens new window)

​ 我们继续往下看,代码如下:

​ 源码目录:src/core/util/next-tick.js

// 省略...
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 当原生 PromiseMutationObserver 都不支持的时候,判断当前环境是否支持原生 setImmediate ,如果支持 timerFunc 使用原生 setImmediatesetImmediate 拥有比 setTimeout 更好的性能,setTimeout 在将回调注册为 (macro)task 之前要不停的做超时检测,而 setImmediate 则不需要,这就是优先选用 setImmediate 的原因。但是 setImmediate 的缺陷也很明显,就是它的兼容性问题,到目前为止只有 IE 浏览器实现了它,所以为了兼容非 IE浏览器我们还需要做兼容处理,此时就使用 setTimeout

​ 分析完异步延迟函数的实现,接下来我们看看nextTick 函数的定义,如下:

​ 源码目录:src/core/util/next-tick.js

const callbacks = []
let pending = false

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) { 
      try {
        cb.call(ctx)
      } catch (e) { 
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) { 
      _resolve(ctx)
    }
  })
  if (!pending) { 
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

nextTick 函数接收两个参数,第一个参数是一个回调函数,第二个参数指定一个作用域。下面我们逐个分析传递回调函数与不传递回调函数这两种使用场景功能的实现,首先我们来看传递回调函数的情况,那么此时参数 cb 就是回调函数。

nextTick 函数会在 callbacks 数组中添加一个新的函数。注意并不是将 cb 回调函数直接添加到 callbacks 数组中,但这个被添加到 callbacks 数组中的函数的执行会间接调用 cb 回调函数,并且可以看到在调用 cb 函数时使用 .call 方法将函数 cb 的作用域设置为 ctx,也就是 nextTick 函数的第二个参数。所以对于 $nextTick 方法来讲,传递给 $nextTick 方法的回调函数的作用域就是当前组件实例对象,当然了前提是回调函数不能是箭头函数,其实在平时的使用中,回调函数使用箭头函数也没关系,只要你能够达到你的目的即可。另外我们再次强调一遍,此时回调函数并没有被执行,当你调用 $nextTick 方法并传递回调函数时,会使用一个新的函数包裹回调函数并将新函数添加到 callbacks 数组中。

​ 在将回调函数添加到 callbacks 数组之后,会进行一个 if 条件判断,判断变量 pending 的真假,pending 变量是一个标识,它的真假代表回调队列是否处于等待刷新的状态,初始值是 false 代表回调队列为空不需要等待刷新。假如此时在某个地方调用了 $nextTick 方法,那么 if 语句块内的代码将会被执行,在 if 语句块内优先将变量 pending 的值设置为 true,代表着此时回调队列不为空,正在等待刷新。既然等待刷新,那么当然要刷新回调队列啊,怎么刷新呢?这时就用到了我们前面讲过的 timerFunc 函数,我们知道这个函数的作用是将 flushCallbacks 函数分别注册为 microtask(macro)task。但是无论哪种任务类型,它们都将会等待调用栈清空之后才执行。

​ 例如下面案例:

created () {
  this.$nextTick(() => { console.log(1) })
  this.$nextTick(() => { console.log(2) })
  this.$nextTick(() => { console.log(3) })
}
1
2
3
4
5

​ 上面的代码中我们在 created 钩子中连续调用三次 $nextTick 方法,但只有第一次调用 $nextTick 方法时才会执行 timerFunc 函数将 flushCallbacks 注册为 microtask,但此时 flushCallbacks 函数并不会执行,因为它要等待接下来的两次 $nextTick 方法的调用语句执行完后才会执行,或者准确的说等待调用栈被清空之后才会执行。也就是说当 flushCallbacks 函数执行的时候,callbacks 回调队列中将包含本次事件循环所收集的所有通过 $nextTick 方法注册的回调,而接下来的任务就是在 flushCallbacks 函数内将这些回调全部执行并清空。

​ 接下来我们看看flushCallbacks 函数的定义,如下:

​ 源码目录:src/core/util/next-tick.js

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
1
2
3
4
5
6
7
8
9
10
11

flushCallbacks 函数首先将变量 pending 重置为 false,接着开始执行回调,但需要注意的是在执行 callbacks 队列中的回调函数时并没有直接遍历 callbacks 数组,而是使用 copies 常量保存一份 callbacks 的复制,然后遍历 copies 数组,并且在遍历 copies 数组之前将 callbacks 数组清空。

​ 例如下面案例:

created () {
  this.name = 'test1'
  this.$nextTick(() => {
    this.name = 'test2'
    this.$nextTick(() => { console.log('第二个 $nextTick') })
  })
}
1
2
3
4
5
6
7

​ 上面代码中我们在外层 $nextTick 方法的回调函数中再次调用了 $nextTick 方法,理论上外层 $nextTick 方法的回调函数不应该与内层 $nextTick 方法的回调函数在同一个 microtask 任务中被执行,而是两个不同的 microtask 任务,虽然在结果上看或许没什么差别,但从设计角度就应该这么做。

​ 我们注意上面代码中我们修改了两次 name 属性的值(假设它是响应式数据),首先我们将 name 属性的值修改为字符串 test1,我们前面讲过这会导致依赖于 name 属性的渲染函数观察者被添加到 queue 队列中,这个过程是通过调用 src/core/observer/scheduler.js 文件中的 queueWatcher 函数完成的。同时在 queueWatcher 函数内会使用 nextTickflushSchedulerQueue 添加到 callbacks 数组中,所以此时 callbacks 数组如下:

callbacks = [
  flushSchedulerQueue // queue = [renderWatcher]
]
1
2
3

​ 同时会将 flushCallbacks 函数注册为 microtask,所以此时 microtask 队列如下:

// microtask 队列
[
  flushCallbacks
]
1
2
3
4

​ 接着调用了第一个 $nextTick 方法,$nextTick 方法会将其回调函数添加到 callbacks 数组中,那么此时的 callbacks 数组如下:

callbacks = [
  flushSchedulerQueue, // queue = [renderWatcher]
  () => {
    this.name = 'test2'
    this.$nextTick(() => { console.log('第二个 $nextTick') })
  }
]
1
2
3
4
5
6
7

​ 接下来主线程处于空闲状态(调用栈清空),开始执行 microtask 队列中的任务,即执行 flushCallbacks 函数,flushCallbacks 函数会按照顺序执行 callbacks 数组中的函数,首先会执行 flushSchedulerQueue 函数,这个函数会遍历 queue 中的所有观察者并重新求值,完成重新渲染(re-render),在完成渲染之后,本次更新队列已经清空,queue 会被重置为空数组,一切状态还原。接着会执行如下函数:

() => {
  this.name = 'test2'
  this.$nextTick(() => { console.log('第二个 $nextTick') })
}
1
2
3
4

​ 这个函数是第一个 $nextTick 方法的回调函数,由于在执行该回调函数之前已经完成了重新渲染,所以该回调函数内的代码是能够访问更新后的DOM的,到目前为止一切都很正常,我们继续往下看,在该回调函数内再次修改了 name 属性的值为字符串 test2,这会再次触发响应,同样的会调用 nextTick 函数将 flushSchedulerQueue 添加到 callbacks 数组中,但是由于在执行 flushCallbacks 函数时优先将 pending 的重置为 false,所以 nextTick 函数会将 flushCallbacks 函数注册为一个新的 microtask,此时 microtask 队列将包含两个 flushCallbacks 函数:

// microtask 队列
[
  flushCallbacks, // 第一个 flushCallbacks
  flushCallbacks  // 第二个 flushCallbacks
]
1
2
3
4
5

​ 我们的目的达到了,现在有两个 microtask 任务。

​ 而另外除了将变量 pending 的值重置为 false 之外,我们要知道第一个 flushCallbacks 函数遍历的并不是 callbacks 本身,而是它的复制品 copies 数组,并且在第一个 flushCallbacks 函数的一开头就清空了 callbacks 数组本身。所以第二个 flushCallbacks 函数的一切流程与第一个 flushCallbacks 是完全相同。

​ 最后我们再来看一下最后一个 if 语句,当 nextTick 函数没有接收到 cb 参数时,会检测当前宿主环境是否支持 Promise,如果支持则直接返回一个 Promise 实例对象,并且将 resolve 函数赋值给 _resolve 变量,_resolve 变量声明在 nextTick 函数的顶部。

​ 当 flushCallbacks 函数开始执行 callbacks 数组中的函数时,如果没有传递 cb 参数,则直接调用 _resolve 函数,我们知道这个函数就是返回的 Promise 实例对象的 resolve 函数。这样就实现了 Promise 方式的 $nextTick 方法。

​ 说明:microtask 优先级,Promise > MutationObserver > setImmediate > setTimeout

# 2.6 $watch

​ 接下来是时候看一下 $watch 方法以及 watch 选项的实现了。实际上无论是 $watch 方法还是 watch 选项,他们的实现都是基于 Watcher 的封装。首先我们来看一下 $watch 方法,它定义如下:

​ 源码目录: src/core/instance/state.js

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()
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

$watch 方法允许我们观察数据对象的某个属性,当属性变化时执行回调。所以 $watch 方法至少接收两个参数,一个要观察的属性,以及一个回调函数。通过上面的代码我们发现,$watch 方法接收三个参数,除了前面介绍的两个参数之后还接收第三个参数,它是一个选项参数,比如是否立即执行回调或者是否深度观测等。

​ 说明关于 $watch (opens new window)watch选项 (opens new window) 的更多用法可以查看 Vue API 文档

# 2.6.1 纯对象的情况

​ 首先我们假设传递给 $watch 方法的第二个参数是一个纯对象,$watch 首先定义了 vm 常量,它是当前组件实例对象,接着检测传递给 $watch 的第二个参数是否是纯对象,由于我们现在假设参数 cb 是一个纯对象,所以这段 if 语句块内的代码会执行。

​ 当参数 cb 是一个纯对象,则会调用 createWatcher 函数,并将参数透传,注意还多传递给 createWatcher 函数一个参数,即组件实例对象 vm,接下来我们就看看createWatcher 函数也定义,如下:

​ 源码目录: src/core/instance/state.js

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)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

createWatcher 函数的作用就是将纯对象形式的参数规范化一下,然后再通过 $watch 方法创建观察者。可以看到 createWatcher 函数的最后一句代码就是通过调用 $watch 函数并将其返回。

createWatcher 函数首先检测参数 handler 是否是纯对象,有的同学可能会问:“在 $watch 方法中已经检测过参数 cb 是否是纯对象了,这里又检测了一次是否多此一举?”,其实这么做并不是多余的,因为 createWatcher 函数除了在 $watch 方法中使用之外,还会用于 watch 选项,而这时就需要对 handler 进行检测。总之如果 handler 是一个纯对象,那么就将变量 handler 的值赋给 options 变量,然后用 handler.handler 的值重写 handler 变量的值。

​ 我们举个例子,如下:

vm.$watch('name', {
  handler () {
    console.log('name changed')
  },
  immediate: true
})
1
2
3
4
5
6

​ 如果你像如上代码那样使用 $watch 方法,那么对于 createWatcher 函数来讲,其 handler 参数为:

handler = {
  handler () {
    console.log('change')
  },
  immediate: true
}
1
2
3
4
5
6

​ 所以如下这段代码:

if (isPlainObject(handler)) {
  options = handler
  handler = handler.handler
}
1
2
3
4

​ 等价于:

if (isPlainObject(handler)) {
  options = {
    handler () {
      console.log('change')
    },
    immediate: true
  }
  handler = handler () {
    console.log('change')
  }
}
1
2
3
4
5
6
7
8
9
10
11

​ 这样就可正常通过 $watch 方法创建观察者了。

​ 我们回到 createWatcher 函数,继续往下看,接下来判断 handler 是否是字符串,这说明 handler 除了可以是一个纯对象还可以是一个字符串,当 handler 是一个字符串时,会读取组件实例对象的 handler 属性的值并用该值重写 handler 的值。然后再通过调用 $watch 方法创建观察者。

​ 我们再来看一个案例,如下:

watch: {
  name: 'nameChanged'
},
methods: {
  nameChanged () {
    console.log('name changed')
  }
}
1
2
3
4
5
6
7
8

​ 上面的代码中我们在 watch 选项中观察了 name 属性,但是我们没有指定回调函数,而是指定了一个字符串 nameChanged,这等价于指定了 methods 选项中同名函数作为回调函数。这就是如上 createWatcher 函数中,判断handler 是否是字符串的目的。

# 2.6.2 函数的情况

​ 我们再回到 $watch, 继续往下看,当第二个参数是一个函数的情况下,首先如果没有传递 options 选项参数,那么会给其一个默认的空对象,接着将 options.user 的值设置为 true,我们前面讲到过这代表该观察者实例是用户创建的,然后就到了关键的一步,即创建 Watcher 实例对象。

​ 再往下是一段 if 语句块,,如果发现 options.immediate 选项为真,那么会执行回调函数,不过此时回调函数的参数只有新值没有旧值。同时取值的方式是通过前面创建的观察者实例对象的 watcher.value 属性。我们知道观察者实例对象的 value 属性,保存着被观察属性的值。

​ 最后 $watch 方法还有一个返回值,$watch 函数返回一个函数,这个函数的执行会解除当前观察者对属性的观察。它的原理是通过调用观察者实例对象的 watcher.teardown 函数实现的。

​ 我们再来看一下 watcher.teardown 函数是如何解除观察者与属性之间的关系的,代码如下:

​ 源码目录:scr/core/observer/watchere.js

/**
 * Remove self from all dependencies' subscriber list.
 */
teardown () {
  if (this.active) {
    // remove self from vm's watcher list
    // this is a somewhat expensive operation so we skip it
    // if the vm is being destroyed.
    if (!this.vm._isBeingDestroyed) {
      remove(this.vm._watchers, this)
    }
    let i = this.deps.length
    while (i--) {
      this.deps[i].removeSub(this)
    }
    this.active = false
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

​ 首先检查 this.active 属性是否为真,如果为假则说明该观察者已经不处于激活状态,什么都不需要做,如果为真则会执行 if 语句块内的代码。

​ 首先说明一点,每个组件实例都有一个 vm._isBeingDestroyed 属性,它是一个标识,为真说明该组件实例已经被销毁了,为假说明该组件还没有被销毁,所以以上代码的意思是如果组件没有被销毁,那么将当前观察者实例从组件实例对象的 vm._watchers 数组中移除,我们知道 vm._watchers 数组中包含了该组件所有的观察者实例对象,所以将当前观察者实例对象从 vm._watchers 数组中移除是解除属性与观察者实例对象之间关系的第一步。由于这个参数的性能开销比较大,所以仅在组件没有被销毁的情况下才会执行此操作。

​ 我们知道当一个属性与一个观察者建立联系之后,属性的 Dep 实例对象会收集到该观察者对象,同时观察者对象也会将该 Dep 实例对象收集,这是一个双向的过程,并且一个观察者可以同时观察多个属性,这些属性的 Dep 实例对象都会被收集到该观察者实例对象的 this.deps 数组中,所以解除属性与观察者之间关系的第二步就是将当前观察者实例对象从所有的 Dep 实例对象中移除,这就是 while 循环的作用。

​ 最后会将当前观察者实例对象的 active 属性设置为 false,代表该观察者对象已经处于非激活状态了。

# 2.7 watch 选项

​ 上一章节我们分析了 $watch 的实现,下面我们再来分析 watch 选项的实现, watch 选项的初始化如下:

​ 源码目录:scr/core/instance/state.js

export function initState (vm: Component) {
  // 省略...
  const opts = vm.$options
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}
1
2
3
4
5
6
7

​ 下面我们再来看看 initWatch 函数的定义,代码如下:

​ 源码目录:scr/core/instance/state.js

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)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

​ 可以看到 initWatch 函数就是通过对 watch 选项遍历,然后通过 createWatcher 函数创建观察者对象的。

​ 通过 if 条件我们可以发现 handler 常量可以是一个数组,handler 常量的值是 watch[key],也就是说我们在使用 watch 选项时可以通过传递数组来实现创建多个观察者。

​ 例如:

watch: {
  name: [
    function () {
      console.log('name changed 1')
    },
    function () {
      console.log('name changed 2')
    }
  ]
}
1
2
3
4
5
6
7
8
9
10

# 2.8 深度观测

​ 接下来我们将会讨论深度观测的实现,在这之前我们需要回顾一下数据响应的原理,我们知道响应式数据的关键在于数据的属性是访问器属性,这使得我们能够拦截对该属性的读写操作,从而有机会收集依赖并触发响应。思考如下代码:

watch: {
  a () {
    console.log('a changed')
  }
}
1
2
3
4
5

​ 这段代码使用 watch 选项观测了数据对象的 a 属性,我们知道 watch 方法内部是通过创建 Watcher 实例对象来实现观测的,在创建 Watcher 实例对象时会读取 a 的值从而触发属性 aget 拦截器函数,最终将依赖收集。但问题是如果属性 a 的值是一个对象,如下:

data () {
  return {
    a: {      
      b: 1    
    }  
  }
},
watch: {
  a () {
    console.log('a changed')
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

​ 数据对象 data 的属性 a 是一个对象,当实例化 Watcher 对象并观察属性 a 时,会读取属性 a 的值,这样的确能够触发属性 aget 拦截器函数,但由于没有读取 a.b 属性的值,所以对于 b 来讲是没有收集到任何观察者的。这就是我们常说的浅观察,直接修改属性 a 的值能够触发响应,而修改 a.b 的值是触发不了响应的。

​ 深度观测就是用来解决这个问题的,深度观测的原理很简单,既然属性 a.b 中没有收集到观察者,那么我们就主动读取一下 a.b 的值,这样不就能够触发属性 a.bget 拦截器函数从而收集到观察者了吗,其实 Vue 就是这么做的,只不过你需要将 deep 选项参数设置为 true,主动告诉 Watcher 实例对象你现在需要的是深度观测。我们找到 Watcher 类的 get 方法,如下:

​ 源码目录:scr/core/observer/watchere.js

/**
 * Evaluate the getter, and re-collect dependencies.
 */
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

​ 我们知道 Watcher 类的 get 方法用来求值,在 get 方法内部通过调用 this.getter 函数对被观察的属性求值,并将求得的值赋值给变量 value,同时我们可以看到在 finally 语句块内,如果 this.deep 属性的值为真说明是深度观测,此时会将被观测属性的值 value 作为参数传递给 traverse 函数,其中 traverse 函数的作用就是递归地读取被观察属性的所有子属性的值,这样被观察属性的所有子属性都将会收集到观察者,从而达到深度观测的目的。

​ 接下来我们看看 traverse 函数的定义,如下:

​ 源码目录: src/core/observer/traverse.js

const seenObjects = new Set()
/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}
1
2
3
4
5
6
7
8
9
10

traverse 函数将接收被观察属性的值作为参数,拿到这个参数后在 traverse 函数内部会调用 _traverse 函数完成递归遍历。

​ 接下来我们再来看看 _traverse 函数的定义,如下:

​ 源码目录: src/core/observer/traverse.js

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  const isA = Array.isArray(val)
  if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
    return
  }
  if (val.__ob__) {
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  if (isA) { // 数组情况
    i = val.length
    while (i--) _traverse(val[i], seen)
  } else { // 对象情况
    keys = Object.keys(val)
    i = keys.length
    while (i--) _traverse(val[keys[i]], seen)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

_traverse 函数接收两个参数,第一个参数是被观察属性的值,第二个参数是一个 Set 数据结构的实例,可以看到在 traverse 函数中调用 _traverse 函数时传递的第二个参数 seenObjects 就是一个 Set 数据结构的实例。

​ 在 _traverse 函数的开头声明了两个变量,分别是 ikeys,这两个变量在后面会使用到,接着检查参数 val 是不是数组,并将检查结果存储在常量 isA 中。

​ 接着是对参数 val(被观察属性的值) 的检查,我们知道既然是深度观测,所以被观察属性的值要么是一个对象要么是一个数组,并且该值不能是冻结的,同时也不应该是 VNode 实例(这是 Vue 单独做的限制)。只有当被观察属性的值满足这些条件时,才会对其进行深度观测,只要有一项不满足 _traverse 就会 return 结束执行。

​ 接下来的这个 if 语句块的作用是解决循环引用导致死循环的问题,为了更好地说明问题我们举个例子,如下:

const obj1 = {}
const obj2 = {}

obj1.data = obj2
obj2.data = obj1
1
2
3
4
5

​ 上面代码中我们定义了两个对象,分别是 obj1obj2,并且 obj1.data 属性引用了 obj2,而 obj2.data 属性引用了 obj1,这是一个典型的循环引用,假如我们使用 obj1obj2 这两个对象中的任意一个对象出现在 Vue 的响应式数据中,如果不做防循环引用的处理,将会导致死循环。

​ 为了避免这种情况的发生,我们可以使用一个变量来存储那些已经被遍历过的对象,当再次遍历该对象时程序会发现该对象已经被遍历过了,这时会跳过遍历,从而避免死循环。if 语句块,用来判断 val.__ob__ 是否有值,我们知道如果一个响应式数据是对象或数组,那么它会包含一个叫做 __ob__ 的属性,这时我们读取 val.__ob__.dep.id 作为一个唯一的 ID 值,并将它放到 seenObjects 中:seen.add(depId),这样即使 val 是一个拥有循环引用的对象,当下一次遇到该对象时,我们能够发现该对象已经遍历过了:seen.has(depId),这样函数直接 return 即可。这就是避免循环引用造成的死循环的解决方案。

​ 接着将检测被观察属性的值是数组还是对象,无论是数组还是对象都会通过 while 循环对其进行遍历,并递归调用 _traverse 函数,这段代码的关键在于递归调用 _traverse 函数时所传递的第一个参数:val[i]val[keys[i]]。这两个参数实际上是在读取子属性的值,这将触发子属性的 get 拦截器函数,保证子属性能够收集到观察者,仅此而已。

# 2.9 计算属性

​ 通过前面几章的分析我们知道计算属性,它本质上就是一个惰性求值的观察者。那么下面这一小节我们来分析一下计算属性的实现,计算属性的初始化代码如下:

​ 源码目录:scr/core/instance/state.js

export function initState (vm: Component) {
  // 省略...
  const opts = vm.$options
  if (opts.computed) initComputed(vm, opts.computed) 
  // 省略...
}
1
2
3
4
5
6

​ 这句代码首先检查开发者是否传递了 computed 选项,只有传递了该选项的情况下才会调用 initComputed 函数进行初始化。

​ 下面我们再来看看 initComputed 函数的定义,由于这块代码比较多,我们依旧按照之前的分析方式,分段来讲解,首先看一下如下代码:

​ 源码目录:scr/core/instance/state.js

function initComputed (vm: Component, computed: Object) {
  // 省略...
}
1
2
3

initComputed 函数接收两个参数,第一个参数是组件对象实例,第二个参数是对应的选项。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()
  // 省略...
}
1
2
3
4
5
6
7

initComputed 函数首先定义了两个常量,其中 watchers 常量与组件实例的 vm._computedWatchers 属性拥有相同的引用,且初始值都是通过 Object.create(null) 创建的空对象,isSSR 常量是用来判断是否是服务端渲染的布尔值。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

function initComputed (vm: Component, computed: Object) {
  // 省略...
  for (const key in computed) {
    // 省略...
  }
}
1
2
3
4
5
6

​ 接着开启一个 for...in 循环,这个 for...in 循环用来遍历 computed 选项对象。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

function initComputed (vm: Component, computed: Object) {
  // 省略...
  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }
    // 省略...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 定义了 userDef 常量,它的值是计算属性对象中相应的属性值,我们知道计算属性有两种写法,计算属性可以是一个函数,如下案例:

computed: {
  fullName () {
    return this.firstName + this.lastName
  }
}
1
2
3
4
5

​ 如果你使用上面的写法,那么 userDef 的值就是一个函数:

userDef = fullName () {
  return this.firstName + this.lastName
}
1
2
3

​ 另外计算属性也可以写成对象,如下案例:

computed: {
  fullName: {
    get: function () {
      return this.firstName + this.lastName
    },
    set: function (v) {
      const full = v.split(' ')
      this.firstName = full[0]
      this.lastName = full[1]
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

​ 如果你使用如上这种写法,那么 userDef 常量的值就是一个对象:

userDef = {
  get: function () {
    return this.firstName + this.lastName
  },
  set: function (v) {
    const full = v.split(' ')
    this.firstName = full[0]
    this.lastName = full[1]
  }
}
1
2
3
4
5
6
7
8
9
10

​ 在 userDef 常量的下面定义了 getter 常量,它的值是根据 userDef 常量的值决定的,如果计算属性使用函数的写法,那么 getter 常量的值就是 userDef 本身,即函数。如果计算属性使用的是对象写法,那么 getter 的值将会是 userDef.get 函数。总之 getter 常量总会是一个函数。接着是在非生产环境下如果发现 getter 不存在,则直接打印警告信息,提示你计算属性没有对应的 getter

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

const computedWatcherOptions = { lazy: true }

function initComputed (vm: Component, computed: Object) {
  // 省略...
  for (const key in computed) {
    // 省略...
    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm, // vm  vode
        getter || noop, // 函数
        noop, // 回调函数
        computedWatcherOptions //参数 lazy = true
      )
    }
    // 省略...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

​ 上面这段代码只有在非服务端渲染时才会执行 if 语句块内的代码,因为服务端渲染中计算属性的实现本质上和使用 methods 选项差不多。这里我们着重讲解非服务端渲染的实现,这时 if 语句块内的代码会被执行,可以看到在 if 语句块内创建了一个观察者实例对象,我们称之为 计算属性的观察者,同时会把计算属性的观察者添加到 watchers 常量对象中,键值是对应计算属性的名字,注意由于 watchers 常量与 vm._computedWatchers 属性具有相同的引用,所以对 watchers 常量的修改相当于对 vm._computedWatchers 属性的修改,现在你应该知道了,vm._computedWatchers 对象是用来存储计算属性观察者的。

​ 另外有几点需要注意,首先创建计算属性观察者时所传递的第二个参数是 getter 函数,也就是说计算属性观察者的求值对象是 getter 函数。传递的第四个参数是 computedWatcherOptions 常量,它是一个对象,定义在 initComputed 函数的上方。

​ 我们知道传递给 Watcher 类的第四个参数是观察者的选项参数,选项参数对象可以包含如 deepsync 等选项,当然了其中也包括 lazy 选项,通过如上这句代码可知在创建计算属性观察者对象时 lazy 选项为 true,它的作用就是用来标识一个观察者对象是计算属性的观察者,计算属性的观察者与非计算属性的观察者的行为是不一样的。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

const computedWatcherOptions = { lazy: true }

function initComputed (vm: Component, computed: Object) {
  // 省略...
  for (const key in computed) {
    // 省略...
    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) { 
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

​ 这段代码首先检查计算属性的名字是否已经存在于组件实例对象中,我们知道在初始化计算属性之前已经初始化了 propsmethodsdata 选项,并且这些选项数据都会定义在组件实例对象上,由于计算属性也需要定义在组件实例对象上,所以需要使用计算属性的名字检查组件实例对象上是否已经有了同名的定义,如果该名字已经定义在组件实例对象上,那么有可能是 data 数据或 props 数据或 methods 数据之一,对于 dataprops 来讲他们是不允许被 computed 选项中的同名属性覆盖的,所以在非生产环境中还要检查计算属性中是否存在与 dataprops 选项同名的属性,如果有则会打印警告信息。如果没有则调用 defineComputed 定义计算属性。

​ 下面我们再来看看 defineComputed 函数的定义,代码如下:

​ 源码目录:scr/core/instance/state.js

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key) 
      : createGetterInvoker(userDef) 
    sharedPropertyDefinition.set = noop 
  } else {
    sharedPropertyDefinition.get = userDef.get 
      ? shouldCache && userDef.cache !== false 
        ? createComputedGetter(key) 
        : createGetterInvoker(userDef.get) 
      : noop
    sharedPropertyDefinition.set = userDef.set || noop
  }
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) { 
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

​ 在 defineComputed 函数开头定义了 shouldCache 常量,它的值与 initComputed 函数中定义的 isSSR 常量的值是取反的关系,也是一个布尔值,用来标识是否应该缓存值,也就是说只有在非服务端渲染的情况下计算属性才会缓存值。

​ 接着是一个 if...else 语句块,作用是为 sharedPropertyDefinition.getsharedPropertyDefinition.set 赋予合适的值。首先检查 userDef 是否是函数,如果是函数则执行 if 语句块内的代码,如果不是函数则说明 userDef 是对象,此时会执行 else 分支的代码。假如 userDef 是函数,在 if 语句块内首先会使用三元运算符检查 shouldCache 是否为真,如果为真说明不是服务端渲染,此时会调用 createComputedGetter 函数并将其返回值作为 sharedPropertyDefinition.get 的值。如果 shouldCache 为假说明是服务端渲染,由于服务端渲染不需要缓存值,所以会调用 createGetterInvoker 函数并将其返回值作为 sharedPropertyDefinition.get 的值。另外由于 userDef 是函数,这说明该计算属性并没有指定 set 拦截器函数,所以直接将其设置为空函数 noopsharedPropertyDefinition.set = noop

​ 如果代码走到了 else 分支,那说明 userDef 是一个对象,如果 userDef.get 存在并且是在非服务端渲染的环境下,同时没有指定选项 userDef.cache 为假,则此时会调用 createComputedGetter 函数并将其返回值作为 sharedPropertyDefinition.get 的值,否则 sharedPropertyDefinition.get 的值为 createGetterInvoker 函数执行的返回值。同样的如果 userDef.set 函数存在,则使用 userDef.set 函数作为 sharedPropertyDefinition.set 的值,否则使用空函数 noop 作为其值。

​ 接着是一个 if 条件语句块,在非生产环境下如果发现 sharedPropertyDefinition.set 的值是一个空函数,那么说明开发者并没有为计算属性定义相应的 set 拦截器函数,这时会重写 sharedPropertyDefinition.set 函数,这样当你在代码中尝试修改一个没有指定 set 拦截器函数的计算属性的值时,就会得到一个警告信息。

​ 最后通过 Object.defineProperty 函数在组件实例对象上定义与计算属性同名的组件实例属性,而且是一个访问器属性,属性的配置参数是 sharedPropertyDefinition 对象。

​ 下面我们再来看看 sharedPropertyDefinition 对象的定义,代码如下:

​ 源码目录:scr/core/instance/state.js

/**
 *  configurable:当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false
 *  enumerable:当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false
 * 数据描述符:
 *  value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined
 *  writable:当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。默认为 false
 * 存取描述符:
 *  get:属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。默认为 undefined
 *  set:属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。默认为 undefined
 */
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true, 
  get: noop,
  set: noop
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

​ 总之,无论 userDef 是函数还是对象,在非服务端渲染的情况下,配置对象 sharedPropertyDefinition 最终将变成如下这样:

sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: createComputedGetter(key),
  set: userDef.set // 或 noop
}
1
2
3
4
5
6

​ 举个例子,假如我们像如下这样定义计算属性:

computed: {
  fullName () {
    return this.firstName + this.lastName
  }
}
1
2
3
4
5

​ 那么定义 fullName 访问器属性时的配置对象为:

sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: createComputedGetter(key),
  set: noop // 没有指定 userDef.set 所以是空函数
}
1
2
3
4
5
6

​ 下面我们再来看看 createComputedGetter 方法的定义,代码如下:

​ 源码目录:scr/core/instance/state.js

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) { 
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 可以看到 createComputedGetter 函数只是返回一个叫做 computedGetter 的函数,并没有做任何其他事情。也就是说计算属性真正的 get 拦截器函数就是 computedGetter 函数,如下:

sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) { 
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  },
  set: noop // 没有指定 userDef.set 所以是空函数
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

​ 分析 createComputedGetter 返回函数具体实现之前我们举个例子,代码如下:

data () {
  return {
    firstName: 'Bill',
    lastName: 'Gates'
  }
},
computed: {
  fullName () {
    return this.firstName + this.lastName
  }
}
1
2
3
4
5
6
7
8
9
10
11

​ 如上代码中,我们定义了本地数据 data,它拥有一个响应式的属性 firstNamelastName,我们还定义了计算属性 fullName,它的值将依据 firstNamelastName 的值来计算求得。另外我们假设有如下模板:

<div>{{ fullName }}</div>
1

​ 模板中我们使用到了计算属性,我们知道模板会被编译成渲染函数,渲染函数的执行将触发计算属性 fullNameget 拦截器函数,那么 fullName 的拦截器函数是什么呢?就是我们前面分析的 sharedPropertyDefinition.get 函数,我们知道在非服务端渲染的情况下,这个函数为:

sharedPropertyDefinition.get = function computedGetter () {
  const watcher = this._computedWatchers && this._computedWatchers[key]
  if (watcher) { 
    if (watcher.dirty) {
      watcher.evaluate()
    }
    if (Dep.target) {
      watcher.depend()
    }
    return watcher.value
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

​ 也就是说当 fullName 属性被读取时,computedGetter 函数将会执行,在 computedGetter 函数内部,首先定义了 watcher 常量,它的值为计算属性 fullName 的观察者对象,紧接着如果该观察者对象存在,则会分别执行观察者对象的 depend 方法和 evaluate 方法,最后返回计算属性的值。这里需要注意的是 watcher.dirty 值是 true,因为我们再 initComputed 函数中实例化 watcher 时第四个参数是 computedWatcherOptions{ lazy: true } ,而在 Watcher 构造函数的 constructor 中有这样的一句 this.dirty = this.lazy ,所以在此处的 watcher.dirty 值是 true

​ 下面我们再来看看 Watcher 类的 evaluate 方法的定义,代码如下:

​ 源码目录:scr/core/observer/watcher.js

/**
 * Evaluate the value of the watcher.
 * This only gets called for lazy watchers.
 */
evaluate () {
  this.value = this.get()
  this.dirty = false
}
1
2
3
4
5
6
7
8

​ 我们知道计算属性的观察者是惰性求值,所以在创建计算属性观察者时除了 watcher.lazy 属性为 true 之外,watcher.dirty 属性的值也为 true,代表着当前观察者对象没有被求值,而 evaluate 方法的作用就是用来手动求值的。可以看到在 evaluate 方法内部调用观察者对象的 this.get 方法求值,同时将this.dirty 属性重置为 false

​ 我们在前面讲过了,在创建计算属性观察者对象时传递给 Watcher 类的第二个参数为 getter 常量,它的值就是开发者在定义计算属性时的函数(或 userDef.get),所以在 evaluate 方法中求值的那句代码最终所执行的求值函数就是用户定义的计算属性的 get 函数。

​ 在我们当前案例中,计算属性 fullName 依赖了数据对象的 firstNamelastName 属性,那么属性 firstNamelastName 将收集计算属性 fullName计算属性观察者对象,而 计算属性观察者对象 将收集 渲染函数观察者对象

​ 假如此时我们修改响应式属性 firstNamelastName 的值,那么将触发属性 firstNamelastName 所收集的所有依赖,这其中包括计算属性的观察者。我们知道触发某个响应式属性的依赖实际上就是执行该属性所收集到的所有观察者的 update 方法,现在我们就找到 Watcher 类的 update 方法。

​ 下面我们再来看看 Watcher 类的 update 方法的定义,代码如下:

​ 源码目录:scr/core/observer/watcher.js

/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 函数会重新计算,然后对比新旧值,如果变化了则执行回调函数,那么这里这个回调函数是 this.dep.notify(),在我们这个场景下就是触发了渲染 watcher 重新渲染。 通过以上的分析,我们知道计算属性本质上就是一个 computed watcher,也了解了它的创建过程和被访问触发 getter 以及依赖更新的过程,其实这是最新的计算属性的实现,之所以这么设计是因为 Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变花才会触发渲染 watcher 重新渲染,本质上是一种优化。

​ 下面我们再来看看 Watcher 类的 depend 方法的定义,代码如下:

​ 源码目录:scr/core/instance/state.js

/**
 * Depend on all deps collected by this watcher.
 */
depend () {
  // 获取计算watcher的所有deps
  let i = this.deps.length
  while (i--) {
    // 为该deps增加渲染watcher
    this.deps[i].depend()
  }
}
1
2
3
4
5
6
7
8
9
10
11

depend 方法的内容很简单,遍历 this.deps,执行每一个 this.dep.depend 方法。这里我们首先要知道 this.deps 属性是什么,实际上计算属性的观察者与其他观察者对象不同,不同之处首先会体现在创建观察者实例对象的时候,当创建计算属性观察者对象时,由于第四个选项参数中 options.lazy 为真,所以计算属性观察者对象的 this.lazy 属性的值也会为真,所以对于计算属性的观察者来讲,这说明计算属性的观察者是一个惰性求值的观察者。

​ 下面我们再来看看 Dep 类的 depend 方法的定义,代码如下:

​ 源码目录:scr/core/instance/dep.js

depend () {
  // 把当前Dep对象实例添加到当前正在计算的Watcher的依赖中
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}
1
2
3
4
5
6

​ 此时我们已经知道了 this.dep[i] 属性是一个 Dep 实例对象,所以 this.dep[i].depend() 这句代码的作用就是用来收集依赖。那么它收集到的东西是什么呢?这就要看 Dep.target 属性的值是什么了,我们回想一下整个过程:首先渲染函数的执行会读取计算属性 fullName 的值,从而触发计算属性 fullNameget 拦截器函数,最终调用了 dep.depend() 方法收集依赖。这个过程中的关键一步就是渲染函数的执行,我们知道在渲染函数执行之前 Dep.target 的值必然是 渲染函数的观察者对象

# 2.10 同步执行

​ 通常情况下当数据状态发生改变时,所有 Watcher 都为异步执行,这么做的目的是出于对性能的考虑。但在某些场景下我们仍需要同步执行的观察者,我们可以使用 sync 选项定义同步执行的观察者,如下:

new Vue({
  watch: {
    someWatch: {
      handler () {/* ... */},
      sync: true
    }
  }
})
1
2
3
4
5
6
7
8

​ 如上代码所示,我们在定义一个观察者时使用 sync 选项,并将其设置为 true,此时当数据状态发生变化时该观察者将以同步的方式执行。这么做当然没有问题,因为我们仅仅定义了一个观察者而已。

​ 在我们之前对 setter 的分析过程知道,当响应式数据发送变化后,触发了 watcher.update(),只是把这个 watcher 推送到一个队列中,在 nextTick 后才会真正执行 watcher 的回调函数。而一旦我们设置了 sync,就可以在当前 Tick 中同步执行 watcher 的回调函数。

​ 源码目录:src/core/observer/watcher.js

/**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 只有当我们需要 watch 的值的变化到执行 watcher 的回调函数是一个同步过程的时候才会去设置该属性为 true

# 2.11 Watcher 类型

  • depp watcher
  • user watcher
  • computed watcher
  • sync watcher

# 3. 总结

​ 计算属性本质上是 computed watcher ,而侦听属性本质上是 user watcher 。就应用场景而言,计算属性适合用在模板渲染中,某个值是依赖了其它的响应式对象甚至是计算属性计算而来;而侦听属性适用于观测某个值的变化去完成一段复杂的业务逻辑。

watcher 的 4 个 options ,通常我们会在创建user watcher 的时候配置 deepsync ,可以根据不同的场景做相应的配置。