Vue响应式原理(6)-调度器实现

1. 可调度性

可调度性指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

js 复制代码
const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }

effect(() => {
    console.log(obj.foo)
})
obj.foo++

console.log('end')

在副作用函数中打印 obj.foo,完成了依赖收集过程。随后对 obj.foo 进行自增操作,触发依赖会使副作用函数再次执行。因此最终打印结果为:

arduino 复制代码
1
2
end

假如我们想在不调整代码顺序的情况下让输出结果变成:

arduino 复制代码
1
end
2

换言之,我们想让副作用函数的触发延迟执行,此时我们可以为 effect 函数设计一个选项参数 options:

js 复制代码
effect(
    () => {
        console.log(obj.foo)
    },
    // options
    {
        // 调度器 scheduler
        scheduler(fn) {
            // ...
        }
    }
)

2. 调度器实现

options 是一个对象,其中有 scheduler 调度器属性,对象形式方便后期继续扩展其他属性。scheduler 是一个函数,可以用于自行决定副作用函数的执行形式与执行时机。在 effect 注册副作用函数过程中,将 options 对象作为第二个参数传入,我们需要将其作为一个属性挂载在副作用函数上,在后续触发响应的时候可以获取到 options。因此我们需要对 effect 函数进行相应的修改:

js 复制代码
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn

        effectStack.push(effectFn)
        fn()

        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }

    // 将 options 挂载到 effectFn 上
    effectFn.options = options
    effectFn.deps = []
    effectFn()
}

有了调度函数,我们在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:

js 复制代码
function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    // 根据 key 取得所有副作用函数 effects
    const effects = depsMap.get(key);
    const effectsToRun = new Set();
    effects && effects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    });
    
    effectsToRun.forEach((effectFn) => {
        // 如果副作用函数存在options.scheduler调度器属性,
        // 则调用该调度器,并将副作用函数作为参数传递给调度器
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

3. 调度器改变副作用函数执行顺序

如上面的代码所示,在 trigger 动作触发副作用函数执行时,我们优先判断该副作用函数是否存在options.scheduler 调度器,如果存在,则把当前副作用函数作为参数传递给调度器函数并执行调度器函数,由用户自己控制副作用函数的执行形式与执行时机;如果不存在调度器属性,则直接执行副作用函数。

有了调度器的设置,我们就能实现之前所描述的需求,改变代码的执行顺序:

js 复制代码
const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }

effect(() => {
    console.log(obj.foo)
},{
    options: {
        scheduler(fn) {
            // 将副作用函数作为参数传递给setTimeout,使其变为宏任务,在同步任务执行完后执行
            setTimeout(fn)
        }
    }
})
obj.foo++

console.log('end')

在 scheduler 中,我们将取到的副作用函数 fn 传递给 setTimeout 函数,就可以开启一个宏任务来执行副作用函数 fn,这样第二次副作用函数的执行就会在所有同步代码执行完成后,就能获得我们预期的输出:

js 复制代码
1
end
2

4. 调度器改变代码执行次数

在下面这段代码中,我们在副作用函数中读取了 obj.foo,随后对 obj.foo 进行了两次自增操作:

js 复制代码
const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }

effect(() => {
    console.log(obj.foo)
})
obj.foo++
obj.foo++

显而易见,副作用函数会执行三次,分别是在 effect 中注册的时候初次调用,两次自增操作都会触发依赖调用副作用函数,因此输出结果为:

js 复制代码
1
2
3

以上的输出结果显然符合预期,但是仔细思考其实我们对于 obj.foo 中间的自增操作并不关心,无论 obj 的 foo 属性自增了多少次,我们所关心的都是他的最终值。因此我们希望所有中间的自增操作都不会触发副作用函数的执行。这同样需要借助调度器来实现:

js 复制代码
// 定义一个任务队列
const jobQueue = new Set();
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve();
// 一个标志代表是否正在刷新队列
let isFlushing = false;
function flushJob() {
  // 如果队列正在刷新,则什么都不做
  if (isFlushing) return;
  // 设置为 true,代表正在刷新
  isFlushing = true;
  // 在微任务队列中刷新 jobQueue 队列
  p.then(() => {
    jobQueue.forEach((job) => job());
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false;
  });
}

effect(
  () => {
    console.log(obj.foo);
  },
  {
    scheduler(fn) {
      // 每次调度时,将副作用函数添加到 jobQueue 队列中
      jobQueue.add(fn);
      // 调用 flushJob 刷新队列
      flushJob();
    },
  }
);

在这里我们首先设置了一个 Set 类型的变量 jobQueue,其主要作用是存储所有需要执行的副作用函数,选用 Set 类型是为了利用其自动去重的能力,防止相同的副作用函数重复添加。随后我们在全局添加了一个 isFlushing 变量来表示队列是否在刷新中。同时我们设置了 flushJob 函数,在该函数中,我们首先对 isFlushing 变量值进行判断:如果值为 true 则表明当前队列在刷新中,因此直接退出不做任何操作;如果值为 false 则可以进入后续代码,首先将 isFlushing 变量置为 true,防止后续 flushJob 函数重复执行,这就表明我们无论重复多少次 flushJob 调用都只会在一个周期内执行一次。我们设置了一个 promise 实例变量 p,在 flushJob 内通过 p.then 将一个函数添加到微任务队列,在微任务队列内完成对 jobQueue 的遍历执行,并在 p.finally 将 isFlushing 变量值复原为 false,使得后续 flushJob 函数能正常进入。

整段代码的效果是,连续对 obj.foo 执行两次自增操作,会同步 且连续地执行两次 scheduler 调度函数,这意味着同一个副作用函数会被 jobQueue.add(fn) 语句添加两次,但由于 Set 数据结构的 去重能力,最终 jobQueue 中只会有一项,即当前副作用函数。类似地,flushJob 也会同步且连续地执行两次,但由于 isFlushing 标志的存在,实际上 flushJob 函数在一个事件循环内只会执行一次,即在微任务队列内执行一次。当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。由于此时 jobQueue 队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段 obj.foo 的值已经是 3 了,这样我们就实现了期望的输出:

复制代码
1
3

这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,但是大致实现思路是相同的。

相关推荐
Autumn_ing3 小时前
Design.md生成UI怎么验收?举例运营后台的颜色、状态和数据核对
前端·ui·ai·设计系统·paico·ai生成ui·design.md
keyipatience3 小时前
JavaScript学习(1)书写位置,变量、数据类型、运算符
开发语言·前端·javascript·css·学习
朱涛的自习室4 小时前
我给 Gemini 4 和 DeepSeek 搞了套「末位淘汰」,AI 干活会更猛吗?(Day 3)
android·前端·后端
java1234_小锋6 小时前
【技术专题】HTML5 网页技术 - HTML5简介 & HTML5 文档结构
前端·html·html5
鶴哥只手遮天6 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 1 章 项目全景:monorepo 架构与工程化体系
前端·vue.js·开源
流星。。方6 小时前
小团队官网SEO落地:前端语义化与静态生成的工程实践
前端·状态模式
用户530429765797 小时前
盘点危化品搬运诊断的6类工具与自处理边界判断
前端
curtain7 小时前
从古法编程到 Agent:我入行这两年,AI 改变了什么
前端·openai
zyfts7 小时前
10分钟自制Codex钉钉通知Skill:AI干完活自动钉钉提醒你
前端
大孜然7 小时前
孜然QQ频道自动化程序机器人配置教程
前端