阅读 3280

vue3响应式系统源码解析-Effect篇

前言

为了更好的做解释,我会调整源码中的接口、类型、函数等声明顺序,并会增加一些注释方便阅读

如果之前的文章都看过的话,我们应该已经明白是如何劫持数据了。但还有两个大问题一直没解决,即具体是如何收集依赖,又是如何触发监听函数的。从前文中,我们大致能猜到:向effect函数传递一个原始函数,会创建一个监听函数,并且会立即执行一次。而第一次执行时,就能通过读操作中的track收集到依赖,并在写操作时,通过trigger时再次触发这个监听函数。而这些主要方法的内部逻辑就在 effect 文件中。

Effect

外部引入

import { OperationTypes } from './operations'
import { Dep, targetMap } from './reactive'
import { EMPTY_OBJ, extend } from '@vue/shared'
复制代码

effect 文件的外部引入不多,EMPTY_OBJ指代一个空对象{}extend是一个扩展对象的方法,类似 lodash 中的_.extend。而DeptargetMap正是我们需要在effect中探索的。

类型与常量

归功于之前看过单测,所以看这里的类型,会轻松很多。如果您没有看过,直接看结论也行。

// 迭代行为标识符
export const ITERATE_KEY = Symbol('iterate')

// 监听函数的配置项
export interface ReactiveEffectOptions {
  // 延迟计算,为true时候,传入的effect不会立即执行。
  lazy?: boolean
  // 是否是computed数据依赖的监听函数
  computed?: boolean
  // 调度器函数,接受的入参run即是传给effect的函数,如果传了scheduler,则可通过其调用监听函数。
  scheduler?: (run: Function) => void
  // **仅供调试使用**。在收集依赖(get阶段)的过程中触发。
  onTrack?: (event: DebuggerEvent) => void
  // **仅供调试使用**。在触发更新后执行监听函数之前触发。
  onTrigger?: (event: DebuggerEvent) => void
  //通过 `stop` 终止监听函数时触发的事件。
  onStop?: () => void
}

// 监听函数的接口
export interface ReactiveEffect<T = any> {
  // 代表这是一个函数类型,不接受入参,返回结果类型为泛型T
  // T也即是原始函数的返回结果类型
  (): T
  [effectSymbol]: true
  // 暂时未知,猜测是某种开关
  active: boolean
  // 监听函数的原始函数
  raw: () => T
  // 暂时未知,根据名字来看是存一些依赖
  // 根据类型来看,存放是二维集合数据,一维是数组,二维是ReactiveEffect的Set集合
  deps: Array<Dep> // === Array<Set<ReactiveEffect>>
  // 以下同上述ReactiveEffectOptions
  computed?: boolean
  scheduler?: (run: Function) => void
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
  onStop?: () => void
}

// debugger事件,这个基本不需要解释
export type DebuggerEvent = {
  effect: ReactiveEffect
  target: object
  type: OperationTypes
  key: any
} & DebuggerEventExtraInfo

// debugger拓展信息
export interface DebuggerEventExtraInfo {
  newValue?: any
  oldValue?: any
  oldTarget?: Map<any, any> | Set<any>
}

// 存放监听函数的数组
export const effectStack: ReactiveEffect[] = []
复制代码

基本骨架

// 是否是监听函数
export function isEffect(fn: any): fn is ReactiveEffect {
  return fn != null && fn._isEffect === true
}
// 生成监听函数的effect方法
export function effect<T = any>(
  // 原始函数
  fn: () => T,
  // 配置项
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  // 如果该函数已经是监听函数了,那赋值fn为该函数的原始函数
  if (isEffect(fn)) {
    fn = fn.raw
  }
  // 创建一个监听函数
  const effect = createReactiveEffect(fn, options)
  // 如果不是延迟执行的话,立即执行一次
  if (!options.lazy) {
    effect()
  }
  // 返回该监听函数
  return effect
}
// 创建监听函数的方法
function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  // 创建监听函数,通过run来包裹原始函数,做额外操作
  const effect = function reactiveEffect(...args: unknown[]): unknown {
    return run(effect, fn, args)
  } as ReactiveEffect
  // 监听函数标识符
  effect._isEffect = true
  // 依旧不知道做什么用的开关
  effect.active = true
  // 原始函数
  effect.raw = fn
  // 应该是存什么依赖的数组
  effect.deps = []
  // 获取配置数据
  effect.scheduler = options.scheduler
  effect.onTrack = options.onTrack
  effect.onTrigger = options.onTrigger
  effect.onStop = options.onStop
  effect.computed = options.computed
  return effect
}
复制代码

