前言
Event Loop大家都有所耳闻,但是,由于我们日常业务中并不需要写那么复杂的异步任务,所以很多人对Event Loop都一知半解,今天就讨论一下。
Event Loop概念
Event loop是一个JS引擎执行模型,在不同的地方有不同的实现。浏览器和NodeJS基于不同的技术实现了各自的Event Loop。
微任务队列和宏任务队列
微队列,microtask,也叫jobs。 另一些异步任务的回调会依次进入micro task queue,等待后续被调用,这些异步任务包括:
- process.nextTick (Node独有)
- Promise的then的回调(注意,new Promise()的函数参数是同步代码,then的回调才是异步)
- await下方的代码和赋值代码(注意,await后方的函数是同步代码)
- Object.observe(草案已废弃,这里仅做记录)
- MutationObserver
宏队列,macrotask,也叫tasks。 一些异步任务的回调会依次进入macro task queue,等待后续被调用,这些异步任务包括:
- setTimeout的回调
- setInterval的回调
- setImmediate的回调
- requestAnimationFrame (浏览器独有)
- I/O
- UI rendering (浏览器独有)
如果你致力于弄懂Event Loop,请背过这些任务名,至少要记住:then
的回调、await
下面的代码属于微队列,setTimeout
的回调、setInterval
的回调属于宏队列。要记牢。
调用栈
可以理解为JS引擎的工作台。任务会从宏任务队列或者微任务队列放到调用栈,然后再执行这些任务。
异步方法
setTimeout
这些都是window
的异步方法,注意,异步方法本身是同步执行的,也就是说,下面代码中,setTimeout
是立即执行的,但是setTimeout
的回调函数就不会立即执行,而是1秒之后执行。
console.log(1);
setTimeout(() => {}, 1000);
console.log(2);
JavaScript代码执行的具体流程
- 执行全局Script同步代码,这些同步代码有一些是同步语句(new Promise()的函数参数是同步代码),有一些是异步语句(比如setTimeout等);
- 异步语句的回调任务会根据上方介绍的规定,放入微任务队列或宏任务队列。
- 全局Script代码执行完毕后,调用栈Stack会清空;
- 从微队列(microtask queue)中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
- 继续取出位于微队列队首的任务,放入调用栈Stack中执行,以此类推,直到把微队列中的所有任务都执行完毕。注意,如果在执行微队列任务的过程中,如果又产生了新的微任务,那么会加入到微任务队列的末尾,也会在这个周期被调用执行;
- 微任务队列中的所有任务都执行完毕,此时微任务队列为空队列,调用栈Stack也为空;
- 取出宏队列(macrotask queue)中位于队首的任务,放入Stack中执行;
- 继续观察微队列是否有任务,如果有,则全部执行微队列任务;
- 重复7-8步骤;
- 宏队列执行完毕后,调用栈Stack为空;
- 重复第4-8个步骤;
- ......
通俗说:微队列就是牛逼,优先级就是高,每执行一串微队列,则执行一个宏队列任务,然后又去执行微队列,重复重复再重复下去。。。这就好比VIP通道跟普通通道,VIP全部优先,服务完了之后看看普通通道有没有人,有则服务一个,然后赶紧看看VIP是不是又来人了,没有的话再服务一个普通人,然后再看看VIP是不是又来人了。周而复始。
例题1
试着自己回答一下这道题,求打印顺序:
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);
})
setTimeout(() => {
console.log(6);
})
console.log(7);
解答:
第1步,console.log(1);,打印1
位置 | 任务 |
---|---|
调用栈 | console.log(1); |
微任务 | |
宏任务 |
第2步,setTimeout,不打印
位置 | 任务 |
---|---|
调用栈 | setTimeout |
微任务 | |
宏任务 | setTimeout的回调 |
第3步,new Promise的函数参数,注意,Promise的函数参数是同步执行的,打印4
位置 | 任务 |
---|---|
调用栈 | new Promise的函数参数 |
微任务 | new Promise的then的回调 |
宏任务 | setTimeout 的回调 |
第4步,第二个setTimeout,不打印
位置 | 任务 |
---|---|
调用栈 | 第二个setTimeout |
微任务 | new Promise的then的回调 |
宏任务 | setTimeout 的回调,第二个setTimeout的回调 |
第5步,console.log(7);,打印7
位置 | 任务 |
---|---|
调用栈 | console.log(7); |
微任务 | new Promise的then的回调 |
宏任务 | setTimeout 的回调,第二个setTimeout的回调 |
第6步,第一个微任务,即new Promise的then的回调,打印5
位置 | 任务 |
---|---|
调用栈 | new Promise的then的回调 |
微任务 | |
宏任务 | setTimeout的回调,第二个setTimeout的回调 |
第7步,第一个宏任务,即setTimeout的回调,打印2,同时,Promise.resolve().then()
的回调进入微队列
位置 | 任务 |
---|---|
调用栈 | setTimeout的回调 |
微任务 | Promise.resolve().then() 的回调 |
宏任务 | 第二个setTimeout的回调 |
第8步,第一个微任务,即Promise.resolve().then()
的回调,打印3
位置 | 任务 |
---|---|
调用栈 | Promise.resolve().then() 的回调 |
微任务 | |
宏任务 | 第二个setTimeout的回调 |
第9步,第一个宏任务,即第二个setTimeout的回调,打印6
位置 | 任务 |
---|---|
调用栈 | 第二个setTimeout的回调 |
微任务 | |
宏任务 |
是不是跟你演算的一样呢?
例题2
考察then方法链的执行顺序:
new Promise((resolve, reject) => {
console.log(1)
resolve(2)
}).then((data) => {
// 1号回调
console.log(data);
return 3
}).then((data) => {
// 2号回调
console.log(data);
})
new Promise((resolve, reject) => {
console.log(5)
resolve(6)
}).then((data) => {
// 3号回调
console.log(data);
return 7;
}).then((data) => {
// 4号回调
console.log(data);
})
你以为会打印1 2 3 5 6 7吗?错!你以为会打印1 5 2 3 6 7吗?也错!
new Promise的函数参数是同步代码,所以先打印1。同时将第一个then的回调(1号回调)放入微队列。
同理,打印5,将第二个new Promise的第一个then的回调(3号回调)放入微队列。
执行1号回调,打印2,将2号回调放入微队列。
执行3号回调,打印6,将4号回调放入微队列。
执行2号回调,打印3。
执行4号回调,打印7。
所以结果是1 5 2 6 3 7,你演算对了吗?
例题3
有如下代码:
setTimeout(() => {
console.log(1)
setTimeout(() => {
console.log(2)
})
})
不许修改这段代码,只允许在外层作用域添加代码,如何实现在打印1
跟打印2
中间插入打印3
?
其实这个题跟例题2类似,考察的也是基本知识:
setTimeout(() => {
console.log(1)
setTimeout(() => {
console.log(2)
})
})
setTimeout(() => {
console.log(3)
setTimeout(() => {
console.log(4)
})
})
结果:打印 1 3 2 4。
原因:同例题2。
例题4
这次考察对async/await的执行顺序的理解:
console.log('script start');
async function async1() {
await async2();
console.log('async1 end');
};
async function async2() {
console.log('async2 end');
};
async1()
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise((resolve, reject) => {
console.log('promise start');
resolve()
})
.then(() => console.log('promise end'))
console.log('script end')
打印
script start
执行
async1()
,async1
的第一行是await async2()
,这一句应拆开考察,其中async2()
会同步执行,同时把await
下方的所有代码放到微队列。执行setTimeout,不打印。同时把回调放入宏队列。
执行
new Promise
的函数参数,打印promise start
,同时把then的回调放入微队列 ,此时,微队列有2块代码,分别是await下面的代码(即console.log('async1 end');
),以及console.log('promise end')
。console.log('script end'),打印
script end
。执行微队列第一个,也就是打印
async1 end
执行微队列第二个,也就是打印
promise end
执行宏任务,也就是打印
setTimeout
注意:低版本的Chrome浏览器(大约是70版本之前)会先打印promise end
,后打印async1 end
,原因我忘却了,大致是Chrome的早期实现里有Bug,其实原因也不重要,既然高版本的浏览器纠正归来了,就行了。
例题5
证明await的赋值操作是异步任务:
let x = 5;
let y;
function ret() {
x += 1;
console.log('x是', x);
console.log('y是', y);
return x;
}
async function a() {
y = await ret();
console.log(y);
}
async function b() {
y = await ret();
console.log(y);
}
a()
b()
得到:
x是 6
y是 undefined
x是 7
y是 undefined
6
7
我们用反证法,假定await赋值是同步操作,那么a()
的y = await ret()
会同步执行,之后才执行b()
的ret()
,此时y有值,不应该是undefined
,但事实是undefined
,说明await赋值是异步操作。
例题6
证明new Promise()的函数参数和await后面的函数是同步任务,证明很简单:
var i;
for (i = 0; i < 20; i++) {
new Promise(resolve => {
console.log(i)
})
}
也是反证法,假如new Promise()的函数参数是异步任务,那么应该像setTimeout
一样打印20个20
,然而事实上会打印等差数列。
注意,不要用for(let i = 0;)
这种写法,因为这会形成一个特殊作用域,不能反证出我们的结论。
function log(x) { console.log(x) }
async function a(i) {
await log(i)
}
var i;
for (i = 0; i < 20; i++) {
a(i);
}
function log(x) { console.log(x) }
var i;
for (i = 0; i < 20; i++) {
async function a() {
await log(i)
}
a();
}
同理,因为上面2段代码的结果也都是等差数列,所以await后面的语句是同步任务。
例题7
证明await会暂停for循环:
var i;
function ret() {
console.log('ret里的i', i);
return 100;
}
async function a() {
for (i = 0; i < 20; i++) {
await ret();
console.log(i);
}
}
a();
得到等差数列。
虽然微队列放入了20个任务,但是由于await会暂停循环,也就是说i并不会像setTimeout时一样自顾自的增长为20,而是会等待每一个微任务执行完毕,由此证明await会暂停for循环。
相反,setTimeout不会阻止for循环:
var i;
function ret() {
console.log('ret里的i', i);
return 100;
}
async function a() {
for (i = 0; i < 20; i++) {
setTimeout(() => {
ret();
console.log(i);
});
}
}
a();
总结
随时遇到异步,随时放入队列,这是起码的准则。
先同步任务,同步任务有异步回调的时候,根据规则放入队列。
同步任务都完成了就执行微队列。有异步则继续放入队列。
微队列都完成了就执行宏队列第一个。有异步则继续放入队列。
观察微队列,有则执行,没则执行宏队列第二个。
重复3/4/5步骤。