实现调度执行
约 1749 字大约 6 分钟
2026-03-29
可调度性
前面已经把副作用函数的依赖关系处理得比较完整了:
- 读取属性时通过
track收集依赖 - 修改属性时通过
trigger找到对应的副作用函数 - 触发更新时默认会立即重新执行副作用函数
但这里还有一个问题:副作用函数一旦被
trigger找到,就会立刻执行,执行时机完全固定比如下面这段代码:
const obj = { age: 18 } effect(function effectFn() { layer.innerHTML = proxy.age; console.log(proxy.age); }); proxy.age++; console.log('结束了');现在它的输出顺序一定是:
18 19 结束了原因也很简单
proxy.age++触发set以后,trigger会立刻把依赖age的副作用函数重新执行一遍但很多时候,并不希望它立刻执行,而是希望能够自己决定:什么时候执行、一次修改执行几次、是同步执行,还是异步执行。所以就需要一个可调度的副作用函数
重要
所谓可调度性,说的就是当
trigger找到某个副作用函数以后,不是立刻执行它,而是把 "怎么执行" 的控制权交出去- 读取属性时通过
给
effect增加调度器选项要做到这一点,最直接的方式就是在调用
effect时,允许传入第二个参数。这个参数通常是一个配置对象,其中最关键的属性就是scheduler这样一来,副作用函数本身除了记录依赖关系,还可以顺手挂上自己的调度策略
这里有一个细节要注意:
调度器控制的是 副作用函数后续被
trigger触发时如何执行,而不是第一次注册时是否执行。第一次注册effect的时候,仍然会先执行一次,用来完成初始依赖收集代码
effect.tslet activeEffect; const effectStack = []; function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn); activeEffect = effectFn; effectStack.push(effectFn); fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; }; // 把调度器等配置挂到当前副作用函数身上 effectFn.options = options; effectFn.deps = []; // 首次注册时仍然先执行一次,用来建立依赖关系 effectFn(); }把执行权从
trigger交给scheduler既然副作用函数身上已经挂上了
scheduler,那么接下来就应该在trigger里使用它。之前的trigger做法很直接,只要找到了对应的副作用函数,就立刻执行effectsToRun.forEach((effectFn) => effectFn());接下来就是实现这个
scheduler的逻辑了:关键点
- 如果当前副作用函数存在
scheduler,就不直接执行 - 而是把当前副作用函数交给
scheduler来执行 - 如果没有
scheduler,再退回原来的默认行为
这样以后每次
trigger发生时,副作用函数是否立即执行,就不再是写死的了代码
effect.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) => { // 如果 effect.options.schedular 存在,则执行 effect.options.schedular // 并将副作用函数作为参数传递进去 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { // 否则直接执行副作用函数 effectFn(); } }); }index.tseffect( function effectFn() { layer.innerHTML = proxy.age; console.log(proxy.age); }, { scheduler(effectFn) { setTimeout(effectFn, 0); }, }, ); proxy.age++; console.log('结束了');这样再执行上面的代码,输出顺序就会变成:
18 结束了 19原因也很清楚:
- 第一次调用
effect时,副作用函数会立即执行,所以先打印18 - 执行
proxy.age++时,trigger找到了这个副作用函数 - 但这次不是直接执行,而是把它交给
scheduler scheduler里用了setTimeout,所以真正的重新执行被放到了下一轮任务里- 当前同步代码继续往下走,于是先打印
结束了 - 等宏任务执行时,副作用函数才重新运行,于是再打印
19
- 如果当前副作用函数存在
任务队列去重
如果调度器只能控制 "同步还是异步",那作用其实还不够大。它更重要的意义在于:可以把多次重复触发合并成一次执行
看下面这段代码:
effect(function effectFn() { layer.innerHTML = proxy.age; console.log(proxy.age); }); proxy.age++; proxy.age++; proxy.age++; proxy.age++;默认情况下,它会输出:
18 19 20 21 22但对于界面更新来说,很多时候我们并不关心中间过程,而只关心最后一次结果。也就是说,这里更理想的行为其实是:
18 22要做到这一点,就可以在调度器里引入一个任务队列,把短时间内重复触发的副作用函数先收集起来,等当前同步任务全部结束以后,再统一执行
实现这个思路需要三个部分:
- 一个
Set类型的任务队列jobQueue,用来自动去重 - 一个
Promise.resolve(),用来把刷新逻辑放进微任务队列 - 一个
isFlushing标记,避免在同一个事件循环里重复刷新
代码
scheduler.tsconst jobQueue = new Set(); const p = Promise.resolve(); let isFlushing = false; function flushJob() { if (isFlushing) { return; } isFlushing = true; p.then(() => { jobQueue.forEach((job) => job()); }).finally(() => { isFlushing = false; }); }index.tseffect( function effectFn() { layer.innerHTML = proxy.age; console.log(proxy.age); }, { scheduler(effectFn) { jobQueue.add(effectFn); flushJob(); }, }, ); proxy.age++; proxy.age++; proxy.age++; proxy.age++;这段代码执行时,整个过程可以拆成下面几步来看:
- 第一次执行
proxy.age++时,会触发set,进而触发trigger trigger发现当前副作用函数存在scheduler,所以不会直接执行effectFn,而是把它交给schedulerscheduler先把effectFn添加到jobQueue中- 随后调用
flushJob(),这时isFlushing还是false,所以会先把它改成true - 接着通过
p.then(...)把 "刷新任务队列" 这件事放进微任务队列中 - 第二次以及后续每一次执行
proxy.age++时,仍然会重复触发set -> trigger -> scheduler - 在这些后续触发中,
scheduler还是会尝试把effectFn添加到jobQueue - 但因为
jobQueue是Set,所以同一个effectFn不会重复加入,队列里始终只有这一份副作用函数 - 同时后续每一次都会再次调用
flushJob(),但这时isFlushing已经是true了,所以会直接返回,不会重复注册新的微任务 - 等到当前这轮同步代码全部执行结束以后,微任务队列开始执行
- 这时
p.then(...)中的逻辑会遍历jobQueue并执行里面的副作用函数 - 由于队列中最终只有一个
effectFn,所以副作用函数也只会重新执行一次
最终打印结果就是:
18 22为什么
jobQueue要用Set因为连续多次修改同一个响应式数据时,被加入队列的其实往往是同一个副作用函数。
Set天生具备去重能力,这样无论加入多少次,队列里最终都只会保留一份- 一个
