Vue 源码分析系列之二:深入 Vue React 响应式编程

前言

本文是笔者所总结的有关 Vue 源码分析系列之一,本文将从源码的角度来剖析 Vue React 的机制和原理;

本文为作者原创作品,转载请注明出处;

Vue React

运行机制

有关 Vue React 的特性和功能描述可以参考官网 https://vuejs.org/v2/guide/reactivity.html 里面详细的介绍了 Vue React 的运行机制,

Models are just plain JavaScript objects. When you modify them, the view updates.

当你修改这些 Javascript 的 Models,然后相应的 view 也跟着发生改变;这就是 Vue React 既是响应式编程的运行机制;但是要如何做到这一点呢?这就需要有一套行之有效的设计方案,而这套方案对于 Vue 的底层框架和实现来说,其实并不是那么简单;

实现原理

还是以上一篇系列文章中的 Demo 为例,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="example">{{ message }}</div>
<script>
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message' // change data
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
vm.$el.textContent === 'new message' // true
})
</script>

当 options (构造 Vue 实例时候所使用到的参数)中的 data 对象的属性 message 发生改变以后,通过 el 属性所绑定的 DOM 元素既是 div#example 也会立即发生改变;那么 Vue 是怎么做到的呢?先来看一下官网上的这张模块设计图,
vue react design.png

这张图清晰的描绘了各个组件之间的逻辑和相应的职责,

  • Data
    以上述的 Demo 为例,其对应的就是 options.data 对象中的 message 属性;通过 Object.defineProperty() 方法依次对 options.data 中的属性进行扩展,这里主要是扩展出 setter & getter 的特性,这样,当对 data 对象中的属性进行赋值或者取值操作的时候,该事件便可以被捕获,这个 Vue 便可以检测该属性值是否发生过改变,若改变过,则调用 Component Render Function 对 DOM 进行重绘;
  • Watcher
    当 Data 的属性发生改变以后,便会触发 Watcher,由 Vue 源码分析系列之一:Vue 实例化过程 的 mount el 章节的分析可知,从源码的层面,Watcher 与 Vue 实例是多对一的关系,不过在实际使用过程当中却是一对一的关系,因为在定义 Vue 实例的时候,只能通过 el 绑定到一个 DOM 节点上;该 Watcher 实例保存在 vm 实例的 _watchers 队列中;
  • Component Render Function
    当 Watcher 捕获到属性的改变以后,调用 Component Render Function 对相关的 DOM 节点进行重绘;

所以归纳起来,以上述的 Demo 为例,当 data.message 属性发生改变,便会通知 Watcher 实例,Watcher 实例便会调用 Component Render Function 对相应的 DOM 节点进行重新绘制;

异步更新

不过,当捕获到 data.message 属性的变化以后,Watcher 并不是立即调用 Component Render Function 对 DOM 节点进行重新绘制的,而是将该事件缓存在一个全局的事件队列中异步执行的;这样做有其必然性,因为 javascript 本身是单线程的,一个接一个的执行是它的必然的选择,同时其实也是性能最优化的选择,想想 Event Loop 的设计;另外由此带来的好处是,如果该事件在同一个时间单位内发生多次,同一个类型的事件将只会在缓存队列中被注册一次,那么仅需要对 DOM 节点使用最新的状态绘制一次就可以了,在这种场景下,极大的提高了绘制 DOM 节点的性能;

源码分析

术语

为了描述方便,笔者定义了如下的术语

  • React 参数
    表示 data、compouted 以及 watch 对象中所参与 React 作用的参数;
  • data React 参数
    表示与 data 对象相关的 React 参数;
  • computed React 参数
    表示与 computed 对象相关的 React 参数;
  • watch React 参数
    表示与 watch 对象相关的 React 参数;
  • method React 参数
    表示与 method 对象相关的 React 参数;

初始化流程

与 Vue React 相关的初始化流程主要包含两个部分,

  1. React 参数的监听初始化流程,该部分介绍了如何通过 Object.defineProperties 的方式为参数设置 settergetter 的监听,
  2. 另外一部分是构造初始化流程,这部分描述了如何构造 Watcher 组件以及相关的 vnode 和 patch 更新 DOM 节点的特性;

Demo 1

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
<!DOCTYPE html>
<html lang="en">
<head>
<script src="./vue.js"></script>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="example">
<p>Saled Price: "{{ price }}"</p>
<p>The Profit: "{{ profit }}"</p>
<p>The Cost: "{{ cost }}"</p>
<p>The Sliding Discount: "{{ discount() }}"</p>
</div>
<script>
var vm = new Vue({
el: '#example',
data: {
price: 100,
cost: ''
},
computed: {
// a computed getter
profit: function () {
// `this` points to the vm instance
// 假设利润只有 20%
return this.price * 0.2
}
},
watch: {
// 假设成本有 80%
price: function(val){
price = val;
this.cost = price * 0.8
}
},
methods: {
// 返回一个随机的折扣比例值,好玩的是,每次只要 react 参数发生变化,这个方法都会被调用一次;
discount: function(){
var random = Math.floor((Math.random() * 10) + 1);
return [0.20, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27, 0.28, 0.29][random]
}
}
})
</script>
</body>
</html>

(备注,上面的 Mustache 语法既 {{}} 的方式不能用在 HTML 元素标签中,如果需要直接在 HTML 元素中使用 Vue 参数,必须使用 v-bind 指令)

笔者所构建的该实例的目的是试图去涵盖 Vue React 的所有核心的功能点,DataComputed Properties 以及 Watcher

  • DataVue 源码分析系列之一:Vue 实例化过程中已经有过详细的描述,这里不再赘述;

  • Computed Properties
    profit 作为 React 参数需要在 Vue 和 HTML 同时定义,这里要注意 profit 作为 computed properties 在 Vue 实例中定义的方式,

    1
    2
    3
    4
    5
    6
    7
    8
    computed: {
    // a computed getter
    profit: function () {
    // `this` points to the vm instance
    // 假设利润只有 20%
    return this.price * 0.2
    }
    },

    profit 作为 computed 对象的属性定义,它本身是一个 React 参数,作用于 Vue React,其值是一个匿名 function 的引用,要特别注意的是该 function 中的实现,其内部引用了另一个 React 参数 data.price (实例中通过 this.price 引用),为什么要这样呢?为什么必须引用 data React 参数呢?官网 Computed vs Watched Property 章节中是这样来解释的,当 data.price 的值发生了变化以后,它会作用到引用了它的 Computed Properties,这里既是 profit,调用 profit 相关的“匿名方法”并通过 return 将计算后的值赋值给 profit,而又因为 profit 本身是一个 React 参数,所以,一旦它发生了变化,便会触发 Vue React 机制对相应的 DOM 节点进行重新绘制;所以,这里要特别注意的是,Computed Properties 的特性是,监控 data React 参数的变化并对 computed React 参数重新赋值进而导致重新绘制 Compouted Properties 所对应的 DOM 节点

  • Watcher
    先来看一下 Watcher 的定义方式,

    1
    2
    3
    4
    5
    6
    7
    watch: {
    // 假设成本有 80%
    price: function(val){
    price = val;
    this.cost = price * 0.8
    }
    }

    WatcherComputed Properties 的功能非常的相似,就像是同一个功能的两种不同的表达方式而已,Watcher 通过其属性的名字来监听 data React 参数的变化,Computed Properties 是通过其属性所对应的“匿名方法”的内部逻辑来监听 data React 参数的变化的,然后对各自的 React 参数进行重新赋值,然后触发 Vue React 对相应的 DOM 节点进行重新绘制;看看是如何通过 Watcher 来绘制 DOM 的,一旦 data.price 的值发生变化,将 data.price 作为参数回调 watch.price 方法,然后将计算后的值赋值给 Watcher 关联的 React 参数 data.cost,同样,因为 data.cost 是 React 参数,因此它会触发 Vue React 并对相应的 DOM 节点进行重新绘制;所以说,WatcherComputed Properties 所要实现的功能及其的相似,都是对 data React 参数进行监控,然后改变自己各自所对应的 React 参数导致 DOM 节点重新绘制;但是,我们应该注意到如下的一些异同,

    • Computed Properties 在某些时候比使用 Watcher 更方便,这点可以从官网有关 fullname, firstname, lastname 的这个例子中看到;
    • Watcher 在某些情况下是“不可替代”的,这点可以从官网聊天机器人的例子中可以看到,虽然 data React 参数 question 通过用户的输入在不停的变换,但是在 Watcher 的回调方法中使用 _.debounce 方法可以有效的控制两件事情:1) 有效的降低远程 API 调用的次数;2) 有效的降低 watch React 参数既 data.answer 的变化频率,进而同时也消除对 DOM 节点的重复且多余的绘制;
    • Watcher 的执行方式也有些不同,当你执行上述 Demo 的示例的时候,在对 price 在进行初始赋值和绘制的时候,watch 所对应的 React 参数 cost 并不会被绘制;不过在初始渲染以后,如果对 data.price 进行更改,那么 Watcher 的 React 将会被激活,进而 cost 将会被绘制;这部分的特殊逻辑可以通过如下的方式来进行测试,在 Chrome 控制台输入如下的命令进行测试,

      1
      2
      3
      console.log(vm.price); // => 100
      vm.price = 200
      console.log(vm.cost); // => 160

      然后回到浏览器,可以看到 cost 相关的 DOM 节点也被重新绘制了;

    总结,Computed Properties vs WatcherComputed Properties 是期望通过监控其他属性来改变自己的属性,所以,使用一个“匿名函数”的方式注册,目的就是为了在该“匿名函数”中去监控其它属性;而 Watcher 的目的很直接,就是监控自己属性的变化,并且在 Watcher 方法中可以扩展出相关的监控特性,比如上面的聊天机器人的例子。

  • Methods
    首先要注意其绑定的方式,因为这里是把它当做一个方法进行绑定,所以在 HTML 中的绑定方式如下,表示直接调用 discount 方法;

    1
    <p>The Sliding Discount: "{{ discount() }}"</p>

    再来看看 methods 在 vm 中的定义,很简单,就是返回一个随机的折扣值;

    1
    2
    3
    4
    5
    6
    7
    methods: {
    // 返回一个随机的折扣比例值,好玩的是,每次只要 react 参数发生变化,这个方法都会被调用一次;
    discount: function(){
    var random = Math.floor((Math.random() * 10) + 1);
    return [0.20, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27, 0.28, 0.29][random]
    }
    }

    那么是如何通过 method react 参数 discount() 来触发 Vue React 的呢?经过笔者的测试,但凡是 data React 参数、computed React 参数或者 watch React 参数发生变化,都会触发 Methods React 参数进行重新绘制;可以在 Chrome 控制台中进行如下的测试,

    1
    > vm.price = 300

    当修改 data React 参数 price 的同时,Methods React 参数 discount() 发生重新绘制;

    1
    > vm.cost = 100

    当修改 computed React 参数 cost 的同时,Methods React 参数 discount() 也会发生重新绘制;

    通过设置 Methods 的 React 参数的方式也可以实现类似 Computed Properties 的特性,比如监控 data.price 的变化计算出 profit 的值,因为一旦 data.price 发生变化,一定会触发 Methods 进行重新绘制,所以可以使用如下的方式实现并取代现有 profit 的实现方式,

    1
    2
    3
    4
    5
    methods: {
    profit: function(){
    return this.price * 0.2
    }
    }

    这样,只要 data.price 一旦发生变化,通过 {{ profit() }} 的方式也可以获取得到 profit 的值;但是为什么我们又不这样来做呢?如果 profit 仅仅是通过 data.price 的变化来计算所得,那么用上面的方式是完美的,因为它的作用和使用 computed 的方式一样,只有当 data.price 发生改变以后才会重新绘制 DOM 节点,但是,如果 methods profit() 需要通过直接调用 vm.profit() 来计算得出的话,那么不管 data.price 是否发生了变化,都会重新计算 profit 并对 DOM 进行渲染,这一点笔者通过 discount() 的方式在控制台中进行过测试,

    1
    2
    3
    4
    > vm.discount()
    0.25
    > vm.discount()
    0.26

    这一点其实是很显然的,因为你每次相当于重新调用一次这个方法,当然每次执行的结果不同;但是要注意的的是,类似的情况下,computed 的方式不同,虽然 computed 的属性同样引用的是一个方法,但是 computed 会缓存当前的计算值,只有当 data.price 真的发生变化以后,才会真正重新执行该方法,如果 data.price 值保持不变,则直接返回之前所缓存的值;这个特性在官网 Computed Caching vs Methods 有过非常详细的描述;因此针对 MethodsComputed Properties如果需要使用某些动态规则基于 data.price 计算出 profit,那么这个时候必须使用 Methods 的方式,保证每次调用此方法的时候得到的是一个动态的值,但是,如果规则是固定的,并且仅当 data.price 发生变化以后才会重新计算出 profit 的值,那么这个时候,使用 Computed Properties 是最佳的选择

