深入理解JavaScript事件循环机制

众所周知,JavaScript 是一门单线程语言,虽然在 html5 中提出了 Web-Worker ,但这并未改变 JavaScript 是单线程这一核心。可看HTML规范中的这段话:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

为了协调事件、用户交互、脚本、UI 渲染和网络处理等行为,用户引擎必须使用 event loops。Event Loop 包含两类:一类是基于 Browsing Context ,一种是基于 Worker ,二者是独立运行的。

而这种考察不论是面试求职,还是日常开发工作,我们经常会遇到这样的情况:给定的几行代码,我们需要知道其输出内容和顺序。因为JavaScript是一门单线程语言,所以我们可以得出结论:

JavaScript是按照语句出现的顺序执行的

所以我们以为JS都是这样的:

let a = '1';
console.log(a);
let b = '2';
console.log(b);

结果是1,2
然而实际上JS是这样的

setTimeout(function(){
    console.log('定时器开始')
});
new Promise(function(resolve){
    console.log('马上执行for循环');
    for(var i = 0; i < 10000; i++){
        i == 99 && resolve();
    }
}).then(function(){
    console.log('执行then函数')
});
console.log('代码执行结束');

依照JS是按照语句出现的顺序执行这个理念,输出结果:

//"定时器开始"
//"马上执行for循环"
//"执行then函数"
//"代码执行结束
去chrome上验证下,结果完全不对,实际上是:
//"马上执行for循环"
//"代码执行结束"
//"执行then函数"
//"定时器开始"
这就要求我们弄懂JavaScript的执行机制了。

1.关于JavaScript

JavaScript是一门单线程语言,在最新的HTML5中提出了Web-Worker,但JavaScript是单线程这一核心仍未改变。所以一切JavaScript版的”多线程”都是用单线程模拟出来的,一切JavaScript多线程都是纸老虎!

2.JavaScript事件循环

既然JS是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理JS任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:

  • 同步任务

  • 异步任务

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。关于这部分有严格的文字定义,但本文的目的是用最小的学习成本彻底弄懂执行机制,所以我们用导图来说明:

在这里插入图片描述
  • 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入Event Table并注册函数。

  • 当指定的事情完成时,EventTable会将这个函数移入Event Queue。

  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。

    上述过程会不断重复,也就是常说的Event Loop(事件循环)。

在这里插入图片描述

再通俗点就是同步和异步任务分别进入不同的执行环境,同步的进入主线程,即主执行栈,异步的进入Event Queue。主线程内的任务执行完毕为空,会去Event Queue读取对应的任务,推入主线程执行。 上述过程的不断重复就是我们说的Event Loop(事件循环)。

在事件循环中,每进行一次循环操作称为tick,通过阅读规范可知,每一次tick的任务处理模型是比较复杂的,其关键的步骤可以总结如下:

  • 在此次tick中选择最先进入队列的任务(oldest task),如果有则执行(一次)

  • 检查是否存在Microtasks,如果存在则不停地执行,直至清空Microtask Queue

  • 更新render

  • 主线程重复执行上述步骤

可以用一张图来说明下流程:

在这里插入图片描述

按照上图这种分类方式通俗点来讲就是JS 的执行机制是:

  • 执行一个宏任务,过程中如果遇到微任务,就将其放到微任务的【事件队列】里

  • 当前宏任务执行完成后,会查看微任务的【事件队列】,并将里面全部的微任务依次执行完

这里相信有人会想问,什么是microtasks?规范中规定,task分为两大类, 分别是Macro Task (宏任务)和Micro Task(微任务), 并且每个宏任务结束后, 都要清空所有的微任务,这里的Macro Task也是我们常说的task,有些文章并没有对其做区分,后面文章中所提及的task皆看做宏任务(macro task)。

macro-task(宏任务)主要包含:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、setImmediate(Node.js 环境)

micro-task(微任务)主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)

setTimeout/Promise等API便是任务源,而进入任务队列的是由他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。
分析示例代码

console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');

1、整体script作为第一个宏任务进入主线程,遇到console.log,输出script start
2、遇到setTimeout,其回调函数被分发到宏任务Event Queue中
3、遇到Promise,其then函数被分到到微任务Event,Queue中,记为then1,之后又遇到了then函数,将其分到微任务Event Queue中,记为then2
4、遇到console.log,输出script end
1、执行微任务,首先执行then1,输出promise1,然后执行then2,输出promise2,这样就清空了所有微任务
2、执行setTimeout任务,输出setTimeout 至此,输出的顺序是:script start, script end,promise1, promise2, setTimeout

再来一个例子:

setTimeout(()=>{
console.log("定时器开始执行");
})
new Promise(function(resolve){
    console.log("准备执行for循环了");
    for(var i=0;i<100;i++){
        i==22&&resolve();
    }
}).then(()=>console.log("执行then函数"));
console.log("代码执行完毕");

首先执行script下的宏任务,遇到setTimeout,将其放到宏任务的【队列】里 遇到 new

Promise直接执行,打印"准备执行for循环" 遇到then方法,是微任务,将其放到微任务的【队列里】 打印 “代码执行完毕”

本轮宏任务执行完毕,查看本轮的微任务,发现有一个then方法里的函数, 打印"执行then函数" 到此,本轮的event loop

全部完成。 下一轮的循环里,先执行一个宏任务,发现宏任务的【队列】里有一个 setTimeout里的函数,执行打印"定时器开始执行"

所以最后的执行顺序就是:【准备执行for循环–>代码执行完毕–>执行then函数–>定时器开始执行】
再来个有难度的例子

console.log('script start');
setTimeout(function() {
  console.log('timeout1');
}, 10);
new Promise(resolve => {
    console.log('promise1');
    resolve();
    setTimeout(() => console.log('timeout2'), 10);
}).then(function() {
    console.log('then1')
})
console.log('script end');

首先,事件循环从宏任务(macrotask)队列开始,最初始,宏任务队列中,只有一个script(整体代码)任务;当遇到任务源(task source)时,则会先分发任务到对应的任务队列中去。所以,就和上面例子类似,首先遇到了console.log,输出script start; 接着往下走,遇到setTimeout任务源,将其分发到任务队列中去,记为timeout1; 接着遇到promise,new promise中的代码立即执行,输出promise1,然后执行resolve,遇到setTimeout,将其分发到任务队列中去,记为timemout2,将其then分发到微任务队列中去,记为then1; 接着遇到console.log代码,直接输出script end 接着检查微任务队列,发现有个then1微任务,执行,输出then1 再检查微任务队列,发现已经清空,则开始检查宏任务队列,执行timeout1,输出timeout1; 接着执行timeout2,输出timeout2 至此,所有的都队列都已清空,执行完毕。其输出的顺序依次是:script start, promise1, script end, then1, timeout1, timeout2

流程图

在这里插入图片描述

有个小tip:从规范来看,microtask优先于task执行,所以如果有需要优先执行的逻辑,放入microtask队列会比task更早的被执行。

记住,JavaScript是一门单线程语言,异步操作都是放到事件循环队列里面,等待主执行栈来执行的,并没有专门的异步执行线程。

你可能感兴趣的:(深入理解JavaScript事件循环机制)