完善 effect、track 和 trigger

2024-05-14 19:30:41

上一篇已经梳理了 effect 的基本逻辑:执行副作用函数时收集依赖,响应式数据变化时再触发这些副作用函数。

这一篇开始把这套逻辑拆到项目结构里,并把 tracktriggereffect 串起来。

最终的依赖结构大概是这样:

可以先把这张图理解成四层关系:

  • targetMap:保存所有响应式对象的依赖关系
  • propMap:保存某个对象下,不同属性的依赖关系
  • typeMap:保存某个属性下,不同读取行为的依赖关系
  • depSet:真正保存副作用函数的集合

依赖不是简单按属性保存,而是会继续区分读取行为。比如读取属性、判断属性是否存在、遍历对象,它们后续要被不同的修改行为触发。

改造

1. 创建 effect

先创建 effect/effect.js,这里主要放四部分内容:

  • activeEffect:当前正在执行的副作用函数
  • targetMap:全局依赖关系
  • effectStack:处理嵌套 effect
  • cleanup:清理旧依赖
/**
 * 用于记录当前活动的 effect
 */
export let activeEffect

/**
 * 用来存储对象和其属性的依赖关系
 */
export const targetMap = new WeakMap()

const effectStack = []

export function effect(fn) {
  const environment = () => {
    try {
      activeEffect = environment
      effectStack.push(environment)
      cleanup(environment)
      return fn()
    }
    finally {
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1]
    }
  }
  environment.deps = []
  environment()
  return environment
}

export function cleanup(environment) {
  const deps = environment.deps
  if (deps.length) {
    deps.forEach((dep) => {
      dep.delete(environment)
    })
    deps.length = 0
  }
}

这里的 effect 默认会立即执行一次。

执行时会先把当前环境函数赋值给 activeEffect,然后入栈。这样 track 在收集依赖时,就能知道当前依赖的是哪个副作用函数。

执行完成后,不管中间有没有报错,都会在 finally 里恢复上一个 activeEffect,避免嵌套 effect 时依赖收集错乱。

2. 改造 track

接下来改造 track.js

track 的职责是:根据当前读取行为,把 activeEffect 收集到对应的依赖集合里。

import { ITERATE_KEY, TrackOpTypes } from '../constant/enum.js'
import { activeEffect, targetMap } from './effect.js'

let shouldTrack = true

export function pauseTracking() {
  shouldTrack = false
}

export function resumeTracking() {
  shouldTrack = true
}

function track(target, type, key) {
  if (!shouldTrack || !activeEffect)
    return

  let propMap = targetMap.get(target)
  if (!propMap) {
    targetMap.set(target, propMap = new Map())
  }

  // 如果是在循环的话 key 是 undefined
  if (key === TrackOpTypes.ITERATE) {
    key = ITERATE_KEY
  }

  let typeMap = propMap.get(key)
  if (!typeMap) {
    propMap.set(key, typeMap = new Map())
  }

  // 根据 type 去找对应的 set
  let depSet = typeMap.get(type)
  if (!depSet) {
    typeMap.set(type, depSet = new Set())
  }

  if (!depSet.has(activeEffect)) {
    depSet.add(activeEffect)
    activeEffect.deps.push(depSet)
  }
}

export default track

这段逻辑可以按顺序拆开看:

  1. 如果当前不允许收集,或者没有正在执行的 effect,直接返回。
  2. 先根据 target 找到对象对应的 propMap
  3. 再根据 key 找到属性对应的 typeMap
  4. 最后根据读取行为 type 找到真正保存副作用函数的 depSet

有个特殊的情况是:遍历对象时没有具体的属性名,所以用 ITERATE_KEY 作为统一标识。

同时,收集依赖时要把 depSet 反向记录到 activeEffect.deps 里,这样后面 cleanup 才能找到并删除旧依赖。

3. 改造 trigger

trigger 的职责是:数据发生变化时,找到需要重新执行的副作用函数。

有个关键点是:触发更新的行为,和当初收集依赖的行为,不是一一相同的。

比如:

  • 修改已有属性时,主要影响读取这个属性的逻辑
  • 新增属性时,除了影响读取这个属性的逻辑,还会影响遍历和 in 判断
  • 删除属性时,也会影响读取、遍历和 in 判断

所以先建立一个映射关系:

import { TrackOpTypes, TriggerOpTypes } from '../constant/enum.js'

