关于 JS 事件循环 event loop

2,748 阅读12分钟

大家好,我是林一一,下面的这篇文章知识点事件循环 event Loop 在JS 中很重要,这篇文章能带你读懂Event Loop 是怎样工作的和包含面试的经典面试题,开始阅读吧😆。

思维导图

eventLoop.png

一、JS异步编程基本概念

JS 之所以是单线程的是因为浏览器(多线程)只分配一个线程来执行 JS 代码,之所以只分配一个线程试因为浏览器考虑到多线程操作会导致的一些问题,假设 JS 是多线程的,其中一个线程在 DOM 节点上添加内容,而另一个线程在这个节点上删除内容,那么浏览器该执行哪一个呢?所以 JS 的设计就是单线程的。但是单线程会造成很多的任务都需要等待执行,所以就引入了浏览器的事件循环机制。

进程和线程 Tip

进程中可以包括多个线程,比如打开一个页面,这个页面就占用了计算机的一个进程,页面加载时,浏览会分配一个线程去计算DOM树,一个去执行 JS 代码,其他的线程去加载资源文件等。

二、event loop

JS 主线程不断的循环往复的从任务队列中读取任务,执行任务,这种运行机制称为事件循环(event loop)。推荐看一个 2分钟了解event loop

宏任务和微任务

浏览器的事件循环(event loop)中分成宏任务和微任务。JS 中任务分成同步任务和异步任务。

1. 宏任务(macro task)

JS 中主栈执行的大多数的任务,例如:定时器,事件绑定,回调函数,node中fs操作模块等就是宏任务 script,setTimeout, setInterval, setImmediate(Node), I/O(Node), UI rendering, requestAnimationFrame

2. 微任务(micro task)

promise, async/await, process.nextTick, MutationObserver等就是微任务。

思考:为什么要引入微任务,只有宏任务可以吗?

微任务的引入是为了解决异步回调的问题,假设只有宏任务,那么每一个宏任务执行完后回调函数也放入宏任务队列,这样会造成队列多长,回调的时间变长,这样会造成页面的卡顿,所以引入了微任务。

思考,为什么 await 后面的代码会进入到promise队列中的微任务?

async/await 只是操作 promise 的语法糖,最后的本质还是promise。举一个小栗子

async function async1() {
	console.log('async1 start');
	await async2();
	console.log('async1 end');
}
// 上面的代码等价于 ==>
async function async1() {
    console.log('async1 start');
    Promise.resolve(async2()).then(() => {
        console.log('async1 end')
    })
}

3. 宏任务和微任务的执行顺序(很重要)

  1. 主栈队列就是一个宏任务,每一个宏任务执行完就会执行宏任务中的微任务,直到微任务全部都执行完,才开始执行下一个宏任务。
  2. JS 中任务的执行顺序优先级是:主栈全局任务(宏任务) > 宏任务中的微任务 > 下一个宏任务。,所以 promise(微任务) 的执行顺序优先级高于setTimeout定时器。
  3. 不能满目的将 .then 的回调放入微任务队列;因为没有调用 resolve或者reject 之前是不算异步任务完成的, 所以不能将回调随意的放入微任务事件队列
  4. await 是一个让出线程的标志。await 后面的表达式会先执行一遍,将 await 下面的代码加入到 micro task中这个微任务是 promise 队列中微任务,然后就会跳出整个 async 函数来继续执行后面的代码。
  5. process.nextTick 是一个独立于 eventLoop 的任务队列,主栈中的宏任务每一次结束后都是先执行 process.nextTick队列,再执行微任务 promise.then()
  6. 每一个宏任务和宏任务的微任务执行完后都会对页面 UI 进行渲染。
  • 看到一张 浏览器渲染整个流程的概要图
  • 从上面看到 requestAnimationFram > reauestidleCallback > setTimeout 的

热身1 先看一个小栗子

// A 任务
setTimeout(() => {
    console.log(1)
}, 20)

// B 任务
setTimeout(() => {
    console.log(2)
}, 0)

