这篇博客从设计原理上介绍Vue3的响应式系统,并通过示例解释其中的主要流程,针对细节不做过多讨论
一、设计模式-观察者模式
Vue3响应式系统,本质上是观察者模式的一种应用,只不过由于封装较为精妙,能够完全自主绑定观察者与被观察者之间的依赖关系,无需开发者主动关联。下面简要介绍该模式,简要类图如下:
-
被观察者AbstractBeObserved
被观察者对象,可以一对多的存在多个观察者对象,当被观察者发生更改时,通知观察者。
-
观察者IObserved
被观察者等待通知,然后做出后续的动作。
采用这种模式可以将观察者和被观察者耦合降低,观察者和被观察者只用关注于自身的行为。比如观察者不用考虑被观察者的变更时机,被观察者也不用考虑自身是否有被观测。以上面的结构为例,可以轻松实现以下代码:
//观察者接口
interface IObserved {
notify:() => void
}
// 被观察者抽象类
abstract class AbstractBeObserved {
private observedSet:Set<IObserved> = new Set()
add(observed:IObserved){
this.observedSet.add(observed)
}
remove(observed:IObserved){
this.observedSet.delete(observed)
}
notify(){
this.observedSet.forEach((observed) => {
observed.notify()
})
}
}
//观察者类
class ConcreteObserved implements IObserved {
notify(){
console.log('观察者调用notify方法')
}
}
//被观察者类
class ConcreteBeObserved extends AbstractBeObserved {
}
// 创建被观察者对象
let beObserved = new ConcreteBeObserved()
// 添加一个观察者
beObserved.add(new ConcreteObserved())
// 通知观察者
beObserved.notify()
上述示例只是一个简单的观察者模式的介绍,我们类比到Vue3的响应式系统上,可以很清晰的知道,响应式对象就是被观察者,观测到变更的后续行为有如下三类:
- watch的监听方法被调用
- 计算属性需要被重新计算
- render方法被调用
至于观察者,被封装了起来,我们在日程开发中无法感知。
二、响应式对象
在Vue3中,其实提供了不少的API供我们创建响应式对象,但总的来说,响应式对象一共分为3类。虽然3类对象适用的场景不一样,但都实现了一个共同的特性,那就是读写操作拦截。在读操作中,添加观察者,在写操作中,通知观察者。
存在读操作,表示当前运行环境中使用了这个对象,因此可以说当前运行环境依赖于这个对象,则这个对象需要被观测。当这个对象被观测到变更后,即发生写操作,需要通知运行环境做出相应的变化。
-
reactive对象
reactive对象是一种代理对象,在Vue3中,有浅代理,深代理,只读代理等好几种代理方式,它们的原理都是一致的,通过Proxy实现。Proxy可以针对对象的读写操作进行拦截并改写,因此便提供了添加观察者和通知观察者的时机。
-
ref对象
我在最开始看代码时,其实不太明白ref对象的设计含义,因为reactive对象已经实现了读写操作的拦截,感觉没必要再设计ref。但在开发中,我们常常用到的是普通类型,而不是复杂的对象类型,我以如下代码举例:
const isShow = ref(true) const wrap = reactive({ isShow:false })假如只是想单纯的控制一个显示隐藏,那么你愿意使用一个布尔值,还是一个对象内嵌一个布尔值来控制?答案肯定是显而易见的,一个简单的布尔值更加便于阅读和理解。但reactive的实现是基于Proxy的,Proxy只能作用于对象,无法作用于普通的基础类型,因为这些类型没有属性。
ref对象是一个普通基础类型的封装,内部保存了值,暴露一个名为value的读写器用于读写,并且在此实现相应的操作拦截,虽然ref对象是设计用于基础类型,但依旧兼容用于对象类型。
-
computedRef对象
上面的reactive对象针对对象类型进行包装,ref对象针对其他基础类型进行包装,支持了所有数据的响应式实现,那么computedRef对象的设计是干什么的呢?它其实就是计算属性,本质上是基于已经存在的响应式对象衍生出来的一个响应式对象。它的实现和ref对象很相似,内部存有一个计算值,对外暴露了一个名为value的读写器,当读取的时候,判断是否需要重新计算。
三、ReactiveEffect对象
通过观察者模式,我们知道,被观察者和观察者是缺一不可的。而Vue3内部,存在一个类,叫做ReactiveEffect,这个类的实例对象,就是所谓的观察者。主要有以下2个作用。
-
提供响应式运行环境
前文说过,响应式对象是被观察者,但并不是所有的都是。只有在响应式运行环境中被使用过的响应式对象,才成为被观察者。ReactiveEffect提供了响应式的运行环境,所有在这个环境中进行读操作的响应式对象,都会进行依赖收集。
-
优化依赖收集
这块是类比Vue2进行的优化,在后续博文进行代码层面分析时阐述。
ReactiveEffect对象主要提供了2个方法,run,stop。
-
run
run方法内部采用了try...finally的结构,因此分为2段:
-
try
- 激活当前ReactiveEffect,保存父ReactiveEffect对象和当前shouldTrack状态,这儿是为了依赖收集结束后还原之前的状态,因为依赖收集可能存在嵌套收集的情况,比如深层属性的获取,当本层收集结束后,上层的状态应该保持不变。
- 进行依赖标记,用于优化依赖收集
- 调用创建对象时传入的回调方法,这个方法内部会进行依赖收集
-
finally
- 将当前ReactiveEffect重置为其父节点,还原shouldTrack状态
- 依赖标记检测,清除冗余依赖
- 检测是否已经异步停止监测
-
-
stop
- 将当前ReactiveEffect失活,即停止监测,如果当前ReactiveEffect是激活的,则异步停止。
四、依赖收集与更新
当一个ReactiveEffect对象的run方法被执行后,会调用创建时传入的回调方法fn。在执行fn时,如果有响应式对象进行了读操作,则会判断当前是否有激活的ReactiveEffect对象和shouldTrack标识是否为true,如果满足,则进行依赖收集。
一个依赖的本质是一个ReactiveEffect的集合,定义如下:
type TrackedMarkers = {
w: number
n: number
}
export type Dep = Set<ReactiveEffect> & TrackedMarkers
简单来说,一个依赖数据与一组ReactiveEffect对象进行关联,当依赖数据变更时,则这一组ReactiveEffect对象都要执行相应的监听回调。
五、示例代码阐述
接下来,我以计算属性为例子,阐述整个流程。简要测试代码如下:
const num1 = ref(1)
const num2 = ref(2)
const total = computed(() => {
return num1.value + num2.value
})
expect(total.value).toBe(3)
num2.value = 10
expect(total.value).toBe(11)
相应的流程图如下:
六、总结
这篇博客主要从设计层面阐述了响应式系统的大致实现原理,后续博客将从代码层面分析各个模块的实现细节。