如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~
作者:前端小王hs
阿里云社区博客专家/清华大学出版社签约作者/csdn百万访问前端博主/B站千粉前端up主
此篇文章是博主于2022年学习《Vue.js设计与实现》时的笔记整理而来
书籍:《Vue.js设计与实现》 作者:霍春阳
本篇博文将在书第1.7节的基础上进一步解析,附加了测试的代码运行示例,以及对书籍中提到的ES6中的数据结构及其特点进行阐述,方便正在学习Vue3或想分析Vue3源码的朋友快速阅读
如有帮助,不胜荣幸
前置章节:
- 深入理解Vue3.js响应式系统基础逻辑
- 深入理解Vue3.js响应式系统设计之栈结构和循环问题
调度执行
在前面的章节我们学习到,只要修改了代理对象obj
的属性就会触发trigger()
执行,那么在4.7节
,作者向我们介绍了如何去实现可控制的去执行trigger()
,也就是决定副作用函数执行的时机、次数以及方式(原文)
那么在这一节,将会涉及到宏任务和微任务知识,这是JavaScript
的高阶知识,我们先来介绍下
在JavaScript
中,异步任务被分为两种主要的类别:宏任务(MacroTask)和微任务(MicroTask)
这两种任务类型在事件循环(Event Loop) 中被处理,但它们有不同的执行优先级和时机
宏任务包括:
- script(整体代码)
- setTimeout
- setInterval
- setImmediate(Node.js环境)
- I/O
- UI渲染(浏览器)
- MessageChannel(消息通道)
- postMessage
- requestAnimationFrame(浏览器)
微任务包括:
- Promise.then() 或 .catch()
- MutationObserver(浏览器)
- process.nextTick(Node.js环境)
- queueMicrotask()(较新的API)
执行的过程是,在每次宏任务执行完后,会检查微任务队列是否有微任务,如果有,那么执行(且执行完所有微任务),如果没有,则从宏任务队列中继续执行下一个宏任务。也就是说,微任务是在两个宏任务之间执行的
而如果存在同步代码,则宏任务队列的异步函数会在同步代码执行后再执行,可看下面这个例子:
现在,我们来看一个需求
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });
effect(() => {
console.log(obj.foo);
});
obj.foo++;
console.log('结束了');
这段代码中,正常的输出顺序是1、2、最后是结束了,这点非常简单,但如果想把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.options
中fn()
,关键是scheduler
里应该怎么处理呢?其实非常简单,只需添加一个setTimeout
,然后在定时器里执行fn()
,还记得我们在上面说过,这是一个宏任务,代码如下:
// effect()内
{
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(fn);
}
}
现在,我们来分析一下代码的执行顺序:
- 执行
effect
,那么会输出1
,以及把scheduler(fn){}
传给options
- 执行
obj.foo++
,那么先会涉及到一个读取,但这里我们无需关心读取,只看trigger
- 在
trigger
中执行effectFn.options
中fn()
,那么这是一个宏任务,会被送进队列中等待当前宏任务执行完成 - 由于
setTimeout
是一个异步函数,而console.log('结束了')
是一个同步代码,所以会先输出结束了,然后再输出完成了++
之后的2
下面,我们再来看一个更加进阶的操作,我们直接分析需求和实现过程。首先是需求,代码如下:
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });
effect(() => {
console.log(obj.foo);
});
obj.foo++;
obj.foo++;
通过逻辑,我们知道会按顺序输出1
、2
和3
,现在的需求是直接输出1
和3
,也就是跳过2
。1
我们知道,直接由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++;
我们分析一下实现的过程:
- 执行
effect
,那么首先输出当前的1
,并且把scheduler
里的函数放进effectFn.options
中 - 执行
obj.foo++
,读取我们还是不分析,我们直接看触发时执行scheduler
里的函数 - 执行
jobQueue.add(fn)
,这是一个Set
数据结构,也就是里面的值是唯一的 - 执行
flushJob
,将isFlushing
置为true
,然后把jobQueue
里的每一项fn()
添加到微任务队列中,并且在执行完后会将isFlushing
置为false
,不过此时并没有执行 - 我们知道,微任务之后在宏任务执行完后才会执行,那么此时
flushJob
执行完了,obj.foo++
也执行完成了,foo
变为2
- 注意!
flushJob
是同步代码,obj.foo++
也是同步代码,所以先执行obj.foo++
,那么在这里的时候,jobQueue.add(fn);
是无效的,因为Set
里的都是唯一值,那么他会执行flushJob
- 执行
flushJob
时,由于前面我们已经置为了ture
,所以直接return
了 - 那么当所有的同步代码执行完后,就查看微任务队列了,就把
p.then
里的代码拿出来执行 - 执行
console.log(obj.foo);
,此时就已经了是3
了!
如何设计只执行一次?结合Set
只保存唯一值、微任务的特性和函数开关(布尔值)
分析的时候是不是觉得有点绕?只要搞清楚了同步和异步以及宏任务和微任务就清晰了,但步骤确实是有点多,也不得不佩服高级前端工程师的设计思路,一环套一环
如果你看到这里,那么可以和hr夸夸其谈:
- 什么是宏任务和微任务
- 有同步函数和异步函数、异步函数有微任务时的执行顺序
- 如何设计只执行一次的逻辑
结语
那么到这里,我们就跟着书籍解决了如何实现调度的问题,下一节笔记我们来探讨如何实现computed,这是实现响应式核心的关键API
谢谢大家的阅读,如有错误的地方请私信笔者
笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs!