Vue 3.x 响应式原理——effect源码分析

3,007 阅读11分钟

在上一篇文章Vue 3.x 响应式原理——ref源码分析中,笔者简述了Vue 3.x 的 ref API 的实现原理,本文是响应式原理核心部分之一,effect模块用于描述 Vue 3.x 存储响应,追踪变化,这篇文章从effect模块的tracktrigger开始,探索在创建响应式对象时,立即触发其getter一次,会使用track收集到其依赖,在响应式对象变更时,立即触发trigger,更新该响应式对象的依赖。

阅读此文之前,如果对以下知识点不够了解,可以先了解以下知识点:

笔者之前也写过相关文章,也可以结合相关文章:

从track开始

track是收集依赖的函数,怎么理解呢,例如我们使用计算属性computed时,其依赖的属性更新会引起计算属性被重新计算,就是靠得这个track。在reactive模块时,我们就看到了响应式对象的getter都会在内部调用这个track

function createGetter(isReadonly: boolean) {
  return function get(target: object, key: string | symbol, receiver: object) {
    // 通过Reflect拿到原始的get行为
    const res = Reflect.get(target, key, receiver)
    // 如果是内置方法,不需要另外进行代理
    if (isSymbol(key) && builtInSymbols.has(key)) {
      return res
    }
    // 如果是ref对象,代理到ref.value
    if (isRef(res)) {
      return res.value
    }
    // track用于收集依赖
    track(target, OperationTypes.GET, key)
    // 判断是嵌套对象,如果是嵌套对象,需要另外处理
    // 如果是基本类型,直接返回代理到的值
    return isObject(res)
      // 这里createGetter是创建响应式对象的,传入的isReadonly是false
      // 如果是嵌套对象的情况,通过递归调用reactive拿到结果
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }
}

在阅读reactive模块的代码时我们就带有这样的疑问:怎么理解这里的track调用呢?笔者之前有看过 Vue 1.x 的响应式源码的部分,这里猜想应该是和 Vue 1.x 差不多的,相关文章可见Vue源码学习笔记之Dep和Watcher

我们假设,在初始化响应式对象时,就会调用其getter一次,在getter调用前,我们初始化一个结构,假设叫dep,在初始化这个响应式对象,即其getter调用过程中,如果对其它响应式对象进行取值,则会触发了其它响应式对象的getter方法,在其它响应式对象的getter方法中,调用了track方法,track方法会把被依赖的响应式对象及其相关特征属性存入其对应的dep中,这样在被依赖者更新时,这次初始化的响应式对象会重新调用getter,触发重新计算。

现在,我们开始来看track,并从中印证我们的猜想:

// 全局开关,默认打开track,如果关闭track,则会导致 Vue 内部停止对变化进行追踪
let shouldTrack = true

export function pauseTracking() {
  shouldTrack = false
}

export function resumeTracking() {
  shouldTrack = true
}

export function track(target: object, type: OperationTypes, key?: unknown) {
  // 全局开关关闭或effectStack为空,无需收集依赖
  if (!shouldTrack || effectStack.length === 0) {
    return
  }
  // 从effectStack取出一个叫做effect的变量,这里先猜想:effect用于描述当前响应式对象
  const effect = effectStack[effectStack.length - 1]
  // 如果当前操作是遍历,标记为遍历
  if (type === OperationTypes.ITERATE) {
    key = ITERATE_KEY
  }
  // targetMap是在创建响应式对象时初始化的,target是响应式对象,targetMap映射到一个空map,这个map指的就是depsMap
  // 所以可以看出来,targetMap两层map,第一层从响应式对象映射到depsMap,第二层才是depsMap,通过后面的代码我们知道depsMap是相关操作:SET,ADD,DELETE,CLEAR,GET,HAS,ITERATE到一个Set的映射,Set里存放的是对应的effect
  // 如果depsMap为空,这时候在targetMap里面初始化一个空的Map
  let depsMap = targetMap.get(target)
  if (depsMap === void 0) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // 通过key拿到dep这个Set
  let dep = depsMap.get(key!)
  // 如果dep为空,初始化dep为一个Set
  if (dep === void 0) {
    depsMap.set(key!, (dep = new Set()))
  }
  // 开始收集依赖:将effect放入dep,并且更新effect里的deps属性,将dep也放到effect.deps里,用于描述当前响应式对象的依赖
  if (!dep.has(effect)) {
    dep.add(effect)
    effect.deps.push(dep)
    // 开发环境下,触发相应的钩子函数
    if (__DEV__ && effect.options.onTrack) {
      effect.options.onTrack({
        effect,
        target,
        type,
        key
      })
    }
  }
}

通过上面的代码,基本印证了刚刚我们的猜想,不过有几个地方我们可能有点似懂非懂:

  • effectStack是一个什么结构,为什么从effectStack栈顶部effectStack[effectStack.length - 1]取到的就恰好是用于描述当前需要收集依赖的响应式对象的effect
  • effect的结构又是怎样的,是在哪里被初始化的?
  • 收集到的依赖deps,又是怎么在对应的响应式对象更新时,对应更新具有依赖的响应式对象的?