可以看到,这两个方法,其实都没做什么关键性的逻辑,也都比较易懂。主要是给监听函数赋一些属性。核心还是在那个run方法中,那里才是真正的监听执行逻辑。不过也有一个不易明白之处是这里:

// 如果该函数已经是监听函数了,那赋值fn为该函数的原始函数
if (isEffect(fn)) {
  fn = fn.raw
}
复制代码

这段逻辑代表着,如果传递的函数已经是监听函数了,并不会直接返回旧的监听函数,而是用其原始函数构建一个新的监听函数,这在我们的单测篇中略有体现。effect方法永远都返回一个新函数,不过暂时不知道这样设计的原因是什么。

继续看run方法。

// 监听函数执行器
function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
  // 如果这个active开关是关上的,那就执行原始方法,并返回
  if (!effect.active) {
    return fn(...args)
  }
  // 如果监听函数栈中并没有此监听函数,则:
  if (!effectStack.includes(effect)) {
    // 还不知道具体做什么用的清除行为
    cleanup(effect)
    try {
      // 将本effect推到effect栈中
      effectStack.push(effect)
      // 执行原始函数并返回
      return fn(...args)
    } finally {
      // 执行完以后将effect从栈中推出
      effectStack.pop()
    }
  }
}

// 传递一个监听函数,做某种清除操作
function cleanup(effect: ReactiveEffect) {
  // 获取本effect的deps,然后循环清除存储了自身effect的引用
  // 最后将deps置为空
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}
复制代码

这个run方法,由于引入了cleanupeffectStack,又多了一些判断,有点儿看不明白。

问题 1:effect.active是什么个逻辑?

我们搜索下修改effect.active的方法,只有一处:

export function stop(effect: ReactiveEffect) {
  // 如果active为true,则触发effect.onStop,并且把active置为false。
  if (effect.active) {
    cleanup(effect)
    if (effect.onStop) {
      effect.onStop()
    }
    effect.active = false
  }
}
复制代码

看过单测篇的话,可能记得有stop这个 api,向它传参监听函数,可以使得这个监听函数失去响应式逻辑。那active这个逻辑就明白了。

问题 2:既然执行前effectStack.push(effect),执行后effectStack.pop()。那为什么还会存在effectStack.includes(effect)这种情况呢?

遇到问题不要慌,记住,我们有单测大法!我们把这个 if 逻辑去掉,再跑下 effect 的单测,然后就会发现两个单测抛错了。

✕ should avoid implicit infinite recursive loops with itself (26ms)

✕ should allow explicitly recursive raw function loops (12ms)

再搜一下单测代码,我们就知道啦,原来是为了避免递归循环的。比如在监听函数中,又改变了依赖数据,按正常逻辑是会不断的触发监听函数的。但通过effectStack.includes(effect)这么一个判断逻辑,自然而然就避免了递归循环。

然后还有个更令人不解的cleanup。不解的核心原因是不知道这个deps: Array<Set<ReactiveEffect>>是怎么写入的。为什么监听函数内部会存着一堆监听函数集合。在这里为什么又要删除它。我们先保留疑问,后面会解答。

另外,从effect函数到run函数,我们能发现一个显然不合理之处:

// ...监听函数类型
interface ReactiveEffect<T = any> {
  (): T
  // ...
}
const effect = function reactiveEffect(...args: unknown[]): unknown {
  return run(effect, fn, args)
} as ReactiveEffect
// ...
fn(...args)
复制代码

可以看到,新构建的监听函数reactiveEffect,竟然是有传参的,而原始函数,以及监听函数的接口类型类型都是() => T,是没有传参的...这里产生了不一致。按道理来说,由于监听函数的基本套路还是自动触发的,所以应该是没有参数的。所以此处的args其实是没有意义的。真要传,在 ts 环境下,也会由于类型不一致而报错的。

真要想支持传参的话,为了保留原始函数类型(目前是 unknow),需要写不少类型推导。而且还得限制入参函数必须都是可选的,因为传入的函数会立即执行一次...所以还是别传参了...不管怎么说,这里感觉可以优化一下。

回过来,现在我们的最大问题其实是,这个effect为什么又会被存在自己的deps里,又是如何被触发。这其中的逻辑显然是在之前一直看到的tracktrigger中。

track

看 track 之前,还得先复习一下targetMap,之前我们大致知道它是这么一个结构:

export type Dep = Set<ReactiveEffect>
export type KeyToDepMap = Map<string | symbol, Dep>
export const targetMap = new WeakMap<any, KeyToDepMap>()
复制代码

打平了看,就是这样:WeakMap<Target, Map<string | symbol, Set<ReactiveEffect>>>

这是一个三维的数据结构。Target我们之前就知道了,是被劫持的原始数据。根据我们现有的知识(以及我的提前告知),我们能知道。二维KeyToDepMapkey,就是这个原始对象的属性 key。而Dep就是存放着监听函数effect的集合。然后再来看track代码:

// 收集依赖的函数
export function track(
  // 原始数据
  target: object,
  // 操作行为
  type: OperationTypes,
  key?: string | symbol
) {
  // 如果shouldTrack开关关闭,或effectStack中不存在监听函数,则无需要收集
  if (!shouldTrack || effectStack.length === 0) {
    return
  }
  // 获取effect栈最后一个effect
  const effect = effectStack[effectStack.length - 1]
  // 是迭代操作的话,重新赋值一下key
  if (type === OperationTypes.ITERATE) {
    key = ITERATE_KEY
  }
  // 获取二维map,不存在的话,则初始化
  let depsMap = targetMap.get(target)
  if (depsMap === void 0) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // 获取effect集合,无则初始化
  let dep = depsMap.get(key!)
  if (dep === void 0) {
    depsMap.set(key!, (dep = new Set()))
  }
  // 如果集合中,没有刚刚获取的最后一个effect,则将其add到集合dep中
  // 并在effect的deps中也push这个effects集合dep
  if (!dep.has(effect)) {
    dep.add(effect)
    effect.deps.push(dep)
    // 开发环境下时,触发track钩子函数
    if (__DEV__ && effect.onTrack) {
      effect.onTrack({
        effect,
        target,
        type,
        key
      })
    }
  }
}
复制代码

唔,有点儿绕。心中有不少疑问,一个个来摸索。

问题 1:为什么从effectStack尾部获取的effect就是依赖该target的监听函数。

那是因为这段逻辑:

try {
  // 将本effect推到effect栈中
  effectStack.push(effect)
  // 执行原始函数并返回
  return fn(...args)
} finally {
  // 执行完以后将effect从栈中推出
  effectStack.pop()
}
复制代码

fn内引用了依赖数据,执行fn触发这些数据的get,进而走到了track,而此时effectStack堆栈尾部正好是该effect。不过这里就有一个隐藏的限制,fn,也就是传给effect的原始函数,内部的依赖逻辑必须是同步的。比如这样是行不通的:

let dummy
const obj = reactive({ prop: 1 })
effect(() => {
  setTimeout(() => {
    dummy = obj.prop
  }, 1000)
})
obj.prop = 2
复制代码

obj.prop的变更,并不会让监听函数重新执行。fn也不能是一个async函数。

不过,在 vue3 中的watch函数是支持async。这个在此处就不讨论了,主要我还没研究...

问题 2:targetMapeffect的依赖映射到底是怎么样的。

targetMapdepsMap中存了effect的集合dep,而effect中又存了这个dep...乍看有点儿懵,而且为什么要双向存?

其实刚刚我们已经看到了一部分原因,就是在run方法中执行的cleanup。每次 run 之前,会执行它:

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
  }
}
复制代码

仔细阅读track方法,我们大致能理清targetMapeffect.deps中存着的数据具体是怎么样的了。分两步解释:

  1. 对于一个响应式数据,它在targetMap中存着一个Map数据(我称之为「响应依赖映射」)。这个响应依赖映射的key是该响应式数据的某个属性值,value是所有用到这个响应数据属性值的所有监听函数,也即是Set集合dep
  2. 而对于一个监听函数,它会存放着 所有存着它自身的dep