// C 任务
setTimeout(() => {
    console.log(3)
}, 10)

// D
setTimeout(() => {
    console.log(5)
}, 10)

console.log(4)
/* 输出
*   4 -> 2-> 3 -> 5 -> 1
*/

任务队列.jpg

在主线程的主任务(宏任务)先自上而下执行,遇到 setTimeout 代码都是下一个(宏任务)。所以都会被加入到等待队列中,浏览器有专门监听等待队列中的代码,在主栈中的同步代码执行完成后,等待队列中的任务先到执行时间的就先执行,如果等待任务队列中有两个同时到执行时间的异步代码,那么先入队列的就先到主栈中执行。所以等待队列中 B 任务执行后到 C 任务到 D 任务 再到 A 任务。输出的结果就是4 -> 2-> 3 -> 1

热身2 将上面的栗子改一下

// A 任务
setTimeout(() => {
    console.log(1)
}, 20)

// B 任务
setTimeout(() => {
    console.log(2)
}, 0)

// C 任务
setTimeout(() => {
    console.log(3)
}, 30)

console.log(4)
/* 输出
*   4 -> 2-> 1 -> 3
*/

这题的原理和上面一样,任务A 的执行时间比 C 任务先到了就先输出了 1 后输出 3。


var Func = () => console.log('a');
setTimeout(Func,0);
console.log('change');
Func = () => console.log('another a');

//输出 change, a

上面的输出结果中不是 another a 的原因是setTimeout(Func, 0) 中保存 Func 地址已经放入到了宏任务队列中,即使Func后面的引用地址发生了改变,也不会影响 setTimeout 的回调函数Func,所以输出结果就是a

node 中的事件循环

node 11 以后 node 的执行顺序和浏览器的一致,在 node11 之前和浏览器的执行顺序有差别

三大重点阶段

  • timers 阶段执行的是 setTimeout 和 setInterval 的回调函数,并且由 poll 阶段控制。
  • poll 是一个很重要的阶段

poll阶段.jpg

  1. 查看有没有定时器,如果有定时器且已经到达执行时间了,那么会执行,eventLoop 将回到 timers 阶段
  2. 没有定时器,会去看 poll 的事件循环队列。如果 poll 队列不为空,那么会遍历对调队列并执行,直到队列为空。
  3. 如果 poll 队列为空。那么会去看有没有 setImmediate 函数需要执行。如果有 setImmediate 需要执行那么 poll 阶段会停止,后进入到 check 阶段执行 setImmediate 。如果没有 setImmediate() poll 阶段会等待一段时间,等回调加入道队列中并立即执行。
  4. 过了等待的时间限制后,会自动进入到 check 阶段。

Node 10以前:

  • 执行完一个阶段的所有任务
  • 执行完nextTick队列里面的内容
  • 然后执行完微任务队列的内容

三、思考题求输出顺序(chrome 浏览器为准)

1. 来一道思考题,求输出结果

let xhr = new XMLHttpRequest()
xhr.open('post', 'api')
xhr.onreadystatechange = () =>{
    if(xhr.readyState == 2){
        console.log(2)
    }
    if(xhr.readyState == 4){
        console.log(4)
    }
}
xhr.send()
console.log(3)
/* 输出
*   3 2 4
*/

2. 再来一道思考题,在同步请求中下面代码输出的是什么

let xhr = new XMLHttpRequest()
xhr.open('get', 'xxx', false)
xhr.send()

xhr.onreadystatechange = () => {
    console.log(xhr.readyState)
}

没有输出,上面的两道题在 面试 | Ajax,fetch,axios的超高频面试题 有解析。

3. 一道 Ajax 异步思考题,求输出结果。

let xhr = new XMLHttpRequest()
xhr.open('post', 'api')
xhr.onreadystatechange = () =>{
    console.log(xhr.readyState)
}
xhr.send()
/* 输出
*   2 -> 3 -> 4。
*/