简单的来看一下这个 Demo 的执行情况,在 IntellJ 中设置默认浏览器 Chrome 来执行本例子,

  1. 启动
    demo first time execution snapshot.png
    可以看到,第一次启动的时候,cost 的值显示的仍然是初始值,正如笔者之前所描述的那样,这是 Watcher 的特性,只有当 data.price 完成初始化以后,再次发生变化的时候,才会通过 watch React 参数 cost 的变化进而对 DOM 节点进行重新绘制;
  2. 打开 Chrome 控制台,输入如下的命令,
    demo test in chrome console.png
    可以看到 Watcher 方式工作了,同时注意两次 Methods 方式的变化

备注,上述的 Watcher 指的是 React 参数模式,与实现原理中的 Watcher 组件的概念不同;

监听初始化流程

这部分参考 Vue 实例化过程的 init State 小节,其核心逻辑就是通过 defineReactive() 方法扩展出属性的 settergetter 特性,这样一旦相关的 React 参数的值被 set 或者被 get,都可以被监听;参考下一小节 Define Reactive 来看看 React 参数的特性是如何被扩展的;

Define Reactive

这是一个非常核心的方法,它定义了 React 中非常重要的一环,它提供了对 React 参数进行监控的能力,一旦被监控的参数值发生变化,都可以被感知并扩展出特有的操作;其原理其实就是通过 Object.defineProperty 扩展出更多的存取特性,使得当这些参数的值在发生存取的时候,可以被回调;

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
function defineReactive (
obj,
key,
val,
customSetter,
shallow
) {
var dep = new Dep();

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

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

var childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var 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) {
var 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 ("development" !== 'production' && customSetter) {
customSetter();
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
});
}