下面在针对上述三点可能的疑问,回到effect模块的源码来寻找答案:

看effect的结构

首先来看effectStackeffect的结构:

export interface ReactiveEffect<T = any> {
  (): T // ReactiveEffect是一个函数类型,其参数列表为空,返回值类型为T
  _isEffect: true // 标识为effect
  active: boolean // active是effect激活的开关,打开会收集依赖,关闭会导致收集依赖无效
  raw: () => T // 原始监听函数
  deps: Array<Dep> // 存储依赖的deps
  options: ReactiveEffectOptions // 相关选项
}

export interface ReactiveEffectOptions {
  lazy?: boolean // 延迟计算的标识
  computed?: boolean // 是否是computed依赖的监听函数
  scheduler?: (run: Function) => void // 自定义的依赖收集函数,一般用于外部引入@vue/reactivity时使用
  onTrack?: (event: DebuggerEvent) => void // 本地调试时使用的相关钩子函数
  onTrigger?: (event: DebuggerEvent) => void // 本地调试时使用的相关钩子函数
  onStop?: () => void // 本地调试时使用的相关钩子函数
}

// 判断一个函数是否是effect,直接判断_isEffect即可
export function isEffect(fn: any): fn is ReactiveEffect {
  return fn != null && fn._isEffect === true
}

通过上面的代码可以知道,effect是一个函数,其下挂载了一些属性,用于描述其依赖和状态。其中raw是保存其原始监听函数,这里我们可以猜想effect既然也是函数类型,那么其调用时,除了调用原始函数raw之外,还会进行依赖收集,下面来看effect的代码:

// effectStack是用于存放所有effect的数组
export const effectStack: ReactiveEffect[] = []

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  // fn已经是一个effect函数了,利用fn.raw重新创建effect
  if (isEffect(fn)) {
    fn = fn.raw
  }
  // 创建监听函数
  const effect = createReactiveEffect(fn, options)
  // 如果不是延迟执行,立刻调用一次effect来进行收集依赖
  if (!options.lazy) {
    effect()
  }
  return effect
}

// 停止收集依赖的函数
export function stop(effect: ReactiveEffect) {
  // 当前effect是active的
  if (effect.active) {
    // 清除effect的所有依赖
    cleanup(effect)
    // 如果有onStop钩子,调用钩子函数
    if (effect.options.onStop) {
      effect.options.onStop()
    }
    // active标记为false,标记这个effect已经停止收集依赖了
    effect.active = false
  }
}

// 创建effect
function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  // effect其实就是调用run,在下面可以看到run就是收集依赖的过程
  const effect = function reactiveEffect(...args: unknown[]): unknown {
    return run(effect, fn, args)
  } as ReactiveEffect
  // 初始化时,初始化effect的各项属性
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}
// 开始收集依赖
function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
  // 当active标记为false,直接调用原始监听函数
  if (!effect.active) {
    return fn(...args)
  }
  // 当前effect不在effectStack中,就开始收集依赖
  if (!effectStack.includes(effect)) {
    // 收集依赖前,先清理一次effect的依赖
    // 这里先清理的一次的目的是重新对同一个属性创建新的监听时,要先把原始的监听的依赖清空
    cleanup(effect)
    try {
      // effect放入effectStack中
      effectStack.push(effect)
      // 调用原始函数,在这里调用原始函数时,如果原始函数里面对响应式对象进行取值了,会触发这个响应式对象的getter,在其getter中调用了track,就收集到依赖了
      return fn(...args)
    } finally {
      // 调用完成后,出栈
      effectStack.pop()
    }
  }
}

