前言
事件循环机制是浏览器按照我们的期望有条不紊地执行任务的重要保障,也是我们前端面试过程中几乎必考的一道题目,但很多时候,我们提到事件循环,想到的只是Promise,setTimeout
,哪些属于宏任务,哪些属于微任务,还有一大堆让你讲出输出顺序的题目,很少涉及背后的原由,为什么它要这么设计?以及什么情况下,事件循环机制会给我们意料之外的结果?本节,我就和大家一起,聊一聊我对Javascript
事件循环机制的理解
热身问题
这里抛出第一个问题,同样是经典的考察输出顺序,看看有多少同学能够答对
const button = document.createElement('button') button.addEventListener('click', () => { Promise.resolve().then(() => console.log('micro 1')) console.log('event 1'); }) button.addEventListener('click', () => { Promise.resolve().then(() => console.log('micro 2')) console.log('event 2'); }) button.innerText = 'test' document.body.appendChild(button) button.click() 复制代码
看完上面的代码,请回答以下两个问题
- 执行代码控制台会输出什么?
- 如果我用鼠标点击按钮触发事件,输出结果一样吗?为什么?
关于第一问,我想大部分同学都能轻松的回答上来,不就是先执行同步的任务,遇到微任务把任务推到微任务队列中,同步任务处理完了,再回来处理微任务,很简单嘛,所以答案是
event 1 event 2 micro 1 micro 2 复制代码
没错这就是第一问的答案,这时看到第二问,可能有的同学就会有点疑问了,这不一样么?难道还有区别?我们先把代码放到浏览器中执行一下看看
可以看到,答案是
event 1 micro 1 event 2 micro 2 复制代码
所以为什么会不一样呢?这就是我们今天想要探究的事件循环机制背后藏了多少东西
关于含糊不清的宏任务
其实对上面问题的理解出现偏差,很大程度是因为对宏任务没有理解好(实际上看了很多资料,官方似乎并没有哪个文档提及到宏任务这个词——macroTask)
个人理解宏任务是为了理解方便所造的一个词,大部分资料将setTimeout,setInterval等
视为产生宏任务的方法,但是关键点却很少提及
宏任务产生后,并不会那马上进入任务队列中,而是交给了相关的线程(这里是容易忽视的一个点)
很多案例在分析时,喜欢用setTimeout(fn,0)
举例,产生宏任务后就直接丢到宏任务队列中,等待执行,这是有问题的,实际上并没有什么宏任务队列,宏任务产生的回调方法本身也是一个普通的任务
一个简单的例子
setTimeout(() => { console.log(1); }, 3000) setTimeout(() => { console.log(2); }, 2000) setTimeout(() => { console.log(3); }, 1000) 复制代码
输出的结果是3,2,1
,如果宏任务产生后就直接将回调方法丢到任务队列中等待执行,那么结果就乱了!
为什么它能正确的进行回调处理,是因为有另外的线程在正确的时机将回调任务推到任务队列中进行执行!
关于微任务的诞生
提到微任务,很多人会将它和Promise
联系起来,但这不是微任务产生的初衷
微任务的出现,最早是浏览器想提供给开发者一种监控DOM变化的方法(例如插入元素到页面中)
如果没有微任务,像这种代码代码,浏览器将产生200个事件(插入一个,修改一个)
for (let i = 0; i <100; i++) { const span = document.createElement('span') span.textContent = 'Hello' } 复制代码
我们的期望是类似上面的操作,只产生一个事件而不是200次,解决方案是使用DOM变化事件的观察者,于是浏览器创建了一个新的队列就叫做微任务队列,存在于当次的执行环境中
ui 渲染会等待所有微任务执行完成之后才能执行,之后才是宏任务(这也是后面我为什么把浏览器渲染作为一个事件循环的起点的原因)
下面两个案例,有兴趣的同学可以自己浏览器试试,有助于理解任务执行的机制
- 不会阻塞页面
function loop() { setTimeout(() => { loop() }, 0) } loop() 复制代码
- 阻塞了页面
function loop() { Promise.resolve().then(loop) } loop() 复制代码
微任务如果不为空会一直执行,并且阻塞后续的所有任务(UI渲染)
这也就是为什么我们遇到的微任务,常常都是发生在一项任务之后
并且任何javascript
运行的时候都可能执行微任务
浏览器是怎么工作的
浏览器是一个多进程,多线程的状态在进行工作的
上面说到,异步的宏任务JS
引擎会移交给浏览器的其它线程处理,那么浏览器都有哪些线程,以及哪些和异步任务相关呢?
具体到一个标签tab
页面,就是一个进程,里面有五个主要的线程在一起工作
简单说下它们主要负责做什么
线程 | 作用 |
GUI渲染线程 | 页面绘制 |
js 线程 |
执行 js 脚本(与GUI互斥,如果 js 执行时间过长会造成页面卡顿) |
定时触发器线程 | 定时器setInterval 与setTimeout 所在线程 |
事件触发线程 | 用来控制事件轮询,异步事件,JS 引擎自己忙不过来,需要浏览器另开线程协助 |
异步http 请求线程 |
XMLHttpRequest 在连接后是通过浏览器新开一个线程请求, 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript引擎的处理队列中等待处理 |
看到这里就比较清晰,setTimeout 这个API会产生一个宏任务丢给浏览器的定时触发器线程,当时间到的时候,定时触发器线程会把回调任务推进JS
任务队列中等待执行
上面的那个点击输出顺序问题其实也与这相关,对于事件的处理,浏览器有相应的事件触发线程去做处理,你注册一个事件,点击时就会触发一个事件,把回调方法推到任务队列中执行,并且每一个事件都是相互独立的任务,彼此之间不会相互影响(也就是说它们其实不是在同一个事件循环周期中发生的)
而如果你是btn.click()
实际上是由JS
引擎直接触发,不经过事件触发线程,也就没有了所谓的宏任务,只有在同一事件循环中的微任务被先后触发
理解完这个点,我们就来一起完整的回顾下
为什么要有事件循环机制
老生常谈,Javascript
是一门单线程的语言,同一时间只能做一件事,但是现实场景中,不管是用户交互,还是接口请求,或者是定时器,都是常见的异步场景,如果不引入其它机制,让js
按顺序执行任务,那么页面就会失去响应,js
线程被阻塞,于是,为了让这些异步的任务不阻塞JS线程的执行,浏览器提供了事件循环的机制,来实现异步事件的回调
事件循环机制在做什么
浏览器在js
执行栈工作的过程中,还同时存在着Task Queue
和microtask Queue
这两个任务队列,里面就存放着我们待执行的任务与微任务,所谓循环机制,就是浏览器会在合适的时候,不断地查看这两个任务队列,有没有需要执行的任务,如果有,就把任务拿到执行栈中执行,执行完了之后后开始新一轮的询问查看,如此反复
各种任务执行的时机是什么
在分析执行时间前我们先来理解下各种任务的概念
同步任务与异步任务
我们使用同步的代码或是异步的代码会生成不同的任务,对此浏览器会有不同的执行策略
- 同步代码
任务立即放入JS
线程引擎(执行栈),并原地等待结果
- 异步代码
先放入宿主环境中(相应的微任务队列或者线程),不等待结果,不阻塞主线程继续往下执行,等待js
引擎执行栈中任务都完成时,会去任务队列中取出任务来执行,也就是异步结果在将来执行
宏任务与微任务
宏任务与微任务都属于异步任务,它们通过调用不同的api
进人任务队列
- 宏任务
宏任务可通过setTimeout、setInterval、setImmediate、event等方式触发,并由相应的线程控制进入任务队列(着重关注setTimeout和event
,很多题目场景会涉及)
- 微任务
微任务通过process.nextTick、Promises、**MutationObserver**等方式进入微任务队列(着重关注Promise,nextTick
,很多题目场景会涉及)
这里需要注意,异步代码会产生异步任务,但是宏任务产生的回调不一定会进入任务队列,浏览器会有相应的线程去处理这些异步任务,并在合适的时候,把任务回调推入任务队列中
时机
接下来就是关键了,不同的异步任务的触发时机究竟是什么
定义循环的开始和结束
首先,每一个 eventLoop
都是一个单独的循环只有当当前事件循环完成时,才会进入下一个循环
这里我们把浏览器完成渲染的时候作为一个新的事件循环的开始
事件循环流程
- 浏览器完成渲染(开始)
当界面渲染完成时,我们开启一个新的事件循环
- 开始执行任务队列
这里的任务不是指宏任务,而是正常的执行代码,JS
遇到所谓的宏任务(setTimeout,event事件等
)会将任务的回调交给相应的线程处理,线程处理完成后会将相关的回调事件推入任务队列,等待执行
举个例子:
JS
遇到setTimeout(fnA, 1000)
这个异步代码会产生一个异步任务,给到浏览器对应的线程(这里是定时触发器线程)
等待 1 秒后,定时器触发线程会把相应的回调任务推进任务队列中
JS
通过事件循环机制,读取并执行任务队列中的任务
- 清空任务队列中的任务
- 开始执行微任务队列中的任务
- 清空微任务队列中的任务
注意:如果产生新的微任务,也属于同一循环周期,需要全部微任务完成才能进入下个阶段
- 浏览器渲染(结束)
到这里,一个事件循环结束,进入下一个循环
总结
个人关于宏任务与微任务的一点理解是:
宏任务是交由浏览器或是当前宿主环境(node)进行处理的,它们会在合适的时机将宏任务的回调方法交给js
引擎执行
微任务则直接存在于js
引擎的执行环境中,执行时机是在主函数执行结束之后,并且除非清空微任务,否则不会进入下一个事件循环中