完善副作用机制
约 5927 字大约 20 分钟
2026-03-21
缺陷
在最开始的章节中,用
buckets来存储副作用相关的函数,它是一个Set集合, 它的执行逻辑是只要 get 发生了,就把当前
effect放到buckets中get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }只要 set 发生了,就把
buckets中的所有副作用函数重新执行一遍set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver); buckets.forEach((effect) => effect()); return result; }set根本不知道这次改的是哪个key引起了这次更新,也不知道哪个effect真正依赖这个key建立映射关系
要解决上面的缺陷,可以先把核心拆成三个角色来看待:
提示
- 被读取的代理对象 ->
target - 被读取的属性名 ->
key - 通过
effect(fn)注册的副作用函数 ->effectFn
那么就有以下几种可能性:
- 一个副作用函数读取一个对象的一个属性,就是
target -> key -> effectFn - 两个副作用函数读取了同一个对象的同一个属性,那么同一个
key就会对应多个副作用函数 - 一个副作用函数读取了同一个对象的多个属性,那么一个
effectFn也会和多个key建立关系 - 不同的副作用函数读取的是不同对象上的不同属性,关系就会继续向外扩展,分别建立关系
把这些情况合在一起以后,最终就会整理出下面这种数据结构

最终的依赖数据结构 重要
整个依赖结构就是
WeakMap -> Map -> Set:最外层按target区分,中间层按key区分,最内层再保存当前属性对应的副作用函数集合代码
effect.tsconst buckets = new WeakMap<object, Map<PropertyKey, Set<() => void>>>(); let activeEffect: (() => void) | undefined; function effect(fn: () => void) { activeEffect = fn; fn(); } function track(target: object, key: PropertyKey) { // 没有正在注册的副作用函数时,当前读取不需要收集依赖 if (!activeEffect) { return; } // 第一层:根据 target 找到当前对象对应的依赖表 let depsMap = buckets.get(target); if (!depsMap) { buckets.set(target, (depsMap = new Map())); } // 第二层:根据 key 找到当前属性对应的副作用函数集合 let deps = depsMap.get(key); if (!deps) { depsMap.set(key, (deps = new Set())); } // 第三层:把当前正在收集的副作用函数加入集合 deps.add(activeEffect); } function trigger(target: object, key: PropertyKey) { const depsMap = buckets.get(target); if (!depsMap) { return; } const deps = depsMap.get(key); if (!deps) { return; } deps.forEach((effectFn) => effectFn()); } effect(function effectFn() { layer.innerHTML = proxy.name; });handler.tsconst handler = { get(target: object, key: PropertyKey, receiver: object) { const result = Reflect.get(target, key, receiver); track(target, key); return result; }, set(target: object, key: PropertyKey, value: unknown, receiver: object) { const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; }, };- 被读取的代理对象 ->
副作用函数的依赖清理
当副作用函数内部存在分支逻辑时,依赖关系就不是固定不变的。比如下面这个三元表达式:
effect(function effectFn() { layer.innerHTML = proxy.flag ? proxy.name : proxy.age; });当
flag为true时,当前副作用函数会读取flag和name。当flag为false时,当前副作用函数会读取flag和age这就意味着:副作用函数每次执行时,真正参与依赖收集的属性可能都不一样。如果不把上一次执行留下来的依赖关系清掉,就会出现旧依赖残留的问题。比如先把
flag改成false,这时候界面其实已经不再依赖name了,但后面修改name,effectFn还是会重新执行重要
核心其实就一句:每次副作用函数重新执行前,先把自己从旧依赖里删掉,再按本轮真实读取重新收集
为了做到这一点,可以直接在
effectFn自己身上挂一个deps数组,临时记录当前副作用函数被收集进了哪些Set。这样每次重新执行副作用函数之前,就可以先遍历effectFn.deps,把它从这些Set中删除,再把deps数组清空,最后再重新建立本轮依赖关系
effectFn 反向记录依赖集合 关键点
effect(fn)这里不再直接把用户传入的fn挂到activeEffect上,而是再包一层effectFn,把依赖清理和重新收集的时机都收口到这个包装函数里track在把activeEffect收集进deps之后,还要把当前这个deps反向记录到activeEffect.deps上,这样后面cleanup才知道要去哪些集合里删除自己trigger触发时不能直接遍历原来的deps,而是要先拷贝出一个新的Set再执行;否则副作用函数执行过程中一边清理一边重新收集,就会和当前遍历的集合相互影响
代码
effect.tslet activeEffect; function effect(fn) { const effectFn = () => { // 每次重新执行前,先把上一次留下来的依赖关系清掉 cleanup(effectFn); // 执行用户函数前,把当前包装后的副作用函数挂到 activeEffect 上 activeEffect = effectFn; fn(); }; // 用来反向记录:当前 effectFn 被收集进了哪些依赖集合 effectFn.deps = []; effectFn(); } function cleanup(effectFn) { const { deps } = effectFn; if (!deps.length) { return; } for (let i = 0; i < deps.length; i++) { // deps[i] 就是某个属性对应的副作用函数集合 deps[i].delete(effectFn); } // 当前这轮旧依赖全部删除后,再把记录清空,等待下一次重新收集 deps.length = 0; } function track(target, key) { if (!activeEffect) { return; } let depsMap = buckets.get(target); if (!depsMap) { buckets.set(target, (depsMap = new Map())); } let deps = depsMap.get(key); if (!deps) { depsMap.set(key, (deps = new Set())); } deps.add(activeEffect); // 把当前依赖集合反向记录到 effectFn 身上,后面 cleanup 会用到 // 这里面存进去的是两个不同 key 对应的 Set,而这两个 Set 里保存的成员其实都是同一个 effectFn activeEffect.deps.push(deps); } function trigger(target, key) { const depsMap = buckets.get(target); if (!depsMap) { return; } const deps = depsMap.get(key); if (!deps) { return; } const effectsToRun = new Set(deps); effectsToRun.forEach((effectFn) => effectFn()); }index.tseffect(function effectFn() { console.log('fn'); layer.innerHTML = proxy.flag ? proxy.name : proxy.age; }); btn1.addEventListener('click', () => { proxy.flag = false; }); btn2.addEventListener('click', () => { proxy.name = '李四'; });这里最容易绕住的一点是:
flag改变以后,并不是重新创建了一个新的effectFn,而是重新执行了之前已经创建并收集进去的那个effectFn反向依赖的清理过程:
- 调用
effect(fn)时,会先创建一次包装后的effectFn - 创建完成后,会先执行
effectFn.deps = [],给当前副作用函数准备一个数组,用来反向记录 "自己被收集进了哪些依赖集合" - 接着首次执行
effectFn(),进入函数内部以后,会先cleanup(effectFn),但第一次deps还是空数组,所以这里不会删任何内容 - 然后把
activeEffect指向当前这个effectFn,再去执行传入的fn() fn()内部读取了flag,于是会触发get -> track(target, 'flag')track会先把这个effectFn收集到flag对应的Set里,然后再执行activeEffect.deps.push(deps),把当前这个Set反向记录到effectFn.deps上- 当后面执行
proxy.flag = false时,会触发set -> trigger(target, 'flag') trigger会找到flag对应的依赖集合deps- 这个
deps里面保存的,就是之前已经收集进去的那个effectFn trigger再把这个旧的effectFn取出来重新执行,所以它当然还能在开头调用cleanup(effectFn),把自己上一次留下来的依赖先清掉cleanup(effectFn)执行时,会遍历effectFn.deps,把这个副作用函数从上一次收集到的那些Set里逐个删除- 删除完成以后,会把
effectFn.deps.length重置为0,这样上一轮依赖关系就被清空了 - 接着继续往下执行,把
activeEffect再次指向当前这个effectFn - 然后重新执行用户传入的
fn() - 这一次执行
fn()时,会先再次读取flag - 因为现在
flag已经变成了false,所以三元表达式后续真正会读取的是age,而不是上一轮的name - 于是这一轮
track最终重新建立出来的依赖关系就会变成flag + age - 这样后面如果再去修改
name,由于当前这轮依赖里已经没有它了,effectFn就不会再被错误触发
处理嵌套的 effect
effect本身也有可能发生嵌套。在 Vue 组件的渲染过程中,父组件执行渲染函数时,很可能会继续执行子组件的渲染逻辑放到现在这个简化版响应式系统里,本质上就相当于外层
effect内部又触发了一次新的effecteffect(() => { Foo.render(); effect(() => { Bar.render(); }); });但前面的实现还不支持这种情况。比如下面这个例子中,外层副作用函数负责读取
proxy.name,内层副作用函数负责读取proxy.ageeffect(function effectFn1() { console.log('外层 effectFn1 执行'); effect(function effectFn2() { console.log('内层 effectFn2 执行'); layer2.innerHTML = proxy.age; }); layer1.innerHTML = proxy.name; }); btn1.addEventListener('click', () => { proxy.name = '李四'; }); btn2.addEventListener('click', () => { proxy.age = 30; });正常情况下,外层
effectFn1和内层effectFn2分别会和自己读取到的属性建立关系,而页面首次打开时,打印结果是:外层 effectFn1 执行 内层 effectFn2 执行这个阶段看起来一切正常。但当点击按钮修改
proxy.name的时候,理想情况应该是重新触发外层effectFn1,而外层再次执行时,又会顺带执行一次内层effectFn2也就是说,理论上应该再次打印,可实际在只有单个
activeEffect的时候,最终往往只会留下内层副作用函数的记录,导致修改proxy.name时,真正被触发的不是外层effectFn1,而是内层effectFn2重要
问题的根源不在于有没有收集依赖,而在于:嵌套执行时,当前 "正在收集依赖的副作用函数" 到底是谁。单独一个
activeEffect变量在进入内层effect时就会被覆盖掉这件事可以拆成这样来理解:
- 外层
effectFn1开始执行时,会先把activeEffect设为effectFn1 - 还没等外层执行完,就在内部又调用了
effect(effectFn2) - 内层
effectFn2执行时,又会把activeEffect覆盖成effectFn2 - 如果此时没有一套 "恢复上一层 effect" 的机制,那么等外层继续往下读取
proxy.name时,当前被收集进去的就不再是外层effectFn1,而会错误地变成内层effectFn2
解决嵌套问题,关键不是再加一个新的全局变量,而是引入一个 副作用函数栈
effectStack。每次副作用函数执行前,先把自己压入栈顶;执行完成后,再把自己弹出,并且始终让activeEffect指向当前栈顶的副作用函数这样嵌套多少层都没有关系,因为每一层执行结束以后,都能恢复到上一层真正正在运行的那个副作用函数
关键点
activeEffect表示 "当前这一刻正在收集依赖的是谁"effectStack表示 "嵌套执行时,各层副作用函数的调用上下文"- 内层副作用函数执行结束以后,必须把
activeEffect恢复成上一层,也就是当前栈顶的那个副作用函数
代码
effect.tslet activeEffect; const effectStack = []; function effect(fn) { const effectFn = () => { cleanup(effectFn); // 进入当前副作用函数前,先把它记为当前激活的副作用函数 activeEffect = effectFn; // 再把当前副作用函数压入栈顶 effectStack.push(effectFn); fn(); // 当前副作用函数执行完成后,将其弹出栈 effectStack.pop(); // activeEffect 始终指向当前栈顶的副作用函数 activeEffect = effectStack[effectStack.length - 1]; }; effectFn.deps = []; effectFn(); }index.tseffect(function effectFn1() { console.log('外层 effectFn1 执行'); effect(function effectFn2() { console.log('内层 effectFn2 执行'); layer2.innerHTML = proxy.age; }); layer1.innerHTML = proxy.name; }); btn1.addEventListener('click', () => { proxy.name = '李四'; }); btn2.addEventListener('click', () => { proxy.age = 30; });effectStack的工作过程- 外层
effectFn1执行时,会先把activeEffect设置为effectFn1,然后把它压入effectStack - 接着执行外层副作用函数内部的逻辑,在这个过程中又调用了内层
effect - 内层
effect执行时,会先把activeEffect设置为effectFn2,然后再把它压入effectStack - 随后执行内层副作用函数,这时依赖收集对应的就是内层
effectFn2 - 内层副作用函数执行结束以后,会先把
effectFn2从栈中弹出 - 弹栈完成后,栈顶重新变回外层
effectFn1,因此再把activeEffect恢复为effectFn1 - 这样外层继续读取
proxy.name时,最终建立起来的依赖关系才还是name -> effectFn1 - 修改
name时,真正被trigger找到并重新执行的是外层effectFn1;内层effectFn2之所以也会打印,是因为它定义并执行在外层effectFn1的函数体内部,外层重新执行时会连带把内层这段逻辑再跑一遍
- 外层
无限递归的处理
解决了嵌套
effect以后,代码里还有一个很严重的问题:副作用函数在执行过程中,如果既读取了某个响应式属性,又修改了同一个属性,就会把自己再次触发起来,最终形成无限递归比如下面这段代码:
effect(function effectFn() { console.log('fn'); proxy.age++; });乍一看这里只是做了一次自增,但它实际上等价于:
effect(function effectFn() { console.log('fn'); proxy.age = proxy.age + 1; });这样一拆开以后,问题就清楚了:
- 先读取
proxy.age - 读取会触发
track,于是当前副作用函数effectFn会被收集到age对应的依赖集合里 - 然后再给
proxy.age赋新值,赋值会触发trigger trigger又会把刚刚收集进去的这个effectFn重新取出来执行- 可这时候第一次执行其实还没有结束,于是它又开始第二次执行
- 第二次执行过程中又会重复同样的读取和设置
- 最终就会不断地递归调用自己
重要
问题的根源是:
trigger在触发副作用函数时,把 "当前正在执行的那个副作用函数" 也一起取出来再次执行了既然如此,处理思路就很直接:在
trigger里构造待执行副作用函数集合时,如果某个副作用函数和当前的activeEffect是同一个,就不要把它重新加入本轮执行队列也就是说,之前这种“把当前
deps里的内容全部放进effectsToRun”的做法已经不够用了。现在构造effectsToRun时,需要先做一层过滤关键点
track收集的是 "当前正在执行的副作用函数"trigger触发时,要避免把 "当前这一轮正在执行的副作用函数" 再次取出来执行- 所以判断条件其实很简单:
effectFn !== activeEffect
代码
trigger.tsfunction trigger(target, key) { const depsMap = buckets.get(target); if (!depsMap) { return; } const deps = depsMap.get(key); const effectsToRun = new Set(); deps && deps.forEach((effectFn) => { // 当前正在执行的副作用函数不应该再次触发自己 if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); effectsToRun.forEach((effectFn) => effectFn()); }index.tseffect(function effectFn() { console.log('fn'); proxy.age++; });执行顺序
- 调用
effect(effectFn),第一次执行副作用函数 - 执行到
proxy.age++时,先读取proxy.age - 读取触发
track(target, 'age'),当前副作用函数被收集到age对应的依赖集合中 - 然后再对
proxy.age赋值 - 赋值触发
trigger(target, 'age') trigger会遍历age对应的副作用函数集合- 如果不做判断,就会把当前这个仍在执行中的副作用函数再次加入待执行队列
- 这样它就会在自己还没执行完的时候,再次触发自己
- 因此这里必须过滤掉
activeEffect,只执行那些“不是当前正在运行的副作用函数”
- 先读取
已封装
effect的边界处理到这里还有一个容易被忽略的边界问题:如果传给
effect的,本身就已经是一个包装过的副作用函数,那么就不应该再重复包一层比如:
const effectFn = effect(() => { console.log('副作用函数'); }); effect(effectFn);如果不做处理,第二次调用
effect(effectFn)时,传进去的已经不是最原始的用户函数了,而是前一次创建出来的包装函数。这样继续再包一层,虽然勉强也能执行,但会让副作用函数一层套一层,既没有必要,也会让后续的依赖收集和调度关系变得更乱所以这里可以在副作用函数身上额外挂两个字段:
_isEffect:用来标记当前函数已经是一个副作用函数raw:用来保存最原始的用户函数
这样在后面执行
effect(fn)时,如果发现传入的fn本身已经是一个副作用函数,就可以直接取它的raw,避免重复封装代码
effect.tsfunction effect(fn, options = {}) { // 如果传进来的已经是一个包装后的副作用函数,就直接取出原始函数 if (fn._isEffect) { fn = fn.raw; } const effectFn = () => { cleanup(effectFn); activeEffect = effectFn; effectStack.push(effectFn); const res = fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; return res; }; effectFn.options = options; effectFn.deps = []; effectFn._isEffect = true; effectFn.raw = fn; if (!options.lazy) { effectFn(); } return effectFn; }for...in 的响应式处理
前面已经解决了普通属性读取、分支切换、嵌套
effect和无限递归的问题,但还有一个细节不能漏掉:for...in循环本身也应该具备响应式能力比如下面这段代码:
effect(() => { console.log('---触发---'); for (const key in proxy) { console.log(key); } });。 proxy.bar = 'bar';按照直觉来说,第一次执行副作用函数时会遍历对象上的所有属性;后面如果给对象新增了
bar属性,那再次执行时,循环次数显然应该发生变化,所以副作用函数也应该重新执行一次但如果只按普通属性的
key去做依赖收集,就会遇到一个问题:for...in循环并不是在读取某个具体属性值,而是在依赖 "这个对象当前有哪些可枚举属性"也就是说,它依赖的不是某个明确的
key,而是对象整体的可枚举键集合重要
for...in依赖的不是单个属性值,而是对象的 "可枚举键集合"。因此这里不能继续沿用具体属性名作为依赖标识,而是要额外引入一个专门表示 "遍历行为" 的标识处理方式也很直接:定义一个唯一的
Symbol,专门用来表示 "当前副作用函数依赖的是对象的遍历结果",比如:const ITERATE_KEY = Symbol('');接着,在
for...in循环真正会触发的代理拦截里收集这个依赖。对Proxy来说,对象被遍历键名时触发的是ownKeys,所以这里不能只写get和set,还要把ownKeys也补上代码
handler.tsconst ITERATE_KEY = Symbol(''); const handler = { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; }, ownKeys(target) { track(target, ITERATE_KEY); return Reflect.ownKeys(target); }, };index.tseffect(() => { console.log('---触发---'); for (const key in proxy) { console.log(key); } }); proxy.bar = 'bar';但只做到这一步还不够。因为即使
ownKeys已经把当前副作用函数收集到了ITERATE_KEY对应的依赖集合里,后面proxy.bar = 'bar'触发trigger时,传进去的仍然是具体属性名bar,并不会自动去找ITERATE_KEY对应的副作用函数所以在
trigger里,除了取出当前具体key对应的副作用函数集合,还要额外把ITERATE_KEY对应的那一组副作用函数也取出来,一并加入待执行队列为什么这里还要额外处理
ITERATE_KEYfor...in第一次执行时,会触发ownKeysownKeys内部执行track(target, ITERATE_KEY),于是当前副作用函数会被记录到ITERATE_KEY对应的依赖集合中- 后面执行
proxy.bar = 'bar'时,会触发set set内部调用trigger(target, 'bar')- 如果
trigger只去取bar对应的副作用函数集合,那就找不到for...in这类遍历相关的副作用函数 - 因此这里必须再额外取出
ITERATE_KEY对应的副作用函数集合,并一起加入effectsToRun
代码
trigger.tsfunction trigger(target, key) { const depsMap = buckets.get(target); if (!depsMap) { return; } const deps = depsMap.get(key); const effectsToRun = new Set(); deps && deps.forEach((effectFn) => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); // 再额外取出与 for...in 遍历相关的副作用函数 const iterateEffects = depsMap.get(ITERATE_KEY); iterateEffects && iterateEffects.forEach((effectFn) => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); effectsToRun.forEach((effectFn) => effectFn()); }不过这里其实还可以继续优化:并不是所有
set都应该触发ITERATE_KEY对应的副作用函数。真正会影响for...in结果的,是新增属性或删除属性,而不是单纯修改某个已有属性的值也就是说,像下面这种修改:
proxy.foo = 'new value';并不会改变对象有几个键,
for...in的遍历结果也不会变,所以严格来说,不需要重新触发遍历相关的副作用函数这一层更细的区分,需要结合后面“操作类型(例如
ADD、SET、DELETE)”一起来处理。这里先把for...in的基础响应式能力建立起来,后面再继续细化
