先看一个简单的问题
setTimeout(()=>{
console.log(1)
},0)
Promise.resolve().then(()=>{
console.log(2)
})
console.log(0)
复制代码
这个问题是一个对JS事件循环的最基本考察,包括对宏任务、微任务的理解。
执行结果是:
0
2
1
复制代码
如果对于这类问题你还是一知半解,那么你需要了解一下什么是JS事件循环(event loop)
Event loop
我们知道JS是单线程、非阻塞的
- 这里的单线程并不是指整个JS事件循环是单线程(事实上事件循环中有多个线程参与),而是JS引擎执行线程是单线程(在chrome里就是V8所在的线程),也就是上图中左侧部分。该线程只负责解析JS代码并处理JS执行栈中的代码块。
- 非阻塞指的是JS引擎执行线程不会被I/O阻塞(网络请求、文件读取等)
这得益于JS使用的事件循环机制,也就是Event Loop。
setTimeout函数
先来看一下setTimeout这个最常用的异步函数是如何在Event Loop中运作的
setTimeout(() =>
console.log(1)
}, 0)
console.log(0)
复制代码
- 「JS引擎线程」运行JS执行栈中执行如上代码,调用了浏览器的web api:setTimeout函数,如图中「1」
在上图中可以看到,setTimeout这类异步接口实际上不在JS引擎中,而是由浏览器中的Web(图中的V8是chrome中的JS引擎,safari、firefox则是各自的引擎,参考《主流浏览器内核及JS引擎》)
- 然后「JS引擎线程」执行了console.log(0),控制台中输出了0
- 浏览器中的「定时器线程」接管该定时任务,当定时任务超时后,将回调函数即console.log(1)塞入到宏任务队列中,等待调度,如图中「2」
- 「event loop线程」检查JS执行栈是否为空,如果是,则将宏任务队列中的任务塞入JS执行栈中,如图中「8」
- 「JS引擎线程」处理执行栈中的console.log(1)代码,控制台中输出1
从这个流程可以看出,无论超时设置的多短,setTimeout中的回调函数必须等到下一次事件循环才能被处理,这就不难理解为什么0会在1之前输出了
promise.then函数
Promise.resolve().then(()=>{
console.log(2)
})
console.log(0)
复制代码
- 「JS引擎线程」运行JS执行栈中执行如上代码,由于Promise首先执行了resolve函数变为终结状态,因此立即执行promise.then方法,此方法实际上是创建了一个微任务,并将console.log(2)塞入微任务队列中,如图「5」所示。
- 然后「JS引擎线程」执行了console.log(0),控制台中输出了0
- 「event loop线程」检查JS执行栈是否为空,如果是,则将微任务队列中的任务塞入JS执行栈中,如图中「8」
- 「JS引擎线程」处理执行栈中的console.log(2)代码,控制台中输出2
宏任务与微任务
再回头看看文章开始的那个问题,只是在上述场景的基础上混合了宏任务与微任务
setTimeout(()=>{
console.log(1)
},0)
Promise.resolve().then(()=>{
console.log(2)
})
console.log(0)
复制代码
- setTimeout和promise.then的回调函数依次被塞入宏任务队列和微任务队列
- console.log(0)执行完后JS执行栈被清空
- Event Loop线程检测到JS执行栈为空,由于微任务队列比宏任务队列的优先级高,微任务被优先放入JS执行栈中处理
- 微任务处理完后,再处理宏任务队列中的任务
- 所以输出结果是0, 2, 1
任务分类
JS事件循环中的任务分为两大类
- 宏任务,即task
- 微任务,即microTask,也称job
对于浏览器环境和Node环境,提供的接口会有些许差别
宏任务接口
接口 | 浏览器 | Node |
---|---|---|
I/O操作 | ✅ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
微任务接口
接口 | 浏览器 | Node |
---|---|---|
process.nextTick | ❌ | ✅ |
promise.then/catch/finally | ✅ | ✅ |
MutationObserver | ✅ | ❌ |
任务优先级
- 整体上,微任务的处理优先于宏任务。准确的说是微任务会在本此事件循环结束前处理,宏任务会在下次事件循环开始时处理。
- 在Node环境中,process.nextTick产生的微任务优先级最高,会被插到微任务队列的最前端优先处理;而setImmediate产生的宏任务优先级最高,会在所有宏任务之前执行
Promise
Promise是一种异步解决方案,比以前的回调函数方式使用起来更直观和合理。
- 一个Promise对象有三种状态:pending(挂起), fulfilled(成功), rejected(失败)
- 当我们通过new Promise方法创建一个Promise对象时,这个对象处于pending状态。
- 对于Promise.then方法,有如下几点:
- 首先,为了保证Promise的链式调用,Promise规范定义then方法必须返回一个Promise对象
- 其次,then方法中会将成功和时报回调函数用微任务包装
- 最后,会根据当前Promise对象的状态来决定如何处理:
- pending状态下,then方法会将包装后的成功和失败回调函数放入各自的队列中保存(暂且称之为成功回调队列,失败回调队列),这两个队列由resolve和reject方法来处理
- fulfilled状态下,then方法会直接调用包装后的成功回调,即将成功回调函数放入微任务队列中,等待Event Loop调度
- 同样,rejected状态下,then方法会直接调用包装后的失败回调,即将失败回调函数放入微任务队列中,等待Event Loop调度
- 对于Promise的resolve和reject方法,以resolve为例,其会将成功回调队列中的所有任务一次执行,由于成功回调队列中的任务都被微任务包装过,执行他们即会将回调函数依次放入微任务队列中,等待Event Loop调度
来看下面的例子:
let promise = new Promise((resolve, reject) => {
console.log(1)
setTimeout(() => {
resolve(2)
}, 1000)
})
promise.then(data => {
console.log(data)
})
console.log(0)
复制代码
- 执行new Promise构造函数
- 控制台打印 1
- 创建一个定时任务,超时后会执行resolve方法
- 执行promise.then,由于此时promise还处于pending状态,因此将console.log(data)函数用微任务包装后,塞入成功回调队列,伪代码如下:
// 此处是伪代码,实际包装比这个复杂
const successCb = () => {
queueMicroTask((data) => {
console.log(data)
})
}
successCbQueue.push(successCb)
复制代码
- 执行控制台输出0
- 1秒后定时器超时,将resolve(2)回调函数放入宏任务队列
- Event Loop调度宏任务,并由JS执行线程执行了resolve(2),此操作会导致promise对象的状态改变,并开始处理成功回调队列中的任务
- 处理回调任务队列,将(data) => console.log(data)放入微任务队列中
- Event Loop调度微任务,执行(data) => console.log(data),此时的入参data是resolve(2)传入的参数2,因此此时控制台输出2
- 打印顺序为 1, 0, 2
链式调用
通过上面的例子,Promise的基本执行顺序应该能清楚了,下面再看一个更复杂的问题,即当Promise.then链式调用时,代码是如何运行的。
问题来源于这片文章:《从一道让我失眠的 Promise 面试题开始,深入分析 Promise 实现细节》
Promise.resolve().then(() => {
console.log(0);
return Promise.resolve(4);
}).then((res) => {
console.log(res)
})
Promise.resolve().then(() => {
console.log(1);
}).then(() => {
console.log(2);
}).then(() => {
console.log(3);
}).then(() => {
console.log(5);
})
复制代码
在看这个复杂的问题前,先看两个关于Promise.resolve和Promise.then的简单例子
Promise.resolve()
Promise.resolve是一个静态方法,它实际上是创建了一个立即resolve的Promise对象,以下两段代码是等同的
Promise.resolve(0)
// 等同于
new Promise((resolve, reject) => {
resolve(0)
})
复制代码
所以,
Promise.resolve(0).then(data => console.log(data))
复制代码
这段代码的执行过程:
- 创建一个Promise对象,立即将其状态改变为fulfilled
- 执行promise.then方法,由于此时的promise状态是fulfilled,会直接将其后的成功回调函数塞入微任务队列,等待调度
Promise.then()
我们知道,then方法会返回一个新的Promise对象,从而提供了链式调用的特性。
来看这个例子:
Promise.resolve(0).then(data => {
console.log(data)
return 1
}).then(data => {
console.log(data)
})
复制代码
我们省略细节,简单描述这个事件循环的过程
- 第一个then创建了一个微任务
- 执行第一个微任务,控制台输出0,并且返回1,返回的1将作为下一次then的成功回调函数的入参
- 第二个then创建一个新的微任务
- 执行第二个微任务,控制台输出1
可以看到,当then返回一个普通对象时,会简单的将此对象作为入参交给下一次then调用。但是,如果当then返回一个Promise对象时会发生什么?
来看这段代码
Promise.resolve(0).then(data => {
console.log(data)
return Promise.resolve(1)
}).then(data => {
console.log(data)
})
复制代码
在第一个then中,我们返回了一个fulfilled状态的Promise对象,如果还是按照普通对象的处理逻辑,直接把该Promise对象作为入参交给下一个then函数,那么上述代码的输出会变成
0
Promise {<fulfilled>: 1}
复制代码
显然,这不是我们预期的结果。我们希望的是将Promise.resolve(1)执行完毕后,将其resolve的结果(例子里就是1)交给下一个then来使用。
所以,实际当then方法中返回一个Promise对象时,会先执行这个Promise对象,然后再处理其后的then方法。
为了理解这个机制,可以简单的理解为,在两个then函数中,插入了一个then函数
// 简化理解代码执行顺序,实际处理过程中会多塞一次微任务队列,后面会谈到
Promise.resolve(0).then(data => {
console.log(data)
}).then(data => {
return 1
}).then(data => {
console.log(data)
})
复制代码
例子中代码的执行结果是
0
1
复制代码
实际上,为了处理then方法中返回的Promise对象,机制要比上面的简化写法复杂。
- V8首先创建了一个 NewPromiseResolveThenableJobTask类型的微任务,然后将此任务放入微任务队列中等待处理(微任务+1)
- 当上面的微任务被调度,处理NewPromiseResolveThenableJobTask时,实际就是调用了Promise.then方法,此时又创建了一个微任务(微任务+1)
- 所以,实际上需要两次微任务才能把这个Promise对象转变为fulfilled状态
详细源码分析请参考这篇文章:juejin.cn/post/695345…
综合分析
有了以上基础,我们再回到那个复杂的问题
// PromiseA
Promise.resolve().then(() => {
console.log(0);
return Promise.resolve(4);
}).then((res) => {
console.log(res)
})
// PromiseB
Promise.resolve().then(() => {
console.log(1);
}).then(() => {
console.log(2);
}).then(() => {
console.log(3);
}).then(() => {
console.log(5);
})
复制代码
为了便于说明,将上述代码做一个等价的改写
// 1.创建一个状态为fulfilled的Promise
const PromiseA = Promise.resolve()
// 2.由于PromiseA的状态为fulfilled,其后then函数中的成功回调会直接放入微任务中等待处理,PromiseA1的状态为pending
const PromiseA1 = PromiseA.then(() => {
console.log(0);
return Promise.resolve(4);
})
// 3.由于PromiseA1的状态为pending,其后then函数会被放入成功回调队列中,等待PromiseA1的状态变为fulfilled时再处理
const PromiseA2 = PromiseA1.then((res) => {
console.log(res)
})
// 4.创建一个状态为fulfilled的Promise
const PromiseB = Promise.resolve()
// 5.由于PromiseB的状态为fulfilled,其后then函数中的成功回调会直接放入微任务中等待处理,PromiseB1的状态为pending
const PromiseB1 = PromiseB.then(() => {
console.log(1);
})
// 6.由于PromiseB1的状态为pending,其后then函数会被放入成功回调队列中,等待PromiseB1的状态变为fulfilled时再处理
const PromiseB2 = PromiseB1.then(() => {
console.log(2);
})
// 7.同上,放入PromiseB2的成功回调队列中
const PromiseB3 = PromiseB2.then(() => {
console.log(3);
})
// 8.同上,放入PromiseB3的成功回调队列中
const PromiseB4 = PromiseB3.then(() => {
console.log(5);
})
复制代码
上述代码会先按顺序同步执行,可以看到,此时的微任务队列中只有第2步和第5步创建的两个微任务任务
console.log(0)
return Promise.resolve(4);
console.log(1)
这两个任务按顺序依次执行,产生的结果为
- 控制台依次输出0, 1
- 由于PromiseA的成功回调中返回了一个Promise对象,根据我们之前的讨论,此操作需要两轮微任务才能将此PromiseA1的状态改变为fulfilled,所以PromiseA2需要两轮后才能执行
- PromiseB1状态变为fulfilled,其后的then方法的成功回调会继续放入微任务中处理
第二轮微任务队列状态
等待PromiseA1状态改变为fulfilled
console.log(2)
同理,第三轮微任务队列状态
继续等待PromiseA1状态改变为fulfilled
console.log(3)
第四轮中,PromiseA1的状态终于改变为fulfilled,其后的then方法的成功回调会可以放入微任务中处理
console.log(res) // res = 4
console.log(3)
第五轮
console.log(5)
所以,控制台的输出结果是
0
1
2
3
4
5
复制代码
async/await
async/await语法糖的出现,让JS代码书写和阅读起来更加直观,让我们能够使用同步的思维来书写异步逻辑。
相对于Promise的等价写法
async/await实际上是使用Genorator机制实现的语法糖,为了便于理解其执行顺序,对于这两个语法糖,可以简化理解为:
- async:被async修饰的function,会自动返回一个Promise对象,比如有如下函数funcB
async function funcB() {
console.log("I'm function B")
}
// 等同于
function funcB() {
return new Promise((resolve, reject) => {
console.log("I'm function B")
resolve()
})
}
// 可以看到,funcB会同步执行控制台输出I'm function B,然后返回一个状态为fulfilled的Promise对象
复制代码
- await:在一个函数内,出现在await语句后的代码(不包含await这一行),会被包裹到一个then函数中,以下面的例子来说明:
async function funcB() {
console.log("I'm function B")
}
async function funcA() {
console.log("funcA start")
await funcB()
console.log("funcA end")
}
// 由上面的例子知道,funcB实际被包装成了一个Promise,那么上述代码可以简单的等价于
function funcA() {
console.log("funcA start")
new Promise((resolve, reject) => {
console.log("I'm function B")
resolve()
}).then(() => {
// 将await语句后的内容包裹在then函数中执行
console.log("funcA end")
})
}
复制代码
关于async的额外探讨
如果async func中本身就返回了一个Promise,那么是如何处理呢?
// 对于如下的funcB
async function funcB() {
console.log("I'm function B")
return Promise.resolve().then(() => {
console.log('function B end')
})
}
// 是等价于这种额外的Promise包装
new Promise((resolve, reject) => {
console.log("I'm function B")
return Promise.resolve().then(() => {
console.log('function B end')
}).then(() => {
resolve()
})
})
// 还是等价于直接返回原Promise
console.log("I'm function B")
return Promise.resolve().then(() => {
console.log('function B end')
})
// 结果是第二种,直接返回原来的Promise,不再做额外的包装
复制代码
为了验证上面的说法,来看下面这个例子
async function funcA() {
await funcB()
await funcC()
console.log(4)
}
async function funcB() {
console.log(1)
}
async function funcC() {
console.log(2)
return Promise.resolve().then(() => {
console.log(3)
})
}
funcA()
Promise.resolve().then(() => {
console.log(5)
}).then(() => {
console.log(6)
}).then(() => {
console.log(7)
}).then(() => {
console.log(8)
})
// 1, 2, 5, 3, 6, 7, 4, 8
复制代码
值得注意的是,「4」会输出在「7」之后。看完下面的等价写法就能理解为何会如此
以上代码等价于
// 等价写法
new Promise((resolve,reject) => {
console.log(1)
resolve()
}).then(() => {
console.log(2)
return Promise.resolve().then(() => {
console.log(3)
})
}).then(() => {
console.log(4)
})
Promise.resolve().then(() => {
console.log(5)
}).then(() => {
console.log(6)
}).then(() => {
console.log(7)
}).then(() => {
console.log(8)
})
// 1, 2, 5, 3, 6, 7, 4, 8
复制代码
注意:这里涉及到了我们之前讨论的一个点,即then中返回Promise,会需要两次微任务处理才能完成状态转化,因此这里的「4」输出在了「7」后面。这也解释了async/await写法下,为何「4」会输出在「7」后面。
有了以上的分析,我们再来看看如下代码的执行顺序
async function funcA() {
console.log("funcA start")
await funcB()
console.log("funcA end")
}
async function funcB() {
console.log("funcB start")
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("funcB end")
resolve()
}, 1000)
})
}
console.log("script start")
funcA()
console.log("script end")
复制代码
上述代码等价于
console.log("script start")
console.log("funcA start")
console.log("funcB start")
new Promise((resolve, reject) => {
setTimeout(() => {
console.log("funcB end")
resolve()
}, 1000)
}).then(() => {
console.log("funcA end")
})
console.log("script end")
复制代码
- 首先控制台会依次输出「script start」、「funcA start」、「funcB start」
- 执行setTimeout,等待超时
- 将console.log(“funcA end”)挂入Promise的成功执行队
- 输出「script end」
- 定时器超时,插入宏任务,等待调度
- Event Loop线程调度宏任务,输出「funcB end」,并改变Promise状态为fulfiiled,执行成功队列中的任务(微任务包装后的console.log(“funcA end”))
- 将console.log(“funcA end”)放入微任务队列,等待调度
- Event Loop线程调度微任务,输出「funcA end」
- 因此,输出结果为「script start」、「funcA start」、「funcB start」、「script end」、「funcB end」、「funcA end」
我们再来点变化,在funcB中,不返回包含了定时器的Promise
async function funcA() {
console.log("funcA start")
await funcB()
console.log("funcA end")
}
// 去掉了return
async function funcB() {
console.log("funcB start")
new Promise((resolve, reject) => {
setTimeout(() => {
console.log("funcB end")
resolve()
}, 1000)
})
}
console.log("script start")
funcA()
console.log("script end")
复制代码
输出结果变为了「script start」、「funcA start」、「funcB start」、「script end」、「funcA end」、「funcB end」
这里其实不难理解,由于funcB中去掉了return,其不需要等待定时器的Promise执行结束,因此,「funcA end」会在「funcB end」之前执行。为了更直观,同样做一个等价写法
console.log("script start")
console.log("funcA start")
// 由于funcB本身没有返回Promise,我们需要为其额外的包装一层Promise
new Promise((resolve, reject) => {
console.log("funcB start")
new Promise((resolveInner, rejectInner) => {
setTimeout(() => {
console.log("funcB end")
resolveInner()
}, 1000)
})
resolve()
}).then(() => {
console.log("funcA end")
})
console.log("script end")
复制代码
以上,等价写法仅用于直观理解await/async的执行顺序,不等同与实现原理。
小结
- 理解Event Loop的关键,
- 首先知道Event Loop运行所涉及的几个主要模块有哪些:JS引擎执行线程、JS执行栈、Web/Nodejs Api、宏任务、微任务、Event Loop调度线程。
- 其次要知道他们之间的关系,V8仅包含了JS引擎执行线程、JS执行栈、Promise接口,其余的部分由浏览器或node环境提供
- JS代码的执行顺序,只需要理清哪些是宏任务,哪些是微任务。微任务在本次事件循环末尾执行,宏任务在下次事件循环开始执行,所以表象是微任务的会在宏任务之前执行。
- 微任务中process.nextTick优先级高于其它微任务
- 微任务没有递归限制,宏任务有栈溢出限制。所以微任务使用是要注意不要滥用递归,否则会导致无限循环。
- setTimeout的超时时间准确的说是在至少在多长时间之后执行该回调,比如setTimeout(() => xxx, 1000)指的是至少1000ms后执行xxx操作。因为setTimeout的回调在宏任务中,如果本次事件循环中执行了大量的同步操作或微任务,会推迟该宏任务的调度。
- Promise的执行需要理解resolve和then函数的行为
- resolve会改变Promise对象的状态,并执行该Promise对象下所有的成功回调函数。
- then会根据Promise对象的状态来包装成功/失败回调函数,此回调函数执行时会先进入微任务队列
- then本身会返回一个新的Promise对象
- 如果then的回调函数中返回了一个Promise对象,则需要执行两次微任务才能将then返回的Promise对象状态改变为fulfilled状态
- async/await可以用Promise来等价改写,async将函数包装成一个Promise对象,await则是将其后的代码放入then中执行。
以下参考文章都可深度阅读了解
参考
Tasks, microtasks, queues and schedules(重点推荐,直观展示JS执行栈、微任务、宏任务的执行过程)
菲利普·罗伯茨在JS 2014 conf关于EventLoop的演讲
event loop可视化(菲利普·罗伯茨做的一个可视化工具,配合视频看)