js中的事件循环模型与特殊的定时器
事件循环模型与定时器
重新认识定时器
js中有两种定时器,一种是循环定时器setInterval
,一种是间隔定时器setTimeout
setInterval
与setTimeout
的不同之处在于,前者会在根据的时间间歇性执行回调函数,后者则是在设定的时间后执行一次回调函数
平时我们在用定时器的时候,是否考虑过一个问题
定时器真的是严格按照我们设定的时间,定时执行的吗?
<body>
<button id="btn">点我运行</button>
<script>
document.getElementById('btn').onclick = function () {
var start = Date.now()
setTimeout(() => {
var end = Date.now()
console.log(`定时器执行了${end - start}毫秒`)
}, 2000)
}
</script>
</body>
由这个小实验可以看到,定时器并不是严格按照设定时间执行其中的回调函数的,这个时间获取小到可以忽略不计,但是它有没有可能大到严重影响我们整个js脚本,乃至页面呢? 答案是: 非常有可能!
我们都知道js是单线程运行的,也就是一次只能进行一个任务,所以如果定时器前面有一个同步任务需要完成相当久时间,那么这个同步任务就会阻塞定时器的执行,从而造成定时器严重不准时运行
document.getElementById('btn').onclick = function () {
var start = Date.now()
for (let index = 0; index < 10000000; index++) {
var arr = new Array(1000)
}
setTimeout(() => {
var end = Date.now()
console.log(`定时器执行了${end - start}毫秒`)
}, 2000)
}
造成这个原因涉及到了下面要介绍的一个概念: JS的事件循环模型(Event Loop)
但是介绍这个概念之前,我们先来证明一下js是单线程运行的
js是单线程运行的
setTimeout(()=>{
console.log('timeout 1111')
}, 1000)
setTimeout(()=>{
console.log('timeout 3333')
}, 3000)
setTimeout(()=>{
console.log('timeout 2222')
}, 2000)
function func () {
console.log('func()')
}
func()
console.log('alert()之前')
alert('用于暂停主线程运行')
console.log('alert()之后')
上面这个例子是这样执行的:
-
页面一加载完毕,就打印输出
'func()'
与'alert()之前'
,马上弹出alert('用于暂停主线程运行')
-
接着定时器时间间隔计数根据浏览器的版本与牌子,选择阻塞计时和非阻塞计时,我的chrome是非阻塞计时,即我此时长时间不点alert弹窗的确认按钮,浏览器在背后也会自动计时定时器
-
之后我们点确认按钮,定时器的输出就会一起出来
这恰恰证明了js是单线程执行的, 一次只能运行一个任务,只有等特定任务执行完后,才能执行下一个任务。
那这特定任务指的是什么,执行完特定任务后,再执行剩下的什么呢?
这就是下一章节介绍的事件循环模型(Event Loop)所包含的内容
事件循环模型(Event Loop)
我们对上面这种图中对应的概念进行标注
首先,我们需要知道js引擎执行代码是按照一定顺序的,这就引申出js代码执行时机的问题
根据js代码执行时机的问题,这里将代码分为两大类: 1.初始化代码 2. 回调代码
初始化代码就是js引擎在页面初始化后马上同步执行的代码
初始化代码有:
- 定时器申明(内部回调函数会被分到回调代码)
- 绑定dom事件监听
- 发送AJAX请求
回调代码是在特定的实际才执行的代码
- 各种回调函数
事件循环模型的基本运行流程
-
先执行初始化代码(主线程中),将对应回调代码交给对应的模块管理(对应Web APIs部分)
-
在特定时刻或回调条件触发时,对应的模块会将回调函数及其内部数据添加到回调队列中
-
只有当所有的初始化代码执行完毕后,才会从回调队列中读取并执行其中的回调函数
<body>
<button id="btn">点我运行</button>
<script>
function test1 () {
console.log('test1()')
}
test1()
document.getElementById('btn').onclick = function () {
console.log('点击了btn')
}
setTimeout(()=>{
console.log('setTimeout()')
}, 1000)
function test2 () {
console.log('test2()')
}
test2()
/*
根据事件循环模型:
初始化代码: 1. 定义test1函数
2. 绑定dom事件
3. 定义定时器
4. 定义test2函数
回调代码: 1. onclick回调函数
2. 定时器回调函数
*/
/*
所以输出是:
'test1()'
'test2()'
接着看点击事件先响应,还是定时器计数先结束
*/
</script>
</body>
至此,我们就知道前面定时器的回调执行时机不准的原因是: 定时器指定的时间并不代表执行时间,而是将回调函数加入任务队列的时间
事件循环模型重要的两个部分
第一部分: 用于处理回调代码的模块,对应的是图中的WebAPIs部分,它们运行在分线程中
这些模块并不是由JS引擎管理着,而是由浏览器进行实现、管理,这也就是为什么前面提到浏览器版本、品牌会对定时器计时有影响
第二部分: 是回调队列, 在执行初始化代码过程中,将其中的回调代码交给对应的模块管理,当特定条件出发时,回调代码也不是立刻执行的,而是放到回调队列中, 因为要等初始化代码全部执行完,才能执行回调函数, 所以回调队列起到的是一个缓冲的作用
到这,事件循环模型的知识就总结的较为清晰了,但是还有一个地方需要补充
回调代码(异步任务)中,各种异步任务之间是否也有优先级呢? 来看看这个例子
setTimeout(()=>{
console.log('timeout')
},0)
let p = Promise.resolve('promise')
p.then(
result => {
console.log(result)
}
)
/*
运行结果:
'promise'
'timeout'
*/
就会看到promise异步回调是会比setTimeout定时器回调先执行的,这说明各种异步任务之间也是有优先级之分(先后执行顺序)
哪异步任务的优先级是如何区分,就是下章节所要介绍的知识
宏任务与微任务
参考于博主听风是风的《JS执行机制详解,定时器时间间隔的真正含义》
JavaScript引擎将整代码所对应的任务整体分为宏任务与微任务
宏任务有:
- script环境
- 定时器setTimeout、setInterval
- I/O
- 事件
- postMessage
- MessageChannel
- setImmediate (Node.js)
微任务有:
- Promise
- process.nextTick
- MutaionObserver
根据上面的例子,我们就能知道,微任务的优先级高于宏任务
这里着重需要注意的是: script环境也属于宏任务,所以上来就执行的同步代码是属于宏任务的
那前面说的,微任务的优先级高于宏任务,但是宏任务的同步代码又是首先执行。这不就冲突、乱套了吗
所以比较准确的说法应该是: 除了宏任务中同步执行的初始化代码,微任务的优先级是高于宏任务的
用流程图表示一下
最后通过一道题目来展现巩固一下这个过程
const P1 = function () {
return new Promise((resolve) => {
console.log('p1')
setTimeout(() => {
resolve()
})
})
}
const P2 = function () {
return new Promise((resolve) => {
console.log('p2')
resolve()
})
}
setTimeout(() => {
console.log('s1')
P1().then(() => {
console.log(1)
})
})
setTimeout(() => {
console.log('s2')
P2().then(() => {
console.log(2)
})
})
/*
最后输出是:
s1
p1
s2
p2
2
1
*/
图片配合文字解析
第1步: 初始化代码
第2步: 将代码中的回调代码(异步回调)交给对应模块处理
这里比较容易误会的地方是,为什么只交定时器的两个回调。
因为P1,P2这两个是分别要等这两个定时器的回调执行才会执行,现在P1,P2的一切都还处于完成初始化代码的状态
第3步: 此时初始化代码(同步代码)所有都执行完了,开始轮询任务队列中的回调代码(异步代码)
此时任务队列的状态
第4步: 轮询执行第一个回调函数,也就是setTimeout1, setTimeout1的又会开启它事件循环
第5步: setTimeout1的事件循环中,由于console.log('s1')
和P1
这些都是初始化代码(同步代码), 所以先输出打印s1
第6步: 然后执行P1()
, P1又开始P1的事件循环,同步代码console.log('p1')
输出打印p1,同步代码执行完毕,将其中的回调代码(P1中的定时器)放入任务队列中,P1的事件循环结束
此时任务队列的状态
第7步: 将setTimeout1中还有then回调经对应模块处理后,放入任务队列,setTimeout1整个回调执行完成
此时任务队列的状态
第8步: 接着轮询执行setTimeout2,开始setTimeout2的事件循环
第9步: setTimeout2的事件循环中,同步代码console.log('s2')
输出打印s2
第10步: 然后执行P2()
, P2又开始P2的事件循环,同步代码console.log('p2')
和resolve()
输出打印p2,然后改变Promise状态,同步代码执行完毕,没有回调代码,所以P2的事件循环结束
此时任务队列的状态
按理说根据队列的特性,此时我们应该轮询执行P1中定时器的回调函数,但是别忘记了定时器是宏任务,promise是微任务,微任务的优先级比宏任务高。
所以,改变一下任务队列
第10步: 由于Promise的状态已被改变,setTimeout2中的then回调执行,打印输出2
此时任务队列的状态
第11步: 轮询执行P1中的定时器回调,改变P1的Promise的状态
第12步: 由于Promise的状态已被改变,setTimeout1中的then回调执行,输出1
写在最后
这篇笔记,是我写的最没把握的一篇,因为总结到最后,我感觉有太多的地方我无法理解,甚至很可能理解的也是错误的
不管怎样先记录下来,等到时真正明白了或意识到什么地方错了再回来修改
十分欢迎读者提出其中的错误