从观察者模式看 vue 的响应式系统

809 阅读6分钟

vue3 的 @vue/reactivity 包是框架响应系统的底层实现,其内部使用了观察者模式。我们知道观察者模式是用来定义一对多的依赖关系,一个对象状态发生变化时,所有依赖于它的对象都能得到通知。vue 框架实现的效果是,修改了响应式状态对象,所有依赖该状态对象的组件渲染函数重新执行并更新 DOM。下面我们从观察者模式角度来看一下 vue 是如何实现其响应系统的。

Dep 和 ReactiveEffect

观察者模式主要的参与者有主题(Subject)和观察者(Observer),对应到 vue 系统中就是 Dep 和 ReactiveEffect。Dep 是一个 Set 类的别名。ReactiveEffect 类是观察者,其内部属性 deps 类型是 Dep 的数组,用来保存所有依赖。下图是 Dep 和 ReactiveEffect 的依赖关系图

ReactiveEffect 构造函数接受两个参数 fn 和 scheduler,fn 是实际执行的函数,其依赖都在 fn 函数内被收集。scheduler 是个调度函数,ReactiveEffect 被触发时,如果存在 scheduler 就执行 scheduler,否则执行 fn。核心代码如下

export class ReactiveEffect<T = any> {
  deps: Dep[] = []
  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
  ) {
  }

  run() {
    try {
        // activeEffect 是一个全局对象,fn 被执行时通过 activeEffect 能访问到当前 ReactiveEffect
        activeEffect = this
        return this.fn()
      } finally {
        activeEffect = undefined
      }
  }
}

依赖关系的构建

上图可以看到,和观察者模式的一对多关系不一样,Dep 和 ReactiveEffect 是多对多的关系。vue3 框架有多种方式创建两者的关联关系,比如 watchEffect、watch、computed 等。watchEffect 函数定义如下,接受一个 effect 函数

function watchEffect(
  effect: (onCleanup: OnCleanup) => void,
  options?: WatchEffectOptions
): StopHandle

watchEffect 内部会使用 effect 生成 ReactiveEffect 对象。当 effect 函数执行时,可以通过一个全局变量 activeEffect 访问到对应的 ReactiveEffect 对象,effect 函数执行完成后将 activeEffect 置空。

当 effect 函数内部使用了响应对象时,会触发 track 函数,用来让 activeEffect 追踪到这个响应式对象。这里需要提前说明一下,vue3 里的响应对象实现有 Ref、ShallowReactive、ComputedRefImpl 等。这些响应对象内部都会保存一个 Dep 对象,用来关联 ReactiveEffect。

function track(target: object, type: TrackOpTypes, key: unknown) {
  // 先找到响应对象对应的 Dep
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = createDep()))
  }

  // 追踪该 Dep
  trackEffects(dep)
}

function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  ...
  // 创建 ReactiveEffect 和 Dep 的关联关系
  dep.add(activeEffect!)
  activeEffect!.deps.push(dep)
  ...
}

有了这个关联关系,当响应对象状态发生变化时,框架会执行 trigger 函数触发所有依赖响应对象的 effect 重新执行。可以看下 trigger 代码

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  // 这里通过 target type key 三个参数找到所有需要重新执行的 effect
  ...
  triggerEffects(createDep(effects))
}

function triggerEffects(
  dep: Dep | ReactiveEffect[],
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  for (const effect of isArray(dep) ? dep : [...dep]) {
    if (effect.scheduler) {
      // 执行 effect 的调度器,一般用来推迟 effect 的执行
      effect.scheduler()
    } else {
      // 立即重新执行 effect
      effect.run()
    }
  }
}

框架使用 track 和 trigger 这俩函数追踪依赖和触发执行。这俩函数的执行机制是通过 Proxy 代理的方式实现,具体实现逻辑这里不做说明。

这里特别说一下 ReactiveEffect 的 scheduler 方法的作用, trigger 函数执行时,会判断 effect.scheduler 存在就执行,用来执行 effect 的调度器。这个调度器可以实现推迟执行 effect 的作用,比如将 effect 作为微任务执行

ref 和 RefImpl

之前说了响应对象的实现方式有多种,Ref 就是其中一种。响应对象内部保存了 Dep 对象,用来实现观察者模式的主题(Subject)功能。vue3 导出的 ref 是一个函数,用来生成一个 Ref 对象。RefImpl 是 Ref 的实现类。