defineReactive 方法接受 5 个参数,不过最核心的是前面两个参数

  • obj 既 vm 对象;
  • key vm 对象的参数,该参数将会被监控以便实现 Vue 的 React 相关操作;

最核心的代码在 23 - 57 行,通过使用 Object.defineProperty(obj, prop, descriptor) 方法来扩展对象属性的存取操作(参考 Object.defineProperty 小节的内容);主要来关注两部分的逻辑,

  • getter

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    get: function reactiveGetter () {
    var value = getter ? getter.call(obj) : val;
    if (Dep.target) {
    dep.depend();
    if (childOb) {
    childOb.dep.depend();
    if (Array.isArray(value)) {
    dependArray(value);
    }
    }
    }
    return value
    },
    • Dep.target 保存的是与当前 Vue instance 相关的 Watcher 实例;
    • 核心代码在上述代码第 4 行,其执行的逻辑是将 Watcher 对象注入到当前的 dep 实例中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      /**
      - Add a dependency to this directive.
      */
      Watcher.prototype.addDep = function addDep (dep) {
      var id = dep.id;
      if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id);
      this.newDeps.push(dep);
      if (!this.depIds.has(id)) {
      dep.addSub(this);
      }
      }
      };

      最终会执行到上述的 Watcher 实例方法 addDep,代码第 10 行,将 Watcher 实例注入到该 Dep 对象实例的 subs 队里中;这样,在 setter 过程中将会调用该 Dep 实例的 notifiy 方法,并调用该 Watcher 实例的 update 方法来更新 DOM 视图,相关逻辑参考 Setter 部分;要特别注意的是,上述的逻辑是针对每一个属性来设置的,通常情况下,Vue 实例的 data 对象中会包含多个属性,而每一个属性对应对应一个组件实例,一个组件实例对应一个 Watcher 对象实例,参考官文的描述,

      Every component instance has a corresponding watcher instance

      也就是说,一个 Vue 实例可以创建多个 Watcher 实例,并且与 React 参数之间是一对一的关系;注意这里所指的 component instance 指的是通过 React 参数所定义的 vnode,每个 vnode 对应一个 Watcher 实例;比如 data.price 和 HTML node: <p>Saled Price: "{{ price }}"</p> 就构成一个 vnode,而一个 vnode 对应一个 Watcher 对象;

  • setter

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    set: function reactiveSetter (newVal) {
    var 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 ("development" !== 'production' && customSetter) {
    customSetter();
    }
    if (setter) {
    setter.call(obj, newVal);
    } else {
    val = newVal;
    }
    childOb = !shallow && observe(newVal);
    dep.notify();
    }
    • 代码第 2 - 6 行,判断 newVal 和属性原来的值 value 是否相等,若相等,则直接返回,不执行后续逻辑;
    • 代码第 17 行,调用 dep.notify()

      1
      2
      3
      4
      5
      6
      7
      Dep.prototype.notify = function notify () {
      // stabilize the subscriber list first
      var subs = this.subs.slice();
      for (var i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
      }
      };

      setter 部分的分析可知,subs 保存的就是该 Vue 实例中所对应的所有的 Watcher 实例,然后直接调用 Watcher 对象的 udpate() 方法来更新 DOM 视图; 注意,一个 Vue 实例中可能包含多个 Component,所以,这里可能对应多个 Watcher 实例,所以这里需要依次遍历 subs 变量中的所有的 Watcher 实例并执行相关的 udpate() 方法来更新各自的视图;回到 Demo 我们来理解一下这里的含义,在初始化过程中,Vue 为 data、computed 和 watch React 参数分别创建了一个 Watcher 对象,也就是分别为 price、profit 和 cost React 参数都分别创建了一个 Watcher 对象,他们分别对应如下的 HTMl 元素,

      1
      2
      3
      <p>Saled Price: "{{ price }}"</p>
      <p>The Profit: "{{ profit }}"</p>
      <p>The Cost: "{{ cost }}"</p>

      唯一需要注意的是,这里不会对 method React 参数构造 Watcher 对象;从之前的分析可知,Vue 为 price $\leftrightarrow$ {{ price }}、profit $\leftrightarrow$ {{ profit }} 以及 cost $\leftrightarrow$ {{ cost }} 分别构建了一个 vnode 来绘制相关的 DOM 节点,Vue 再为每个 vnode 创建了一个 Watcher 实例;所以,这里创建了三个 Watcher 实例,从下面的 debug 信息中可以清晰的看到,
      debug three watcher instances.png
      由此,这里我们得到一个非常重要的信息,那就是除了 method React 参数以外,其它的 React 参数都会一一对应一个 Watcher 对象,并由该 Watcher 对象来负责重新绘制 DOM 节点

