EcmaScript 的标准定义中没有事件循环,HTML 的标准中定义了事件循环(目前 HTML 有 WHATWG 规范 和 W3C 标准):
在WHATWG 规范,定义了在主线程的循环系统中,可以有多个消息队列,比如鼠标事件的队列,IO完成消息队列,渲染任务队列,并且可以给这些消息队列排优先级。
但是在浏览器实现的过程中,目前只有一个消息队列,和一个延迟执行队列。
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.
根据标准中对事件循环的定义描述 => 事件循环本质上是 user agent (如浏览器端) 用于协调用户交互(鼠标、键盘)、脚本(如 JavaScript)、渲染(如 HTML DOM、CSS 样式)、网络请求等行为的一个机制。
各种浏览器事件同时触发时,肯定有一个先来后到的排队问题。决定这些事件如何排队触发的机制,就是事件循环。这个排队行为以 JavaScript 开发者的角度来看,主要是分成两个队列:
- 一个是 JavaScript 外部的队列。外部的队列主要是浏览器协调的各类事件的队列,标准文件中称之为 Task Queue。
- 另一个是 JavaScript 内部的队列。这部分主要是 JavaScript 内部执行的任务队列,标准中称之为 Microtask Queue。
Task queues 不是队列(虽然这么称呼),是有序集合 (Set),因为传统的队列都是先进先出(FIFO)的,而这里的队列则不然,排到最前面但是没有满足条件也是不会执行的(比如外部队列里只有一个 setTimeout 的定时任务,但是时间还没有到,没有满足条件也不会把他出列来执行)。
浏览器与 Node.js 的事件循环差异
浏览端是将 JavaScript 集成到 HTML 的事件循环之中,Node.js 则是将 JavaScript 集成到 libuv 的 I/O 循环之中。
- 事件循环的过程没有 HTML 渲染。只剩下了外部队列和内部队列这两个部分。
- 外部队列的事件源不同。Node.js 端没有了鼠标等外设但是新增了文件等 IO。
- 内部队列的事件仅剩下 Promise 的 then 和 catch。
宏任务
由宿主(Node、浏览器)发起。
宏任务代表一个个离散的、独立的工作单元。浏览器完成一个宏任务,在下一个宏任务执行开始前,会对页面进行重新渲染。
常见宏任务包括创建文档对象、解析HTML、执行主线JS 代 以及各种事件如页面加载、输入、网络事件和定时器等。
在代码执⾏环境中按照同步代码的顺序,逐个进⼊⼯作线程挂起,再按照异步任务到达的时间节点,逐个进⼊异步任务队列,最终按照队列中的顺序进⼊函数执⾏栈进⾏执⾏。
| 浏览器 | Node | |
|---|---|---|
| setImmediate(弥补requestAnimationFrame) | ❌ | ✅ |
| setTimeout | ✅ | ✅ |
| setInterval | ✅ | ✅ |
| requestAnimationFrame(下次页面重绘前所执行的操作) | ✅ | ❌ |
| I/O | ✅ | ✅ |
setTimeout
e.g.
setTimeout(() => {
task()
}, 3000)
sleep(10000000)
task()进入Event Table 并注册,计时开始。- 执行
sleep函数,很慢,非常慢,计时仍在继续。 - 3秒到了,计时事件
timeout完成,task()进入Event Queue,但是sleep也太慢了吧,还没执行完,只好等着。 sleep终于执行完了,task()终于从Event Queue 进入了主线程执行。
setTimeout这个函数,是经过指定时间后,把要执行的任务(task())加入到Event Queue中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒。
setTimeout(fn, 0)
setTimeout(fn,0)指某个任务只要主线程执行栈内的同步任务全部执行完成(栈为空),就马上执行。
console.log('先执行这里')
setTimeout(() => {
console.log('执行啦')
}, 0)
// 先执行这里 --> 执行啦
即便主线程为空,0毫秒实际上也是达不到的。根据HTML 的标准,最低是4毫秒。
【注意事项】
- 如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。所以,一些实时性较高的需求就不太适合使用 setTimeout 了,比如用 setTimeout 来实现 JavaScript 动画就不是一个很好的主意。
- 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒:未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。
- 延时执行时间有最大值:Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。
setTimeout 函数执行的间隔时间不一定是约定好的间隔时间,还与当前事件循环中的任务执行的时间有关,如果执行的时间太长的话,setTimeout 里面的函数将会被延迟执行。
【requestAnimationFrame - 请求动画帧 实现的动画效果比 setTimeout 好】
requestAnimationFrame 是在下一帧动画重绘之前执行传入的函数。能够保证传入的回调函数执行次数通常与浏览器屏幕刷新次数相匹配,一般是每秒钟60 次。
- CPU 节能:使用 SetInterval 实现的动画,当页面被隐藏或最小化时,SetInterval 仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,完全是浪费 CPU 资源。而 RequestAnimationFrame 则完全不同,当页面处理未激活的状态下,该页面的屏幕刷新任务也会被系统暂停,跟着系统走的 RequestAnimationFrame 也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了 CPU 开销。
- 函数节流:在高频率事件(resize,scroll 等)中,为了防止在一个刷新间隔内发生多次函数执行,RequestAnimationFrame 可保证每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销,一个刷新间隔内函数执行多次时没有意义的,因为多数显示器每 16.7ms 刷新一次,多次绘制并不会在屏幕上体现出来。
- 减少 DOM 操作:requestAnimationFrame 会把每一帧中的所有 DOM 操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒 60 帧。
setInterval
setInterval会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。
一旦**setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了**。
-
其他事件源:
- 用户交互(鼠标、键盘)
- 网络请求(Ajax)
- History API 操作
- postMessage、messageChannel
-
会触发新一轮Tick
微任务
微任务是更小的任务,是在当前宏任务执行结束后立即执行的任务。如果存在微任务,浏览器会清空微任务队列之后再重新渲染。微任务的例子:Promise 回调函数、DOM 变化等。
JS 引擎中:evaluateScript => queueMicrotask => drainMicrotasks
微任务是随着ECMA 标准升级提出的新的异步任务,每⼀个宏任务执⾏前,程序会先检测中是否有当次事件循环未执⾏的微任务,优先清空本次的微任务后,再执⾏下⼀个宏任务,每⼀个宏任务内部可注册当次任务的微任务队列,再下⼀个宏任务执⾏前运⾏,微任务也是按照进⼊队列的顺序执⾏的。
在当前task 执行结束后立即执行的任务(当前task 后,下一个task 前,渲染前,它的响应速度相比setTimeout(setTimeout 是task) 会更快,因为无需等渲染)。
| 浏览器 | Node | |
|---|---|---|
| Promise.then/catch/finally | ✅ | ✅ |
| MutationObserver | ✅ | ❌ |
| Process.nextTick | ❌ | ✅ |
Mutation Observer
虽然监听 DOM 的需求是如此重要,不过早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。
直到 2000 年的时候引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。
采用 Mutation Event 解决了实时性的问题,因为 DOM 一旦发生变化,就会立即调用 JavaScript 接口。但也正是这种实时性造成了严重的性能问题,因为每次 DOM 变动,渲染引擎都会去调用 JavaScript,这样会产生较大的性能开销。比如利用 JavaScript 动态创建或动态修改 50 个节点内容,就会触发 50 次回调,而且每个回调函数都需要一定的执行时间,这里我们假设每次回调的执行时间是 4 毫秒,那么 50 次回调的执行时间就是 200 毫秒,若此时浏览器正在执行一个动画效果,由于 Mutation Event 触发回调事件,就会导致动画的卡顿。
也正是因为使用 Mutation Event 会导致页面性能问题,所以 Mutation Event 被反对使用,并逐步从 Web 标准事件中删除了。
为了解决了 Mutation Event 由于同步调用 JavaScript 而造成的性能问题,从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。
MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。
优先级小于Promise,一般是Promise 不支持时才会这样做。
它是HTML5 中的新特性,用于监听一个DOM 变动,当DOM 对象树发生任何变动时,Mutation Observer 会得到通知,像v2.4 之前的Vue 源码中利用它来模拟nextTick:
创建一个TextNode 并监听内容变化,然后要nextTick 的时候去改一下这个节点的文本内容。
// vue 源码
var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))
observer.observer(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
Vue(2.5+)的nextTick 实现移除了MutationObserver 的方式(据说是兼容性原因),取而代之的是使用MessageChannel,MessageChannel 属于宏任务,优先级是:MessageChannel->setTimeout。
在ES3 以及以前的版本中,JavaScript 本身没有发起异步请求的能力,也就没有微任务的存在。
在ES5 之后,JavaScript 引入了Promise,这样不需要浏览器,JavaScript 引擎自身也能够发起异步任务了。
ES6 规范中,microtask 称为jobs,macrotask 称为task,宏任务是由宿主发起的,而微任务由JavaScript 自身发起的。
Promise 的polyfill 与官方版本的区别:
- 官方版本中是标准的microtask 形式
- Polypill 一般都是通过setTimeout 模拟的,所以是macrotask
Event Loop 中,每一次循环称为tick,每一次tick 的任务如下:
- 执行一个宏任务(栈中没有就从事件队列中获取);
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中;
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)直至为空;
- 如果宿主为浏览器,开始检查渲染,然后GUI 线程接管渲染;
- 渲染完毕后,JS 线程继续接管,开始下一轮tick,执行宏任务中的异步代码(setTimeout 等回调)
图解
e.g.
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// await 之后的操作被放到了then 方法中,即一个新的微任务
// 控制台打印结果
script start => async2 end => promise => script end
=> async1 end => promise1 => promise2
=> setTimeout
// 拼多多
async1 = async () => {
console.log('async1 start')
await async2()
console.log('async1 end')
}
function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function() {
console.log('setTimeout')
}, 0)
async1()
new Promise(resolve => {
console.log('promise1')
resolve()
})
.then(function() {
console.log('promise2')
return new Promise(function (resolve) {
resolve()
})
})
.then(function() {
console.log('promise3')
})
console.log('script end')
// 正确顺序:
script start => async1 start => async2 => promise1 => script end => async1 end => promise2 => promise3 => setTimeout
// 每次主线程执行栈为空的时候,引擎都会优先处理微任务队列,处理完微任务队列中的所有任务,再处理宏任务
console.log('start here')
const foo = () => (new Promise((resolve, reject) => {
console.log('first promise constructor')
let promise1 = new Promise((resolve, reject) => {
console.log('second promise constructor')
setTimeout(() => {
console.log('setTimeout here')
resolve()
}, 0)
resolve('promise1')
})
resolve('promise0')
promise1.then(arg => {
console.log(arg)
})
}))
foo().then(arg => {
console.log(arg)
})
console.log('end here')
- 'start here'
- 'first promise constructor'
- 'second promise constructor'
- 'end here'
- 'promise1'
- 'promise0'
- 'setTimeout here'
对比node 事件循环
事件的执行机制不同,浏览器的事件循环是在H5 定义的规范,node 中是libuv 库实现的,同一段代码在不同环境下输出不同。
【浏览器事件循环】
JS 是单线程,有一个主线程和调用栈,所有任务被放到调用栈等待主线程执行
- 同步任务进入主线程,异步任务进入Event table,注册回调函数,等异步任务有了结果,把回调函数移入事件队列
- 主线程任务执行完毕,调用栈被清空,去事件队列读取函数到主线程执行
【node 事件循环】
给每一任务都分配了一个队列(队列发生切换时,就会执行微任务)
-
先轮询poll queue 是否有事件,有就依次执行
-
当queue 为空,检查是否有setimmediate 的回调
- 有的话就进入到check 阶段执行,同时检查是否有到期的timer,有的话就把到期timer 的回调放到timer queue
- 如果没有,循环会在poll 阶段停留,直到有I/O 事件返回,循环进入I/O callback 阶段并执行回调
任务队列和执行过程不同:
【浏览器】中将异步任务分成宏任务和微任务,满足执行条件的时候,这些任务分别被放入宏任务队列和微任务队列中,主线程执行完任务,清空执行栈后,清空微任务队列,然后执行一个宏任务,再执行所有微任务,循环。
【node 环境】中有4 个任务队列,包括timer queue、I/O queue、check queue、close queue,每个队列按照循环顺序执行,每次清空一个队列,切换队列的时候,会清空nextTick 队列和微任务队列。
经典习题
e.g. 1
setTimeout(function() {
console.log('setTimeout')
})
new Promise(function(resolve) {
console.log('promise')
}).then(function() {
console.log('then')
})
console.log('console')
- 这段代码作为宏任务,进入主线程。
- 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。
- 接下来遇到了Promise,new Promise 立即执行,then 函数分发到微任务Event Queue。
- 遇到console.log(),立即执行。
- 整体代码script作为第一个宏任务执行结束,看看有哪些微任务?发现then 在微任务Event Queue里面,执行。
- 第一轮事件循环结束了,开始第二轮循环,从宏任务Event Queue开始。发现宏任务Event Queue 中setTimeout 对应的回调函数,立即执行。
- 结束。
e.g. 2
console.log('start here')
new Promise((resolve, reject) => {
console.log('first promise constructor')
resolve()
}).then(() => {
console.log('first promise then')
return new Promise((resolve, reject) => {
console.log('second promise')
resolve()
}).then(() => {
console.log('second promise then')
})
}).then(() => {
console.log('another first promise then')
})
console.log('end here')
- 'start here'
- 'first promise constructor'
- 'end here'
- 'first promise then'
- 'second promise'
- 'second promise then'
- 'another first promise then'
e.g. 3
setTimeout(function() {
console.log('timer1')
}, 0)
requestAnimationFrame(function(){
console.log('UI update')
})
setTimeout(function() {
console.log('timer2')
}, 0)
new Promise(function executor(resolve) {
console.log('promise 1')
resolve()
console.log('promise 2')
}).then(function() {
console.log('promise then')
})
console.log('end')
// promise 1 -- promise 2 -- end -- promise then -- 剩余宏任务看情况了
先分析同步代码和异步代码,Promise对象虽然是微任务,但是 new Promise时的回调函数是同步执⾏的,所以优先输出promise 1 和 promise 2。
在resolve 执⾏时Promise 对象的状态变更为已完成,所以then 函数的回调被注册到微任务事件中,此时并不执⾏,所以接下来应该输出end。
同步代码执⾏结束后,观察异步代码的宏任务和微任务,在本次的同步代码块中注册的微任务会优先执⾏,Promise 为微任务,setTimeout 和requestAnimationFrame 为宏任务,所以Promise 的异步任务会在下⼀个宏任务执⾏前执⾏,所以promise then 是第四个输出的结果。 接下来分析setTimeout 和requestAnimationFrame 两个宏任务,requestAnimationFrame 在中间,setTimeout 是在程序运⾏到setTimeout 时⽴即注册⼀个宏任务,所以timer1 和timer2会按照顺序输出。⽽requestAnimationFrame 是请求下⼀次重绘事件,所以他的执⾏频率要参考浏览器的刷新率。
let i = 0
let d = new Date().getTime()
let d1 = new Date().getTime()
function loop(){
d1 = new Date().getTime()
i++
// 当间隔时间超过1秒时执⾏
if((d1 - d) >= 1000) {
d = d1
console.log(i)
i = 0
console.log('经过了1秒')
}
requestAnimationFrame(loop)
}
loop()
该代码在浏览器运⾏时,控制台会每间隔1 秒进⾏⼀次输出,输出的i 就是loop 函数执⾏的次数。
这个输出意味着requestAnimationFrame 函数的执⾏频率是每秒钟60 次左右,他是按照浏览器的刷新率来进⾏执 ⾏的,也就是当屏幕刷新⼀次时该函数就会触发⼀次,相当于运⾏间隔是16 毫秒左右。
let i = 0
let d = new Date().getTime()
let d1 = new Date().getTime()
function loop(){
d1 = new Date().getTime()
i++
if((d1 - d) >= 1000) {
d = d1
console.log(i)
i = 0
console.log('经过了1秒')
}
setTimeout(loop, 0)
}
loop()
setTimeout(fn, 0) 的执⾏频率是每秒执⾏200 次左右,所以他的间隔是5 毫秒左右。
由于这两个异步的宏任务出发时机和执⾏频率不同,会导致三个宏任务的触发结果不同,如果我们打开⽹⻚时,恰好赶上5 毫秒内执⾏了⽹⻚的重绘事件,requestAnimationFrame 在⼯作线程中就会到达触发时机优先进⼊任务队列,所以此时会输出:UI update –> timer1 –> timer2。 ⽽当打开⽹⻚时上⼀次的重绘刚结束,下⼀次重绘的触发是16 毫秒后,此时setTimeout注册的两个任务在⼯作线 程中就会优先到达触发时机,这时输出的结果是:timer1 –> timer2 –> UI update。
e.g. 4
document.addEventListener('click', function(){
Promise.resolve().then(()=> console.log(1))
console.log(2)
})
document.addEventListener('click', function(){
Promise.resolve().then(()=> console.log(3))
console.log(4)
})
由于该事件是直接绑定在document 上的,所以点击⽹⻚就会触发该事件,在代码运⾏时相当于按照顺序注册了两个点击事件,两个点击事件会被放在⼯作线程中实时监听触发时机,当元素被点击时,两个事件会按照先后的注册顺序放⼊异步任务队列中进⾏执⾏,所以事件1和事件2 会按照代码编写的顺序触发。
由于事件执⾏时并不会阻断JS 默认代码的运⾏,所以事件任务也是异步任务,并且是宏任务,所以两个事件相当于按顺序执⾏的两个宏任务。这样就会分出两个运⾏环境,第⼀个事件执⾏时console.log(2) 是第⼀个宏任务中的同步代码,所以他会⽴即执⾏,⽽Promise.resolve().then(()=> console.log(1))属于微任务,他会在下⼀个宏任务触发前执⾏,所以这⾥输出2 后会直接输出1。⽽下⼀个事件的内容是相同道理,所以输出顺序为:2,1,4,3。