Vue2源码阅读-响应性系统
开始
在继续阅读源码之前,可以先阅读 Vue2 官网的 深入响应式原理 一节,了解一下Vue2响应性的一些基本内容和规则。
后面,我们透过源码,可以更好的理解这些官方文档所说的内容。
什么是响应性
Vue 其中的特点,就是它的数据是响应性的,所谓响应性,说简单点,就是修改数据后,UI可以自动刷新。
例如执行下面的代码,Vue会自动触发UI的更新。
1 | user.id = 1 |
要想实现响应性,首先这个对象一定不是普通的对象。否则,它的变化是监测不到的。要想实现响应式,首先就需要想一个办法,能够人工控制这些上面这条语句的执行。这样就可以监听变化了。解决方法有两种:
- 通过
Proxy
。这是ES6新增的一个特性,但由于偏底层,很多API在IE11等古董浏览器下无法模拟出来。但这种方式定义的响应式数据是十分完美的。后面在研究 Vue3 原理的时候我们会仔细谈到。 - 通过
getter
和setter
。这是Vue2采用的办法。这种方式能够兼容古董浏览器,但是想要实现真正的响应式仍有些局限性,尤其是在数组和对象的监听上。
目标
在这一部分,我们会重点探讨 Vue
有关的几个响应式选项:
- data
- computed
- watch
还有几个实例方法:
vm.$set
vm.$delete
vm.$watch
通过源码,理解下面图中的响应式机制:
从 data 属性开始
上面三个属性中,最简单的莫过于 data
属性了。所以我们从 data
属性开始。
寻找 data 属性的初始化位置
上一节,我们找到了 Vue 源码的入口,就是/src/core/instance/index.js ,我们先打开这个文件,然后删掉一些提示性的代码:
1 | import { initMixin } from './init' |
可以发现,Vue实例的众多功能大多是采用混入的方式来完成的。
Vue的构造函数也非常简单,就是调用了一个实例的 _init 方法。不过我们目前没有找到这个方法,但是下面有个 initMixin
,这似乎会和这个方法有关。
接下来打开/src/core/instance/init.js 这个文件,结果一打开就发现了 initMixin
这个函数,而且里面定义了 _init
方法。
这个方法的函数体也是非常的长,不过我们现在只需要关注 data
这个属性。可是这里面找不到 data
有关的内容,不过有一个类似的词语 state
,我们就先顺着 initState
函数往下找吧。
接下来打开/src/core/instance/state.js 这个文件,找到 initState
这个函数:
1 | export function initState (vm: Component) { |
可以发现,这个函数初始化了几个重要的属性,如 data
、props
、methods
、watch
。当然,我们先看 data
属性。
initData
initData
这个函数似乎看起来挺长,不过大多数仍然是一些检测性的代码,我们简化一下:
1 | function initData (vm: Component) { |
其中,下面几行代码应该很容易看懂:
1 | let data = vm.$options.data |
很明显,就是判断一下 data
属性是不是函数,如果是函数就执行函数调用,否则直接引用源数据。
proxy 函数
接下来, proxy
看起来很吸引人。我们来康康它的源码:
1 | export function proxy (target: Object, sourceKey: string, key: string) { |
结合这个函数的调用,就很容易理解了,这句话 proxy(vm, '_data', key)
做了一件事:就是把 vm._data[key]
代理到 vm[key]
上。这样,通过 vm.xxxx
就可以访问 vm._data.xxxx
了。
我们可以通过一个简单又具体的例子来理解这一点:
点击展开案例
可以看到,我们通过 app.message
就可以代理访问 app._data.message
了。
Observer
初入 Observer
proxy
函数体里面还有一个函数调用,也非常吸引人,就是 observe(data, true)
。
接下来打开/src/core/observer/index.js 这个文件,找到 observe
这个函数:
这个函数仍然有一堆检测性的代码,我们简化一下:
1 | export function observe (value: any, asRootData: ?boolean): Observer | void { |
可以看到,简化后基本上就是 Observer
包装了一下。然后做了一个对于非对象的检测。
接下来我们看 Observer
的代码:
1 |
|
可以看到,这里将 value
分为数组和对象两部分,对于对象,则直接使用 defineReactive
函数来定义响应式属性,对于数组,则做了一些特殊的处理。
Observer与对象
我们先研究value
是对象的情况,以最开始的函数调用 observe(data,true)
为例。
对于对象,它的初始化很简单,就是调用了 walk
方法。
在 walk
方法,也只是对对象的每个属性调用 defineReactive
函数。
至于 defineReactive
做了什么事情这个问题,我们暂且就认为把对象的普通属性转换成getter/setter
吧。这个函数的具体细节,我们会在后面的 变化侦测 小节中提到。
但是我们可以稍微看一部分(省掉了Dep
和检测相关的代码):
1 | export function defineReactive ( |
可以发现,如果没有访问器,则 getter
返回了 val
。而在初始化的过程中,let childOb = !shallow && observe(val)
这一行则又尝试创建一个 Observer
对象。(如果遇到非对象则直接返回)
这意味着,对于对象的响应性转换是递归的,即使是嵌套对象,也可以完整的转换成响应性对象。
Observer与数组
对于数组,可能就显得比较复杂了。所以在看源码之前,我们得回忆一下Vue中数组的响应式检测的一些要点:
Vue 将被侦听的数组的变更方法进行了包裹,所以它们也将会触发视图更新。这些被包裹过的方法包括:
push、pop、shift、unshift、splice、sort、reverseVue 不能检测以下数组的变动:
- 当你利用索引直接设置一个数组项时,例如:
vm.items[indexOfItem] = newValue
- 当你修改数组的长度时,例如:
vm.items.length = newLength
- 当你利用索引直接设置一个数组项时,例如:
对于第一个要点,我们就要想一个办法把原生的方法替换掉。最简单的方法莫过于直接把这几个方法添加到数组的实例方法中,从而替换数组的原生方法,然后要将这些新添加的属性的 enumerable
修饰符设置为 false
。
还有一个办法,就是修改数组实例的原型,这样也避免了在数组中逐一定义实例方法,减少了循环的次数。在ES6中就有 Object.setPrototypeOf
这个函数来完成这一操作。可惜的是,当时ES6的支持度并不理想,只能使用 __proto__
来替代。不过并不是所有浏览器都有 __proto__
这个函数,所以考虑到兼容性就有了下面的代码:
1 | if (hasProto) { |
我们重点研究修改原型这一方案。打开文件/src/core/observer/array.js 。
要修改原型,首先要继承原型:
1 | const arrayProto = Array.prototype |
然后针对所有要覆盖的方法做了一个统一处理。但具体细节我们会在后面提到。
在方法覆盖完毕后,执行了对数组的侦测:
1 | this.observeArray(value) |
我们来仔细看一下 observeArray
函数:
1 |
|
这和 walk
函数有很大的区别。walk
函数把对象的每一个属性转换成 getter
和 setter
,但是 observeArray
并没有这么做,而只是把数组的每个值进行监听。数组的每一个索引并没有被设置成 getter
和 setter
。
为什么数组和对象在响应式转换上会有这样的差异呢?数组和对象的差异是,数组额外提供了很多增删数据的方法且经常被使用,这很容易导致数组位置的大规模变化。如果给每个下标添加 getter
和 setter
,那么一旦执行一些大规模增删数据的操作,那么就得不断地去调用 setter
,这在性能上会有所损耗,且收效很小。
所以Vue选择修改这些操作数组的方法,而不是直接在数组元素上增加 getter
和 setter
。
这样,就很容易理解下面的两句话:
- 当你利用索引直接设置一个数组项时,例如:
vm.items[indexOfItem] = newValue
,因为数组下标不具有响应性 - 当你修改数组的长度时,例如:
vm.items.length = newLength
,同样,这也不具有响应性
关于这一方面的具体理解,可以参考这篇 Issue:
小结:Observer 做了什么
通过上面的一些讨论,可以发现, Observer
的主要目的是将原始对象转换成响应式的 getter
和 setter
,对于不同类别的数据,他们的操作也不相同:
- 对于对象,则会将所有的属性转换成
getter/setter
- 对于数组,则对所有数组值调用
observe
函数。如果数组值是原始值,则没有做任何操作;如果是对象,则将其转换成getter/setter
。
graph LR data[data选项]-->Observer(observe)-->getset[响应式访问器]
依赖收集与变化侦测
单纯为数据定义一个getter
和setter
其实并不能做什么事情。重要的是,当数据发生改变时,是如何通知Vue来刷新UI和更新计算属性中的数据的。不过,目前我们暂时不研究UI的变化,所以在这一小节我们以计算属性为起点来研究这一块。
计算属性的初始化
打开文件/src/core/instance/state.js 。
我们先打开 initState
函数,可以看到初始化计算属性的代码:
1 | if (opts.computed) initComputed(vm, opts.computed) |
接下来找到 initComputed
函数,还是一样,先删掉一些检测性的代码:
1 | function initComputed (vm: Component, computed: Object) { |
可以发现,这个函数主要做了两个工作:
- 给每个
key
定义一个Watcher
。至于Watcher
是干什么的,暂且先不讨论。 - 每个
key
执行defineComputed
函数。
接下来找到 defineComputed
函数,还是一样,我们默认不开启SSR,只考虑传入函数的情况,我们把代码简化一下:
1 | export function defineComputed ( |
最重要的,仍然是它的 getter
,所以我们接着找到 createComputedGetter
这个函数:
1 | function createComputedGetter (key) { |
根据函数调用的逻辑,这里的 this
应该是 vm
。
这里的 watch
就是 initComputed
函数里创建的 watcher
对象,而且每个计算属性都有一个属于自己的 Watcher
对象。
所以我们需要关注几个东西:
Watcher
、watcher.dirty
、watcher.evaluate
和watcher.depend
Dep.target
再探 defineReactive
我们翻到源码的 src/core/observer/index.js
,找到 defineReactive
这个函数(约第135行):
1 | import Dep from './dep' |
可能大家并不知道这个函数是干嘛的,我们先来看注释,可以大概看出这是用来在对象上定义一个响应式的数据的。
第一行代码定义了一个 dep
实例,这又是之前的 Dep
对象
中间一堆代码看上去是做一些判断,先跳过。
接下来是 Object.defineProperty
,熟悉响应式原理的朋友应该清楚这是Vue2的响应式原理的核心。我们就从这里开始看吧。
1 | Object.defineProperty(obj, key, { |
getter 那里可能看起来简单一些,大概是先调用了 getter
,然后调用了 dep.depend
。childOb
可能看起来是一个监听子对象的,暂时也忽略吧。
setter
这里又出现了一个 dep.notify
,看来 dep
对象很重要啊。
初探 Watcher
在 createComputedGetter
这个函数里面我们看到里面一个函数返回的是 watcher.value
,然后找到当前目录下的 watcher.js
,发现它的构造函数里面有一句:
1 | this.value = this.lazy |
这说明,watcher.value
这个值大概率是 get
函数的返回值。
但具体是怎么调用和刷新的,咱们先不深究。我们看到 get
函数:
1 | get () { |
我们还是把几行重要的代码留着:
1 | get () { |
可以看到它和 dep
这个库有着很强的交互。
深究 Dep
我们来找到当前目录下的 dep.js
:
1 | import type Watcher from './watcher' |
这段代码看起来很简洁,但却是这套机制的核心。
首先看 pushTarget
和 popTarget
。很明显,这使用了一个栈,表示把当前的 watcher
推入和弹出 Dep.target
。
再来看上面的get
的代码,我们不妨可以看出其整体流程了:
graph LR PUSH[将当前 watcher 入栈]-->CALL[调用 getter]-->POP[将当前 watcher 出栈]
这也可以看出,Dep.target
表示了当前将要执行的 getter
所在的 watcher
对象。对于计算属性来说,就是在执行计算属性定义的那个函数。在执行前入栈,执行后出栈。
接下来我们回顾 defineReactive
函数,它先创建了一个 Dep
(每个 data 都有一个唯一的 Dep
对象),然后在 getter
里面调用 dep.depend
函数;在 setter
里面则调用 dep.notify
。
先看 depend
函数,就是在目标 watcher 上注册自己。再来看watcher
中的 addDep
这个函数:
1 | addDep (dep: Dep) { |
我们大概知道这个函数主要是在Dep
和Watcher
双方各保留一份对方的引用。
这意味着,在计算属性函数调用的过程中,被访问到的所有 data
都会在内部的 getter
执行 depend
函数,使得计算属性所在的 watcher
知道它依赖了哪些数据属性。
我们再看 notify
代码:
1 | notify () { |
这很明朗了,就是通知 watcher
去更新数据。而且 notify
是通过数据的 set
调用的,这意味着当 data
中的数据修改时,就会触发与之相关联的计算属性进行更新。
计算属性的缓存
在 defineComputed
函数中我们可以看到这一行:
1 | watchers[key] = new Watcher( |
我们找到 computedWatcherOptions
,发现:
1 | const computedWatcherOptions = { lazy: true } |
然后我们来看 watcher
的 update
函数:
1 | update () { |
由于定义 computed
时,lazy
是 true
,所以,它只做了一件事情:把 this.dirty
设置为 true
我们再来看 createComputedGetter
这个函数的这段代码:
1 | if (watcher) { |
结果发现,如果 watcher.dirty
是 true
,则执行 evaluate
函数,我们来看一下:
1 | evaluate () { |
这个函数就是重新执行 get
函数,然后把 dirty
设置为 false
。
这里的逻辑就清晰了。如果 watcher.dirty
是 true
,那么重新执行 getter
,生成新的值;否则,使用原来的值。
小结
计算属性 getter 调用过程
- 把计算属性对应的
watcher
加入依赖目标栈中,同时设置Dep.target
- 执行计算属性的
getter
- 执行过程中,任何被访问到的
data
,其对应的dep
和 计算属性watcher
都会形成响应的依赖
这整个过程称为 依赖收集。而 Dep.target
表示了被访问的 data
的访问源。
被依赖数据的更新
这个就很简单了,它会通过自己的 dep
对象通知所有的 watcher
进行更新。但只有下次访问这个计算属性的时候,才会执行计算属性里面定义的函数。
这个过程称为 依赖更新。
Dep是什么
Dep 是一个对依赖相关操作的封装。对于每一个响应式的数据(详见defineReactive
),都有一个配套的 Dep
对象。
Dep.target
是一个标记,表示当前正在执行 get
操作(获取或刷新数据)的 Watcher
。
有时候调用比较复杂(例如一个计算属性访问了令一个计算属性),这时候就需要使用栈来保存最近在执行 get
操作的一个 Watcher
。
当 Dep.target
被设置以后,表示正在执行一个 get
操作。如果这时候有响应式数据被访问了,则说明这个 watcher
里面定义的 getter
需要这个响应式数据,那么就会把自身添加到正在执行 watcher
的 deps
(通过 depend
操作,并不是立即加入,在get
操作完成后会进行清理),同时也把正在执行的 watcher
添加到自身的 subs
属性。
当响应式数据发生变化时,则通知其Dep
对象(notify
方法),然后 Dep
对象则通知所有依赖它的Watcher
,表示数据变了,需要更新。
Vue则通过这样一个巧妙的机制,实现了依赖收集和更新。
不过,由于采用变量保存当前正在执行的函数,如果这个函数含有异步代码,那么这些异步代码可能会在函数调用结束之后调用,这样,异步代码中的引用的响应式数据就不会被收集了。
数组和对象的变化侦测
上一节,我们仅仅讨论了当每个具有响应性的对象是原始值的情况,现在,我们考虑一下当这个具有响应性的对象是数组或对象的情况。
defineReactive与对象
刚刚在讨论 defineReactive
的时候,我们忽略了 childOb
这个判断。也就是说,前面我们讨论的都是当 data
是一个原始值的时候的情况。
现在我们要考虑数据是数组或对象时的情况了。
我们先用一个简单的例子来理解一下对象的变化检测:
1 | new Vue({ |
那什么时候需要刷新 fullName
呢?一共有三种情况:
user.firstName
发生了变化user.lastName
发生了变化user
发生了变化
这也意味着需要收集上面三个响应式数据对应的依赖。
其中,第三种情况可能不太容易想到。一旦user
发生了变化,例如赋了一个新的值:
1 | this.user = { |
但这毕竟不是对user.firstName
和 user.lastName
单独赋值,而是整体替换。这意味着,如果只收集 user.firstName
和 user.lastName
的依赖,整体替换不会触发更新。
从中我们得到一个结论:对于响应式数据是对象的情况,在收集依赖时,不仅要收集所访问属性的依赖,也要收集对象本身的依赖。
好在,实现这个结论是非常容易的,因为要想访问 user.firstName
,就必须先访问 user
。这样就可以在这个过程中收集这方面依赖。
不过这又出现了一个问题,就是在上面的计算属性中,user
实际上被访问了两次,这意味着同一个 Dep
可能会被添加两次。这是没有必要的,因为 dep
仅仅需要表示是否有依赖关系,但是依赖了几次是不需要管的。所以,尽管访问了两次,但只需要添加一次依赖。
所以,在
/src/core/observer/watcher.js 中的 get
方法中,依赖收集完毕后,会执行 cleanupDeps()
进行清理,把重复的依赖清理掉。
现在我们回到/src/core/observer/index.js 的 defineReactive
函数,看一下 childOb
相关的代码:
1 | // 定义处 |
对于 setter
,如果整体被赋了新的值,那么这个新的值会自动被注册成响应式的。
不过,childOb.dep.depend()
这句可能并不好懂,但对于我们目前的认知,Dep
对象是需要配套使用 depend
和 notify
的。对于对象这种情况,却并没有看到 childOb.dep.notify
这句话。
我们暂且就把它当成为了健全性做的一些预留操作吧。
defineReactive与数组
讨论完对象,我们接着讨论数组。
和对象一样,我们先来看一个例子,来理解一下相应的过程。
假设现在需要通过数组来动态增加或删除一个TODO-LIST,为了保证代码的简洁,我们允许数组元素是字符串或者是对象:
1 | new Vue({ |
在 Observer
这一节提到,对于数组,它的所有下标没有被定义成 getter
和 setter
,而仅仅是对所有值做了一个 observe
操作。
这意味着,要想收集数组的依赖,就只能通过数组本身的 __ob__.dep
属性,而无法通过数组元素的访问。这也是 childOb.dep.depend()
对于数组的含义。
接下来,我们来看/src/core/observer/index.js 的 dependArray(value)
函数:
1 | function dependArray (value: Array<any>) { |
这个函数也是十分简单,对于每个元素的操作和下面的代码几乎是一样的:
1 | if (childOb) { |
接下来我们来研究一下被修改的几个数组方法:/src/core/observer/array.js
1 | const methodsToPatch = [ |
我们发现,在调用的末尾处,调用了数组所在 observer.dep
的 notify
函数。这个就和之前在 defineReactive
中,为对象的 childOb
调用的 depend
函数相对应。
除此之外,如果数组新增了元素,那么这个函数会收集新增的元素,因为这些元素可能没有被转换成响应性。而删除的元素则无需处理。
vm.$set
当通过常规方法无法使数组或对象的更新被检测到,那么就只能通过 vm.$set
或者 vm.$delete
来进行操作了。
首先,我们还是得找到 vm.$set
的定义,通过全局搜索$set
,我们找到了vm.$set
的定义位置在/src/core/instance/state.js
然后找到了 set
函数的位置在/src/core/observer/index.js 。不过还是先删掉一些检测性的代码:
1 | export function set (target: Array<any> | Object, key: any, val: any): any { |
先看对于数组的操作:
1 | if (Array.isArray(target) && isValidArrayIndex(key)) { |
这段代码其实比较好理解,因为它本质还是调用了数组的splice
方法,先删除一个元素,再插入一个元素。
接下来,则是对已有属性的操作:
1 | if (key in target && !(key in Object.prototype)) { |
因为这种情况下的操作本身就具有响应性,所以直接赋值就行了。
最后来看新增属性的操作:
1 | const ob = target.__ob__ |
如果没有找到 __ob__
属性,那么这就是一个普通对象,而不是具有一个响应性的对象,那么直接赋值就行。
如果找到了 __ob__
,那么就是在响应性对象上新增一个属性,则调用 defineReactive
函数来将属性转换成具有响应性的对象。而且这里使用了 ob.dep.notify
来触发更新,和之前 defineReactive
的 childOb.depend
相对应。
可见,之前在 defineReactive
里面,除了把自身加入依赖中,还把childOb.dep
加入依赖,主要是两个目的:
- 方便数组被覆盖的一些操作的更新触发
- 方便通过
vm.$set
新增对象属性时的更新触发
vm.$delete
寻找 vm.$delete
的方法不再赘述。它的源码在/src/core/observer/index.js 。
对于 vm.$delete
操作,我们也简化一下源代码:
1 | export function del (target: Array<any> | Object, key: any) { |
对于数组的处理,它和 vm.$set
方法一样,是通过被覆盖的数组方法来实现的:
1 | if (Array.isArray(target) && isValidArrayIndex(key)) { |
接下来是一个判断 hasOwn(target, key)
。这用来判断对象上的某个属性是来自自身还是来自它的原型。对于来自原型的属性,delete
操作不会作任何事。
1 | if (!hasOwn(target, key)) { |
这里的判断是有必要的,因为要避免不必要的更新被触发。
然后判断是否有 __ob__
属性。对于不具有响应性的对象,没有必要触发更新。
1 | if (!ob) { |
最后则是通过__ob__
的 dep
来触发更新。
Watcher
这一节,我们将深入探讨 Watcher,以及它和 computed
、vm.$watch
以及 watch
选项的一些关系。
Watcher源码:/src/core/observer/watcher.js
介绍
根据之前的一些用法,我们可以看出,Watcher
本质就是一个侦听器对象。
- 监听的内容:它支持函数或者表达式,最终会转换成
getter
。 - 通过
get
函数可以加载或刷新它的值,同时收集依赖。 - 通过
dep.notify
调用watcher
的update
函数,通知watcher
刷新数据并执行回调。 - 通过
value
属性可以拿到缓存的值。
watch 选项与 Watcher
还是一样,我们先打开/src/core/instance/state.js 中的 initState
函数,可以发现初始化 watch
选项的代码:
1 | initWatch(vm, opts.watch) |
接下来找到 initWatch
的代码:
1 | function initWatch (vm: Component, watch: Object) { |
这里为了简便,我们假设 watch[key]
不是数组。
接下来我们找到 createWatcher(vm, key, handler)
这个函数:
1 | function createWatcher ( |
可以发现,watch
属性仅仅只是对 vm.$watch
的一个封装而已。
vm.$watch
和 Watcher
先来回顾一下 vm.$watch
的用法:
vm.$watch( expOrFn, callback, [options] )
参数:
- {string | Function} expOrFn
- {Function | Object} callback
- {Object} [options]
- {boolean} deep
- {boolean} immediate
返回值:{Function} unwatch
用法:
观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。
案例:
1 | // 带监听选项 |
我们先打开/src/core/instance/state.js 中的 stateMixin
函数,可以发现初始化 watch
操作的代码:
1 | Vue.prototype.$watch = function ( |
首先判断回调是否是纯对象(调用toString
函数结果是 [object Object]
):
1 | if (isPlainObject(cb)) { |
如果是纯对象,则跑回去调用 createWatcher
函数,但之前也看到了,createWatcher
函数只是针对参数做一些适配性的处理,然后又调用 vm.$watch
。
可以看到,接下来它创建了一个 Watcher
对象:
1 | new Watcher(vm, expOrFn, cb, options) |
接下来,如果设置了 immediate
选项,则立即调用一次回调函数:
1 | if (options.immediate) { |
至于 pushTarget
和 popTarget
的相关解释,可以参考 Issue#11942 。
接下来返回了一个停止监听的函数:
1 | return function unwatchFn () { |
这个函数仅仅是简单的调用了一个watcher
的teardown
函数。这个函数的细节我们会在后面提到。
lazy 模式与计算属性
lazy
模式是专门针对计算属性的,默认情况下,所有计算属性的 Watcher
全部开启了 lazy
模式。
watcher
内部有一个标记变量 lazy
,表示当前是否开启lazy
模式;同时有一个 dirty
属性,表示当前数据是否过期。
开启 lazy
模式后,value
属性默认为undefined
:
1 | // constructor |
如果需要刷新值,则需要运行evaluate ()
方法:
1 | // evaluate |
但是 evaluate
方法不会检测 dirty
,所以一旦执行就会刷新数据,因此在执行 evaluate
之前,需要判断 dirty
属性:
1 | if (watcher.dirty) { |
当 update
函数被调用时,数据不会被立即刷新,而是在下次访问evaluate
方法的时候刷新:
1 | // update 函数 |
不过,对于 lazy
模式的watcher
,回调函数似乎并没有什么卵用,也不会执行。
深度监听
watcher
使用一个 deep
属性来确定当前是否是深度监听:
1 | // get() |
如果开启了深度监听,则执行了 traverse
函数/src/core/observer/traverse.js :
1 | const seenObjects = new Set() |
这段代码看起来很复杂,实际上核心就是访问一遍整个对象的所有属性 val[i]
或者 val[keys[i]]
。在访问属性的过程中,会将属性对应的依赖收集到这个 watcher
的依赖列表里。
至于 seenObjects
只是临时用来保存已经访问的 dep
的 id
,并没有其他的作用。
取消监听
取消监听实现起来其实比较简单。只需要将它依赖的所有 Dep
解除即可。
首先,watcher
使用了 active
属性来保存当前的 watcher
是否可用:
1 | // constructor |
接下来当 teardown
方法被执行时,则一个个将依赖移除,然后将标志变量this.active
设置为 false
。
1 | teardown () { |
更新调度
对于非 lazy
模式的 watcher
,在 update
函数执行后,会判断它的更新方式是否是同步(默认情况下this.sync = false
):
1 | if (this.sync) { |
可以发现,如果是同步更新,则直接调用 this.run
触发更新的处理,如果是异步,则将其加入队列中。异步调度的具体内容将在后面的小节中提到。
接下来我们可以康康 run
函数做了什么:
1 | run () { |
首先获取了新的值:
1 | const value = this.get() |
接下来有一个判断值得注意:
1 | value !== this.value || |
很明显,这个判断是用来确定是否更新值的。但这个判断不太容易懂,我们来分析一下:
对于原始值来说,如果值没有发生变化,就不需要执行后面的更新操作。
对于对象,即使值没有变,如果某个属性变了,那么仍然属于发生了变更。
只有深度监听的情况才可以监听对象属性的变化。
但也有可能出现下面这种先取值,修改数据后重新赋值的情况:1
2
3let obj = vm.a
obj.b = 2333
vm.a = obj对于深度监听的对象,应该执行更新操作。这应该是出于完备性的一个考虑。
最后则是调用了回调函数:
1 | this.cb.call(this.vm, value, oldValue) |
异步更新队列
在 Vue官网的响应式原理中,提到了 异步更新队列 的有关知识,在这里,我们会对Vue的异步更新队列作进一步的探讨。
这一块的内容需要你提前掌握 javascript
事件循环的有关知识,如果没有掌握,请先去了解这些知识。
nextTick
介绍
对于大多数人来说,这个函数是非常实用的,它可以让你在DOM更新完成后做一些事情。
一个典型的例子,便是刷新路由视图:
1 | <router-view v-if="showView"></router-view> |
1 | export default { |
这样,当执行 this.showView = false
时,路由视图就会被销毁,因为在DOM更新结束前, this.showView = true
还没有被执行。
当路由视图被销毁以后,this.showView = true
便会被执行,这时候会再次加载DOM,从而实现了router-view
组件的刷新。
但如果只是这么写:
1 | this.showView = false |
就不会起到效果,因为DOM的更新是异步集中更新,多个更新最终会被合成为一个。上面的代码也相当于:
1 | this.showView = true |
再例如,如果需要对正在更新的DOM进行一些DOM操作,则最好通过 vm.$nextTick
,因为DOM更新触发之前,你拿到的是更新前的DOM数据。
关于虚拟DOM及其更新的一些解读,请看后面的文章。
nextTick
的源码在 /src/core/util/next-tick.js ,整段代码只有100行左右:
1 | /* @flow */ |
清空队列
我们先从 flushCallbacks
入手:
1 | function flushCallbacks () { |
这个函数的执行逻辑非常简单,就是把数组中所有的回调函数执行一遍,然后清空回调数组。
nextTick 的实现
在了解它的实现之前,可以先看看它的用法。nextTick文档
这里对它的用法作一个简单的描述:
- 如果传入了回调函数,则会执行回调
- 如果没有传入回调,则会返回一个 Promise,待DOM刷新完毕后,执行
.then
回调
理解了它的用法,再去看它的源码便很简单了。
1 | export function nextTick (cb?: Function, ctx?: Object) { |
定时函数
nextTick
函数的源码中,有相当大一部分代码是进行 timerFunc
的设置。而且在不同的版本中,timerFunc
的设置方法也一直在变化。
第一版中 timerFunc 的执行顺序为 Promise
, MutationObserver
, setTimeout
。[1]
在2.5.0这一版中,timerFunc
的顺序被改为 setImmediate
, MessageChannel
, setTimeout
。在这一版,所有的微任务都被取消了,因为微任务的优先级太高了,导致了像 #6566 这样的问题。[1:1]
但是,仅使用宏任务又出现了像 #6813 这样微妙的重绘问题。
所以,在2.6+版本,又改回了微任务,并采用了一些特殊手段来解决 #6566 这样的问题。
目前这个版本的顺序是:Promise
, MutationObserver
,setImmediate
,setTimeout
Watcher 更新调度
queueWatcher
在 更新调度 这一小节里提到,如果更新是异步的,那么便会执行下面的函数:/src/core/observer/scheduler.js
1 | export function queueWatcher (watcher: Watcher) { |
这里有几个要点值得注意一下:
- 每个 watcher 只会被放入一次,这意味着当某个
watcher
收到多个改变时最终只会被执行一次。 - 刷新 watcher 时,watcher 会按照次序放入队列,否则直接加到末尾。
- 刷新后的第一次调用会开启刷新等待。
flushSchedulerQueue
1 | function flushSchedulerQueue () { |
这里有一个值得注意的地方:执行函数之前,需要对 watcher 进行排序。
从注释上看,有三点原因:
组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。[2]
用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。[2:1]
如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。[2:2]
原理图
- 标题: Vue2源码阅读-响应性系统
- 作者: ObjectKaz
- 创建于: 2021-09-26 15:08:49
- 更新于: 2022-03-15 13:53:31
- 链接: https://www.objectkaz.cn/731b8d08837c.html
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。