xhr.onreadystatechange 是异步的会加入到等待队列,主任务执行 xhr.send() 后 ajax 的状态码变成 1。主任务空闲等待任务中的 xhr.onreadystatechange 开始监听到状态码变化,知道状态码由2 -> 3 -> 4 后不再变化。如果不熟悉 ajax 状态码的可以看看 面试 | Ajax,fetch,axios的超高频面试题

4. promise 热身题,求输出结果

console.log(1)
new Promise((resolve, reject) => {
    console.log(2)
    resolve()
}).then(res => {
    console.log(3)
})
console.log(4)
/* 输出
* 1 -> 2 -> 4 ->3 
*/

解答:第一轮宏任务就是主栈中的同步任务,先输出1,JS 代码执行到promise立即执行输出2resolve.then() 中的代码放入到微任务队列,宏任务结束后输出 4,最后执行微任务队列输出3

5. setTimeout 和 Promise 的执行顺序

setTimeout(function () {
    console.log(1)
}, 0);

new Promise(function (resolve, reject) {
    console.log(2);
    resolve();
}).then(function () {
    console.log(3)
}).then(function () {
    console.log(4)
});

console.log(6);
// 2, 6, 3, 4, 1

解答:先开始主栈中的宏任务,遇到setTimeout后丢入宏任务队列等待,遇到promise立即执行输出2resolve()异步的丢入微任务队列,最后输出6,第一个宏任务执行结束开始留下来的微任务,即 .then() 输出 3, 4。第一轮循环结束开始下一轮宏任务 setTimeout,输出1

6. setTimeout 和 Promise 的执行顺序

setTimeout(function () {
    console.log(1)
}, 0);

new Promise(function (resolve, reject) {
    console.log(2)
    for (var i = 0; i < 10000; i++) {
        if (i === 10) {
            console.log(10)
        }
        i == 9999 && resolve();
    }
    console.log(3)
}).then(function () {
    console.log(4)
})
console.log(5);
// 2, 10, 3, 5, 4, 1

这道题的解法和上面相同,都需要区分宏任务和微任务。

7.求输出结果

console.log("start");
setTimeout(() => {
    console.log("children2")
    Promise.resolve().then(() =>{
        console.log("children3")
    })
}, 0)

new Promise(function(resolve, reject){
    console.log("children4")
    setTimeout(function(){
        console.log("children5")
        resolve("children6")
    }, 0)
}).then(res =>{         // flag
    console.log("children7")
    setTimeout(() =>{
        console.log(res)
    }, 0)
})
// start children4 children2 children3  children5  children7 children6
  1. 首先开始主任务中的第一轮宏任务,输出start,遇到 setTimeout 不需要等待 0s 而是直接丢入宏任务队列(有人说需要等待 0s再放入到任务队列是不对的,可以使用console.time/timeEnd来测试),遇到promise立即执行输出children4,又遇到一个setTimeout 直接又丢入到宏任务队列,第一轮宏任务执行完,且没有微任务。问:上面的 .then() (注释的flag处) 是第一轮宏任务循环的微任务吗?不是!因为resolve 都没有执行,promise 的状态都还没有从pending改变,就不是第一轮的微任务。
  2. 开始下一轮的宏任务执行第一个进入的 setTimeout,输出children2,第二轮宏任务结束,开始微任务执行promise 中的.then() 输出 children3。第二轮循环结束
  3. 接着又开始setTimeout 的宏任务,输出children5,微任务输出 children7。这里遇到一个宏任务 setTimeout,丢入宏任务队列。
  4. 又开始新 setTimeout 宏任务,输出 res children6

8. (头条)请写出下面代码的运行结果(不同的环境下输出有差异,下面以最新的 Chromium 为准)

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2')
}
console.log('script start')
setTimeout(function () {
    console.log('setTimeout')
}, 0)
async1()
new Promise((resolve) => {
    console.log('promise1')
    resolve()
}).then(function () {
    console.log('promise2')
})
console.log('script end')
//输出
//script start
//async1 start
//async2
//promise1
//script end
//async1 end
//promise2
//setTimeout