总结

从该方法的实现逻辑我们可以知道,Vue 为每个 React 参数( 除了 method 参数以外 )都绑定了一个 Watcher 对象,用来监听参数的变换,以便更新相应的 DOM 视图

构造初始化流程

这部分内容着重介绍了如何初始化 Watcher 实例,初始化 Component Render Function 的过程;

  • 初始化 Watchers
    注意,笔者这里使用的是复数,Vue 将会为每一个 React 参数(除 Method React 参数以外)创建一个 Watcher 实例,因此 React 参数与 Watcher 实例之间是一对一的关系,一个 React 参数又对应一个 vnode,所以 Watcher 与 vnode 之间也是一对一的关系,而 vm 包含多个 vnode,因此 vm 与 Watcher 之间是 一对多的关系;Watcher 的作用是当某个 React 参数发生了变化,Watcher 便直接通过其 vnode 对 DOM 节点进行渲染;这部分的详细分析参考 Define Reactive 源码分析章节以及 Vue 实例化过程中的 mount el 章节的有关 Watcher 对象实例化的部分都有过详尽的描述;
  • 初始化 Component Render Function
    Component Render Function 其实指的就是 updateComponent 方法,参考 Vue 实例化过程中的 mount el 章节部分;updateComponent 就是 vnode 与 vm.__patch__方法的结合体,当调用 updateCompoennt 方法的时候,便是对 vnode 执行 vm.__patch__,进而重新渲染 DOM 节点;

该部分的源码分析部分参考 Vue 实例化过程中的 mount el 章节部分;

更新渲染流程

此章节,笔者试图去弄懂的是,当某个 React 参数发生变化以后,Vue 是如何对 DOM 进行重新渲染的?

Demo 2

为了能够弄清楚其内部的运行机理,笔者对 Demo 1 进行了简化,

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
<!DOCTYPE html>
<html lang="en">
<head>
<script src="./vue.js"></script>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="example">{{ price }}</div>
<script>
var vm = new Vue({
el: '#example',
data: {
price: 100
}
})

vm.price = 200 // change data
console.log(vm.$el.textContent === '200') // false
Vue.nextTick(function () {
console.log(vm.$el.textContent === '200') // true
})
</script>
</body>
</html>

执行结果,
demo 2 executed result.png

从执行结果上来看,当通过 vm.price = 20 给 React 参数 price 进行赋值以后,DOM 节点并没有立即得到绘制,这就是为什么上述代码第 19 行返回 false 的原因,而通过 Vue.nextTick 方法的回调过程中,DOM 节点重新绘制了,对应上述代码 19 - 22 行;所以,从这个例子中可以看到,Vue React 采用的是某种异步的方式来渲染 DOM 节点的,并不是当 vm 的 React 参数发生变化以后,立刻同步的更新 DOM 节点;那么它的内部原理是怎样的呢?它是如何做的呢?

异步更新队列