那问题来了,effect为什么要存着这么个递归数据呢?这是因为要通过cleanup方法,在自己被执行前,把自己从响应依赖映射中删除了。然后执行自身原始函数fn,然后触发数据的get,然后触发track,然后又会把本effect添加到相应的Set<ReactiveEffect>中。有点儿神奇啊,每次执行前,把自己从依赖映射中删除,执行过程中,又把自己加回去。

对于这种莫名其妙的逻辑,又是使用单测大法的时候了。我把cleanup的逻辑给注释了,再跑一会单测,然后会发现如下单测挂了:

✕ should not be triggered by mutating a property, which is used in an inactive branch (3ms)

其单测逻辑为:

it('should not be triggered by mutating a property, which is used in an inactive branch', () => {
  let dummy
  const obj = reactive({ prop: 'value', run: true })

  const conditionalSpy = jest.fn(() => {
    dummy = obj.run ? obj.prop : 'other'
  })
  effect(conditionalSpy)

  expect(dummy).toBe('value')
  expect(conditionalSpy).toHaveBeenCalledTimes(1)
  obj.run = false
  expect(dummy).toBe('other')
  expect(conditionalSpy).toHaveBeenCalledTimes(2)
  obj.prop = 'value2'
  expect(dummy).toBe('other')
  expect(conditionalSpy).toHaveBeenCalledTimes(2)
})
复制代码

喔~~~这下我们就明白了,原来是为了这种带有分支处理的情况。因为监听函数中,可能会由于 if 等条件判断语句导致的依赖数据不同。所以每次执行函数时,都要重新更新一次依赖。所以才有了cleanup这个逻辑。

这样,我们就基本搞明白track的套路跟tragetMap的逻辑了,然后攻读trigger

trigger

我们先大致瞄一眼这个函数。

// 触发监听函数的方法
export function trigger(
  target: object, // 原始数据
  type: OperationTypes, // 写操作类型
  key?: unknown, // 属性key
  extraInfo?: DebuggerEventExtraInfo // 拓展信息
) {
  // 获取原始数据的响应依赖映射,没有的话,说明没被监听,直接返回
  const depsMap = targetMap.get(target)
  if (depsMap === void 0) {
    // never been tracked
    return
  }
  // 声明一个effect集合
  const effects = new Set<ReactiveEffect>()
  // 声明一个计算属性集合
  const computedRunners = new Set<ReactiveEffect>()
  // OperationTypes.CLEAR 代表是集合数据的清除方法,会清除集合数据的所有项
  // 如果是清除操作,那就要执行依赖原始数据的所有监听方法。因为所有项都被清除了。
  // addRunners并未执行监听函数,而是将其推到一个执行队列中,待后续执行
  if (type === OperationTypes.CLEAR) {
    // collection being cleared, trigger all effects for target
    depsMap.forEach(dep => {
      addRunners(effects, computedRunners, dep)
    })
  } else {
    // key不为void 0,则说明肯定是SET | ADD | DELETE这三种操作
    // 然后将依赖这个key的所有监听函数推到相应队列中
    // 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) {
      // 如果原始数据是数组,则key为length,否则为迭代行为标识符
      const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY
      addRunners(effects, computedRunners, depsMap.get(iterationKey))
    }
  }
  // 声明一个run方法
  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.
  // 大致翻译一下:计算属性的getter函数必须先执行,因为正常的监听函数,可能会依赖于计算属性数据

  // 运行所有计算数据的监听方法
  computedRunners.forEach(run)
  // 运行所有寻常的监听函数
  effects.forEach(run)
}
复制代码

除了addRunnersscheduleRun是黑盒外,其他逻辑大致还是清晰的。唯独这儿:

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) {
  const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY
  addRunners(effects, computedRunners, depsMap.get(iterationKey))
}
复制代码

每次牵扯到数组/集合以及迭代行为的时候,总是难以理解,核心是因为我们对这些数据的底层了解比较少。在这里,我们不解什么情况下会走到第二个逻辑,而且这种情况肯定会重复addRunners,这不要紧吗?没事,不懂就跑单测,我们把第二个 if 注释了,然后跑一下effect单测:

