源码阅读 - Vue的数据响应式原理
没有像 Vue 和 React 这些具有数据响应式特性的前端框架之前,我们从服务端提供过的接口获取到数据要渲染在 html 页面上时,抑或是需要获取表单的值进行计算回显到页面上时,都需要建立很多 DOM 事件监听器,并进行许多的 DOM 操作。举个最简单的例子:用户在 html 页面的表单中输入两个加数 a 和 b ,计算得到两个加数之和,并展示到页面上。这时我们需要监听表单中 a 和 b 两个输入框的变化,拿到变化值相加然后通过修改指定 id 或者 class 的选择器对应的 DOM 来修改两数之和。但是当输入表单不止两个时,就会令人捉急。
而 Vue 的数据响应式大大地优化了这一整套的流程。面对大量的 UI 交互和数据更新,数据响应式让我们做到从容不迫——我们需要去了解数据的改变是怎样被触发,更新触发后的数据改变又是怎样反馈到视图。接下来我们通过对部分 Vue 源码的简单分析和学习来深入了解 Vue 中的数据响应式是怎样实现的。
什么是数据响应式
Vue.js 一个核心思想是数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。——《Vue.js技术揭秘》
没有像 Vue 和 React 这些具有数据响应式特性的前端框架之前,我们从服务端提供过的接口获取到数据要渲染在 html 页面上时,抑或是需要获取表单的值进行计算回显到页面上时,都需要建立很多 DOM 事件监听器,并进行许多的 DOM 操作。举个最简单的例子:用户在 html 页面的表单中输入两个加数 a 和 b ,计算得到两个加数之和,并展示到页面上。这时我们需要监听表单中 a 和 b 两个输入框的变化,拿到变化值相加然后通过修改指定 id 或者 class 的选择器对应的 DOM 来修改两数之和。但是当输入表单不止两个时,就会令人捉急。
而 Vue 的数据响应式大大地优化了这一整套的流程。面对大量的 UI 交互和数据更新,数据响应式让我们做到从容不迫——我们需要去了解数据的改变是怎样被触发,更新触发后的数据改变又是怎样反馈到视图。接下来我们通过对部分 Vue 源码的简单分析和学习来深入了解 Vue 中的数据响应式是怎样实现的。
我们将这一部分的代码分析大致分为三部分:让数据变成响应式、依赖收集和派发更新。
数据响应式的中心思想

该原理图源自 Vue 官方教程的深入响应式原理,这张图向我们展示了 Vue 的数据响应式的中心思想。
我们先来介绍图中的四个模块,黄色部分是 Vue 的渲染方法,视图初始化和视图更新时都会调用 vm._render 方法进行重新渲染。渲染时不可避免地会 touch 到每个需要展示到视图上的数据(紫色部分),触发这些数据的 get 方法从而收集到本次渲染的所有依赖。收集依赖和更新派发都是基于蓝色部分的 Watcher 观察者。而当我们在修改这些收集到依赖的数据时,会触发数据中的 set 属性方法,该方法会修改数据的值并 notify 到依赖到它的观察者,从而触发视图的重新渲染。绿色部分是渲染过程中生成的 Virtual DOM Tree,这棵树不仅关系到视图渲染,更是 Vue 优化视图更新过程的基础。
让数据变成响应式
基本上大家都了解 Vue 的数据响应式原理是由 JS标准内置对象方法 Object.defineProperty 来实现的,而这个方法是不兼容IE8和FF22及以下版本的浏览器的,所以 Vue 也只能在这些版本之上的浏览器中才能正常使用。这个方法的作用是直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。那么数据响应式用这个方法为什么对象添加或修改了什么属性呢?我们从 Vue 的初始化讲起。