具体代码实现如下

class RefImpl<T> {
  private _value: T
  public dep?: Dep = undefined

  constructor(value: T) {
    this._value = value
  }

  get value() {
    trackRefValue(this)
    return this._value
  }

  set value(newVal) {
    this._value = newVal
    triggerRefValue(this, newVal)
  }
}

export function trackRefValue(ref: RefBase<any>) {
  if (!ref.dep) {
    // 这里生成 Ref 对应的 Dep 对象
    ref.dep = createDep()
  }
  // 追踪该 dep 对象
  trackEffects(ref.dep)
}

computed 和 ComputedRefImpl

computed 函数使用其他响应对象来派生出一个新的响应对象,该响应对象会随着其依赖的响应对象改变而改变。从观察者模式可以看到,ComputedRefImpl 同时表现出了主题(Subject)和观察者(Observer)两种特性。函数定义如下,可以看到返回的是一个 Ref 对象。ComputedRefImpl 类就是这个返回 Ref 的实现类。

function computed<T>(
  getter: () => T,
): Readonly<Ref<Readonly<T>>> {
  const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter)

  return cRef as any
}


class ComputedRefImpl<T> {
  public dep?: Dep = undefined
  private _value!: T
  private _dirty = true
  public readonly effect: ReactiveEffect<T>

  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean
  ) {
    this.effect = new ReactiveEffect(getter, () => {
      // 该函数是个调度器,当 effect 重新执行时,会执行该调度器将 _dirty 置为 true,且触发
      if (!this._dirty) {
        this._dirty = true
        triggerRefValue(this)
      }
    })
  }

  get value() {
    const self = toRaw(this)
    trackRefValue(self)
    if (self._dirty) {
      self._dirty = false
      self._value = self.effect.run()!
    }
    return self._value
  }
}

在代码中,ComputedRefImpl 拥有 dep 和 effect 这两个属性,这也是其能同时表现出主题(Subject)和观察者(Observer)两种特性的原因

ComputedRefImpl 使用 _dirty 属性来标识当前是否是脏值,在依赖发生变化时,触发其 effect 执行,将 _dirty 标记为 true。当 ComputedRefImpl 作为依赖被使用时,根据 _dirty 是否为脏值决定是否重新计算

watch

watch 函数经常被拿来和 computed 比较,在源码中可以看到 watch 属于 @vue/runtime-core 包,computed 属于 @vue/reactivity 包。把 watch 独立出来的原因是,watch 依赖框架的 Scheduler 系统。

官方文档介绍的和 watch 相关的函数有4个,watch、watchSyncEffect、watchPostEffect、watchEffect。这里重点看下 watchEffect 的第二个参数 WatchEffectOptions 的定义

// flush 参数定义了 effect 执行的时机
interface WatchEffectOptions {
  flush?: 'pre' | 'post' | 'sync' // default: 'pre'
}

可以看到,watchEffect 函数的第二个参数可以传递 flush 参数,用来指定 effect 函数的执行时机。这个时机是在组件更新之前还是之后,或者是 sync 执行。

框架的 Scheduler 系统存储着异步执行的任务队列,当向队列中放入任务时,就会启动一个微任务,用来在该微任务中清空任务队列。这样看 Scheduler 其实是比微任务粒度更小的异步任务队列。系统内存在3个 SchedulerJob 类型的数组,定义如下

const queue: SchedulerJob[] = []
const pendingPreFlushCbs: SchedulerJob[] = []
const pendingPostFlushCbs: SchedulerJob[] = []

不同的队列保存的任务不同,我们常见的组件更新任务就放在 queue 队列中。当清空任务执行时会按顺序依次执行并清空 pendingPreFlushCbs、queue 和 pendingPostFlushCbs。这样我们就知道 flush 的实现原理,当 flush 为 pre 时,effect 被放到 pendingPreFlushCbs 队列,为 post 时放到 pendingPostFlushCbs 队列,为 sync 时不放到异步队列,直接执行。

回到观察者模式角度,watch 类的几个函数内部都创建了 ReactiveEffect,是一个观察者的角色。比较特殊的是,他们可以把响应放到异步队列延迟执行。