该系列是本人准备面试的笔记,或许有描述不当的地方,请在评论区指出,感激不尽。
其他篇章:
- Promise.try 和 Promise.withResolvers,你了解多少呢?
- 从 babel 编译看 async/await
- Vue.nextTick 从v3.5.13追溯到v0.7.0
- Vue 怎么监听 Set,WeakSet,Map,WeakMap 变化?
- Vue 是怎么从<HelloWorld />、<component is='HelloWorld'>找到HelloWorld.vue
前言
前面已经复习了 Promise 和 async/await 两个用于处理异步操作的语法工具,所以今天来学习事件循环的知识。
首先,跟 ChatGPT 要了一份“事件循环”面试题,祝贺各位勇士挑战成功。
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
return new Promise((resolve) => {
console.log('4');
setTimeout(() => {
console.log('5');
resolve('6');
}, 40);
});
}).then((res) => {
console.log(res);
setTimeout(() => {
console.log('7');
}, 0);
});
}, 0);
Promise.reject('8').catch((err) => {
console.log(err);
return '9';
}).then(async (res) => {
console.log(res);
await Promise.resolve().then(() => {
console.log('10');
return new Promise((resolve) => {
setTimeout(() => {
console.log('11');
resolve('12');
}, 30);
});
}).then((res) => {
console.log(res);
});
console.log('13');
}).finally(() => {
console.log('14');
});
(async () => {
console.log('15');
await new Promise((resolve) => {
console.log('16');
setTimeout(() => {
console.log('17');
resolve();
}, 20);
});
console.log('18');
})();
setTimeout(() => {
console.log('19');
Promise.resolve().then(() => {
console.log('20');
return Promise.reject('21');
}).catch((err) => {
console.log(err);
return '22';
}).then((res) => {
console.log(res);
return new Promise((resolve) => {
console.log('23');
setTimeout(() => {
console.log('24');
resolve('25');
}, 10);
});
}).then((res) => {
console.log(res);
});
}, 10);
console.log('26');
概念
同步与异步
- 同步任务是指代码按顺序执行,每一行代码必须等待上一行代码执行完成。
- 异步任务则不会阻塞后续代码的执行,而是将任务交由其他模块(如定时器、网络请求等)处理,等到结果返回时再执行回调。
调用栈和任务队列
- 调用栈(Call Stack)是一个管理代码执行顺序的数据结构。当函数被调用时,它会被推入调用栈;当函数执行完成时,它会从栈中弹出。
- 任务队列(Task Queue)用于存放异步任务的回调函数。当调用栈清空后,事件循环会从任务队列中取出一个任务放入调用栈执行。
调用栈是“服务台”,只能处理一件事。而任务队列是“待办事项箱”,分为紧急任务(微任务)和普通任务(宏任务)。 而其中的异步任务就像交给外包团队,完成后放回队列等待处理。
单线程 JavaScript
因为 JavaScript 最初被设计为一种操作 DOM 的脚本语言。多个线程同时操作 DOM 容易产生冲突(如同时添加和删除节点),单线程避免了这种复杂性。因为 JavaScript 和渲染共用主线程,所以当 JavaScript 代码运行时,浏览器的渲染引擎会暂停工作。如果某段 JavaScript 长时间执行,页面渲染和用户交互(如点击、滚动)都会被阻塞。
事件循环
- 执行任务队列中的第一个任务。
- 处理所有微任务队列中的任务,直到微任务队列为空。
- 触发渲染步骤(如有必要)。
- 返回任务队列并重复上述流程。
事件循环就像厨房流水线,先处理紧急订单(同步和微任务),再准备次日配送(宏任务),最后打扫(渲染)。
Tips:Loupe 可视化展示函数调用的执行情况。
随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法,提出了任务类型的说法:
-
每个任务都有任务类型,同类型的任务必须在同一个队列,不同类型的任务可以分属不同队列。在一次事件循环中,浏览器可以根据实际情况从不同队列中拿取任务执行。
-
浏览器必须准备好一个微队列,微队列中任务的优先级高于其他所有任务执行。
- 延时队列:用于存放计时器到达后的回调任务,优先级中
- 交互列队:用于存放用户操作后产生的事件处理任务,优先级高
- 微队列:用户存放需要最快执行的任务,优先级最高
运行以下代码,在5秒内点击按钮,就会发现 Promise callback! > click callback! > setTimeout callback!
<html>
<body>
<button>click</button>
</body>
<script>
document.querySelector("button").addEventListener("click", function () {
console.log("click callback!");
});
window.onload = function () {
setTimeout(function () {
console.log("setTimeout callback!");
}, 0);
console.log("setTimeout end");
Promise.resolve().then(() => {
console.log("Promise callback!");
});
console.log("Promise end");
// 堵塞,触发点击事件
const start = Date.now();
while (Date.now() - start < 5000) {}
console.log("end");
};
</script>
</html>
Q: setTimeout 定时器是精准的吗?
A: setTimeout 并不精准,因为它的回调执行时间受到事件循环和调用栈的影响。如果主线程忙于执行其他任务,定时器回调会被延迟。setTimeout 是闹钟,当闹钟响而厨师正忙着时,它只能等厨师闲下来再处理。
任务分类
宏任务
- 初始执行的代码块
- setTimeout / setInterval
- UI 渲染
- 用户交互(如
click、keydown、mousemove等)的事件处理函数 - 通过
MessageChannelAPI 传递消息时,生成的任务 - 使用
window.postMessage传递跨域消息时,回调会加入宏任务队列 - 网络任务(如
XMLHttpRequest或fetch) - 文件操作(如
FileReader)
微任务
- Promise 回调
- MutationObserver 回调
- queueMicrotask
任务类型
根据 W3C 的最新定义,任务来源(Task Sources)被分为以下几类,用于规范不同功能的任务调度与执行顺序:
-
DOM 操作任务来源(The DOM manipulation task source)
- 用于处理与 DOM 操作相关的任务。
- 例如,某些非阻塞的任务在元素被插入文档后触发。
-
用户交互任务来源(The user interaction task source)
- 用于响应用户交互(如键盘输入、鼠标点击)的任务。
- 与用户输入相关的事件(如点击事件)必须通过该任务来源的队列触发。
-
网络任务来源(The networking task source)
- 用于处理网络活动触发的任务。
- 例如,网络请求的响应处理。
-
导航与历史任务来源(The navigation and traversal task source)
- 用于处理导航和历史记录相关的任务。
- 例如,页面跳转或历史记录的遍历。
-
渲染任务来源(The rendering task source)
- 专用于更新页面渲染的任务。
- 确保浏览器按照正确的时序进行布局和绘制。
这些任务来源明确划分了不同类型的任务优先级和作用范围,有助于浏览器协调事件循环,提升性能和响应效率。
面试题解析
1. 同步任务(主线程)
-
console.log("1");:输出 1。 -
遇到第一个
setTimeout,timeout为0,将其回调注册为宏任务,排队等待。注意:如果timeout小于 0,则将timeout设置为 0;如果嵌套层级大于 5,并且timeout小于 4,则将timeout设置为 4。 -
Promise.reject("8"):被拒绝,返回值传递至catch块进入微任务排队。 -
async IIFE块立即执行:console.log("15");:输出 15。- 遇到
await:new Promise部分还是属于同步任务,继续同步执行。 console.log("16");:输出 16。- 遇到
setTimeout,20 毫秒后注册回调为宏任务。
-
遇到
setTimeout,10 毫秒后注册回调为宏任务。 -
console.log("26");:输出 26。
当前同步任务完成,主线程清空,进入微任务阶段。
此时,队列等待情况如下,用上述序号表示:
- 定时器模块:[1.2(0), 1.4.4(20), 1.5(10)]
- 微任务队列:[1.3]
2. 微任务阶段
- 获取微任务:
Promise.reject("8").catch。 console.log(err);:输出 8。return "9";: 返回值,将.then添加微任务。- 检查微任务队列是否为空:
false。 - 取出
.then微任务执行。 console.log(res);:输出返回值 9。await Promise.resolve().then:继续添加微任务。- 因为
await原因,console.log("13");暂不执行。 - 检查微任务队列是否为空:
false。 - 取出
Promise.resolve().then微任务执行 console.log("10");:输出 10。- 遇到
new Promise,同步执行 - 遇到
setTimeout,30 毫秒后注册回调为宏任务。 - 微任务队列为空,结束微任务执行。
所有微任务执行完毕后,进入下一任务阶段。
- 定时器模块:[1.4.4(20), 1.5(10), 2.13(30)]
- 事件队列:[1.2]
3. [1.2] setTimeout 任务
上述 1.2 setTimeout 的 timeout 为 0,所以加入任务队列进行执行。
console.log("2");:输出 2。Promise.resolve().then加入微任务队列。- 结束
setTimeout
- 定时器模块:[1.4.4(20), 1.5(10), 2.13(30)]
- 微任务队列:[3.2]
4. 微任务阶段
console.log("3");:输出 3。new Promise:继续同步执行。console.log("4");:输出 4。setTimeout: 40 毫秒后注册回调为宏任务- 因为还未
resolve,所以.then暂不加入微任务队列
- 定时器模块:[1.4.4(20), 1.5(10), 2.13(30), 4.4(40)]
10毫秒后
- 定时器模块:[1.4.4(10), 2.13(20), 4.4(30)]
- 事件队列:[1.5]
5. [1.5] setTimeout 任务
console.log("19");:输出 19。Promise.resolve().then:加入微任务队列。
- 定时器模块:[1.4.4(10), 2.13(20), 4.4(30)]
- 微任务队列:[5.2]
6. 微任务阶段
console.log("20");:输出 20。return Promise.reject("21"):将.catch加入微任务队列。console.log(err);:输出 21。return "22";:将.then加入微任务队列。console.log(res);:输出 22。new Promise的executor同步执行。console.log("23");:输出 23。setTimeout:10 毫秒后注册回调为宏任务。
- 定时器模块:[1.4.4(10), 2.13(20), 4.4(30), 6.8(10)]
10毫秒后
- 定时器模块:[2.13(20), 4.4(30), 6.8(0)]
- 事件队列:[1.4.4]
7. [1.4.4] setTimeout 任务
console.log("17");:输出 17。resolve():将await至下一await或 函数体结束 添加为微任务。
- 定时器模块:[2.13(10), 4.4(20), 6.8(0)]
- 微任务队列:[7.2]
8. 微任务阶段
console.log("18");:输出 18。
- 定时器模块:[2.13(10), 4.4(20)]
- 事件队列:[6.8]
9. [6.8] setTimeout 任务
console.log("24");:输出 24。resolve("25");:将.then加入微任务。
- 定时器模块:[2.13(10), 4.4(20)]
- 微任务队列:[9.2]
10. 微任务阶段
console.log(res);:输出 resolve 值 25。
- 定时器模块:[2.13(10), 4.4(20)]
10 毫秒后
- 定时器模块:[4.4(10)]
- 事件队列:[2.13]
11. [2.13] setTimeout 任务
console.log("11");:输出 11。resolve("12");:将.then加入微任务。
- 定时器模块:[4.4(10)]
- 微任务队列:[11.2]
12. 微任务阶段
console.log(res);:输出 12。- 结束 Promise,将 await 剩余部分加入微任务队列。
console.log("13");:输出 13。- 完成
.then,将.finally加入微任务。 console.log("14");:输出 14。
- 定时器模块:[4.4(10)]
10 毫秒后
- 事件队列:[4.4]
13. [4.4] setTimeout 任务
console.log("5");:输出 5。resolve("6");:将.then加入微任务队列。
- 微任务队列:[13.2]
14. 微任务阶段
console.log(res);:输出 6。setTimeout:将回调注册为宏任务。
- 定时器模块:[14.2(10)]
- 事件队列:[14.2]
15. [14.2] setTimeout 任务
console.log("7");:输出 7。
终于结束了!!!
最后
这道题称得上全网最复杂吗?ChatGPT说它还可以出一道全宇宙最复杂的。
掘友们可以在评论区留下你们遇到最复杂的事件循环面试题,让大家长长见识。
记得点赞收藏评论一键三连~