这道题的难点在于是 promise2还是 async1 end 先输出。从全局宏任务之上而下执行时 await async2() 后面的代码 console.log('async1 end') 先进入 promise 中的微任务队列,最后.then() 中的console.log('promise2') 再进入到 promise 中的微任务队列。所以再开始下一轮宏任务循环之前先输出了 async1 end 再输出了 promise2。全局中的微任务执行完成开始下一轮宏任务setTimeout 最后输出 setTimeout

9. 将上一道题目变换一下,求输出(不同的环境下输出有差异,下面以最新的 Chromium 为准)

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    new Promise(function (resolve) {
        console.log('promise1');
        resolve();
    }).then(function () {
        console.log('promise2');
    });
}
console.log('script start');
setTimeout(function () {
    console.log('setTimeout');
}, 0)
async1();
new Promise(function (resolve) {
    console.log('promise3');
    resolve();
}).then(function () {
    console.log('promise4');
});
console.log('script end');
//script start, 
// async1 start, 
// promise1, 
// promise3, 
// script end, 
// promise2,
// async1 end,
// promise4, 
// setTimeout

首先开始全局下的宏任务依次输出 script start, async1 start, promise1, promise3, script end。其中 await async2();async2().then()的代码先进入到promise的微任务队列,await async2(); 后面的代码再进入到promise的任务队列,console.log('promise4'); 最后进入到 promise 的任务队列。全局下的宏任务结束,开始全局下的微任务,promise 的微任务队列中按照队列的先进先出原则依次输出,promise2,async1 end,promise4。全局微任务结束,开始下一轮的宏任务setTimeout,最终输出 setTimeout

10. 再来将上面的题目变换一下,求输出(不同的环境下输出有差异,下面以最新的 Chromium 为准)

async function async1() {
    console.log('async1 start');
    await async2();
    setTimeout(function() {
        console.log('setTimeout1')  // 这一部分代码会放入到 promise 的微任务队列中。
    },0)
}
async function async2() {
    setTimeout(function() {
            console.log('setTimeout2')
    },0)
}
console.log('script start');
setTimeout(function() {
    console.log('setTimeout3');
}, 0)
async1();
new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');
// script start, async1 start, promise1, script end, promise2, setTimeout3,  setTimeout2, setTimeout1

按照上面的解析,原理都是一样的,全局下的宏任务执行完成后,开始执行全局下的微任务.then() 中的代码,最后开始下一轮宏任务的执行,下一轮宏任务是 setTimeout3 先执行,因为是setTimeout3 先加入下一个宏任务队列中的,再依次加入setTimeout2, setTimeout1到宏任务队列。所以输出的结果是setTimeout3, setTimeout2, setTimeout1

  1. 微任务队列
new Promise((resolve, reject) => {
    console.log('B');
    resolve(); // 1
}).then(() => {   // 第一个 then 
    console.log('C')
    new Promise((resolve, reject) => {
        resolve()   // 2 
    }).then(() => {
        console.log('D')
    }).then(() => {   // 3
        console.log('E')
    })
}).then(() => {   // 第二个 then 加入
    console.log('F')
});

1 处 resolve(); 是一个微任务先进去队列,由于后面没有同步代码,所以 resolve() 先执行,此时队列为空 第一个 then 先执行 输出 C 。 2 处 resolve() 也是一个微任务放入到微任务队列中 。第一个 then 执行结束,加入第二 then 到队列中 队列中的 resolve() 执行时 输出 D,3 处的 then 是异步的加入到队列中, 执行 第二个 then 的代码输出 F,最后执行队列中的最后一个 then 输出 E

四、参考

《Javascript 忍者秘籍》第二版,事件循环篇

面试题:说说事件循环机制(满分答案来了)

第 10 题:常见异步笔试题,请写出代码的运行结果

Promise 你真的用明白了么

五、结束

js 异步队列的题目就先到这里,如果觉得不过瘾的可以看看这篇文章的 面试 | JS 你不得不懂的 异步编程 | promise 篇 ,如果这篇文章能对你有点帮助或启发的话欢迎 github 留 star,我是林一一,下次见