// 清理依赖的方法,遍历deps,并清空
function cleanup(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

上面的代码基本很好理解,在创建监听时就会调用一次effect,只要effectactive的,就会触发依赖收集。依赖收集的核心是在这里调用原始监听函数时,如果原始函数里面对响应式对象进行取值了,会触发这个响应式对象的getter,在其getter中调用了track

结合上面的代码,再理解track

  • track时,effectStack栈顶就是当前的effect,因为在调用原始监听函数前,执行了effectStack.push(effect),在调用完成最后,会执行effectStack.pop()出栈。
  • effect.activefalse时会导致effectStack.length === 0,这时不用收集依赖,在track函数调用开始时就做了此判断。
  • 判断effectStack.includes(effect)的目的是避免出现循环依赖:设想一下以下监听函数,在监听时,出现了递归调用原始监听函数修改依赖数据的情况,如果不判断effectStack.includes(effect)effectStack又会把相同的effect放入栈中,增加effectStack.includes(effect)避免了此类情况。
const counter = reactive({ num: 0 });
const numSpy = () => {
  counter.num++;
  if (counter.num < 10) {
    numSpy();
  }
}
effect(numSpy);

trigger

通过上面对effecttrack的解析,我们已经基本清楚了依赖收集的过程了,对于整个effect模块的理解,就只差trigger。既然track用于收集依赖,我们很容易知道trigger是响应式数据改变后,通知依赖其的响应式数据改变的方法,通过阅读trigger即可回答上面的问题:收集到的依赖deps,又是怎么在其依赖更新时,对应更新具有依赖的响应式对象的?

下面来看trigger

export function trigger(
  target: object,
  type: OperationTypes,
  key?: unknown,
  extraInfo?: DebuggerEventExtraInfo
) {
  // 通过原始对象,映射到对应的依赖depsMap
  const depsMap = targetMap.get(target)
  // 如果这个对象没有依赖,直接返回。不触发更新
  if (depsMap === void 0) {
    // never been tracked
    return
  }
  // effects集合
  const effects = new Set<ReactiveEffect>()
  // 用于comptuted的effects集合
  const computedRunners = new Set<ReactiveEffect>()
  // 如果是清除整个集合的数据,那就是集合每一项都会发生变化,调用addRunners将需要更新的依赖加入执行队列里面
  if (type === OperationTypes.CLEAR) {
    // collection being cleared, trigger all effects for target
    depsMap.forEach(dep => {
      addRunners(effects, computedRunners, dep)
    })
  } else {
    // SET | ADD | DELETE三种操作都是对于响应式对象某一个属性而言的,只需要通知依赖这一个属性的状态更新
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      addRunners(effects, computedRunners, depsMap.get(key))
    }
    // 此外,对于添加和删除,还有对依赖响应式对象的迭代标识符的数据进行更新
    // also run for iteration key on ADD | DELETE
    if (type === OperationTypes.ADD || type === OperationTypes.DELETE) {
      // 数组是length,对象是ITERATE_KEY
      // 为什么这里要对length单独处理?原因是在对数组、Set等调用push/pop/delete/add等方法时,不会触发对应数组下标的set,而是通过劫持length和ITERATE_KEY的改变来实现的
      // 所以这里要把length或者ITERATE_KEY的依赖更新,这样就可以保证在调用push/pop/delete/add等方法时,也会通知依赖响应式数据的状态更新了
      const iterationKey = isArray(target) ? 'length' : ITERATE_KEY
      // 依赖响应式对象的迭代标识符的数据进行更新
      addRunners(effects, computedRunners, depsMap.get(iterationKey))
    }
  }
  const run = (effect: ReactiveEffect) => {
    scheduleRun(effect, target, type, key, extraInfo)
  }
  // Important: computed effects must be run first so that computed getters
  // can be invalidated before any normal effects that depend on them are run.
  // 进行更新
  // 计算属性的effect必须先执行,因为正常的响应式属性可能会依赖于计算属性的数据
  computedRunners.forEach(run)
  // 再执行正常监听函数
  effects.forEach(run)
}

// 将effect添加到执行队列中
function addRunners(
  effects: Set<ReactiveEffect>,
  computedRunners: Set<ReactiveEffect>,
  effectsToAdd: Set<ReactiveEffect> | undefined
) {
  // effectsToAdd是所有的依赖
  if (effectsToAdd !== void 0) {
    // 将一个effect的依赖都放入执行队列
    effectsToAdd.forEach(effect => {
      // 对computed的对象单独处理,computed是分开的队列
      if (effect.options.computed) {
        computedRunners.add(effect)
      } else {
        effects.add(effect)
      }
    })
  }
}

// 触发所有依赖更新
function scheduleRun(
  effect: ReactiveEffect,
  target: object,
  type: OperationTypes,
  key: unknown,
  extraInfo?: DebuggerEventExtraInfo
) {
  // 开发环境,触发对应钩子函数
  if (__DEV__ && effect.options.onTrigger) {
    const event: DebuggerEvent = {
      effect,
      target,
      key,
      type
    }
    effect.options.onTrigger(extraInfo ? extend(event, extraInfo) : event)
  }
  // 调用effect,即监听函数,进行更新
  if (effect.options.scheduler !== void 0) {
    effect.options.scheduler(effect)
  } else {
    effect()
  }
}

上面的代码根据注释也很好理解,trigger就是当响应式属性更新时,通知其依赖的数据进行更新。在trigger内部会维护两个队列effectscomputedRunners,分别是普通属性和计算属性的依赖更新队列,在trigger调用时,Vue 会找到更新属性对应的依赖,然后将需要更新的effect放到执行队列里面,执行队列是Set类型,可以很好地保证同一个effect不会被重复调用。在完成了依赖查找之后,对effectscomputedRunners进行遍历,调用scheduleRun进行更新。

小结

本文讲述了effect模块的原理,通过track入手,了解到effect的结构,知道effect内部有一个deps的属性,这个属性是一个数组,用来存储监听函数的依赖。在响应式对象初始化时,getter调用,会调用track收集依赖,在对其属性进行更改、删除、增加时,会调用trigger来更新依赖,完成了数据通知和响应。