Event Loop是什么
因为js设计之初,多线程的执行模式还不流行,所以一直以为,js都是单线程执行的。但是js拥有异步执行的能力,这依赖于事件循环(Event Loop)的执行模式。我们将通过js在浏览器中的执行来研究一下该模式。
其中涉及到一些概念,我们先简单研究一下,以便后续更好地了解。
进程和线程
参考阮一峰的解释,将整个CPU比喻为一座工厂,进程就是其中的车间,车间中的需要完成的工序就是线程。一个工厂可以有多个车间,每个车间有一个或者多个工序,但是必须 按照顺序执行,这就是单线程的概念。也是浏览器事件执行的基础。
浏览器渲染过程
浏览器是一个多进程应用,每一个窗口就是一个进程,其中包含以下线程:
- GUI渲染线程
负责渲染页面,布局和绘制
页面需要重绘和回流时,该线程就会执行
与js引擎线程互斥,防止渲染结果不可预期
- JS引擎线程
负责处理解析和执行javascript脚本程序
只有一个JS引擎线程(单线程)
与GUI渲染线程互斥,防止渲染结果不可预期
- 事件触发线程
用来控制事件循环(鼠标点击、setTimeout、ajax等)
当事件满足触发条件时,将事件放入到JS引擎所在的执行队列中
- 定时触发器线程
setInterval与setTimeout所在的线程
定时任务并不是由JS引擎计时的,是由定时触发线程来计时的
计时完毕后,通知事件触发线程
- 异步http请求线程
浏览器有一个单独的线程用于处理AJAX请求
当请求完成时,若有回调函数,通知事件触发线程
各个进程之间的关系
- 同步任务都在js引擎线程上完成,当前的任务都存储在执行栈中;
- js引擎线程执行到setTimeout/setInterval的时候,通知定时触发器线程,间隔一定时间,触发回调函数;
- 定时触发器线程在接收到这个消息后,会在等待的时间后,将回调事件放入到由事件触发线程所管理的事件队列(事件队列分为宏任务队列和微任务队列)中;
- js引擎线程执行到XHR/fetch时,通知 异步http请求线程,发送一个网络请求;
- 异步http请求线程在请求成功后,将回调事件放入到由事件触发线程的事件队列中;
- 如果JS引擎线程中的执行栈没有任务了,JS引擎线程会询问事件触发线程,在 事件队列中是否有待执行的回调函数,如果有就会加入到执行栈中交给JS引擎线程执行;
- JS引擎线程空闲之后,GUI渲染线程开始工作
总结:
- JS 是可以操作 DOM 的, 因此浏览器设定 GUI渲染线程和 JS引擎线程为互斥关系;
- setTimeout/setInterval和 XHR/fetch代码执行时, 本身是同步任务,而其中的回调函数才是异步任务
- JS引擎线程只执行执行栈中的事件
- 执行栈中的代码执行完毕,就会读取事件队列中的事件
- 事件队列中的回调事件,是由各自线程插入到事件队列中的
- 如此循环
js如何异步执行
了解了浏览器多线程之间的关联之后,我们开始探究,js是如何依赖Event Loop,进行异步操作的。
执行栈和事件队列
在分析多线程之间的关系时,我们提到了两个概念,执行栈和执行队列
执行栈
栈,是一种数据结构,具有先进后出的原则。JS 中的执行栈就具有这样的结构,当引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入执行栈,每遇到一个函数调用,就会往栈中压入一个新的上下文。引擎执行栈顶的函数,执行完毕,弹出当前执行上下文
事件队列
事件队列是一个存储着 异步任务 的队列,按照先进先出的原则执行。事件队列每次仅执行一个任务。当执行栈为空时,JS 引擎便检查事件队列,如果事件队列不为空的话,事件队列便将第一个任务压入执行栈中运行。
宏任务和微任务
异步任务又分为宏任务跟微任务、他们之间的区别主要是执行顺序的不同。
宏任务(macrotask)
也叫tasks,一些异步任务的回调会依次进入macro task queue,等待后续被调用,这些异步任务包括:
- 包括整体代码script
- setTimeout
- setInterval
- requestAnimationFrame
微任务(microtask)
也叫jobs,另一些异步任务的回调会依次进入micro task queue,等待后续被调用,这些异步任务包括:
- Promise
- MutationObserver
理解Event Loop
示例
下面上一道很经典的题目:
console.log('1');
setTimeout(()=>{
console.log('2');
},100);
setTimeout(()=>{
console.log('3');
},0);
console.log('4');
没有研究event loop之前,答案很可能以为是1 3 4 2,但是实际答案是1 4 3 2。其中的原理下面来分析一下。
异步任务执行的时候,有这样一个顺序:
- 执行全局Script代码,如果碰到异步任务,将该任务放入微任务队列中
- 全局Script执行完,执行栈清空
- 从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行
- 一个微任务执行完毕之后,再从微任务队列中取出一个任务放入执行栈执行,若微任务中还有微任务,则放入当前微任务队列末尾
- 微任务队列为空,执行栈也为空,此时从宏任务队列取出一个任务执行,如果其中有微任务,放入微任务队列
- 重复执行3-5步骤...
- 重复执行3-5步骤...
*注:
- 宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务;
- 微任务队列中所有的任务都会被依次取出来执行,直到队列为空;
- GUI渲染线程在微任务执行完,执行栈为空,下一个宏任务执行之前执行,。
以上就是浏览器事件循环——event loop。
理解了异步任务的执行顺序之后,再来回顾上面这道题:
console.log('1');
setTimeout(()=>{
console.log('2');
},100);
setTimeout(()=>{
console.log('3');
},0);
console.log('4');
- 执行整个script,console.log('1')是同步任务,setTimeout是宏任务,js引擎线程通知事件触发线程 ,在定时n秒后存入宏任务队列中,所以先存入console.log('3'),后存入console.log('2');;
- 执行下一个同步任务console.log('4');;
- 执行栈为空,查询微任务队列也为空,查询宏任务队列
- 根据先进先出原则,执行console.log('3');,后执行console.log('2');
- 输出1 4 3 2;
实践
再来2道题巩固一下:
一.
setTimeout(() => {
console.log('A');
}, 0);
var obj = {
func: function() {
setTimeout(function() {
console.log('B');
}, 0);
return new Promise(function(resolve) {
console.log('C');
resolve();
});
},
};
obj.func().then(function() {
console.log('D');
});
console.log('E');
- 第一个setTimeout放到宏任务队列,此时宏任务队列为['A'];
- 接着执行obj的func方法,将setTimeout放到宏任务队列,此时宏任务队列为['A', 'B']
- 函数返回一个Promise,因为这是一个同步操作,所以先打印出'C';
- 接着将then放到微任务队列,此时微任务队列为 ['D'];
- 接着执行同步任务console.log('E');,打印出 'E';
- 因为微任务优先执行,所以先输出 'D';
- 最后依次输出 ['A', 'B'];
- 输出结果:C E D A B
二.
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(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
await前面的代码是同步的,调用此函数时会直接执行;而await a(); 这句可以被转换成 Promise.resolve(a()); await 后面的代码 则会被放到 Promise.then() 方法里。因此上面的代码可以被转换成如下形式:
function async1() {
console.log('async1 start'); // 2
Promise.resolve(async2()).then(() => {
console.log('async1 end'); // 6
});
}
function async2() {
console.log('async2'); // 3
}
console.log('script start'); // 1
setTimeout(function() {
console.log('settimeout'); // 8
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1'); // 4
resolve();
}).then(function() {
console.log('promise2'); // 7
});
- 首先打印出script start
- 接着将settimeout添加到宏任务队列,此时宏任务队列为['settimeout']
- 然后执行函数async1,先打印出async1 start,又因为Promise.resolve(async2()) 是同步任务,所以打印出async2,接着将async1 end 添加到微任务队列,此时微任务队列为['async1 end']
- 接着打印出promise1,将promise2 添加到微任务队列,此时微任务队列为['async1 end', promise2]
- 打印出script end
- 因为微任务优先级高于宏任务,所以先依次打印出 async1 end和promise2
- 最后打印出宏任务settimeout
我自己是一名从事了多年的前端老程序员,小编为大家准备了新出的前端编程学习资料,免费分享给大家!
如果你也想学习前端,那么帮忙转发一下然后再关注小编后私信【1】可以得到我整理的这些前端资料了(私信方法:点击我头像进我主页有个上面有个私信按钮)
閱讀更多 前端明澈 的文章