源码阅读 - 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 中拿到新旧两个值的原因。
至此,数据的更新派发也通知到了各个组件,便又可以进行视图的更新渲染,收集依赖,派发更新……