该系列文章连载于公众号coderwhy和掘金XiaoYu2002中
- 对该系列知识感兴趣和想要一起交流的可以添加wx:coderwhy666,拉你进群参与共学计划,一起成长进步
- 课程对照进度:JavaScript高级系列174-184集(coderwhy)
- 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力
脉络探索
- 在本章节中,我们首先会来探索async异步函数与其关键字await和普通函数的区别,并结合上一章节最后的案例,提出使用async/await的解决方案
- 紧接着我们会通过学习操作系统的进程线程工作方式概念,引出浏览器中的JS线程,以JS线程引出浏览器中的事件循环
- 在事件循环中存在着事件队列,而事件队列又能细分为宏任务队列与微任务队列,我们会详细学习宏任务与微任务之间的区别
- 在该基础上,结合前面几章所学的Promise,做上几道与宏任务微任务相关的代码执行面试题,来巩固所学知识是否牢固,最后我们会通过事件队列,学习在Node中的执行清空,与在浏览器中进行一个对比
一、async/await
1.1 async函数与普通函数的区别
-
async关键字用于声明一个异步函数:
-
sync是
synchronous
单词的缩写,同步、同时,前缀没有"a-"。async是asynchronous
单词的缩写,在英语中,前缀 "a-" 通常表示否定或“不”的意思,在这里可以理解为异步、非同步 -
chron词根来自希腊语,意味着“时间”,后缀**"-ous"**表示具有某种特质或特性,因此这两个单词的相同部分合并为
时间性质
,也是共通的部分,ECMA委员会选择抽离出异步单词最代表的部分async
,即asyn异步+chronous(时间性质)的开头c
-
-
我们先来看看async异步函数的写法,乍一看和生成器函数的写法很像
- 不同之处在于生成器的*符号位于function和函数名的中间,而async是位于function的左侧
- 只要是async的异步函数写法,不管是匿名函数还是箭头函数或者class中的方法都是async作为最左侧
//异步函数async使用方式1
async function foo1() {
}
//异步函数async使用方式2
const foo2 = async () => {
}
//异步函数async使用方式3
class Foo {
async bar() {
}
}
- 以上三种写法是异步函数的三种基础写法,那该异步函数代码又要如何执行呢?
- 这是一个很好的问题,因为在学习生成器函数时,我们已经见过不同于普通函数的调用方式,我们可以推测异步函数大概率也有所区别
- 我做出如下推测:1、要么foo函数无法正常执行 2、要么foo函数体内的内容是异步的,会落后于同步执行
- 写出如下代码,foo函数调用来判断是否能正常执行,调用前后加上控制台打印来测试foo函数是否会异步执行
async function foo() {
console.log("foo function start~")
console.log("内部的代码执行1")
console.log("内部的代码执行2")
console.log("内部的代码执行3")
console.log("foo function end~")
}
console.log("script start")
foo()
console.log("script end")
- 但很可惜,我们猜错了,首先代码可以正常执行,其次和普通函数的调用是一样的,函数体的内容并没有异步
- 这就太奇怪了,那这个async异步函数和普通函数到底有什么区别呢?
//结果 按顺序执行
// script start
// foo function start~
// 内部的代码执行1
// 内部的代码执行2
// 内部的代码执行3
// foo function end~
// script end
- 区别一在于返回值上,异步函数所返回的内容是一个封装后的Promise,这就很让人惊喜了
- 因为我们清楚知道Promise的异步的API,还具备多个与异步相关的实例或者静态方法,天然适合异步操作
- 同时也能够解释为什么调用async异步函数和普通函数并没有区别,即使这个函数在调用时表现得像同步函数,它返回的依然是一个 Promise 对象,但如果不进行使用的话,也就仅仅是返回一个包裹正常返回内容的Promise,并不会产生什么特殊内容
- 在这里,我们就能够先得出两个结论了:
- 异步函数不等于总是异步
- 异步函数的返回值是一个Promise
图29-1 async异步函数的返回值
- 所以姑且暂时当作一个Promise来进行使用吧!
- 在正常执行完async异步函数内的代码后,可以对返回的Promise进行额外的扩展操作
- 既然当作Promise进行看待,那除了返回普通值之外,也就包括的thenable和Promise两种返回值选择
- 异步函数的返回值和普通函数相比,会进行额外处理:
- 情况一:普通值,异步函数的返回值相当于被包裹到Promise.resolve中;
- 情况二:返回值是Promise,状态由会由Promise决定;
- 情况三:异步函数的返回值是一个对象并且实现了thenable,那么会由对象的then方法来决定
async function foo() {
//1.返回普通值
return '小余-Promise'
}
//async异步函数会对返回的值进行包裹Promise
foo().then(res => {
console.log(res);//小余-Promise
})
// 2.返回thenable
// return {
// then: function(resolve, reject) {
// resolve("coderwhy")
// }
// }
// 3.返回Promise
// return new Promise((resolve, reject) => {
// setTimeout(() => {
// resolve("JSJSJS")
// }, 2000)
// })
- 这是async异步函数的一个特点,但肯定不是全部,因为如果想要对函数的返回值进行Promise包裹,我们大可以使用Promise.resolve静态方法,async单从这方面来说,并没有解决我们的痛点问题,只是多了一种选择,这与它的重要程度不够匹配
- 除了区别一的返回值外,还有和普通函数的区别二,也就是异常,这个异常不会被浏览器立即处理,而是进行Promise.reject(error)操作,也就是能够被我们的Promise捕获到错误
- 普通函数一旦抛出异常,在终端输出是将会直接抛出堆栈跟踪的错误信息,并且不会继续往下执行
- 但async异步函数可以视为Promise进行使用,除了正常返回值,自然也包含了接收异常,这些都是在学习Promise中有进行说明的
- 抛出的异常信息会直接进入Promise第二阶段
rejected
状态,作为reject值进行返回,从而被catch实例方法接收,此时我们就能够进行返回的报错进行额外处理
async function foo() {
console.log("foo function start~")
console.log("中间代码~")
// 异步函数中的异常, 会被作为异步函数返回的Promise的reject值的
throw new Error("error message")
console.log("foo function end~")
}
// 异步函数的返回值一定是一个Promise
foo().catch(err => {
console.log("coderwhy err:", err)
})
console.log("后续还有代码~~~~~")
1.2 async函数中的关键字await
- 在生成器函数中,我们有yield关键字,而在async异步函数中,我们也有一个叫做await的关键字
- async函数相对于普通函数另外一个特殊之处就是可以在它
内部使用await关键字
,而普通函数中是不可以的
- "Await" 在英语中意为“等待”,这个词非常直观地描述了其在异步操作中的作用:等待异步操作(通常是一个返回 Promise 的操作)的完成。当一个函数被标记为
async
,它表明该函数将以异步的方式执行,而await
用于指定函数内部需要等待的具体点,直到某个异步操作完成(即 Promise 解决)后才继续执行
- "Await" 在英语中意为“等待”,这个词非常直观地描述了其在异步操作中的作用:等待异步操作(通常是一个返回 Promise 的操作)的完成。当一个函数被标记为
图29-2 await只能在async异步函数内使用
async function foo() {
await 表达式(Promise)
}
- 乍一看和yield的功能有点似曾相识,让我们先来跑上一章节末尾案例,让await在我们脑海中具象化一下
1.3 方案4:async/await解决方案
- 通过第三方库co配合生成器函数,已经可以很简便的实现异步代码的多次嵌套网络请求问题,就算只是单次网络请求,使用该方案也很方便,而这也是编程世界开源精神的体现,但不得不说,前端的发展太快的,已经有更方便的方法推出,也就是
async/await异步函数组合
- await也具备暂停的作用,会等待 Promise 解决拿到结果后恢复执行
- 该暂停性质和yield一样,是类似函数分割的效果,因此请求结果返回之前,后续代码不会执行
- 而类似函数分割,因此可以看成多个async异步函数的连续拆分调用,所以await返回值就是正常async异步函数返回的形式,是一个Promise
- 和正常的Promise二阶段通过then调用接收不同,await直接将其作为返回值进行返回,形成同步代码的视觉效果,解决了异步回调等问题
- await会等到Promise的状态变成fulfilled状态,之后继续执行异步函数
//最终写法:方案四:async/await的解决方案
function requestData(url){
return new Promise((resolve,reject) => {
setTimeout(()=>{
resolve(url)
},2000)
})
}
async function getData(){
//使用await
const res1 = await requestData("小余")//这里未返回结果不能执行下一个,因为需要res1的结果
console.log("res1",res1);
const res2 = await requestData(res1 + "coderwhy")
console.log("res2",res2);
const res3 = await requestData(res2 +"不知道去哪了")
console.log("res3",res3);
}
getData()//使用了语法糖,去除了后面不断回调的情况
- await关键字的后面除了跟异步请求之外,还能跟什么表达式?
- 首先是任意值,其次是异步的Promise,亦或者Thenable对象(会根据对象的then方法调用来决定后续的值)
- 当然像接收的Promise除了返回正常值之外,还有可能是reject拒绝状态,那么会将这个reject结果直接作为函数的Promise的reject值
// 2.跟上其他的值
async function foo() {
// const res1 = await 123
//跟上Thenable对象
// const res1 = await {
// then: function(resolve, reject) {
// resolve("abc")
// }
// }
const res1 = await new Promise((resolve) => {
resolve("coderwhy")
})
console.log("res1:", res1)
}
// 3.reject值
async function foo2() {
const res1 = await requestData()
console.log("res1:", res1)
}
foo2().catch(err => {
console.log("err:", err)
})
- 返回值也非常简单,除了从
Promise
实例或 thenable 对象取得的处理结果,该等待的值需要满足thenable,否则返回表达式本身的值
// 异步函数示例
async function foo() {
//返回值不满足thenable,则直接返回值本身
const nonPromiseValue = "Hello, world!";
const result = await nonPromiseValue;
console.log(result); // 输出 "Hello, world!"
}
foo();
二、操作系统-进程-线程
- 线程和进程是操作系统中的两个概念:
- 进程(process):计算机
已经运行的程序
,是操作系统管理程序的一种方式 - 线程(thread):操作系统能够运行运算调度的最小单位,通常情况下它被包含在进程中
- 进程(process):计算机
- 听起来很抽象,这里还是给出我的解释:
- 进程:我们可以认为,启动一个应用程序,就会默认启动一个进程(也可能是多个进程);
- 线程:每一个进程中,都会启动至少一个线程用来执行程序中的代码,这个线程被称之为主线程
- 所以我们也可以说进程是线程的容器;(进程包含线程)
- 在传统的操作系统中,程序并不能独立运行,资源分配和程序独立运行的基本单位都是进程,当一个作业作为"进程"进入内存后,就获得在CPU上运行的机会。进程作为基本单位,意味着有多个进程,由进程管理模块进行管理,并把CPU提供给最紧迫的作业,使其投入运行
- 由于操作系统为了提高CPU的运行效率,允许了多个进程同时处于执行状态,并发运行,同时运行会让多个进程需要进行争抢有限的CPU资源,进程管理模块就是为此设计的
- 各个程序并发执行,共享系统资源,共同决定这些资源的状态,程序这个静态概念的词汇已经不能反映程序活动的特征,因此引入"进程"概念来描述程序动态执行的过程
- 进程内有多个状态,以就绪、运行、阻塞三状态为基础模型不断细化扩展,从而最大限度提升CPU的利用率,这个优化提升非常复杂,涉及到很多角度
- 当我们抱着无所不用其极的极致优化心态,想着如果进程还不够快,怎么样才能更快时,线程就出现了
- 在进一步提高并发程度时,出现了进程之间的切换的时间开销比重越来越大的问题,此时就需要考虑在进一步提高并发时,又能减少操作系统的开销
- 因此引入了线程,此时进程只负责完成第一个属性任务作为开端,后续的所有属性任务执行交给线程,此时线程成为进程中的一个实体,作为系统调度的基本单位。但线程执行任务和进程执行问题有什么区别?在这个过程中并没有体现出来
- 主要的区别,在于系统的资源都在进程身上,都是在进程管理模块中拿过来的,所以进程很重,背着所有的资源,想要切换的开销就比较大,而线程不一样,线程基本不拥有资源,只具备想要运行就必不可少的部分,因此线程很轻,切换开销很小
- 那线程没有资源怎么行动呢?这就需要说到线程的特点,能够与同一进程下的其他线程共享进程所拥有的全部资源,因此线程既具备运行所需的资源,也具备切换的轻便
- 最后,我们能够理解进程完成资源的初步分配,而进程做到了并发运行效率的进一步提升。在这里需要说明的是,优化性能的效率的思想体现在每一处,没有说明的部分不代表没有优化,且在解决了一个问题后,在该基础上还会出现新的问题,一步步的解决下去,因此操作系统哪怕一小部分都能延伸出极其多概念
图29-3 进程与线程概念
- 操作系统类似于一个大工厂,工厂中里有很多车间,这个车间就是进程,每个车间可能有一个以上的工人在工厂,这个工人就是线程
2.1 操作系统的工作方式
- 操作系统是如何做到同时让多个进程(边听歌、边写代码、边查阅资料)同时工作呢?
- 这是因为CPU的运算速度非常快,它可以快速的在多个进程之间迅速的切换
- 当我们进程中的线程获取到时间片时(即进程管理模块所分配的CPU资源运行时间),就可以快速执行我们编写的代码
- 对于用户来说是感受不到这种快速的切换的
- 可以在Mac的活动监视器或者Windows的资源管理器(win11中被称为任务管理器)中查看到很多进程:
图29-4 window11中的进程分配
2.2 浏览器中的JavaScript线程
- 我们经常会说JavaScript是
单线程
的,但是JavaScript的线程应该有自己的容器进程:浏览器或者Node
- 浏览器是一个进程吗,它里面只有一个线程吗?
- 目前多数的浏览器其实都是
多进程
的,当我们打开一个tab页面时就会开启一个新的进程,这是为了防止一个页面卡死而造成所有页面无法响应,整个浏览器需要强制退出 - 每个进程中又有很多的线程,其中包括执行JavaScript代码的线程
- 目前多数的浏览器其实都是
- JavaScript的代码执行是在一个单独的线程中执行的:
- 这就意味着JavaScript的代码,在
同一个时刻只能做一件事
- 如果这件事是
非常耗时
的,就意味着当前的线程就会被阻塞
- 那为什么JavaScript的代码执行只在一个单独的线程上执行,而不是多个线程?因为多线程操作是很容易产生不安全的数据的,我们访问数据的时候通常都是需要上锁的(多线程的情况),而上锁再解锁的操作是比较耗时的。且JS主要用于客户端,客户端的任务相对较少,并不需要考虑并发的需求,这时候就能够回顾刚才讲到的线程是出于什么目的而诞生的,心中就有答案了
- 例如我们之前在终端使用Node执行代码时,会创建一个Node进程出来,在Node进程中有JS进程用来执行代码文件,运行代码只会在一个线程里面运行,因此在同一时间内,我们只能运行一行代码
- 这就意味着JavaScript的代码,在
- 所以真正耗时的操作,实际上并不是由JavaScript线程在执行的,以防JS线程出现阻塞问题
- 由于浏览器的每个进程是多线程的,那么可以由其他线程来完成这个耗时的操作,让JS线程专注于代码的运行
- 比如网络请求、定时器等耗时操作都由浏览器其余线程完成,例如定时器设置2s后执行,当运行到该行代码时,会传递给浏览器,由浏览器的其余线程来"计时"这2s,当2s结束后,浏览器会通知JS线程执行定时器内的代码,因此这2s的时间是不在JS线程中处理的
- 这些都是浏览器算好时间后,通知JS线程把代码执行一下,那如果有很多的定时器、网络请求等耗时操作,那JS线程怎么知道到时间后要执行的是哪一行代码,这就需要说明到一个概念:事件循环
- 在事件循环中,有一个事件队列,浏览器到时间准备让JS线程执行代码时,会将要执行的代码放入这个事件队列中,然后JS线程可以从这个队列中挨个取出需要执行的部分,该队列遵循先进先出原则
- 这是浏览器执行代码的完整逻辑
图29-5 浏览器执行代码流程
三、浏览器事件循环
3.1 认识事件循环
- 在真实的业务代码中,通常是几千行几万行的代码,如果在执行JavaScript代码的过程中,有异步操作呢?
- 中间我们插入了一个setTimeout的函数调用,对代码的执行会造成什么影响?
- 这个函数被放到入调用栈中,执行会立即结束,并不会阻塞后续代码的执行
- 这里需要补充定时器setTimeout本身是一个同步函数,本质上和我们所调用的foo函数并没有什么太大区别,能够直接调用是因为挂载在window上,所以可以直接全局使用。称之为异步的是定时器内的回调函数,会由于我们设定时间执行而被传给浏览器的其他线程,等待计时结束后交回给JS线程进行执行
console.log("script start")
// 业务代码
setTimeout(function () {
console.log('小余');
}, 1000)
console.log("后续代码~")
console.log("script end")
- 而JS线程之所以知道需要执行哪一行代码,哪一个回调函数,是因为浏览器维护着一个事件队列,浏览器将需要执行的代码放到事件队列中,然后通知JS线程去执行,JS线程会从事件队列中获取需要执行的代码
- 队列queue本质上也是一种强大简单的数据结构,归属于线性结构,与栈结构不同,它遵循先进先出(FIFO, First-In-First-Out)的原则,在很多领域都有广泛的应用,在网络中,可以用于流量管理和数据包的调度,而在我们的浏览器中,可以使用队列来通知JS线程,代码的执行顺序
图29-6 队列进出
- 这一套完整的流程,形成一个循环,也是被称为事件循环的原因,因此也可以认为这是一个执行模型
- 执行顺序如下:1.执行代码脚本 -> 2.栈操作(同步代码) -> 3.推送耗时操作给浏览器其余线程 -> 4.推送到事件队列 -> 5.任务处理(宏微任务) -> 4与5循环重复(保持应用持续响应外部事件)
- 事件循环的“闭环”机制基于持续监控调用栈和任务队列的状态。事件循环确保每当调用栈为空时,都会从任务队列中取出新的事件处理。通过这种方式,它循环处理所有待办的事件和任务,直到没有更多任务为止。整个过程是一个连续的循环,确保了JavaScript环境可以连续运行,处理所有异步事件和操作
图29-7 事件循环
3.2 宏任务与微任务
- 在之前我们主要使用过以下3种异步,分别为定时器、queueMicrotask和Promise,他们都接收一个回调且非立即执行
- 这些异步方法,最终都会将回调放入事件队列中(结束耗时操作后),等待着JS线程的执行
- 在学习过队列后,queueMicrotask就很容易理解,这是一个微任务队列,而什么是微任务,则是我们接下来要说明的内容
setTimeout(() => {
console.log("setTimeout")
}, 1000)
queueMicrotask(() => {
console.log("queueMicrotask")
})
Promise.resolve().then(() => {
console.log("Promise then")
})
- 正常的函数代码,像下方会先执行bar函数,在bar函数中有foo函数则继续执行,最后打印"其他代码"。这些正常的函数代码都处于执行的最顶层,会被直接执行,通常也被称为main script,与异步执行有所不同
function foo() {
console.log("foo")
}
function bar() {
console.log("bar")
foo()
}
bar()
console.log("其他代码")
-
那setTimeout和queueMicrotask是一样的吗?虽然他们都会前往队列,但事件队列是可以继续细分为两个队列,分别为宏任务队列和微任务队列
宏任务队列(macrotask queue)
:ajax、setTimeout、setInterval、DOM监听、UI Rendering(UI渲染)等微任务队列(microtask queue)
:Promise的then回调、 Mutation Observer API、queueMicrotask()等
-
为什么要继续细分为两个队列?
- 细分为宏任务和微任务队列可以优先处理更紧急的操作,提高应用的响应性并避免界面阻塞
- 耗时更多的交给宏任务队列,关键的小任务(如更新数据绑定、处理 Promise)交给微任务可以迅速完成,不必等待可能较长时间的宏任务(如 I/O 操作)。这种快速响应是现代 Web 应用能够提供良好用户体验的关键因素之一
- 因此通过优先执行微任务,JS 引擎确保在重渲染界面之前,状态更新等操作可以尽快执行,从而提高界面响应性
-
进程的切换耗时占比太高,所以诞生线程来进行快速切换提升并发。而事件队列由于一些事件耗时太高,所以区分出微任务来执行更关键以及耗时更少的部分,以便提高代码执行效率防止阻塞以及用户体验,当不清楚这是不是一个微任务队列时,首先可以去查询相关资料,也可以思考一下,它需不需要较高耗时
-
在事件队列中,存在一个规范:在执行任何宏任务之前,都需要确保微任务队列已经被清空
因此事件循环对于两个队列的优先级是这样的:
- main script中的代码优先执行(编写的顶层script代码)
- 在执行任何一个宏任务之前(不是队列,是一个宏任务),都会先查看微任务队列中是否有任务需要执行
- 也就是宏任务执行之前,必须保证微任务队列是空的
- 如果不为空,那么就优先执行微任务队列中的任务(回调)
- 下面我们通过几道面试题来练习一下对该内容的理解程度
四、Promise执行面试题
4.1 面试题一
-
面试题一重在考验代码的执行顺序,存在的了main script代码、定时器、Promise、queueMicrotask
-
在面对多种类型代码交缠在一起时,我们首先需要厘清思路,再来确定代码究竟是如何执行的
- 在以下代码案例中,我们可以将其区分为6块内容,其中1、3是定时器,2、6是Promise,4为main script代码,5为queueMicrotask方法
- 然后根据优先度执行可以分为:同步代码执行、微任务执行、宏任务执行
- 定时器耗时是宏任务,Promise的then方法和queueMicrotask是微任务,main script是同步代码
- 因此执行先后顺序为:4、2、5、6、1、3,在面对同优先度时,考虑事件队列先进先出原则,位于前列的代码优先执行
-
在整理脉络中,我们再继续向下划分,在1中存在Promise,内部优先度依旧如上,同时需要注意Promise阶段一的代码为main script代码,属于同步代码
-
因此输出内容为:
-
同步代码:2中的
promise1
,4中的数字2
-
微任务:2中then方法的
then1
,5中的queueMicrotask1
,6中的then3
-
宏任务:1中的
setTimeout1
、(then2
、then4
宏任务中的微任务),3中的setTimeout2
- 完整输出结果为:promise1,2,then1,queueMicrotask1,then3,setTimeout1,then2,then4,setTimeout2
-
//1
setTimeout(function () {
console.log("setTimeout1");
new Promise(function (resolve) {
resolve();
}).then(function () {
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then4");
});
console.log("then2");
});
});
//2
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("then1");
});
//3
setTimeout(function () {
console.log("setTimeout2");
});
//4
console.log(2);
//5
queueMicrotask(() => {
console.log("queueMicrotask1")
});
//6
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then3");
});
- 以上难点在于1内的执行顺序,遵循宏微任务的基本规范:执行宏任务之前,所有微任务必须清空
- 由于1是由宏任务包裹微任务,属于必须执行宏任务才会释放微任务内容,因此当清空全局微任务,执行1这个宏任务时,重新开始一次优先度的执行判断,同步代码
console.log("setTimeout1");
先执行,然后执行Promise.then方法微任务 - 而微任务内又嵌套了微任务,依旧是以当前层级继续优先度判定:先执行微任务内的同步任务,再执行微任务内的微任务
- 由于1是由宏任务包裹微任务,属于必须执行宏任务才会释放微任务内容,因此当清空全局微任务,执行1这个宏任务时,重新开始一次优先度的执行判断,同步代码
- 当清空这些微任务后,才会执行下一个宏任务,也就是3
4.2 面试题二
- 第二道题目,考察的是对async/await的了解,async是一个异步函数,在await之外的内容和普通函数没有区别,归属于同步代码,而await作为一个等待形式的关键词,所返回的结果是一个Promise,而Promise归属于微任务队列,因此对于此题目,我们已经有初步的头绪了
- 首先依旧按照执行顺序进行基础排序,为1-5,其中异步函数以调用位置为准
同步代码
:1、3的同步代码、4的Promise阶段一、5,微任务代码
:3的await,4的then方法,宏任务代码
:2- 其中微任务代码3中,存在await返回的微任务代码async2()调用,async2内没有await关键字存在,与普通函数一致,属于同步任务,且和前面同步代码属于同一个代码块
- 同时需要注意,await在async异步函数中,会造成函数后续执行的暂停,直到
await
的 Promise 解决为止,因此后续代码会直接归属到微任务队列中,需要等待同步代码执行后,才会继续执行,因此await后面的代码从概念上已经和await前面代码(包括await那一行)已经不属于同一个代码块 - 这个概念的理解可以借鉴yield的暂停效果,需要使用迭代器的next方法调用,而每一次调用next方法所针对的部分,可以理解为一个个独立的代码块,尽管这些代码块都在同一函数的函数体中
- 因此执行顺序为:
- 同步代码:
script start
、async1 start
、async2
、promise1
、script end
- 微任务代码:
async1 end
、promise2
- 宏任务代码:
setTimeout
- 同步代码:
- 完整执行内容:
script start
、async1 start
、async2
、promise1
、script end
、async1 end
、promise2
、setTimeout
async function async1() {
console.log('async1 start')
await async2();
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
//1
console.log('script start')
//2
setTimeout(function () {
console.log('setTimeout')
}, 0)
//3
async1();
//4
new Promise(function (resolve) {
console.log('promise1')
resolve();
}).then(function () {
console.log('promise2')
})
//5
console.log('script end')
4.3 面试题三
- 本道题考察的是对Promise状态调度之间的熟悉程度(非常难)
- 这道题目难度很高,正常理解会将其拆分为两个Promise链进行执行,对Promise机制理解熟悉,会在第一个Promise链中的返回值中得以拆出第三个Promise链(then方法会被返回的Promise所接管)
- 因此在这个全程微任务的代码中,看似只需要简单的按顺序排列为0、4、1、2、3、5、6即可完成,但这是错误的
- 正确的结果是1、2、3、4、5、6,但为什么会这样?我们需要一步步进行分析
//第一个Promise链
Promise.resolve().then(() => {
console.log(0);
return Promise.resolve(4)
}).then((res) => {
console.log(res)
})
//第二个Promise链
Promise.resolve().then(() => {
console.log(1);
}).then(() => {
console.log(2);
}).then(() => {
console.log(3);
}).then(() => {
console.log(5);
}).then(() => {
console.log(6);
})
- 那要如何理解?我们先来回顾我们认为的答案:
0、4、1、2、3、5、6
- 按照思路,我们会如下拆分为三个Promise链,如果不涉及第三个Promise链的话,从表达形式来说,是没有区别的
- 在拆分为三个Promise的情况下,所返回的结果就是我们所认为的答案。一旦没进行拆分,结果就发生了改变
- 因此我们已经发现问题的位置出在哪里,接下来就要进行测试,来验证想法
//第一个Promise链
Promise.resolve().then(() => {
console.log(0);
})
//第二个Promise链
Promise.resolve(4).then((res) => {
console.log(res)
})
//第三个Promise链
Promise.resolve().then(() => {
console.log(1);
}).then(() => {
console.log(2);
}).then(() => {
console.log(3);
}).then(() => {
console.log(5);
}).then(() => {
console.log(6);
})
- 如果第一个Promise链所return的结果是一个普通值,会怎么样?
- 问题会变得非常简单,结果为0、1、4、2、3、5、6
- 在面试题3中只有main script(同步代码)和微任务,我们来分析一下简单化的案例,首先初始
Promise.resolve()
立即解决,需要注意这一点非常关键
,本质上是new Promise((resolve) => resolve(value))
的简写,因此是同步代码 - 这导致一个问题,在这个看似没有
main script代码
的地方,存在着两个没有具体输出内容,但改变了执行顺序的main script代码
- 执行第一个Promise链同步代码时,后面的then方法被加入微任务队列中(只有第一个then方法),紧接着执行第二个Promise链,后面的第一个then方法也被加入到微任务队列中
- 请注意,这里是关键了,我们的执行顺序已经改变了。为了方便大家理解,我在所有then方法的地方做出了标注,分别为1-7。正常按照为微任务的顺序,应该是1、2、3、4、5、6、7的按顺序执行
- 而现在由于两个立即执行,then的顺序已经改变为1、3开头
- 而当1执行时,2被加入微任务队列中。3执行时,4、5、6、7也被分别按顺序加入微任务队列中
- 因此,此时then正确的执行顺序为:1、3、2、4、5、6、7
- 输出的结果为:0、1、4、2、3、4、5、6
//第一个Promise链
Promise.resolve().then(() => {//1
console.log(0);
return 4
}).then((res) => {//2
console.log(res)
})
//第二个Promise链
Promise.resolve().then(() => {//3
console.log(1);
}).then(() => {//4
console.log(2);
}).then(() => {//5
console.log(3);
}).then(() => {//6
console.log(5);
}).then(() => {//7
console.log(6);
})
//输出的结果为:0、1、4、2、3、4、5、6
- 因此,这需要我们对Promise.resolve静态方法的实现原理有深入理解,清楚知道它是什么内容的简写。也清楚异步代码的执行,会受到连续调用所导致的调用顺序影响
- 在这个基础上,我们再修改一下第一个Promise链所返回的内容,这次我们不返回普通值,而是返回一个thenable,执行顺序还会发生改变吗?
- 事实上,这又改变了,打印结果是:
0、1、2、4、3、5、6
- 这是为什么?0和1的打印都由于Promise.resolve()的原因率先执行没有问题,为什么第一个Promise链先执行后,第二个then方法没有先加入微任务队列?反而由第四个then方法先加入微任务队列中
- then的执行顺序由
1、3、2、4、5、6、7
变为1、3、4、2、5、6、7
,第二个then方法和第四个then方法发生调换 - 这是因为直接return 4相当于return Promise.resolve(4),作为同步代码立刻就执行了,传递给第二个then方法,让第二个then方法得以在第一和三的then方法后被加入微任务队列中
- 事实上,这又改变了,打印结果是:
- 此时由于return的thenable会被视为一个then方法,因此被视为一个微任务,不会立刻执行传递给第二个then方法,而是先push到微任务队列中,我们就暂时管这个return的then方法为第1.5个then方法
- 由于这个微任务不会立即执行,所以第二个then方法会"迟"一步收到消息
- then方法执行顺序(微任务队列顺序)就会变成这样:
1、3、1.5、4、2(被1.5通知传递了)、5、6、7
- 这同时涉及到执行的过程,在往微任务队列中添加新内容时,前面已经加入的微任务已经被拿去执行了,在这极短的1.5then方法执行,通知2then方法时间差中,4then方法已经先进入微任务队列中了,2then方法收到信息抓紧出发,也只能排在4的后面,而后5、6、7的then方法依次加入
- 在这个过程中,我们深刻的感受到微任务的一个特点:时间就是金钱,你不上那我上,我是绝对不可能等你的。就像在吃饭时,嘴巴还在吃着,手上的筷子已经去夹下一道菜了
- 你要是在我嘴巴咀嚼,筷子伸出去夹菜的时候说(1.5then方法通知2then方法):那有道菜也不错,你去夹那道
- 我只能说不好意思,筷子已经伸出去了,不好改变方向,你等我先把手里这道菜(4then方法)放进碗里(微任务队列),我再去夹你说的那道菜(2then方法),然后遵循先夹到碗里的先吃原则
//第一个Promise链
Promise.resolve().then(() => {//1
console.log(0);
return {
then: function (resolve) {//1.5
// 大量的计算
resolve(4)
}
}
}).then((res) => {//2
console.log(res)
})
//第二个Promise链
Promise.resolve().then(() => {//3
console.log(1);
}).then(() => {//4
console.log(2);
}).then(() => {//5
console.log(3);
}).then(() => {//6
console.log(5);
}).then(() => {//7
console.log(6);
})
- 最后,我们再来理解最初的那一个问题,讲返回的内容,由thenable转为
Promise.resolve(4)
- 首先0和1还是最先执行,关键在于
Promise.resolve(4)
晚通知了多久?第二个then方法是什么时候接到通知的 - 虽然Promise.resolve()静态方法通过扩展,能够清楚这是立即执行的同步代码没错,但返回的值是需要then进行接收的。如果不是普通的值,就已经需要多加一次微任务了,而Promise.resolve(4)还需要加一次微任务
- 所以总共加两次微任务,也意味着晚通知两步,在第四个then方法后,跟着的是第五个then方法,而第二个then方法只能追在第五个的后面
- 首先0和1还是最先执行,关键在于
- 因此then方法在这里的执行顺序为:1、3、4、5、2、6、7,而打印结果体现的就是0、1、2、3、4、5、6
//第一个Promise链
Promise.resolve().then(() => {//1
console.log(0);
// 3.return Promise
// 不是普通的值, 多加一次微任务
// Promise.resolve(4), 多加一次微任务
// 一共多加两次微任务
return Promise.resolve(4)
}).then((res) => {//2
console.log(res)
})
//第二个Promise链
Promise.resolve().then(() => {//3
console.log(1);
}).then(() => {//4
console.log(2);
}).then(() => {//5
console.log(3);
}).then(() => {//6
console.log(5);
}).then(() => {//7
console.log(6);
})
- 关于resolve的这一点特性,在MDN文档中如下描述:
- 地址:Promise.resolve() - JavaScript | MDN (mozilla.org)
- 其简写形式所返回的是一个包裹数值的Promise,想要使用,还需要经过一层then调用拿到数值才行
- 那原生的Promise为什么要在这里推迟一次呢?
- 有以下推测,当返回内容为具体数值时,能够针对性处理,而返回一个函数时,也就是我们的resolve函数,在函数中是会存在任何情况的(例如在该函数中继续使用定时器延迟),意味着这个内部then方法的调用时间是有可能比较长的,一旦调用时间长就会转为耗时任务,这不是微任务的初衷,后续排队的微任务有可能执行不到,我们无法针对性处理
- 因此遇到then方法函数时,需要稍微推迟执行,但正常开发中,是不需要担心这类问题的,因为很少会在微任务中不停的嵌套微任务
图29-8 MDN文档对Promise.resolve静态方法描述
- 并且在这里需要注意,MDN文档的resolve函数,错误拼为resolver函数,在图29-8 圈出来的地方,指的是resolve函数,该issue已经在MDN文档的GitHub仓库中发起
图29-9 MDN文档对resolve函数解释
- 在这里插入一道简单的面试题理解,是群里小伙伴所提出的问题
- 首先script标签是宏任务,主要的难点在于宏任务的执行顺序
- 假设两个script标签分别为宏任务1和宏任务2,而宏任务1、2中的定时器分别为宏任务3、4
- 会出现一个问题,执行顺序为:宏任务1、宏任务2、宏任务3、宏任务4。为什么不是宏任务1、3、2、4?
- 这是因为,宏任务会先整体push到宏任务事件队列中。所以首先在宏任务事件队列中,会先是宏任务1、宏任务2。接着开始执行宏任务1,释放出来一个定时器宏任务timer1,在宏任务事件队列中接在了宏任务2的后面,等到宏任务2开始执行,又会释放除一个定时器timer2,接在timer1的后面
- 因此可以看出宏任务与微任务所体现出来的不同特点,在每一层执行宏任务前,先检查一遍是否存在同步代码或者微任务,然后将存在的宏任务加入队列中,将已经存在的微任务或者同步代码进行清空
图29-10 宏任务执行顺序
//脚本1
<script>
console.log('start1');
setTimeout(()=> console.log("timer1"),0)
new Promise((resolve,reject) => {
console.log('p1');
resolve()
}).then(() => {
console.log('then1');
})
console.log('end1');
</script>
//脚本2
<script>
console.log('start2');
setTimeout(()=> console.log("timer2"),0)
new Promise((resolve,reject) => {
console.log('p2');
resolve()
}).then(() => {
console.log('then3');
})
console.log('end2');
</script>
4.4 Node事件循环
- 浏览器中的EventLoop是根据HTML5定义的规范来实现的,不同的浏览器可能会有不同的实现,而Node中是由libuv实现的
- 这里我们来给出一个Node的架构图:
- 我们会发现libuv中主要维护了一个EventLoop和worker threads(线程池)
- EventLoop负责调用系统的一些其他操作:文件的IO、Network、child-processes等
- libuv是一个多平台的专注于异步IO的库,它最初是为Node开发的,但是现在也被使用到Luvit、Julia、pyuv等其他地方,不过这不是我们JS高级系列的重要部分,因此简单了解即可
图29-11 Node架构图
4.4.1 Node事件循环的阶段
- JS的执行环境除了浏览器之外,还有Node环境,这也是属于JS的一个执行环境,在我们过往大大小小的案例中,大多数都是在终端通过node命令执行的代码,例如
node index.js
- 在这个执行过程中,首先会先开启一个Node进程(只要启动一个程序就会开启一个进程)
- 在Node进程中,也是多线程的,其中有一个就是JS线程,这里的JS线程一样用来执行JS代码
- 那如果我们在JS代码中做出耗时操作,例如
定时器
、网络请求
或者文件读取等IO操作
(系统调用)的话,耗时操作需要交给谁呢?毕竟我们现在不是通过浏览器来执行,没办法交给浏览器的其余线程来处理耗时操作 - 最终耗时操作是交给Node中的其他线程来做的,耗时结束后将其加入队列中,然后JS引擎从队列中获取内容,该流程和在浏览器中执行的效果非常相似
图29-12 事件循环队列(Node)
- 我们最前面就强调过,
事件循环像是一个桥梁
,是连接着应用程序的JavaScript和系统调用
之间的通道:- 无论是我们的文件IO、数据库、网络IO、定时器、子进程,在完成对应的操作后,都会将对应的结果和回调函数放到事件循环(任务队列)中
- 事件循环会不断的从
任务队列中取出对应的事件(回调函数)
来执行 - 因此JS本身是不会发起网络请求的,一开始在浏览器中,发起网络请求是由浏览器去做的,浏览器再去调用操作系统,最终由操作系统发送网络请求,然后通过事件循环一层层返回结果,因此我们说浏览器更像是JS和操作系统之间的桥梁,来完成一些JS本身做不了的事情
- 这也是早期JS应用在浏览器的原因,而现在的JS能够运用在很多地方,例如服务器开发中
- 服务器开发对编程语言有一个非常重要的要求:IO操作(输入input/输出output)
- 通常输入表示能够读取一些内容到程序里面,输出表示能够写入一些内容到程序外面,比如往文件或者数据库里写东西,完整的IO操作所指的含义是非常广泛的,我们这里不做过多了解
- 因此很多JS无法实现的事情,可以通过调用其他内容,由其他内容去完成任务,形成一个事件循环,JS再从事件循环中取出结果做一个执行
- 这就是为什么JS能够借用Node去涉及到服务器操作,而在当下的JS,就好似万能一样,什么领域都能去涉足,都是因为这个原因
- 而一次完整的事件循环Tick分成很多个阶段(在Node.js的上下文中,"Tick"指的是事件循环的一次完整迭代或周期):
定时器(Timers)
:本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数待定回调(Pending Callback)
:对某些系统操作(如TCP错误类型)执行回调,比如TCP连接时接收到ECONNREFUSED(拒绝错误信息)idle, prepare
:很少关心,仅系统内部使用轮询(Poll)
:检索新的 I/O 事件;执行与 I/O 相关的回调检测(check)
:setImmediate() 回调函数在这里执行关闭的回调函数
:一些关闭的回调函数,如:socket.on('close', ...)
- 可以看到,事件循环步骤是非常多的,那这到底是如何执行任务的?在事件循环中,维护了非常多的队列
- JS线程会挨个去队列中找内容进行执行,这个队列执行完去找下一个队列,当所有队列都执行完后,回到一开始的队列时,一次完整的流程就结束了,称为一次Tick(完整周期),然后不断的执行Tick
- Node的程序会经常停留(阻塞)在轮询阶段的检查IO上,因为检索到数据时,通常希望能够被程序立刻拿来运行,因此当JS引擎在没有任务执行时,并不是停留在最开始的定时器阶段,而是经常停留在IO阶段,方便以最快的方式检索数据进行程序执行
图29-13 Node事件循环阶段图解
4.4.2 Node的宏任务与微任务
- 我们会发现从一次事件循环的Tick来说,Node的事件循环更复杂,它也分为微任务和宏任务:
- 宏任务(macrotask):setTimeout、setInterval、IO事件、setImmediate、close事件
- 微任务(microtask):Promise的then回调、process.nextTick、queueMicrotask
- 但是,Node中的事件循环不只是 微任务队列和 宏任务队列:
表29-1 微任务队列与宏任务队列总结
队列类型 | 功能描述 | 包含的任务 |
---|---|---|
微任务队列 | 在当前宏任务后、下一个宏任务前执行 | |
Next Tick Queue | 处理通过 process.nextTick() 添加的任务 | process.nextTick() 的回调函数 |
Other Queue | 处理其他微任务 | Promise 的 then 回调、queueMicrotask() |
宏任务队列 | 在各个事件循环阶段执行 | |
Timer Queue | 处理定时器设置的任务 | setTimeout() 、setInterval() |
Poll Queue | 处理I/O事件 | 文件、网络、数据库等的I/O事件回调 |
Check Queue | 在poll阶段后立即执行 | setImmediate() 的回调 |
Close Queue | 处理关闭事件 | 如 socket.on('close', ...) 等事件回调 |
后续预告
- 在下一章节中,首先我们会对异常处理进行一次梳理与学习,探讨多种异常的情况,以及抛出异常后,应该如何正确处理,需要注意哪些规范问题
- 在解决异常处理后,会进入我们的JS模块化系列详解中,探讨什么是模块化?为什么需要模块化?在没有模块化的日子里,都是怎么编写代码的?
- 模块化的发展历程中,都出现过哪些优质的解决方案?AMD/CMD还是CommonJS,亦或者目前使用的ESModule?
- 我们会一步步的讲解其发展过程中,是如何不断的优化已有方案,迭代到目前的形态,以及为什么要这么做?从源码的角度分析这是如何做到的
- 最终着重讲解CommonJS和ESModule这两种主要流行的模块化方案,探索其各种使用方式背后的原理和流程