带你梳理JS的事件循环机制

06-01 1491阅读

1. 为什么需要事件循环?

        事件循环是为了解决JavaScript单线程的语言特性与异步任务需求之间的矛盾而出现的。

        首先,我们要理解JavaScript是一门单线程的语言。

1.1 JS为什么选择单线程?

        所谓单线程,简单来说一个时间只能做一件事,只有做完这件事,才能进行下一件。那为什么选择单线程,不选择多线程呢?这是由JS的用途决定的,JS的用途是与用户交互,以及操作DOM,假设JS有两个线程,一个要在某个DOM节点上添加内容,一个要删除这个节点,那浏览器该以哪个为准呢,事情就变得复杂了。因此,JS在诞生时就是单线程,以后也不会改变,这是这门语言的核心特征。

1.2  异步任务需求

        但是,单线程有个缺点,如果一件任务耗时太长,例如I/O请求,就会阻塞后面的任务。这样肯定是不行的,所以,JS设计者将任务分成同步任务和异步任务。

        简单来说,同步任务就是在主线程(调用栈 Call Stack)中按照书写顺序依次执行的任务。异步任务就是不阻塞主线程,而是交由其他线程或系统处理(如浏览器 Web API 或 Node.js 的 C++ 模块),处理完毕后,将其回调函数推入任务队列(Task Queues)。等到主线程空了,任务队列中的任务再根据FIFO算法被调度到主线程中执行。

1.3 事件循环原理

        那么,事件循环在其中起到什么作用呢?它又是怎么工作的呢?

事件循环的工作流程如下:

1.执行调用栈中的所有同步代码,直到栈空。

2.检查任务队列,依次将任务调度到调用栈中执行,直到队列空。 

3.重复循环。

        只要调用栈空了,就去任务队列中读取任务,这个过程不断重复循环,这就是JavaScript的运行机制, 这种机制就叫做事件循环机制。

        注意,事件循环是以事件为驱动,它并不是按照固定的时间间隔运行的,而是根据任务队列中的任务来触发。当调用栈为空时,事件循环会检查任务队列,如果有任务,就取出执行。所以,事件循环的触发时机取决于是否有任务需要处理,而不是定时器那样的固定周期。

1.4 执行顺序题目1

        理解了上面的概念之后,我们来看一道输出题:

console.log("1"); // 同步任务
setTimeout(() => console.log("2"), 1000); // 异步任务
console.log("3"); // 同步任务

        答案:正确输出是1→3→2,而不是1→2→3,为什么呢?

        分析:首先要知道setTimeout这个函数,它不会立刻返回结果,而是发起了一个异步,它用于在指定的毫秒数后调用函数或计算表达式。这段代码中它接受了两个参数,第一个是回调函数,第二个数是1000。也就是说,它将在1秒后执行() => console.log("2")这个箭头函数,结果是输出2。

        前面说到事件循环机制,它先执行调用栈中的所有同步代码,直到栈空,然后再到任务队列中读取任务执行,这个过程不断重复。

        因此,console.log("1")这个同步任务首先被加入调用栈中执行,输出1,执行完毕后弹出调用栈。

        接着,setTimeout(() => console.log("2"), 0)被加入调用栈,但它是一个异步任务,因此被转交到其他线程处理,等待处理完毕后(对于setTimeout函数,就是计时完毕),将回调函数调入任务队列。

        同时,主线程则继续下一个同步任务console.log("3"),输出3,执行完毕后弹出调用栈。

        此时,代码中没有同步任务了,调用栈已空,于是任务队列中的() => console.log("2")任务进入调用栈执行,输出2。

        这个过程如果不明白的,可以借助Loupe(事件循环模拟器)工具理解,工具作者是Philip Roberts ,latentflip.com/loupe/?code=JC5vbignYnV0dG9uJywgJ2NsaWNrJywgZnVuY3Rpb24gb25DbGljaygpIHsKICAgIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZXIoKSB7CiAgICAgICAgY29uc29sZS5sb2coJ1lvdSBjbGlja2VkIHRoZSBidXR0b24hJyk7ICAgIAogICAgfSwgMjAwMCk7Cn0pOwoKY29uc29sZS5sb2coIkhpISIpOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIkNsaWNrIHRoZSBidXR0b24hIik7Cn0sIDUwMDApOwoKY29uc29sZS5sb2coIldlbGNvbWUgdG8gbG91cGUuIik7!!!PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3Dhttp://latentflip.com/loupe/?code=JC5vbignYnV0dG9uJywgJ2NsaWNrJywgZnVuY3Rpb24gb25DbGljaygpIHsKICAgIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZXIoKSB7CiAgICAgICAgY29uc29sZS5sb2coJ1lvdSBjbGlja2VkIHRoZSBidXR0b24hJyk7ICAgIAogICAgfSwgMjAwMCk7Cn0pOwoKY29uc29sZS5sb2coIkhpISIpOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIkNsaWNrIHRoZSBidXR0b24hIik7Cn0sIDUwMDApOwoKY29uc29sZS5sb2coIldlbGNvbWUgdG8gbG91cGUuIik7!!!PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3D

        强烈推荐这个作者的一个演讲视频,看完可以很好的理解事件循环机制。

