Vue3-响应式设计原理

483 阅读6分钟

这篇博客从设计原理上介绍Vue3的响应式系统,并通过示例解释其中的主要流程,针对细节不做过多讨论

一、设计模式-观察者模式

Vue3响应式系统,本质上是观察者模式的一种应用,只不过由于封装较为精妙,能够完全自主绑定观察者与被观察者之间的依赖关系,无需开发者主动关联。下面简要介绍该模式,简要类图如下:

观察者模式.png

  • 被观察者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)

相应的流程图如下:

计算属性流程图.png

六、总结

这篇博客主要从设计层面阐述了响应式系统的大致实现原理,后续博客将从代码层面分析各个模块的实现细节。