完善 effect、track 和 trigger
上一篇已经梳理了 effect 的基本逻辑:执行副作用函数时收集依赖,响应式数据变化时再触发这些副作用函数。
这一篇开始把这套逻辑拆到项目结构里,并把 track、trigger 和 effect 串起来。
最终的依赖结构大概是这样:

可以先把这张图理解成四层关系:
targetMap:保存所有响应式对象的依赖关系propMap:保存某个对象下,不同属性的依赖关系typeMap:保存某个属性下,不同读取行为的依赖关系depSet:真正保存副作用函数的集合
依赖不是简单按属性保存,而是会继续区分读取行为。比如读取属性、判断属性是否存在、遍历对象,它们后续要被不同的修改行为触发。
改造
1. 创建 effect
先创建 effect/effect.js,这里主要放四部分内容:
activeEffect:当前正在执行的副作用函数targetMap:全局依赖关系effectStack:处理嵌套effectcleanup:清理旧依赖
/**
* 用于记录当前活动的 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
这段逻辑可以按顺序拆开看:
- 如果当前不允许收集,或者没有正在执行的
effect,直接返回。 - 先根据
target找到对象对应的propMap。 - 再根据
key找到属性对应的typeMap。 - 最后根据读取行为
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。
当 lazy 为 false 时,保持默认行为,立即执行。
当 lazy 为 true 时,只返回 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;如果没有,就保持原来的立即执行逻辑。
总结
这一篇主要完成了三件事:
- 把
effect、track、trigger拆到真实文件结构里 - 用
targetMap -> propMap -> typeMap -> depSet保存更完整的依赖关系 - 给
effect增加了lazy和scheduler两个配置能力
这样的话,依赖收集和派发更新就关联起来了