# JavaScript 事件执行机制
首先需要了解 js 解析运行机制,浏览器由很多模块组成,有解析 html 和 css 的模块,有解析 js 、http 请求模块。 一个浏览器通常由以下常驻线程组成:
- GUI 渲染线程
- JavaScript 引擎线程
- 定时触发器线程
- 事件触发线程
- 异步 http 请求线程
其中 JavaScript 引擎线程 v8 引擎就是用来解析 js 的,js 是单线程是因为 v8 引擎是单线程,当 v8 引擎解析到异步代码时,比如定时器,就会把异步代码交给相关的模块处理,处理完之后,再交给事件队列中排队,当执行栈有空时,消息队列就把事件交给调用栈执行。拿定时器来说,当 v8 引擎从上往下执行代码,读到定时器的时候。就会把定时器交给定时器模块处理,然后 v8 引擎继续执行代码,定时器模块进行计时,当时间到了,就会把任务交给事件队列,当执行栈有空闲时,事件队列就会把任务推给执行栈,执行栈执行完毕之后将其弹出;
而事件循环机制就是 JavaScript 中实现异步以及多线程的实现基础;
首先看一段代码:
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('代码执行结束');
执行结果:
// 马上执行for循环啦
// 代码执行结束
// 执行then函数啦
// undefined
// 定时器开始啦
首先我们要明确的是 JavaScript 是一门单线程的语言,虽然在 H5 中有了 Web-Worker,但单线程这一机制是没有变的。现在的 JavaScript 的多线程都是通过单线程模拟出来的(为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质)。
# JavaScript 事件循环
JavaScript 的执行任务分为两类:
- 同步任务
- 异步任务
当我们打开网页的时候,网页的渲染过程就是很多同步任务,比如页面骨架和页面元素的渲染。而像一些大的图片之类占用资源大耗时久的任务,就是异步任务。
上面的图就是说:
- 同步和异步任务分别进入不同的执行“场所”,同步的进入主线程,异步的进入 Event Table 并注册函数。
- 当指定的事情完成时,Event Table 会将这个函数移入 Event Queue。
- 主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行。
- 上述过程会不断重复,也就是常说的 Event Loop(事件循环)。
js 引擎存在 monitoring process 进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去 Event Queue 那里检查是否有等待被调用的函数。 例子:
let data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!')
}
})
console.log('代码执行结束')
上面代码执行解析:
- ajax 进入 Event Table,注册回调函数 success。
- 执行 cconsole.log('代码执行结束')。
- ajax 事件完成,回调函数 success 进入 Event Queue。
- 主线程从 Event Queue 读取回调函数 success 并执行。
# setTimeout
看下面代码:
setTimeout(() => {
task()
},3000)
sleep(10000000)
我们执行上面代码发现,执行 task 函数需要的时间是不止三秒的。上面代码执行解析:
- task() 进入 Event Table 并注册,计时开始。
- 执行 sleep 函数,很长事件,计时仍在继续。
- 3 秒到了,计时事件 timeout 完成,task() 进去 Event Queue,但是 sleep 函数并没有执行完成,等待执行完成。
- sleep 执行完成,task() 从 Event Queue 进入了主线程执行。
也就是说,setTimeout 这个函数,是经过指定时间后,把要执行的任务(本例中为 task() 函数)加入到 Event Queue 中,又因为是单线程任务要一个一个执行,如果前面的任务需要的事件太久,那么只能等着,导致真正的延迟事件远远大于 3 秒。
setTimeout(() => {
console.log('执行啦')
},0);
console.log('先执行这里');
// 先执行这里
// 执行啦
所以说 setTimeout(fn,0) 他并不是立即执行,他的含义是指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。
这里需要注意的是 setTimeout 的最小延迟时间是 4ms。
# setInterval
setInterval 是循环执行,会每隔指定的事件将注册的函数置入 Event Queue,如果前面的任务耗时太久,那么同样需要等待。对于setInterval(fn,ms)来说,我们已经知道不是每过 ms 秒会执行一次 fn,而是每过 ms 秒,会有 fn 进入 Event Queue。一旦setInterval 的回调函数 fn 执行时间超过了延迟时间 ms,那么就完全看不出来有时间间隔了。
# Promise 与 process.nextTick(callback)
process.nextTick(callback) 类似 node.js 版的 "setTimeout",在事件循环的下一次循环前调用 callback 回调函数。process.nextTick() 执行总是早于 setImmediate() 和 setTimeout();
# setTimeout 与 setImmediate
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
// 'timeout'
// 'immediate'
setImmediate() 将在当前事件轮询的末尾处执行;setTimeout 会在最小延迟(4ms)后运行脚本;
但是如果遇到在 IO 操作中情况就会不一样:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
// immediate
// timeout
fs.readFile 的回调是在 poll 阶段执行的,当其回调执行完毕之后,poll 队列为空,而 setTimeout 入了 timers 的队列,此时有代码被 setImmediate(),于是事件循环先进入 check 阶段执行回调,之后在下一个事件循环再在 timers 阶段中执行有效回调。
https://segmentfault.com/a/1190000013102056 https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
# 宏任务、微任务
除了广义的同步任务和异步任务,我们对任务有更精细的定义:
- macro-task(宏任务):包括整体代码 script,setTimeout,setInterval、I/O、setImmediate(IE)、requestAnimationFrame 各种 callback、 UI 渲染等;
- micro-task(微任务):Promise.then catch finally,process.nextTick(callback)、MutaionObserver
不同类型的任务会进入对应的 Event Queue,比如 setTimeout 和 setInterval 会进入相同的 Event Queue。事件循环的顺序,决定 js 代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务,然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。
https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth#%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF%EF%BC%88event_loops%EF%BC%89
如下代码:
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
resolve();
}).then(function() {
console.log('then');
})
console.log('console');
解释:
- 这段代码作为宏任务,进入主线程。
- 先遇到 setTimeout,那么将其回调函数注册后分发到宏任务 Event Queue。
- 接下来遇到了 Promise,new Promise 立即执行,then 函数分发到微任务 Event Queue。
- 遇到 console.log() 立即执行。
- 至此,整体代码 script 作为第一个宏任务执行结束,看看有哪些微任务?我们发现了 then 在微任务 Event Queue 里面,执行。
- ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务 Event Queue 开始。我们发现宏任务 Event Queue 中 setTimeout 对应的回调函数,立即执行。
- 结束
如下面一个复杂的代码:
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
// 1, 7, 6,8, 2, 4, 3, 5, 9, 11, 10 , 12
分析: 第一轮事件循环流程分析:
- 整体 script 作为第一个宏任务进入主线程,遇到 console.log 打印 1;
- 遇到 setTimeout,将其回调函数被分发到宏任务 Event Queue 中,我们暂时且记为 setTimeout1。
- 遇到 process.nextTick(),将其回调函数被分发到微任务 Event Queue 中,我们记为 process1。
- 遇到 Promise,new Promise 直接执行,输出 7,then 被分发到微任务 Event QUeue 中,我们记为 then1。
- 又遇到了 setTimeout,将其回调函数被分发到宏任务 Event Queue 中,我们记为 setTimeout2。
宏任务 Event Queue | 微任务 Event Queue |
---|---|
setTimeout1 | process1 |
setTimeout2 | then1 |
- 上面的表格是第一轮事件循环宏任务结束时各 Event Queue 的情况,此时已经输出了 1 和 7;
- 然后执行微任务 process1 和 then1 执行,
- 执行 process1,输出 6。
- 执行 then1,输出 8。
至此,第一轮事件循环正式结束,这一轮的结果是输出 1, 7, 6, 8,那么第二轮事件循环从 setTimeout1 宏任务开始:
- 首先输出 2,接下来遇到了 process.nextTick(),同样将其分发到微任务 Event Queue 中,记为 process2,new Promise 立即执行输出 4, then 也分发到微任务 Event Queue 中,记为 then2。
宏任务 Event Queue | 微任务 Event Queue |
---|---|
setTimeout2 | process2 |
then2 |
- 第二轮事件循环宏任务结束,我们发现有 process2 和 then2 两个微任务可以执行。
- 输出 3。
- 输出 5。
- 第二轮事件循环结束,第二轮输出 2, 4, 3, 5。
- 第三轮事件循环开始,此时只剩 setTimeout2,执行。
- 直接输出 9。
- 将 process.nextTick() 分发到微任务 Event Queue 中,记为 process3。
- 直接执行 new Promise,输出 11。
- 将 then 分发到微任务 Event Queue 中,记为 then3。
宏任务 Event Queue | 微任务 Event Queue |
---|---|
process3 | |
then3 |
- 第三轮事件循环宏任务执行结束,执行两个微任务 process3 和 then3。
- 输出 10。
- 输出 12。
- 第三轮事件循环结束,第三轮输出 9, 11, 10, 12。
# 其他
# 共享事件循环
在特定情况下,同源窗口之间共享事件循环,例如:
- 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。
- 如果窗口是包含在
<iframe>
中,则它可能会和包含它的窗口共享一个事件循环。 - 在多进程浏览器中多个窗口碰巧共享了同一个进程。 这种特定情况依赖于浏览器的具体实现,各个浏览器可能并不一样。
# 触发微任务
触发微任务不仅仅通过内部使用来驱动诸如 promise 这些;还可以通过 queueMicrotask() 创建一个统一的微任务队列;如下代码:
MyElement.prototype.loadData = function (url) {
if (this._cache[url]) {
queueMicrotask(() => {
this._setData(this._cache[url]);
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url).then(res => res.arrayBuffer()).then(data => {
this._cache[url] = data;
this._setData(data);
this.dispatchEvent(new Event("load"));
});
}
};
# 总结
- js 的异步
JavaScript 是一门单线程语言,不管是什么新的框架新语法糖实现的所谓异步,其实都是用同步的方法去模拟的,需要牢牢把握住单线程这点非常重要。
- 事件循环 Event Loop
事件循环是 js 实现异步的一种方法,也是 js 的执行机制。
- JavaScript 的执行和运行
执行和运行有很大的区别,JavaScript 在不同的环境下,比如 node,浏览器,Ringo(是一个构建在 Java 虚拟机上的 JS 平台) 等,执行方式是不同的。而运行大多指 JavaScript 解析引擎,是统一的。
- setImmediate
微任务和宏任务还有很多种类,比如 setImmediate 等等,执行都是有共同点的。
JavaScript 是一门单线程语言,Event Loop 是 JavaScript 的执行机制
- 微任务是 JS 级别的,宏任务是宿主级别的,是包含关系,不是先后关系;每一次事件轮询中,都是宏任务先执行,微任务后执行。