打通JS事件循环机制
在了解事件循环机制之前,我们先聊下
javascript
的运行机制以及对应的原因
为什么JavaScrip是单线程
因为JS的主要作用是和用户互动,在前端的javascript
操作dom
时,并不会像后端一样通过锁或者其他的方式来保证这种彼此影响的操作的先后顺序,所以为了避免复杂性和确保对于Dom
操作的唯一性,使用单线程是一个利大于弊的方案。
那么web worker呢?这不是一种支持多线程的方案吗?
首先需要明确一点,在worker内,不能直接操作DOM节点,也不能使用window
对象的默认方法和属性。并且这种方案允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制。
EventLoop(事件循环机制)
下面的代码以及对应的展示都是来自于: loupe 代码运行可视化环境
首先,EventLoop在没有事情可干的时候,会保持一种经济的方式一直空转。
首先我们利用(Philip Roberts演讲中的)图片来展示整个浏览器EventLoop
的过程: 在主线程运行的过程中,会主要使用到V8
引擎的两个组件,也就是上面的内存堆(heap)和调用栈(stack):
- 内存堆:这是内存分配发生的地方,也就是我们变量等等这些东西分配的地方;
- 调用栈: 这是在你代码执行时栈帧存放的位置,因为是栈,所以它一次制作一件事。更详细点说,调用栈记录的就是我们的程序所处的位置。栈中的代码调用各种外部API。
那么上面的callback queue
呢?其实这里对应的原本意思是回调队列,但是我们为了便于理解,通常称它为任务队列,在这里去执行我们的异步任务。
借用代码分析
比如我有下面的代码,我们来一点点分析事件循环机制的整体运行过程:
console.log(1);
setTimeout(function(){
console.log(2)
},2000);
new Promise(function(resolve){
console.log(3);
for(let i = 0; i < 3; i++){
i == 2 && resolve();
}
}).then(function(){
console.log(4)
});
console.log(5);
首先猜一猜上面代码的执行顺序,如果不存在事件循环机制,那么结果就应该是:
1 2 3 4 5
但是这样的话如果我们的定时器事件更长一点,那么是不是意味着我们的Promise和后面的console.log(5)
这些原本不需要等待的事件也要等待着200ms呢?这样的话,就造成了加载javascript
导致的页面阻塞了。
所以我们在这里需要引入事件循环机制:
首先执行console.log(1)
,将其压入执行栈中,因为不存在调用webApi
和异步的情况,所以我们直接输出。
然后继续向下执行,我们向执行栈中压入定时器。定时器作为一种延期执行或者定向执行,如果我们在这里等待的话会影响到我们的代码正常执行,所以我们将它放入任务队列中(下图中的anonymous()
代表定时器):
紧接着我们继续执行,来到Promise
这一部分,这里需要注意的是:只有Promise.then
属于微任务(异步任务),new Promise
本身回调函数中的代码依然是同步执行:
这里我们先执行console.log(3)
,然后我们顺序执行循环中的代码:
然后循环过程中当i == 2
时执行resolve()
函数
然后new Promise
的部分执行结束。然后就有好多同学产生疑问了,后面的.then
去哪了,实际上.then
是属于宏任务下的微任务队列,也就是我们整体代码的微任务队列,那么微任务队列会在什么时候执行呢?
当前的宏任务执行完毕后,因为宏任务队列只有一个,而每一个宏任务都有一个自己的微任务队列,然后回去循环当前宏任务对应的微任务队列。
然后我们执行console.log(5)
:
到上面执行完console.log(5)
之后,我们的整体代码的宏任务队列就执行完毕了。目前我们的宏任务队列和微任务队列状态是(此时我们模拟的是将要结束script
宏任务结束的状态):
[
script:{
miscTask: [
x.then(function(){
console.log(4)
}),
]
},
setTimeout: {
miscTask: [
function(){
console.log(2)
}
]
}
]
然后我们执行我们的.then
函数:
到了这里其实并没有结束,因为我们的setTimeout(() => {...}, 2000)
还在我们的任务队列中,然后我们去将回调函数放入我们的执行栈中执行:
setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。
然后我们进入执行栈中去执行对应的定时函数和console
,同时setTimeout
属于window
下的定时器,因此也会调用webApi:
所以最终的结果是:
1 3 5 4 2
所以什么是事件循环机制呢?
- 浏览器的事件循环由一个宏任务队列+多个微任务队列组成。
- 产生的的宏任务和微任务进入各自的队列中。执行完 Script 后,把当前的微任务队列清空。完成一次事件循环。
- 接着再取出一个宏任务,同样把在此期间产生的回调入队。再把当前的微任务队列清空。以此往复。
- 宏任务队列只有一个,而每一个宏任务都有一个自己的微任务队列,每轮循环都是由一个宏任务+多个微任务组成。
关于循环引发的页面静止
首先我们来看一段下面的代码,在页面中点击后进入死循环:
button.addEventListener('click', event => {
while(true);
});
首先点击后发现这有一个点击任务加入任务队列,然后事件循环去执行任务队列中的点击事件:
上图中左侧代表任务队列,中间代表事件循环机制,然后右侧代表浏览器的主要线程,也就是样式计算、Dom渲染
紧接着事件循环机制进入死循环,导致页面所有主线程工作停止。所以我们的页面有时候就会出现直接白屏或者所有的操作没有反应。例如在React
的函数式组件的渲染Dom
中去更新你的state
。
常见任务分类
常见的宏任务
- script(整体代码)
- setTimout
- setInterval
- setImmediate(node 独有)
- requestAnimationFrame(浏览器独有)
- IO
- UI render(浏览器独有)
setTimeout
设置setTimeout
为0时,为什么显示不会去执行每一个过程?
这里需要明白,浏览器的刷新不是说按照我们设定的最小值去执行,而是显示器本身的素质来决定,比如说刷新率60hz,所以导致我们的部分渲染事件白费,因为只会执行其中的一个。
但是setTimeout
本身就不是为了做动画而产生的,由于它的不精确,即便你设置了0,在不同的浏览器上依旧会有默认的最小值,比如Chrome的4ms。正因如此,使用setTimeout
执行动画,往往会产生一些明显的漂移现象(在某一帧里浏览器渲染啥也没干,但是到了下一帧的时候去做了两倍的事情)。
requestAnimationFrame
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。就像下面这样(仅限Chrome):
它的执行过程更像这样,下面的每一个白色透明方块代表一帧内的渲染线程:
相比起setTimeout
它是针对每一帧的过程去处理,避免了执行任务精度丢失的问题。
常见的微任务
- process.nextTick(node 独有)
- Promise.then()
- Object.observe
- MutationObserver
MutationObserver
MutationObserver
接口提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。
为什么要有宏任务和微任务?
为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节中描述的事件循环。每个代理都有一个关联的事件循环,这是该代理独有的。事件循环中的任务被分为宏任务和微任务,是为了给高优先级任务一个插队的机会:微任务比宏任务有更高优先级。