Vue 的初始化
1 | function Vue (options) { |
在 src/core/index.js 中我们可以看到一个 Vue 被导出,这个Vue是在 src/core/instance/index 中定义的,Vue 是一个方法,参数是options,我们大胆猜想这个 Vue 就是进行 vue 实例化的方法,而 options 就是我们传入的 data、computed、methods 等属性。这个 Vue 方法中主要调用了 _init 方法,这个方法是在 initMixin 方法调用时定义在 Vue 原型上的一个方法,_init 方法中对当前传入的 options 进行了一些处理(主要是判断当前实例化的是否为组件,使用 mergeOptions 方法对 options 进行加工,此处不做赘述),然后又调用了一系列方法进行了生命周期、事件、渲染器的初始化,我们主要来关注 initState 这个方法(src/core/instance/state.js):
1 | export function initState (vm: Component) { |
将 Vue 实例上的 data 响应式化
上面的方法中对props、methods、data、computed和watch进行了初始化,这些都是Vue实例化方法中传入参数options对象的一些属性,这些属性都需要被响应式化。而针对于data的初始化分了两种情况[a*],一种是options中没有data属性的,该方法会给data赋值一个空对象并进行observe(该方法之后我们会详细讲述),如果有data属性,则调用initData方法进行初始化。我们主要通过对data属性的初始化来分析Vue中的数据响应式原理。
1 | function initData (vm: Component) { |
initData 方法对 options 中的 data 进行处理,主要是有两个目的:
- 一方面将 data 代理到 Vue 实例上,同时检查 data 中是否使用了 Vue 中的保留字、是否与 props、methods 属性中的数据同名
- 使用 observe 方法将data中的属性变成响应式的
之前我们提到在调用 initState 方法之前会有对 Vue 实例化传入的 options 参数进行处理的环节(mergeOptions),这个过程会将 data 处理成一个函数(例如我们在 vue 组件中的 data 属性),之后会调用 callHook(vm, 'beforeCreate') 方法来触发 beforeCreate 的生命周期钩子,这个方法的调用可能会对 data 属性进行进一步处理,所以方法一开始会对 data 统一做处理使其成为一个function[a*]。
接下来会对 data 中的每一个数据进行遍历[b*],遍历过程将会使用 hasOwn(methods, key)、hasOwn(props, key)、!isReserved(key) 方法对该数据是否占用保留字、是否与 props 和 methods 中的属性重名进行判断,然后调用 proxy 方法将其代理到 Vue 实例上。此处需要注意的是:如果 data 中的属性与 props、methods 中的属性重名,那么在 Vue 实例上调用这个属性时的优先级顺序是 props > methods > data。
最后对每一个 data 中的属性调用 observe 方法,该方法的功能是赋予 data 中的属性可被监测的特性。这个方法和其中使用到的 Observe 类是在src/core/observer/index.js文件中,observe 方法主要是调用Observer类构造方法,将每一个 data中的 value 变成可观察、响应式的:
1 | constructor (value: any) { |
可以看到该构造函数有以下几个目的:
- 针对当前的数据对象新建一个订阅器;
- 为每个数据的value都添加一个__ob__属性,该属性不可枚举并指向自身;
- 针对数组类型的数据进行单独处理(包括赋予其数组的属性和方法,以及 observeArray 进行的数组类型数据的响应式);
- this.walk(value),遍历对象的 key 调用 defineReactive 方法;
defineReactive是真正为数据添加 get 和 set 属性方法的方法,它将 data 中的数据定义一个响应式对象,并给该对象设置 get 和 set 属性方法,其中 get 方法是对依赖进行收集, set 方法是当数据改变时通知 Watcher 派发更新。
依赖收集
依赖收集的原理是:当视图被渲染时,会触发渲染中所使用到的数据的 get 属性方法,通过 get 方法进行依赖收集。
其真实的逻辑我们举例来说明:当前正在渲染组件 ComponentA,会将当前全局唯一的监听器置为这个 Watcher,这个组件中使用到了数据 data () { return { a: b + 1} },此时触发b的 getter 会将当前的 watcher 添加到b的订阅者列表 subs 中。也就是说如果 ComponentA 依赖 b,则将该组件的渲染 Watcher 作为订阅者加入b的订阅者列表中。

我们先看下 get 属性方法:
1 | get: function reactiveGetter () { |
Dep - 订阅器
数据对象中的 get 方法主要使用 depend 方法进行依赖收集,该方法是 Dep 类中的属性方法,继续来看 Dep 类是怎样实现的:
1 | export default class Dep { |
Dep 类中有三个属性:target、id 和 subs,分别表示当前全局唯一的静态数据依赖的监听器 Watcher、该属性的 id 以及订阅这个属性数据的订阅者列表[a*]。另外还提供了将订阅者添加到订阅者列表的 addSub方法、从订阅者列表删除订阅者的 removeSub 方法。
Dep.target 是当前全局唯一的订阅者,这是因为同一时间只允许一个订阅者被处理。target 的意思就是指当前正在处理的目标订阅者,而这个订阅者是有一个订阅者栈 targetStack,当某一个组件执行到某个生命周期的 hook 时(例如 mountComponent),会将当前目标订阅者 target 置为这个 watcher,并将其压入 targetStack 栈顶。
接下来是添加当前数据依赖的 depend方法,Dep.target 对象是一个 Watcher 类的实例,调用 Watcher 类的 addDep 方法,我们看下 addDep 方法将当前的依赖 Dep 实例放在了哪里:
1 | addDep (dep: Dep) { |
我们稍后会对 Watcher 类进行分析,目前先来简单看下 addDep 这个属性方法做了什么。可以看到入参是一个 Dep 类实例,这个实例实际上是当前全局唯一的订阅者,newDepIds 是当前数据依赖 dep 的 id 列表,newDeps 是当前数据依赖 dep 列表,depsId 则是上一个 tick 的数据依赖的 id 列表。这个方法主要的逻辑就是调用当前数据依赖 dep 的类方法 addSub,而这个方法在上面Dep 类方法中可以看到,就是将当前全局唯一的 watcher 实例放入这个数据依赖的订阅者列表中。
target Watcher的产生
我们以生命周期中的 mountComponent 这一个阶段为例来分析上面提到的Dep.target 这个全局唯一的当前订阅者 Watcher 是怎样产生的。
1 | export function mountComponent ( |
通过mountComponent的代码可以看到在触发了beforeMount生命周期钩子后,紧接着创建了一个用于渲染Watcher实例。我们通过Watcher类的构造函数来看创建Watcher实例时做了哪些工作:
1 | constructor ( |
通过 Watcher 类的构造函数传参可以看到 mountComponent 生命周期中创建的 Watcher 是一个渲染 Watcher,将当前的 getter 设置为了 updateComponent 方法(也就是重新渲染视图的方法),最后调用了 get 属性方法,我们接下来看 get 方法做了什么:
1 | get () { |
get 方法首先调用了在 Dep类文件中定义的全局方法 pushTarget,将 Dep.target 置为当前正在执行的渲染 Watcher,并将这个 watcher 压到了 targetStack。接下来调用 wathcer 的 getter 方法:由于此时的 getter 就是 updateComponent 方法,执行 updateComponent 方法,也就是 vm._update(vm._render(), hydrating) 进行渲染,渲染过程中必然会 touch 到 data 中相关依赖的属性,此时就会触发各个属性中的 get 方法(也就是将当前的渲染 Watcher 添加到所有渲染中使用到的数据的依赖列表 subs 中)。
渲染完之后会将当前的渲染 Watcher 从 targetStack 推出,进行下一个 watcher 的任务。最后会进行依赖的清空,每次数据的变化都会引起视图重新渲染,每一次渲染又会进行依赖收集,又会触发 data 中每个属性的 get 方法,这时会有一种情况发生:<div v-if="someBool">{{ a }}</div><div v-else>{{ b }}</div> 第一次当 someBool 为真时,进行渲染,当我们把 someBool 的值 update 为 false 时,这时候属性 a 不会被使用,所以如果不进行依赖清空,会导致不必要的依赖通知。依赖清空主要是对 newDeps 进行更新,通过对比本次收集的依赖和之前的依赖进行对比,把不需要的依赖清除。
派发更新
当我们修改一个存在 data 属性上的值时,会触发数据中的 set 属性方法,首先会判断并修改该属性数据的值,并触发自身的 dep.notify 方法开始更新派发:
1 | set: function reactiveSetter (newVal) { |
接下来让我们进入dep.notify这个方法:
1 | notify () { |
notify 这个方法是每一个数据在响应式化之后自己内部闭包的 dep 实例的方法,还记得之前讲过的 subs 保存着所有订阅该数据的订阅者,所以在 notify 方法中首先对 subs 这个订阅者序列按照其 id 进行了排序。接下来就是调用每一个订阅者 watcher 实例的 update 方法进行更新的派发。update 方法是在 Watcher 类文件中,使用了队列的方式来管理订阅者的更新派发,其中主要调用了 queueWatcher 这个方法来实现该逻辑:
1 | export function queueWatcher (watcher: Watcher) { |
进入该方法后,首先使用一个名为 has 的 Map 来保证每一个 watcher 仅推入队列一次[a*]。flushing 这个标识符表示目前这个队列是否正在进行更新派发,如果是,那么将这个 id 对应的订阅者进行替换,如果已经路过这个 id,那么就立刻将这个 id 对应的 watcher 放在下一个排入队列[b*]。接下来根据waiting 这个标识符来表示当前是否正在对这个队列进行更新派发[c*],如果没有的话,就可以调用 nextTick(flushSchedulerQueue) 方法进行真正的更新派发了。
1 | function flushSchedulerQueue () { |
这个方法首先对队列中的 watcher 按照其 id 进行了排序,排序的主要目的有三:
- 组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。
- 用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。
- 如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。
排序结束之后,便对这个队列进行遍历,并执行watcher.run()方法去实现数据更新的通知,run方法的逻辑是:- 新的值与老的值不同时会触发通知;
- 但是当值是对象或者deep为true时无论如何都会进行通知
所以 watcher 有两个功能,一个是将属性的值进行更新,另一个就是可以执行 watch 中的回调函数 handler(newVal, oldVal),这也是为何我们可以在 watcher 中拿到新旧两个值的原因。
至此,数据的更新派发也通知到了各个组件,便又可以进行视图的更新渲染,收集依赖,派发更新……