✕ should observe iteration (5ms)

✕ should observe implicit array length changes (1ms)

✕ should observe enumeration (1ms)

发现确实跟注释所示,迭代器相关的单测出错了,部分情况下的监听函数没有触发。如果之前精读过reactviehandlers,我们能大致猜想到原因。具体举例来说,类似单测中这样的情况(稍微修改了下单测,更易理解):

it('should observe iteration', () => {
  let dummy
  const list = reactive<string[]>([])
  effect(() => (dummy = list.join(' ')))

  expect(dummy).toBe('')
  list.push('Hello')
  expect(dummy).toBe('Hello')
})
复制代码

此处的effect并没有用到数组的某个具体下标,handlers中的track其实是劫持了数组的length属性(其实还有join方法,但此处无用),并跟踪它的变化。在这种情况下,depsMap其实是lengtheffects的映射关系。(为什么会tracklength可在上篇文章中寻找答案)

而在上篇文章reactive篇中我们又知道。数组push行为触发的 length 变化,是不会再次触发trigger的...于是在这个单测中,就只会触发一次key0valueHellotrigger

在这种情况下,因为key0,所以if(key !== void 0)确实为真值,但depsMap.get(0)其实是为空的。而depsMap.get('length')才是真的有相应effect,因此必须要有第二个逻辑做补充。

那问题又来了...对于这样的操作怎么办?

it('should observe iteration', () => {
  let dummy
  const list = reactive<number[]>([])
  effect(() => {
    dummy = list.length + list[0] || 0
  })

  expect(dummy).toBe(0)
  list.push(1)
  expect(dummy).toBe(2)
})
复制代码

这种情况下,两个if逻辑都会跑到,并且depsMap.get(key)depsMap.get(iterationKey)都有值。是不是会执行两次 effect 呢?其实并不会。我们继续看addRunnersscheduleRun

// 将effect添加到执行队列中
function addRunners(
  effects: Set<ReactiveEffect>, // 监听函数集合
  computedRunners: Set<ReactiveEffect>, // 计算函数集合
  effectsToAdd: Set<ReactiveEffect> | undefined // 待添加的监听函数或计算函数集合
) {
  // 如果effectsToAdd不存在,啥也不干
  if (effectsToAdd !== void 0) {
    // 遍历effectsToAdd
    // 如果是计算函数,则推到computedRunners,否则推到effects
    effectsToAdd.forEach(effect => {
      if (effect.computed) {
        computedRunners.add(effect)
      } else {
        effects.add(effect)
      }
    })
  }
}

function scheduleRun(
  effect: ReactiveEffect,
  target: object,
  type: OperationTypes,
  key: unknown,
  extraInfo?: DebuggerEventExtraInfo
) {
  // 开发环境,并且配置了onTrigger,则触发该函数,传入相应数据
  if (__DEV__ && effect.onTrigger) {
    effect.onTrigger(
      extend(
        {
          effect,
          target,
          key,
          type
        },
        extraInfo
      )
    )
  }
  // 如果配置了自定义的执行器方法,则执行该方法
  // 否则执行effect
  if (effect.scheduler !== void 0) {
    effect.scheduler(effect)
  } else {
    effect()
  }
}
复制代码

这两个方法,看名字很厉害的样子,其实做的事情很简单,就是把依赖这个响应式数据的所有effects添加到相应的Set集里。如果是computed的计算方法,就推到computedRunners里,否则就推正常的effects集合里。由于这两个都是Set集合。

const effects = new Set<ReactiveEffect>()
const computedRunners = new Set<ReactiveEffect>()
复制代码

所以,如果重复添加,是会自动去重的。所以上面两个if逻辑中如果获取到了相同的监听函数,也是会自动去重的,并不会被执行多次。整个effect就是这么简单。没太多花里胡哨的,run就是了。

另外读完以后我们也能知道,computed方法就是一类特殊的,有返回值的effect。那我们顺路看看完。

computed

关于computed我就不事无巨细的讲了,基本大家都明白了,直接贴核心的重点。

