深入理解Vue3.js响应式系统设计之调度执行

285 阅读7分钟

如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~

作者:前端小王hs

阿里云社区博客专家/清华大学出版社签约作者/csdn百万访问前端博主/B站千粉前端up主

此篇文章是博主于2022年学习《Vue.js设计与实现》时的笔记整理而来

书籍:《Vue.js设计与实现》 作者:霍春阳

本篇博文将在书第4.7节的基础上进一步解析,附加了测试的代码运行示例,以及对书籍中提到的ES6中的数据结构及其特点进行阐述,方便正在学习Vue3想分析Vue3源码的朋友快速阅读

如有帮助,不胜荣幸

前置章节:

  1. 深入理解Vue3.js响应式系统基础逻辑
  2. 深入理解Vue3.js响应式系统设计之栈结构和循环问题

调度执行

在前面的章节我们学习到,只要修改了代理对象obj的属性就会触发trigger()执行,那么在4.7节,作者向我们介绍了如何去实现可控制的去执行trigger(),也就是决定副作用函数执行的时机、次数以及方式(原文)

那么在这一节,将会涉及到宏任务微任务知识,这是JavaScript高阶知识,我们先来介绍下

JavaScript中,异步任务被分为两种主要的类别:宏任务(MacroTask)和微任务(MicroTask)

这两种任务类型在事件循环(Event Loop) 中被处理,但它们有不同的执行优先级和时机

宏任务包括:

  1. script(整体代码)
  2. setTimeout
  3. setInterval
  4. setImmediate(Node.js环境)
  5. I/O
  6. UI渲染(浏览器)
  7. MessageChannel(消息通道)
  8. postMessage
  9. requestAnimationFrame(浏览器)

微任务包括:

  1. Promise.then() 或 .catch()
  2. MutationObserver(浏览器)
  3. process.nextTick(Node.js环境)
  4. queueMicrotask()(较新的API)

执行的过程是,在每次宏任务执行完后,会检查微任务队列是否有微任务,如果有,那么执行(且执行完所有微任务),如果没有,则从宏任务队列中继续执行下一个宏任务。也就是说,微任务是在两个宏任务之间执行的

而如果存在同步代码,则宏任务队列异步函数会在同步代码执行后再执行,可看下面这个例子:

执行情况

现在,我们来看一个需求

const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });

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

obj.foo++;

console.log('结束了');

这段代码中,正常的输出顺序是12、最后是结束了,这点非常简单,但如果想把2放在结束了之后呢?那么很明显,就需要控制副作用函数的执行时机

结合上面我们说的宏任务,是不是思路就来了?把结束了这个script语句放到执行完fn()之后即可,下面我们来看看是如何实现的

第一步:给effect函数涉及一个选项参数options,其实就是传入一个对象,对象内是一个可执行函数,参数为fn(),代码如下:

// 书中源代码
effect(
  () => {
    console.log(obj.foo);
  },
  // options
  {
    // 调度器 scheduler 是一个函数  
    scheduler(fn) {
      // ... 这里是调度器函数的实现
    }
  }
);

第二步:在effect内部将options挂载到effectFn上,代码如下:

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中进行判断,如果effectFn.options存在scheduler,那么执行scheduler,代码如下:

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set(
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })

  effectsToRun.forEach(effectFn => {
    // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      // 否则直接执行副作用函数(之前的默认行为)
      effectFn()
    }
  })
}

那么现在,当我们传入scheduler(fn)之后,就会被保存在effectFn.options中,当再次触发trigger时,就会执行effectFn.optionsfn(),关键是scheduler里应该怎么处理呢?其实非常简单,只需添加一个setTimeout,然后在定时器里执行fn(),还记得我们在上面说过,这是一个宏任务,代码如下:

// effect()内
{
  scheduler(fn) {  
    // 将副作用函数放到宏任务队列中执行  
    setTimeout(fn);
  }
}

现在,我们来分析一下代码的执行顺序:

  1. 执行effect,那么会输出1,以及把scheduler(fn){}传给options
  2. 执行obj.foo++,那么先会涉及到一个读取,但这里我们无需关心读取,只看trigger
  3. trigger中执行effectFn.optionsfn(),那么这是一个宏任务,会被送进队列中等待当前宏任务执行完成
  4. 由于setTimeout是一个异步函数,而console.log('结束了')是一个同步代码,所以会先输出结束了,然后再输出完成了++之后的2

下面,我们再来看一个更加进阶的操作,我们直接分析需求和实现过程。首先是需求,代码如下:

const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });  

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

obj.foo++;  
obj.foo++;

通过逻辑,我们知道会按顺序输出123,现在的需求是直接输出13,也就是跳过21我们知道,直接由fn()初次执行就会输出,那3呢?

我们来看一下解决的实现过程,代码如下:

// 定义一个任务队列
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();
    }
});

obj.foo++;
obj.foo++;

我们分析一下实现的过程:

  1. 执行effect,那么首先输出当前的1,并且把scheduler里的函数放进effectFn.options
  2. 执行obj.foo++,读取我们还是不分析,我们直接看触发时执行scheduler里的函数
  3. 执行jobQueue.add(fn),这是一个Set数据结构,也就是里面的值是唯一的
  4. 执行flushJob,将isFlushing置为true,然后把jobQueue里的每一项fn()添加到微任务队列中,并且在执行完后会将isFlushing置为false,不过此时并没有执行
  5. 我们知道,微任务之后在宏任务执行完后才会执行,那么此时flushJob执行完了,obj.foo++也执行完成了,foo变为2
  6. 注意!flushJob同步代码obj.foo++也是同步代码,所以先执行obj.foo++,那么在这里的时候,jobQueue.add(fn);是无效的,因为Set里的都是唯一值,那么他会执行flushJob
  7. 执行flushJob时,由于前面我们已经置为了ture,所以直接return
  8. 那么当所有的同步代码执行完后,就查看微任务队列了,就把p.then里的代码拿出来执行
  9. 执行console.log(obj.foo);,此时就已经了是3了!

如何设计只执行一次?结合Set只保存唯一值、微任务的特性和函数开关(布尔值

分析的时候是不是觉得有点绕?只要搞清楚了同步和异步以及宏任务和微任务就清晰了,但步骤确实是有点多,也不得不佩服高级前端工程师的设计思路,一环套一环

如果你看到这里,那么可以和hr夸夸其谈:

  • 什么是宏任务和微任务
  • 有同步函数和异步函数、异步函数有微任务时的执行顺序
  • 如何设计只执行一次的逻辑

结语

那么到这里,我们就跟着书籍解决了如何实现调度的问题,下一节笔记我们来探讨如何实现computed,这是实现响应式核心的关键API

谢谢大家的阅读,如有错误的地方请私信笔者

笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs