9

Event Loop曾经的理解

首先,JS是单线程语言,也就意味着同一个时间只能做一件事,那么

  • 为什么JavaScript不是多线程呢?这样还能提高效率啊
假定JS同时有两个线程,一个线程在某个DOM节点上编辑了内容,而另一个线程删除了这个节点,这时浏览器就很懵逼了,到底以执行哪个操作呢?

所以,设计者把JS设计成单线程应该就很好理解了,为了避免类似上述操作的复杂性,这一特征将来也不会变。

但是单线程有一个问题:一旦这个线程被阻塞就无法继续工作了,这肯定是不行的

由于异步编程可以实现“非阻塞”的调用效果,引入异步编程自然就是顺理成章的事情了,那么

  • JS单线程如何实现异步的呢?

今天的主咖登场——事件循环(Event Loop),JS异步是通过的事件循环实现的,理解了Event Loop机制,就理解
了JS的执行机制。

先来段代码:

console.log(1)

setTimeout(()=>{
    console.log(2)
}, 0)

for(let i = 3; i < 10000; i++){
    console.log(i)
}
执行结果:1 3 4 5 6 7 ... 9997 9998 9999 2

setTimeout里的函数并没有立即执行,我们都知道这部分叫异步处理模块,延迟了一段时间,满足一定条件后才执行

仔细想想,我们在JS里通常把任务分为“同步任务”和“异步任务”,它们有以下的执行顺序:

  1. 判断任务是同步的还是异步的,如果是同步任务就进入主线程执行栈中,如果是异步任务就进入Event Table并注册函数,当满足触发条件后,进入Event Queue
  2. 只有等到主线程的同步任务执行完后,才会去Event Queue中查找是否有可执行的异步任务,如有,则进入主线程执行

以上两步循环执行,就是所谓的Event Loop,所以上述代码里:

console.log(1) 是同步任务,进入主线程,立即执行
setTimeout 是异步任务,进入Event Table,0ms后(实际时间可能有出入,见注文)进入Event Queue,等待进入主线程
for 是同步任务,进入主线程,立即执行
所有主线程任务执行完后,setTimeout从Event Queue进入主线程执行
*注:HTML5规范规定最小延迟时间不能小于4ms,即x如果小于4,会被当做4来处理。 不过不同浏览器的实现不一样,比如,Chrome可以设置1ms,IE11/Edge是4ms

这就是我之前对Event Loop的理解,但是自从看了这篇文章深入理解JS引擎的执行机制颠覆了我对Event Loop认识三观,看下面的代码

Event Loop现在的理解

console.log("start")
setTimeout(()=>{
    console.log("setTimeout")
}, 0)
new Promise((resolve)=>{
    console.log("promise")
    resolve()
}).then(()=>{
    console.log("then")
})
console.log("end")

尝试按照我们上面的JS执行机制去分析:

console.log("start")是同步任务,进入主线程,立即执行 setTimeout是异步任务,进入Event
Table,满足触发条件后进入Event Queue
new Promise是同步任务,进入主线程,立即执行
.then是异步任务,进入Event Table,满足触发条件后进入Event Queue,排在Event Queue队尾 console.log("end")是同步任务,进入主线程,立即执行

所以执行结果是:start > promise > end > setTimeout > then

But但是,亲自跑了代码结果却是:start > promise > end > then > setTimeout

对比结果发现,难道Event Queue里面的顺序不是队列的先进先出的顺序吗?还是这块执行时有什么改变,事实就是,前面按照同步和异步任务划分的方式并不准确,那么怎么划分才是准确的呢,先看图(转自谷雨JavaScript 异步、栈、事件循环、任务队列):

图片描述

咣咣咣~敲黑板,知识点,知识点,知识点:

Js 中,有两类任务队列:宏任务队列(macro tasks)和微任务队列(micro tasks)

宏任务队列可以有多个,微任务队列只有一个。那么什么任务,会分到哪个队列呢?

宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering.
微任务:process.nextTick, Promise的then或catch, Object.observer, MutationObserver.

那么结合上面的流程图和最初理解的执行机制,总结了一下更为准确的JS执行机制:

  1. 取且仅取一个宏任务来执行(第一个宏任务就是script任务)。执行过程中判断是同步还是异步任务,如果是同步任务就进入主线程执行栈中,如果是异步任务就进入异步处理模块,这些异步处理模块的任务当满足触发条件后,进入任务队列,进入任务队列后,按照宏任务和微任务进行划分,划分完毕后,执行下一步。
  2. 如果微任务队列不为空,则依次取出微任务来执行,直到微任务队列为空(即当前loop所有微任务执行完),执行下一步。
  3. 进入下一轮loop或更新UI渲染。

Event Loop就是循环执行上面三步,接下来使用上面的结论分析个例子帮助理解

  • 微任务里嵌套宏任务
console.log('第一轮');