const triggerTypeMap = {
  [TriggerOpTypes.SET]: [TrackOpTypes.GET],
  [TriggerOpTypes.ADD]: [
    TrackOpTypes.GET,
    TrackOpTypes.ITERATE,
    TrackOpTypes.HAS
  ],
  [TriggerOpTypes.DELETE]: [
    TrackOpTypes.GET,
    TrackOpTypes.ITERATE,
    TrackOpTypes.HAS
  ]
}

有了这个映射后,trigger 就可以根据本次修改行为,反推出哪些读取行为对应的依赖需要执行。

接着在同一个 trigger.js 里实现查找依赖的逻辑:

import { ITERATE_KEY, TriggerOpTypes } from '../constant/enum.js'
import { targetMap } from './effect.js'

// track 的时候建立好了依赖关系,所以 trigger 就是拿到对应的依赖执行
export default function (target, type, key) {
  const effectFns = getEffectFns(target, type, key)
}

function getEffectFns(target, type, key) {
  const propMap = targetMap.get(target)
  if (!propMap)
    return

  /**
   * 如果是新增或者删除,会触发额外的操作
   */
  const keys = [key]
  if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
    keys.push(ITERATE_KEY)
  }

  const effectFns = new Set()

  for (const key of keys) {
    const typeMap = propMap.get(key)
    if (!typeMap)
      continue

    const trackTypes = triggerTypeMap[type]
    for (const trackType of trackTypes) {
      const dep = typeMap.get(trackType)
      if (!dep)
        continue
      for (const effectFn of dep) {
        effectFns.add(effectFn)
      }
    }
  }
  return effectFns
}

这里没有直接执行副作用函数,而是先收集到 effectFns 里。

原因有两个:

  • 同一个副作用函数可能同时存在于多个依赖集合里,用 Set 可以去重
  • 后面执行时还需要跳过当前正在执行的 activeEffect

最后再补上执行的逻辑:

import { activeEffect } from './effect.js'

export default function (target, type, key) {
  const effectFns = getEffectFns(target, type, key)
  if (!effectFns)
    return

  for (const effectFn of effectFns) {
    if (effectFn === activeEffect) {
      continue
    }
    else {
      effectFn()
    }
  }
}

到这里,依赖收集和派发更新的主流程就完整了。

lazy

默认情况下,调用 effect 后会立即执行一次:

effect(() => {
  console.log(state.a)
})

但有些场景不希望它马上执行,而是希望先拿到包装后的环境函数,等需要的时候再手动执行。这就是 lazy

const obj = {
  a: 1,
  b: 2,
  c: {
    name: '小明',
    age: 18
  }
}

const state = reactive(obj)

effect(() => {
  console.log('effect')
  state.a = state.a + 1
}, {
  lazy: true
})

state.a = 100

既然要支持配置,就给 effect 增加第二个参数 options

lazyfalse 时,保持默认行为,立即执行。
lazytrue 时,只返回 environment,不立刻执行。

export function effect(fn, options = {}) {
  const { lazy = false } = options
  const environment = () => {
    try {
      activeEffect = environment
      effectStack.push(environment)
      cleanup(environment)
      return fn()
    }
    finally {
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1]
    }
  }
  environment.deps = []
  if (!lazy) {
    environment()
  }
  return environment
}

这样 effect(fn, { lazy: true }) 就不会立即执行,调用方可以拿到返回的 environment 后再决定什么时候执行。

scheduler

还有一种场景:数据变化后,并不想让副作用函数立刻自动执行。

比如批量更新时,可能需要自己控制执行时机。这时就可以交给 scheduler

触发更新的地方在 trigger,所以只要在执行副作用函数之前,判断它有没有传入 scheduler 就可以了。

先把 options 挂到环境函数上:

export function effect(fn, options = {}) {
  // ...
  environment.deps = []
  environment.options = options
  // ...
}

然后在 trigger 中判断:

export default function (target, type, key) {
  const effectFns = getEffectFns(target, type, key)
  if (!effectFns)
    return

  for (const effectFn of effectFns) {
    if (effectFn === activeEffect) {
      continue
    }
    else {
      if (effectFn.options?.scheduler) {
        effectFn.options.scheduler(effectFn)
      }
      else {
        effectFn()
      }
    }
  }
}

这样修改数据时,trigger 不再只负责直接执行副作用函数。
如果有 scheduler,就把执行权交给 scheduler;如果没有,就保持原来的立即执行逻辑。

总结

这一篇主要完成了三件事:

  • effecttracktrigger 拆到真实文件结构里
  • targetMap -> propMap -> typeMap -> depSet 保存更完整的依赖关系
  • effect 增加了 lazyscheduler 两个配置能力

这样的话,依赖收集和派发更新就关联起来了