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 内部实现了一个更加完善的调度器,但是大致实现思路是相同的。

相关推荐
子兮曰4 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰4 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万4 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝4 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
A黄俊辉A4 天前
uniapp webview中实现 app和内嵌的H5双向通信
vue.js·json
三十而立洋4 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁4 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
honkun64 天前
vue 表格组件 vxe-table 配置 ajax 请求自动加载数据与表单查询
vue.js·vxe-table
kybs19914 天前
全球灾害数据分析可视化 毕业设计-附源码66794
vue.js·spring boot·mysql·安全·django·c#·asp.net
汉堡大王95274 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端