js中的事件循环模型与特殊的定时器

事件循环模型与定时器

重新认识定时器

js中有两种定时器,一种是循环定时器setInterval,一种是间隔定时器setTimeout

setIntervalsetTimeout的不同之处在于,前者会在根据的时间间歇性执行回调函数,后者则是在设定的时间后执行一次回调函数

平时我们在用定时器的时候,是否考虑过一个问题

定时器真的是严格按照我们设定的时间,定时执行的吗?

<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()之后')

上面这个例子是这样执行的:

  1. 页面一加载完毕,就打印输出'func()''alert()之前',马上弹出alert('用于暂停主线程运行')

  2. 接着定时器时间间隔计数根据浏览器的版本与牌子,选择阻塞计时和非阻塞计时,我的chrome是非阻塞计时,即我此时长时间不点alert弹窗的确认按钮,浏览器在背后也会自动计时定时器

  3. 之后我们点确认按钮,定时器的输出就会一起出来




这恰恰证明了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

写在最后

这篇笔记,是我写的最没把握的一篇,因为总结到最后,我感觉有太多的地方我无法理解,甚至很可能理解的也是错误的

不管怎样先记录下来,等到时真正明白了或意识到什么地方错了再回来修改

十分欢迎读者提出其中的错误

原文地址:https://www.cnblogs.com/fitzlovecode/p/jsadvanced13.html