代理
下面这段代码是vue使用的典型方式:
1 | <div id="app-5"> |
1 | var app5 = new Vue({ |
我们可以看到当我们给this.message
赋值时,视图会自动更新。这就是vue数据响应系统做的事情,当然vue做的事情远比这个复杂地多,但这是核心理念。原理也很简单,vue帮我们代理了数据的赋值操作,在数据赋值时进行了DOM的更新,这些对于vue使用者是不可见的,也是无需考虑的。
实现数据代理在js中有两种方式,一个是ES5的getter
和setter
方法;一个是ES6的proxy
api。vue2.5及以下采用的是getter
和setter
方法;即将出来的vue3.0全部改为proxy
方式来实现。
getter和setter
Object提供一个方法definePropery可以让我们给一个对象的属性定义getter
和setter
,从而代理对象属性的取值和赋值操作,用法如下:
1 | var o = {}; |
有了这个特性,我们就可以实现最简单的双向数据绑定。比如vue中的v-model效果。
1 | <input type="text" id="name"></input> |
1 | var data = {}; |
由此我们便实现了最简单的双向数据绑定。当然,vue实现响应式数据的思路和上面不一样,要复杂的多,但是基本理念就是通过劫持数据的赋值和取值操作来完成的。
观察者模式
我们要想实现vue的响应式数据,就要给vue初始化对象的data和props的所有属性设置setter和getter函数。vue源码src/core/instance/state.js
的initData
函数有如下代码,其中observe
都是循环遍历data对象,给每个属性都设置setter和getter。1
2
3// src/core/instance/state.js
// observe data
observe(data, true /* asRootData */)
我们可以知道的是setter函数中的操作肯定是要更新DOM的,那每个数据绑定的DOM不同,绑定的属性也不同,如果像我们上面那样把每个响应式数据和具体DOM的属性绑定起来,就太麻烦和复杂了。Vue采取的策略是虚拟DOM
,每次数据setter操作,都会根据你写的template
或者render
生成虚拟DOM
,然后和之前的虚拟DOM
进行比较,如果有不同,则进行DOM更新,而且只更新有变化的部分;如果相同就不做操作。这种方式可以解决我们的问题,性能也没有问题。在vue实例mount的过程中会执行以下代码:1
2
3updateComponent = () => {
vm._update(vm._render(), hydrating)
}
这句就是用来执行DOM的更新渲染的,我们在响应式数据的setter中应该执行updateComponent
就能达到我们的目的了。看起来很简单是吧,但是现在有两个问题:
- 我们在data和props中声明的属性不一定都绑定到Dom上了,如果是没有绑定到Dom上的数据,在进行setter的时候,也要DOM更新操作,虽然不会引起真正的DOM更新,但也是很浪费性能。
- 我们数据setter可能会不止有Dom更新的任务,比如watch了一个属性,那么这个属性就有Dom更新和watch绑定的回调两个任务。
数据setter绑定不同的任务,在数据改变时,执行所有绑定的任务,这个不就是观察者模式嘛。。。
观察者模式一个典型的例子就是DOM元素的事件绑定
1 | var btnDom = document.getElementById('btn'); |
我们给btnDom绑定click
事件,就相当于在观察
btnDom,当btnDom被点击,就会调用我们绑定的事件。现在我们的响应式数据(data和props)就是我们观察的对象,我们把需要执行的任务放入响应式数据的setter里,在响应式数据被赋值的时候,执行这些任务。观察者模式这个名字是和现实的一个类比,有的有观察者
实例,有的直接注册回调函数
,其实本质是一样的,这些观察者实例
或者回调函数
都在观察的动作中被放入被观察者
的实例中的,在被观察者
发生改变时,执行注册在自己身上的回调函数
或者通知观察者
。下面是一段典型的观察者模式实现:
1 |
|
Subject实例通过addObserver
和removeObserver
来添加和删除观察者,然后在合适的时机通过notify
方法通知所有的观察者。vue也是类似的实现机制,不过Vue的设计比较巧妙,实现形式有所不同。vue有3个类是用来处理响应式数据的观察者模式:Observer
、Watcher
、Dep
。
- Observer, 该类主要作用是用来定义属性的getter和setter方法
- Watcher, 观察者类,且同时用于$watch实例方法和watch指令
- Dep, 观察者容器,每一个响应式数据的属性都拥有一个自己独立的Dep实例,盛放自己的观察者
我们上面写的观察者模式或者是事件绑定,需要我们主动去添加观察者
,那么在响应式数据这个模式当中我们应该在何时去收集属性自己的观察者那?答案是在响应式数据的getter
中收集。因为被观察的数据在求值的时候肯定会触发getter
函数,这是一个很好的时机,而且也能避免没有参与DOM更新的属性被绑定DOM更新的观察者
。所以,vue采用的方式是在getter
中收集观察者
,在setter
中通知观察者
。
Observer
类型中有一个defineReactive
函数,这个函数主要是用来定义属性的getter和setter方法,下面是一个简化版的defineReactive
函数,去掉了一个边界情况的数据,只考虑对象这种响应式数据。
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 /**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
shallow?: boolean
) {
const dep = new Dep()
val = obj[key];
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
if (Dep.target) {
dep.depend()
}
return val
},
set: function reactiveSetter (newVal) {
/* eslint-disable no-self-compare */
if (newVal === val || (newVal !== newVal && val !== val)) {
return
}
val = newVal
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
上面代码还是很清楚简单的,在getter
中的dep.depend
就是收集观察者
;在setter
中dep.notify
就是通知观察者
。每一个响应式属性都拥有自己的闭包Dep
实例,这个Dep
实例中装载这所有该属性的观察者
。那么Dep.target
是什么东西那?它就是我们所要收集的观察者。这里Dep.target
可能会有点迷糊,我们先来考虑一下vue的$watch实例方法或者watch指令是怎么用的:
1 | vm.$watch(expOrFn, function(){...}); |
当expOrFn
的值发送变化时,执行回调函数。而$watch
方法就是新建了一个Watcher
实例:
1 | Vue.prototype.$watch = function ( |
现在我们需要明确的一点就是,其实通过改变数据来更新DOM这个操作,其实就是创建了一个渲染函数的Watcher
实例,跟我们使用$watch
方法去观察一个数据是一样的,只不过这个操作是Vue主动做的,只不过它观察的是所有<template>
或者render
中的数据。下面这段代码就是创建渲染函数的Watcher
实例:
1 | new Watcher(vm, updateComponent, noop, { |
updateComponent
函数上面已经说过,是用来更新DOM操作的。我们先来看一下Watcher类构造函数的参数,Watcher(vm, expOrFn, cb, options)
一共有4个:
vm
, Vue实例expOrFn
, 被观察的表达式或者函数,用来求值同时触发被观察数据的getter
函数,用于收集该观察者,所以我们上面说的Dep.target
就是在expOrFn
求值之前被赋值为该观察者实例的cd
,回调函数,被观察数据改变时,执行的回调函数options
, 一些参数设置isRenderWatcher
,是否为渲染函数的观察者
那我们看这个渲染函数
的观察者实例的构造参数就有些奇怪,因为它的回调是noop
,noop
是定义了一个空函数。这不是很奇怪吗,在数据变化的时候什么都不做,怎么更新DOM?实际上在数据发生变化的时候,Watcher实例都要对expOrFn
重新求一遍值,这样才能知道expOrFn
的值有没有变化,进而决定是否要执行cb
;而updateComponent
这个更新DOM的函数同时满足触发getter
和更新DOM的需求,所以在这里就不需要设置cb
了,同样如果我们在编码时,有这样同时满足收集依赖和满足回调的函数,也可以这样用。
避免重复收集观察者
那每次数据发生变化的时候,观察者
都对expOrFn
求值,岂不是每次都会触发getter
函数,造成依赖重复收集?的确会,而且即便在一次求值过程中,也可能触发同一个数据多次(比如同一个属性出现在模板多个地方),不过Vue已经实现了避免收集重复依赖的处理:
1 | class Watcher{ |
上述代码就是Vue用来避免收集重复依赖的,我们知道,在响应式数据的getter
中,我们会调用该属性所拥有的Dep
实例的depend
方法来收集观察者
。我们可以看到在depend
方法中调用了观察者实例方法addDep
,而在addDep
方法中我们可以看到又调用了Dep
的实例方法addSub
给Dep
这个容器加入观察者
。有点绕,两个类来回调用,目的只有一个,就是避免收集重复的观察者
。
我们可以看到addDep
方法中有3个值,决定了是否添加观察者
,我们先来说明一下这3个值的作用:
- newDepIds,本次求值所收集的
Dep
实例Id列表,用来避免本次求值的重复收集,在每次求值完成之后都会被赋值给depIds,然后被清空 - depIds,上次求值所收集的
Dep
实例Id列表,用来避免两次求值之间的重复收集以及去除废弃的观察者
- newDeps, 本次求值所收集的
Dep
实例列表, 在每次求值完成之后都会被赋值给deps,然后被清空
这样看代码的逻辑就很清楚了,先判断在本次求值中是否已经收集了该Dep
实例,如果没有,则将该Dep
的id添加到newDepIds
,然后再判断,该Dep
实例是否存在于上次求值的Dep
实例Id列表,如果没有,则将该观察者
放入Dep
实例中,作为该Dep
实例所属的响应式数据所拥有的观察者
。
在每次求值之后,都会执行cleanupDeps
,用于给newDepIds
赋值给depIds
,清空newDepIds
, 并且去除废弃的观察者
,下面这段代码就是去除废弃的观察者
:
1 | cleanupDeps () { |
凡是在上次求值过程中存在的Dep
实例,在本次求值中不存在了,说明该Dep
实例已经被废弃了,更直白的说法就是该Dep
实例所属的响应式数据已经不在本次求值过程中了,需要把该观察者
在Dep
实例中去除。
异步更新队列
在同一个js任务队列
中,我们可能改变多个模板中的响应式数据,这样会造成多次触发渲染函数的观察者
,造成多次重复地渲染DOM,造成性能浪费。解决这个问题的办法在于我们要有一个合适的时机统一处理一个js任务队列
中所有被触发的观察者
,对于重复的观察者
只执行一次。这个合适的时机就是微任务
,在js任务队列
执行完之后,会立即执行在本次任务队列
中产生的所有微任务。且在两次js任务队列
之间会穿插着DOM更新,所以在微任务
中把所有相关的数据更新,是最优的。下面queueWatcher
是异步模式下观察者
被通知时执行的操作,queue
存放在本次任务队列
中所有被通知的观察者
,nextTick
是Vue实现的微任务
机制(在不支持微任务的情况下,回退到宏任务
),flushSchedulerQueue
则是用来执行queue
中的观察者
,并清空queue
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
queue.push(watcher)
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}