// 函数重载
// 入参为getter函数
export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T>
// 入参为配置项
export function computed<T>(
  options: WritableComputedOptions<T>
): WritableComputedRef<T>
// 真正的函数实现
export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
  let getter: ComputedGetter<T>
  let setter: ComputedSetter<T>

  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
    setter = __DEV__
      ? () => {
          console.warn('Write operation failed: computed value is readonly')
        }
      : NOOP
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }

  let dirty = true
  let value: T

  const runner = effect(getter, {
    lazy: true,
    // mark effect as computed so that it gets priority during trigger
    computed: true,
    scheduler: () => {
      dirty = true
    }
  })
  return {
    _isRef: true,
    // expose effect so computed can be stopped
    effect: runner,
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
      }
      // When computed effects are accessed in a parent effect, the parent
      // should track all the dependencies the computed property has tracked.
      // This should also apply for chained computed properties.
      trackChildRun(runner)
      return value
    },
    set value(newValue: T) {
      setter(newValue)
    }
  }
}
复制代码

可以看到,逻辑还是挺简单的。首先,computed的返回值是一个Ref类型数据。每次get值时,如果没执行过监听函数,也就是dirty === true时,执行一遍监听函数。避免重复获取时,重复执行。

一般来说,向computed传递的是一个function类型,只是获取一个计算类的数据,返回的数据是无法修改的。但也有例外,如果传入的是一个配置项,指定了gettersetter方法,那也是允许手动变更computed数据的。

大致逻辑比较简单,仅有一个trackChildRun需要多理解一下:

function trackChildRun(childRunner: ReactiveEffect) {
  if (effectStack.length === 0) {
    return
  }
  // 获取父级effect
  const parentRunner = effectStack[effectStack.length - 1]
  // 遍历子级,也即是本effect,的deps
  for (let i = 0; i < childRunner.deps.length; i++) {
    const dep = childRunner.deps[i]
    // 如果子级的某dep中没有父级effect,则将父级effect添加本dep中,然后更新父级effect的deps
    if (!dep.has(parentRunner)) {
      dep.add(parentRunner)
      parentRunner.deps.push(dep)
    }
  }
}
复制代码

单看代码,想去理解意思其实是比较绕的。我们先理解trackChildRun到底是为了什么。同样的,使用单测,一招鲜吃遍天。注释掉它再跑下单测:

✕ should trigger effect (3ms)

✕ should work when chained (2ms)

✕ should trigger effect when chained (1ms)

✕ should trigger effect when chained (mixed invocations) (1ms)

✕ should no longer update when stopped (1ms)

再找到相应单测,我们就了解它的用处了,即是为了让依赖computedeffect实现监听逻辑。以单测举例来说:

it('should trigger effect', () => {
  const value = reactive<{ foo?: number }>({})
  const cValue = computed(() => value.foo)
  let dummy
  effect(() => {
    dummy = cValue.value
  })
  expect(dummy).toBe(undefined)
  value.foo = 1
  expect(dummy).toBe(1)
})
复制代码

如果我们没有trackChildRun的逻辑,当value变更时,cValue的计算函数确实是能执行的。但是cValue读跟写并没有tracktrigger的逻辑,当cValue变更时,自然也无法触发监听函数。为了解决这个问题,于是就有了trackChildRun

监听函数,也就是单测中的() => { dummy = cValue.value },在它第一次执行时,由于使用到了cValue,进行了一次计算函数调用,进而走到trackChildRun

而此时,这个监听函数() => { dummy = cValue.value }还未执行完,因此它还在effectStack队列末尾。将其从末尾将其取出,即是所谓的computed的父级effect

而计算函数自身也是一个effect,之前我们说过,它的deps存着所有存着它的dep。而这个dep又指向targetMap中的相应数据。由于都是引用数据,所以只要把父级effect补充到computed.deps,就等同于做到了父级effect依赖于computed函数内部依赖的响应数据。

这两段话说起来确实有点绕,多理解理解就好。但文章到这也差不多结束了,后面我看看,能不能出一张大图,把整套响应式系统涉及的所有相关数据给绘制清楚,方便大家更直观的了解。

其他几篇文章可以戳专栏主页自行查看。下周我再汇总一篇,做个引导,并把这过程中有变更的代码再调整一下。谢谢您的阅读。