本文主要讨论浏览器环境上的js单线程机制和js异步的实现,关于其他环境下,比如node就暂时不讨论
先来说结论:
- JS本身是没有办法真正实现异步的,异步的实现需要环境的支持(比如浏览器,因为浏览器是多线程的)。
- JS有两个任务队列,分别是宏任务(macrotasks)队列和微任务(microtask)队列,JS也只有一个任务执行栈,执行栈只能放一个宏任务或一个微任务,执行完这个任务后清空执行栈。
- 浏览器有一个event table,用来确定宏任务或者微任务何时被添加到相应队列的队尾(个人觉得这个才是JS异步的核心)。
- 当用户打开一个页面的时候,JS的执行栈有一个宏任务,也就是一整个<script>代码,JS就会执行这个宏任务,当遇到宏任务时候,就会把这个宏任务注册到event table,JS继续执行下面的(同步)代码,这个宏任务什么时候被添加到宏任务队尾是由浏览器来控制,同样的遇到微任务,也会有类似的操作,当<script>的(同步)代码执行完后就会来一个个的处理微任务队列里的微任务,当微任务队列里的微任务执行完了,就会从宏任务队列取出一个宏任务,重复进行上述操作,这个过程叫Event Loop(事件循环)。粗略的讲,就是“一个宏任务———几个微任务”,这样子不断的循环。
图片来自:Event loop: microtasks and macrotasks (javascript.info)
这里rander就把他当作一类普通的宏任务就可以了,执行的循序取决于渲染事件在宏任务队列里的顺序,并不一定是像图中一样的这么规律。
哪些是macrotask?
- setTimeOut/setInterval函数
- I/O操作(addEventListener的回调等)
- <script>代码
- 界面的渲染
- .……欢迎评论区补充
哪些是microtask?
- Promise的then、catch、finally、all、race
- await(本质也是Promise)
- queueMicrotask
- MutationObserver
- ……欢迎评论区补充
从setTimeOut来看macrotask
先上题目:
console.log('1')
setTimeout(()=>{
console.log('2');
},0);
console.log('3');
显然,这个的输出结果是1-3-2。
为什么不是1-2-3呢?setTimeout函数的时间设置不是0秒么?
前面说了setTimeOut是一个macrotask,这里的时间参数是0s(这里的0秒指的是从event table被压入宏任务队列的时间,不是真的过了0秒就马上执行),setTimeOut的回调函数在event table停了“0s”后马上被排到宏任务队列的队尾,但是这个宏任务要等执行栈里的宏任务执行完后再放入执行栈执行。
当前的宏任务执行完后,也就是输出1和2后,从宏任务队列里面拿出一个宏任务(setTimeOut的回调函数),放入执行栈并执行,然后输出3。
一个宏任务何时被放入event table取决于它什么时候被执行到(这里的“执行到”并不是指的宏任务被整个执行了,单纯的指V8引擎,碰到宏任务的标志,比如setTimeOut),一个宏任务什么时候被排到宏任务的队尾就要看触发条件(各种事件、定时器)什么时候触发,这里的setTimeOut就是什么时候时间到了,就被排到队尾。
这里的计时操作并不是JS的线程来干的,而是浏览器。
从promise来看microtask
这里不会promise的可以先去补一下,要不然有点吃力
console.log('script start');
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出结果:script start-->promise1-->script end-->promise2
!!!这里要注意,promise的里面的执行函数是同步的,异步的是then的回调。
来看看执行的过程:
- 先执行console.log('script start'),输出script start。
- 执行new Promise,并把后面then里的回调函数(微任务)放入event table。
- 执行new Promise 里的执行函数,执行到 console.log('promise1'),输出promise1,执行到resolve(),就把对应的微任务从event table排到微任务队列的队尾。
- 执行console.log('script end'),输出script end。
- 这里是一个分界线,一个执行栈里的一个宏任务执行完了,从微任务队列里一个个取出微任务放到执行栈执行,也就是把前面then的回调取出来,并执行,输出promise2。
复杂的情况下
function fn(){
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3)
});
});
new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data);
Promise.resolve().then(() => {
console.log(6)
}).then(() => {
console.log(7)
setTimeout(() => {
console.log(8)
}, 0);
});
})
setTimeout(() => {
console.log(9);
})
console.log(10);
}
fn();
输出的结果:1,4,10,5,6,7,2,3,9,8
执行过程:
- 首先一开始<script>这个宏任务存在于执行栈里被执行,执行fn函数,执行到console.log(1),输出1。
- 执行栈:栈顶【(1-33)】栈底
- 宏任务队列:队头【】队尾
- 微任务队列:对头【】队尾
- 碰到setTimeOut就把一个宏任务(5-8行)注册到event table,然后因为这里的定时是0s,马上被压入宏任务队列的队尾。
- 执行栈:栈顶【(1-33)】栈底
- 宏任务队列:队头【(5-8)】队尾
- 微任务队列:对头【】队尾
- 碰到new Promise,救把这个Promise后面第一个then的回调(14-26行)作为一个微任务注册到event table。
- 执行栈:栈顶【(1-33)】栈底
- 宏任务队列:队头【(5-8)】队尾
- 微任务队列:队头【】队尾
- 执行Promise内部的执行函数,执行到console.log(4),就输出4,执行到resolve(5)就把event table里对应的微任务(14-26行)压入微任务队列队尾。
- 执行栈:栈顶【(1-33)】栈底
- 宏任务队列:队头【(5-8)】队尾
- 微任务队列:队头【(14-26)】队尾
- 又碰到setTimeOut,和第2步骤一样的操作。
- 执行栈:栈顶【(1-33)】栈底
- 宏任务队列:队头【(5-8)(29-30)】队尾
- 微任务队列:队头【(14-26)】队尾
- 执行 console.log(10),输出10,至此一个宏任务被执行完毕,执行栈是空的了。
- 执行栈:栈顶【】栈底
- 宏任务队列:队头【(5-8)(29-30)】队尾
- 微任务队列:队头【(14-26)】队尾
- 开始一个个执行微任务队列里的微任务。此时微任务队列只有一个微任务(14-26行),就是步骤4的那个微任务,执行这个微任务。
- 执行栈:栈顶【(14-26)】栈底
- 宏任务队列:队头【(5-8)(29-30)】队尾
- 微任务队列:队头【】队尾
- 执行console.log(data),这里的data因为是5,输出5。
- 碰到了Promise.resolve(),这个就和new Promise((resolve)=>{resolve()})是一样的,后面第一个then的回调函数(18-19行)先被注册到event table然后马上被压入微任务队尾,到这里步骤4的那个微任务(14-26行)就算是结束了。
- 执行栈:栈顶【】栈底
- 宏任务队列:队头【(5-8)(29-30)】队尾
- 微任务队列:队头【(18-19)】队尾
- 微任务队列多了一个步骤9压入的微任务(18-19行),取出这个微任务,放入执行栈马上执行,执行console.log(6),输出6,步骤9压入的微任务执行完毕。因为then会隐式返回一个Promise.resolve(undefined),所以会把后面第一个then的回调(20-25行)在event table注册,然后马上压入微任务(走个过场)。
- 执行栈:栈顶【】栈底
- 宏任务队列:队头【(5-8)(29-30)】队尾
- 微任务队列:队头【(20-25)】队尾
- 在微任务队列里取出步骤10(20-25行)压入的微任务,放入执行栈,执行console.log(7),输出7,碰到setTimeOut,参考步骤2,宏任务队列又多了一个宏任务(23-24行)。步骤10的微任务(20-25行)执行完毕。
- 执行栈:栈顶【】栈底
- 宏任务队列:队头【(5-8)(29-30)(23-24)】队尾
- 微任务队列:队头【】队尾
- 现在微任务队列为空,开始从宏任务取出宏任务,一个个执行。
- 取出步骤2注册的宏任务,执行console.log(2),输出2,注册一个微任务(7-8行),并马上压入微任务队列,步骤2注册的宏任务执行完毕,执行刚刚压入微任务队列的微任务(7-8行),执行console.log(3),输出3。
- 执行栈:栈顶【(5-8)】栈底
- 宏任务队列:队头【(29-30)(23-24)】队尾
- 微任务队列:队头【(7-8)】队尾
- 微任务队列空了,就从宏任务队列拿出步骤5注册的宏任务(29-30行),执行console.log(9),输出9。清空执行栈
- 执行栈:栈顶【(29-30)】栈底
- 宏任务队列:队头【(23-24)】队尾
- 微任务队列:队头【】队尾
- 微任务队列还是空的,取出步骤11注册的宏任务(23-24行),执行console.log(8),输出8。清空执行栈。
- 执行栈:栈顶【(23-24)】栈底
- 宏任务队列:队头【】队尾
- 微任务队列:队头【】队尾
- 此时,两个任务队列都是空的。执行完毕!
- 执行栈:栈顶【】栈底
- 宏任务队列:队头【】队尾
- 微任务队列:队头【】队尾
参考连接
前端干货:JS的执行顺序 - 简书 (jianshu.com)