【中文字幕】Philip Roberts:到底什么是Event Loop呢?(JSConf EU 2014) https://www.bilibili.com/video/BV1oV411k7XY/?share_source=copy_web&vd_source=2ea0872ee94da416e76fe584e8238538

        这个作者的也很棒,讲得更深入一点。

【事件循环】【前端】事件原理讲解,超级硬核,忍不住转载】 https://www.bilibili.com/video/BV1K4411D7Jb/?share_source=copy_web&vd_source=2ea0872ee94da416e76fe584e8238538

1.5 思考题:setTimeout(callback,0)是什么意思?

        如果仔细看了前面两个视频,setTimeout(callback, 0) 表示将 callback 函数延迟到当前同步代码执行完毕,主线程空闲后立即执行。它并非“立即执行”,而是将回调推入任务队列,等待事件循环调度。所以setTimeout(callback,s)的真正含义并不是在指定的毫秒数后调用函数,而是最快s毫秒后调用函数,因为它需要等待主线程空后再被调用。

2. 异步任务的细化

2.1 宏任务与微任务

        事实上,异步任务还分为宏任务和微任务。

        在事件循环的早期设计中,所有异步任务都进入同一个任务队列。但随着前端复杂度提升,任务优先级问题显露出来:

  • 紧急任务需要优先处理(如 Promise 状态更新)

  • 非紧急任务可以延后(如 UI 渲染前的计算)

            因此,现代事件循环将异步任务细分为两类:

    任务类型 典型 API执行优先级
    宏任务

    主线程代码、setTimeout、setInterval、setImmediate、requestAnimation、I/O、script

    微任务Process.NextTick(Node.js环境)、Promise.then、MutationObserver、Object.observe

    2.2 事件循环的完整流程

            首先,明确几个概念:

    宏任务队列:存放待执行的宏任务回调(如 setTimeout、DOM 事件 的回调)。

    微任务队列:存放待执行的微任务回调(如 Promise.then、MutationObserver 的回调)。

    调用栈:实际执行代码的地方,无论是同步代码还是异步回调(宏任务/微任务),都需推入调用栈运行。

    同步任务与宏任务:整个脚本(主线程代码),即标签里的代码,本身就是一个宏任务,主线程执行脚本中的同步代码属于初始宏任务。整个脚本的执行是第一个宏任务,同步代码是它的组成部分。

            于是,事件循环的工作流程就变成:

    1.按照代码书写顺序执行初始宏任务:

    • 如果遇到同步代码,直接推入调用栈执行。

    • 如果遇到宏任务,将回调函数推入宏任务队列。

    • 如果遇到微任务,将回调函数推入微任务队列。 

      2.清空微任务队列:

      • 当前宏任务执行完毕后,依次将微任务队列中的所有微任务推入调用栈执行,直到微任务队列清空。

      • 注意:若微任务中又生成新的微任务,新微任务也会在此阶段被立即执行。

        3.渲染更新(如有必要):

        • 浏览器判断是否需要渲染(通常根据屏幕刷新率,如 60Hz 对应约 16.6ms/次)。

          4.开启下一轮事件循环:

          • 从宏任务队列中取出下一个宏任务执行,重复上述流程。

            2.3 执行顺序题目2

            console.log("1"); // 同步任务(属于初始宏任务)
            setTimeout(() => console.log("2"), 0); // 宏任务
            // 微任务
            Promise.resolve().then(() => {  
              console.log("3"); 
              setTimeout(() => console.log("4"), 0); // 嵌套宏任务
              Promise.resolve().then(() => console.log("5")); // 嵌套微任务
            });
            console.log("6"); // 同步任务(属于初始宏任务)

                    正确输出:1 → 6 → 3 → 5 → 2 → 4。

                    分析:

            1.按照书写顺序执行初始宏任务:

            (1)遇到同步代码console.log("1"),直接推入调用栈执行,输出1。

            (2)遇到宏任务setTimeout(),将回调 () => console.log("2") 推入宏任务队列。

            (3)遇到微任务Promise.resolve().then(),将回调() => { 

              console.log("3"); 

              setTimeout(() => console.log("4"), 0); 

              Promise.resolve().then(() => console.log("5")); 

            }推入微任务队列。

            (4)遇到同步代码console.log("6"),直接推入调用栈执行,输出6。

            (5)检查调用栈为空,当前宏任务执行完毕,进行下一步。

            2.清空微任务队列:

            (1)取出第一个微任务console.log("3"),推入调用栈执行,输出3。

            (2)遇到宏任务setTimeout(),将回调 () => console.log("2") 推入宏任务队列。

            (3)遇到微任务Promise.resolve().then(),将回调 () => console.log("5") 推入 微任务队列。

            (4)检查微任务队列未空,继续清空。取出微任务 () => console.log("5"),推入调用栈执行,输出 5。

            (5)检查微任务队列为空,进行下一步。

            3.下一轮事件循环

            (1)从宏任务队列中取出第一个宏任务console.log("2"),推入调用栈执行,输出2。

            (2)调用栈为空,进行下一步。

            (3)微队列为空,进行下一步。

            4.再下一轮事件循环

            (1)从宏任务队列中取出第一个宏任务console.log("4"),推入调用栈执行,输出6。

            (2)调用栈为空,进行下一步。

            (3)微队列为空,进行下一步。

            (4)宏队列为空,结束。

            2.4 执行顺序题目3

                console.log("1")              //同步任务
                                          
                setTimeout(() => {            //第一个宏任务
                    console.log('2');
                   
                    setTimeout(() => {        //嵌套宏任务
                        console.log('3')
                    }, 0);
                 
                    new Promise(resolve => {  //同步任务
                        resolve()
                        console.log('4')
                    }).then(() => {           //微任务
                        console.log('5')
                    })
                }, 0)
                
                setTimeout(() => {            //第二个宏任务
                    console.log('6')
                }, 0)

                    正确输出:1 → 2 → 4 → 5 → 6 → 3。

            分析:

            1.按照书写顺序执行初始宏任务:

            (1)遇到同步任务console.log("1"),直接推入调用栈执行,输出1。

            (2)遇到第一个宏任务setTimeout(),将回调推入宏任务队列。

            (3)遇到第二个宏任务setTimeout(),将回调推入宏任务队列。

            (4)调用栈为空,进行下一步。

            (5)微队列为空,进行下一步。

            2.第二轮事件循环

            (1)从宏任务队列中取出第一个宏任务的回调推入调用栈执行。

            (2)遇到同步任务console.log("2"),输出2。

            (3)遇到嵌套宏任务setTimeout(),将回调推入宏任务队列。

            (4)遇到同步任务new Promise(),输出4。

            (5)遇到微任务promise.then(),将回调推入微任务队列。

            (6)调用栈为空,进行下一步清空微任务。

            (7)取出微任务console.log("5"),推入调用栈执行,输出5。

            (8)微任务队列为空,进行下一轮循环。

            3.第三轮事件循环

            (1)从宏任务队列中取出第二个宏任务console.log("6"),推入调用栈执行,输出6。

            (2)调用栈为空,进行下一步。

            (3)微队列为空,进行下一轮循环。

            4.第四轮事件循环

            (1)从宏任务队列中取出嵌套宏任务console.log("3"),推入调用栈执行,输出3。

            (2)调用栈为空,进行下一步。

            (3)微队列为空,进行下一步。

            (4)宏队列为空,结束。

            2.5 执行顺序题目4

            console.log('1');
            setTimeout(function() {
                console.log('2');
                Promise.resolve().then(function() {
                    console.log('3');
                })
                new Promise(function(resolve) {
                    console.log('4');
                    resolve();
                }).then(function() {
                    console.log('5')
                })
            })
            Promise.resolve().then(function() {
                console.log('6');
            })
            new Promise(function(resolve) {
                console.log('7');
                resolve();
            }).then(function() {
                console.log('8')
            })
            setTimeout(function() {
                console.log('9');
               Promise.resolve().then(function() {
                    console.log('10');
                })
                new Promise(function(resolve) {
                    console.log('11');
                    resolve();
                }).then(function() {
                    console.log('12')
                })
            })

            请思考这题。

            参考:

            JavaScript 运行机制详解:再谈Event Loop - 阮一峰的网络日志

            一篇搞定(Js异步、事件循环与消息队列、微任务与宏任务) - 知乎

            【事件循环】【前端】事件原理讲解,超级硬核,忍不住转载_哔哩哔哩_bilibili

            【中文字幕】Philip Roberts:到底什么是Event Loop呢?(JSConf EU 2014)_哔哩哔哩_bilibili

免责声明:我们致力于保护作者版权,注重分享,被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理! 图片声明:本站部分配图来自人工智能系统AI生成,觅知网授权图片,PxHere摄影无版权图库和百度,360,搜狗等多加搜索引擎自动关键词搜索配图,如有侵权的图片,请第一时间联系我们。

相关阅读

目录[+]

取消
微信二维码
微信二维码
支付宝二维码