官网中 Async Update Queue 对这种异步的更新的机制进行了比较详细的描述,当有某个更新事件发生以后,并不会立刻对 DOM 节点进行绘制,而是会将该更新事件缓存在队列中,稍后执行;并且如果相同的更新事件重复的往待执行队列中插入的时候,该更新事件只会被插入一次,这样的好处就是,该更新事件只会对其所关联的 DOM 节点使用最新的状态值绘制一次即可,可以假设一种场景,极短的时间之内循环的为某个 React 参数赋值 100 次,那么如果是同步更新的话,那么对应的 DOM 节点将会在相应极短的时间之内被渲染 100 次,对于渲染 DOM 这样的操作而言,这是非常耗费资源的,因此,如果我们只需要绘制最后一次的结果,那么无疑,我们便节省了 99% 的性能开销;

备注,正是因为 Vue 使用了异步更新队列的方式以及使用 patch 的方式局部更新 DOM 节点的方式,保证了前端的流畅和性能;因此能够获得非常好的用户体验;

源码分析

该章节笔者主要通过异步更新的流程来对源码进行剖析,整个异步更新的流程分为两个部分,事件注册流程和事件执行流程;

事件注册流程

async update register watcher process.png

当 Vue React 事件产生,以 Demo 2 为例,当 data.price 发生变化以后,

  1. 将触发 defineReactive 方法对象中的 set:reactiveSetter 方法,然后判断 data.price 改变后的值是否与原值相同,若相同则直接返回,若不同则调用 Dep 的实例方法 notify,
  2. 在 Dep 实例方法 notify 方法中,会遍历 vm 实例中的所有 Watcher 实例,然后依次执行 Watcher 的实例方法 udpate(),
  3. Watcher 实例方法 update()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Watcher.prototype.update = function update () {
    /* istanbul ignore else */
    if (this.lazy) {
    this.dirty = true;
    } else if (this.sync) {
    this.run();
    } else {
    queueWatcher(this);
    }
    };

    调用 queueWatcher 方法试图将当前的 watcher 注册到事件队列 queue 中;

  4. 将 watcher 注册到事件队列 queue 中,

    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
    /**
    + Push a watcher into the watcher queue.
    + Jobs with duplicate IDs will be skipped unless it's
    + pushed when the queue is being flushed.
    */
    function queueWatcher (watcher) {
    var 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.
    var 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;
    nextTick(flushSchedulerQueue);
    }
    }
    }

    注意,注册之前会根据 watcher 的 ID 先判断当前的 watcher 是否已经注册过,若注册过,则什么都不做,若果没有注册,则将 watcher 实例注册到事件队列 queue 中,其实这里叫做 watcher 队列更合适,因为 queue 里面存储的都是待执行的 watcher;最后,将 flushSchedulerQueue 作为回调方法调用 nextTick 方法,

    1
    2
    3
    4
    5
    // queue the flush
    if (!waiting) {
    waiting = true;
    nextTick(flushSchedulerQueue);
    }

    waiting 是个全局的参数,只有全局方法 resetSchedulerState() 方法负责将其值设置成 false,

    1
    2
    3
    4
    5
    6
    7
    8
    function resetSchedulerState () {
    index = queue.length = activatedChildren.length = 0;
    has = {};
    {
    circular = {};
    }
    waiting = flushing = false;
    }
  5. nextTick 流程初探,
    从后续的执行流程分析中可以知道,这里由 nextTick 负责回调的方法 flushSchedulerQueue 将负责触发 Watcher 实例的 run() 方法,该方法的逻辑就是负责执行对 DOM 节点的渲染,当所有事件队列 queue 中的所有 watcher 任务都执行完以后,调用 resetSchedulerState() 将 waiting 值设置为 false,当下次有事件注入的时候,再次开启 waiting 并调用 flushSchedulerQueue 直至所有 queue 中的事件执行完毕;所以 nextTick 所扮演的角色其实就是一个全局的周期性执行的方法/任务,专门负责回调 flushSchedulerQueue 方法和用户自定义的回调方法,后续可以看到,Vue 根据不同浏览器的特性,分别定义了 macroTimerFunc 和 microTimerFunc 的周期性执行逻辑定义了 nextTick 方法,nextTick 的核心使命就是触发上述的回调方法,Demo 2 的执行流程中注册了两个回调方法,一个是这里的 flushSchedulerQueue,一个就是用户自定义的回调方法,

    1
    2
    3
    Vue.nextTick(function () {
    console.log(vm.$el.textContent === '200') // true
    })

    一个匿名的方法,打印出被渲染后的 DOM 节点的值;而 flushSchedulerQueue 在 watcher 事件注册的时候就被作为回调方法注入了 nextTick,因此 flushSchedulerQueue 一定先于用户的回调方法之前执行完毕,而 flushSchedulerQueue 会一次性执行完所有 queue 中当前所缓存的 watcher 事件,因此这也就保证了,当回调用户自定义的回调方法以前,所有相关的 DOM 节点都 100% 已经被渲染了;更多详细的分析,笔者将会在执行事件流程章节中进行更为详细的分析;