setTimeout(() => {                    //为了便于叙述时区分,标记为 setTimeout1
    console.log('第二轮');
    Promise.resolve().then(() => {    //为了便于叙述时区分,标记为 then1
        console.log('A');
    })
}, 0);

setTimeout(() => {                    //为了便于叙述时区分,标记为 setTimeout2
    console.log('第三轮');
    console.log('B');
}, 0);

new Promise((resolve)=>{              //为了便于叙述时区分,标记为 Promise1
    console.log("C")
    resolve()
}).then(() => {                       //为了便于叙述时区分,标记为 then2
    Promise.resolve().then(() => {    //为了便于叙述时区分,标记为 then3
        console.log("D")
        setTimeout(() => {            //为了便于叙述时区分,标记为 setTimeout3
            console.log('第四轮');
            console.log('E');
        }, 0);
    });
});

执行结果:第一轮 > C > D > 第二轮 > A > 第三轮 > B > 第四轮 > E

分析:

loop1:
第一步:首先执行全局宏任务,里面同步任务有下面两个,都立即进入主线程执行完后出栈

1.console.log('第一轮')
2.Promise1

输出 “第一轮” > “C”

异步任务有三个,分别进入相应的任务队列:

1.setTimeout1,该任务按照划分标准是 宏任务

setTimeout(() => {
    console.log('第二轮');
    Promise.resolve().then(() => {
        console.log('A');
    })
}, 0);
2.setTimeout2,该任务按照划分标准是 宏任务

setTimeout(() => {
    console.log('第三轮');
    console.log('B');
}, 0);
3.then2,该任务按照划分标准是 微任务

.then(() => {
    Promise.resolve().then(() => {
        console.log("D")
        setTimeout(() => {
            console.log('第四轮');
            console.log('E');
        }, 0);
    });
});
所以此时宏任务队列为: setTimeout1,setTimeout2
微任务队列为: then2

第二步:loop1 微任务队列不为空,then2出队列并执行,然后这个微任务里的 then3继续进入微任务队列 ,setTimeout3进入宏任务队列队尾

那么此时微任务队列为: then3
宏任务队列为:setTimeout1,setTimeout2,setTimeout3

但是此时第二步并没有完,因为微任务队列只要不为空,就一直执行当前loop的微任务,所以从微任务队列取出 then3 执行输出 “D”

此时微任务队列为:
宏任务队列为:setTimeout1,setTimeout2,setTimeout3

到目前为止,当前loop的微任务对列为空,进入下一个loop,输出情况是“第一轮” > “C” > “D”

loop2:
第一步:在宏任务队列队首里取出一个任务执行,即setTimeout1执行输出“第二轮”,并then1进入微任务队列

此时微任务队列为: then1
宏任务队列为:setTimeout2,setTimeout3

第二步:loop2 微任务队列不为空,则从微任务队列取出then1执行,输出“A”

此时微任务队列为:
宏任务队列为:setTimeout2,setTimeout3

到目前为止,当前loop的微任务对列为空,进入下一个loop,输出情况是“第一轮” > “C” > “D” > “第二轮” > “A”

loop3:
第一步:在宏任务队列队首里取出一个任务执行,即setTimeout2执行输出“第三轮” > “B”

此时微任务队列为:
宏任务队列为:setTimeout3

第二步:由于loop3 微任务队列为空,则直接进入下一轮loop,输出情况是“第一轮” > “C” > “D” > “第二轮” > “A” > “第三轮” > “B”

loop4:
第一步:在宏任务队列队首里取出一个任务执行,即setTimeout3执行输出“第四轮” > “E”

此时微任务队列为:
宏任务队列为:

第二步:由于loop4 微任务队列为空,宏任务队列也为空,则此次Event Loop结束,最终输出情况是“第一轮” > “C” > “D” > “第二轮” > “A” > “第三轮” > “B” > “第四轮” > “E”

上面的整个过程就是更为准确的Event Loop,下面还有个不同的例子供读者自行尝试

  • 宏任务里嵌套微任务
console.log('第一轮');

setTimeout(() => {
    console.log('第二轮');
    Promise.resolve().then(() => {
        console.log('A');
    })
}, 0);

setTimeout(() => {
    console.log('第三轮');
    console.log('B');
}, 0);

new Promise((resolve) => {
    console.log("C")
    resolve()
}).then(() => {                        //注意,这个函数改动啦
    setTimeout(() => {
        console.log('第四轮');
        console.log('E');
        Promise.resolve().then(() => {
            console.log("D")
        });
    }, 0);
});

执行结果:第一轮 > C > 第二轮 > A > 第三轮 > B > 第四轮 > E > D

Links:

深入理解JS引擎的执行机制
JavaScript 异步、栈、事件循环、任务队列
JavaScript 运行机制详解:深入理解Event Loop
JavaScript:并发模型与Event Loop
JavaScript 运行机制详解:再谈Event Loop[阮一峰]


owlcity
17 声望2 粉丝

热爱技术极客