事件执行流程

事件注册流程作为事件执行流程的入口,
async update dom and user defined callback process.png

  1. 首先,nextTict 方法将 flushSchedulerQueue 注册到全局的 callbacks 队列中,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function nextTick (cb, ctx) {
    var _resolve;
    callbacks.push(function () {
    if (cb) {
    try {
    cb.call(ctx);
    } catch (e) {
    handleError(e, ctx, 'nextTick');
    }
    } else if (_resolve) {
    _resolve(ctx);
    }
    });
    ...
    }

    可见,flushSchedulerQueue 作为 cb 参数被封装到一个匿名方法句柄中并注册到 callbacks 队列中;以便后续回调;对应流程图中步骤 1.1;

  2. 然后根据浏览器所支持的特性,创建 microTimeFunc(),备注,如果是 IE 等浏览器,创建的是 macroTImeFunc(),从其实现来看,主要是在规定的时间内,触发执行某个特定的方法;对应流程图的步骤 1.2 和 1.2.1;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    microTimerFunc = function () {
    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); }
    };

    microTimerFunc 通过 Promise 回调特定方法 flushCallbacks,

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

    依次遍历通过 nextTick 所注册到 callbacks 中的回调方法,显然,第一个回调方法就是 flushSchedulerQueue;对应流程图中的步骤 1.2.1.1;不过,从笔者所绘制的流程图中可以看到,上述的 loop 的回调中同时包含了用户自定义的回调方法的回调逻辑,也就是说 callbacks 此时包含了用户的回调方法的句柄,但是根据上面的流程分析,明明 callbacks 只注册了 flushSchedulerQueue,什么时候注册的用户的回调方法的呢?回顾一下 Demo 2 中的核心片段,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var vm = new Vue({
    el: '#example',
    data: {
    price: 100
    }
    })
    vm.price = 200 // change data
    console.log(vm.$el.textContent === '200') // false
    Vue.nextTick(function () {
    console.log(vm.$el.textContent === '200') // true
    })

    流程图中 1.1 步骤以前,都是在执行到 vm.price = 200 的时候通过触发 setter 机制开始执行的,什么时候开始执行上述第 7 行代码以后的逻辑的呢?答案就在流程图 1.2 macroTimeFunc(),这是一个异步的执行方法,通常是在等待某个时间片段以后执行,因此在 microTimerFunc() 等待的时候,上述代码的 8 - 11 行便执行了,也因此,用户自定义的回调方法也注册到 Global.callbacks 队列中了;这也就是为什么这里通过上述的循环 loop,在执行完 flushSchedulerQueue 回调方法以后同时也会执行用户回调方法的原因

  3. 执行 flushSchedulerQueue 回调,对应流程图 1.2.1.1.1 开始,遍历 queue 中的所有 Watcher 事件,并依次执行 watcher.run() 方法,该方法内部通过 watter.get() 方法调用 this.getter.call(vm) 执行 vm._update(vm._render),该方法会通过相关的 vnode 引用绘制对应的 DOM 节点;参考步骤 1.2.1.1.1.1.1;要注意的饿是,当所有的 Watcher 事件都执行完毕以后,会调用 resetSchedulerState() 将 waiting 值设置为 false,这样等下一次 setter 事件发生,那么会再次初始化一个 microTimerFunc() 再次通过 flushCallbacks() 执行 queue 中的 Watcher 事件;
  4. 当通过 flushSchedulerQueue 执行完所有 Watcher 事件以后,便会开始回调用户自定义的回调方法,见流程图 1.2.1.1.2;这也就是为什么,在调用用户回调方法之前,一定能够确保其相关的 DOM 节点一定会被重新绘制的原因;

如何调试

  • 调试事件注册流程
    直接将断点打在 vue.js 中 defineReactive 方法中的 set 方法的内部,向下面这样,
    how to debug ansyc update.png
    然后直接启动 IntellJ 开始调试即可;
  • 调试执行事件流程

附录

Object.defineProperty

语法:

1
Object.defineProperty(obj, prop, descriptor)

参数说明:

1
2
3
obj:必需。目标对象 
prop:必需。需定义或修改的属性的名字
descriptor:必需。目标属性所拥有的特性

通过 Object.defineProperty 方法为对象 obj 的属性 prop 扩展一些特性;包括数据描述和存取器描述;与 vue React 相关的主要是其存取器的相关描述,

  • getter and setter
    直接看下面这个例子,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    var obj = {};
    var initValue = 'hello';
    Object.defineProperty(obj,"newKey",{
    get:function (){
    //当获取值的时候触发的函数
    console.log('hey, the value is retrived');
    return initValue;
    },
    set:function (value){
    //当设置值的时候触发的函数,设置的新值通过参数value拿到
    initValue = value;
    console.log('hey, the value changed to be '+value);
    }
    });
    //获取值
    console.log( obj.newKey ); //hello

    //设置值
    obj.newKey = 'change value'; // 将会同时打印 'hey, the value changed to be change value'

    console.log( obj.newKey ); //change value

    代码第 16 行,在对 obj 进行赋值的时候,将会调用 setter 对属性的扩展,所以会打印出 ‘hey, the value changed to be …’,这样,通过 Object.defineProperty 方法的扩展,我们便可以非常方便的对原生的 obj 对象的属性的赋值 setter 和取值 getter 的动作进行扩展;

  • Vue 正是利用了上述 gettersetter 的特性,来监控 data 对象中的属性的变化的;不过也正是因为使用了 Object.defineProperty 方法扩展相关属性的功能,所以该属性必须是已知的,这也是为什么 Vue 教程中反复强调的,Vue 实例中的 Data 对象中的属性必须提前定义,即便是赋一个空值也必须提前定义的原因,归根结底,实际上就是因为

    1
    Object.defineProperty(obj, prop, descriptor)

    限定了必须对已知的 prop 属性功能进行扩展所导致的;

  • 不过该方法在 IE 8 下不能对任意对象进行扩展,只能对 DOM 对象进行扩展,像上面这个例子是会报错的;这也是为什么 Vue 不支持 IE 8 的原因;

注意事项 - React 参数一定要提前定义

先来看一个例子,

1
2
3
4
5
6
var vm = new Vue({
el: ...,
data: {
a: 1
}
})

当调用 s

1
> vm.a = 2

会触发 Vue React 对 DOM 节点进行重新绘制,但是如果我们动态的给 VM 创建一个新的属性 b 并对它进行赋值,如下,

1
> vm.b = 3

这样并不会触发 Vue React 机制,也不会对任何 DOM 节点进行重新绘制;笔者上述的这个例子摘自 Change Detection Caveats 不过是为什么呢?其实当了解了 Object.defineProperty 是如何扩展属性的 settergetter 特性的时候,便不会奇怪了,因为 Object.defineProperty 在扩展某个对象的属性的特性的时候,要求这些属性必须在对象中提前定义;因此,当我们在使用 Vue 开发的时候,对需要进行 React 的属性一定要记得提前定义,如果在提前定义的时候并不知道初始值是什么的时候,可以给一个空值,比如,

1
2
3
4
5
6
7
var vm = new Vue({
el: ...,
data: {
a: 1,
b: ''
}
})

然后再执行

1
> vm.b = 3

这个时候 Vue React 便可以工作了;但是,这个限制只针对 root-level reactive properties,什么意思,就是 data 对象的根属性,比如上面的 data.a 或者 data.b;但是如果不是一个 root-level reactive properties 的话,是可以将新增加的属性扩展成为 reactive properties 的,来看这样一个例子,

1
2
3
4
5
6
7
var vm = new Vue({
data: {
userProfile: {
name: 'Anika'
}
}
})

userProfile 是 root-level reactive property,但是 userProfile.name 不是;所以,如果我们想要增加一个 userProfile.age 使其成为 reactive property 呢?答案是可行的,可以通过如下的两种方式的任意一种将其定义为 reactive property,

1
Vue.set(vm.userProfile, 'age', 27)

或者,

1
vm.$set(vm.userProfile, 'age', 27)

实际上,vm.\$set 只是 Vue.set 的别名而已,所以上述两个方法实际上是等价的;那么又为什么我们可以对 none-root-level 的属性进行 reactive 扩展呢?实际上,并非是对其进行了 reactive 的扩展,而是本身 data.userProfile 就是一个 reactive 的属性,gettersetter 的扩展特性在初始化 Vue 的时候就绑定到了 data.userProfile 属性上了,所以,但凡是 data.userProfile 对象上发生任何的 getter 或者 setter 事件都会被捕获,自然包括它的新增属性;相关内容在官网 Object Change Detection Caveats 上也有相应的描述;

References

https://vuejs.org/v2/guide/reactivity.html
https://cn.vuejs.org/v2/guide/reactivity.html
Object.defineProperty: https://segmentfault.com/a/1190000007434923
Computed properties: https://vuejs.org/v2/guide/computed.html#ad