前端进阶之路:事件循环机制

976 阅读9分钟

在切入正题之前呢,先说点题外话,最近准备面试,边投简历边刷面经,在刷面经的过程中 遇到频率很高的题目就是事件循环、闭包、原型链等等,当我们听到这些问题的时候,我感觉有90%多的人似懂非懂,也不知道该怎么描述,题主也是一顿百度,开始以为自己理解了,但是前几天的一道笔试题,有一次搞蒙了我,而百度上的文章呢讲的又是浅尝辄止,画几个图,几段简单的代码,会让读者拥有掌握的心里,其实并没有完全掌握,题主也是花费了很长事件才参透其中奥妙,下面给大家一步步讲解,最后,本文是我第一篇技术文章,所以可能会有很多需要改进的地方希望各位能谅解,也欢迎各位同胞提出建议,不啰嗦直接进入主题。

读前准备

在阅读之前,题主建议先去了解如下知识点,如果已经了解可以忽略。

  • 函数调用栈(call stack)
  • 队列数据结构(queue)
  • Promise

正题

好的,我们就先抛出概念,然后再配合代码演示。

  • javascript是单线程的语言,也就是说,同一个时间只能做一件事。
  • 对于同步代码执行是阻塞的,也就是说上一段代码未执行结束之前,当前代码是无法执行的,而对于异步代码,js引擎遇到后不会一直等待其返回结果,而是会将其挂起,其返回结果后会进入任务队列等待主线程处理。
  • 任务队列是和异步任务相关的队列,它是队列这种先入先出的数据结构,和排队是类似的,哪个异步操作完成的早,就排在前面。
  • 事件循环是调控同步和异步任务的。
  • 一个线程中,事件循环是唯一的,但是任务队列可以拥有多个(JS只有俩个一个宏任务队列,一个微任务队列)。
  • 任务队列又分为macro-task(宏任务)与micro-task(微任务)。
    队列数据结构
  • macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
  • micro-task大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)
  • 事件循环的顺序,决定了JavaScript代码的执行顺序。
  • 它从script(整体代码)开始第一次循环。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从宏任务(macro-task)中取出一个放入调用栈执行,执行完毕后再执行所有的微任务(micro-task),所有微任务执行完毕后再从宏任务(macro-task)中取出一个执行......依次循环,直到所有任务执行完毕。
  • 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成。

文字性的东西已经介绍完毕,下面带俩个例子帮助大家理解

  1.     //次例子引用网上常见的一个例子
        setTimeout(function() {
            console.log('1');
        })
        new Promise(function(resolve) {
            console.log('2');
            for(var i = 0; i < 1000; i++) {
                i == 99 && resolve();
            }
            console.log('3');
        }).then(function() {
            console.log('4');
        })
        console.log('5');
    

    我们来一步一步分析:

    首先,初始化如下状态,调用栈里面只有一个全局环境。

    初始化状态图
    然后遇到setTimeout,setTimeout属于宏任务源,所以将其挂起,返回结果后加入宏任务队列。(setTimeout也是一个函数,所以本身是同步的,但是执行其第一个参数是则是异步的,第一个参数也就是一个回掉函数,也就是说加入任务队列的不是setTimeout本身,而是function(){console.log(1);})
    继续执行代码,当遇到Promise后直接执行其构造函数,控制台打印2,resolve后将其then方法放入微任务队列,然后控制台打印3
    继续向下执行打印5,此刻执行栈内已经全部执行完毕,开始检查微任务队列,发现then,将其加入执行栈执行,打印4,这里只有一个任务,如果微任务队列还有其他任务会一直执行,直到清空微任务队列。
    微任务队列执行完毕后,开始检查宏任务队列,发现setTimeout,将其放入执行栈执行,打印1,执行完毕后检查微任务队列,微任务队列为空,在检查宏任务队列,宏任务队列也为空,执行完毕!

  2. 上面是一个比较简单的示例,当时题主在百度里面见到的也都是这样难度的示例,看完之后也是理解了,感觉很简单,但是上面的示例还不能体现出你掌握了整个执行流程,下面是我自己写的一个示例,大家可以先不看下面分析,可以先自己分析一遍输出情况,代码如下:

        console.log('process start!')
        setTimeout(function () {            //s1
            console.log(1);
            setTimeout(function () {        //s2
                console.log(100);
            }, 1000);
            Promise.resolve(11)         //p3
                .then(res => {
                    console.log(res);
                })
        }, 0);
        setTimeout(function () {            //s3
            console.log(2)
            setTimeout(function () {        //s4
                console.log(200);
            }, 500);
            Promise.resolve(12)       //p4 
                .then(res => {
                    console.log(res);
                })
        }, 1000);
        var p1 = new Promise((resolve) => {
            setTimeout(function () {        //s5
                resolve(5)
                console.log(3);
            }, 2000);
            console.log(8);
            let p2 = new Promise(resolve => {
                setTimeout(function () {    //s6
                    console.log(7);
                }, 1000)
                console.log(9);
                resolve(4);
            })
    
            resolve(6);
    
            p2.then(res => {
                console.log(res);
            })
        })
    
        p1.then(res => {
            console.log(res);
        })
        console.log('process end!')
    

    看完上面的代码是不是感觉有点吓人,心里想项目里哪有这样写代码的,哈哈,可能没有,这个代码只是帮助我们理解,虽然代码很长,但是我们一步一步分析。 首先,初始化如下状态,调用栈里面只有一个全局环境,和上面一样哈 ^_^

    初始化状态图
    第一行遇到console打印process start!,继续向下执行遇到setTimeout,返回结果后(这里再次赘述一遍,返回结果可以理解为setTimeout里面第一个参数,也就是回掉函数)将其放入宏任务队列等待执行。
    遇到setTimeout s3,返回结果后将其放入宏任务队列等待执行。
    继续向下,遇到Promise,立即执行其构造函数,遇到setTimeout,返回结果后将其放入宏任务队列,因为s5等待时间是最长的,所以它也是最后一个放入任务队列,题主设置在了这个位置,当然各位也不要钻牛角尖,我们知道它是最后放入的就好,当然下面图可能一下看不懂,看不懂可以先看下面的,下面看完就懂了,如下图。
    遇到console,立即在控制台打印8,遇到Promise立即执行其构造函数,遇到setTimeout s6,返回结果后放入宏任务队列。
    遇到console打印9,遇到p2的resolve,将p2示例的then方法放入微任务队列。
    遇到p1的resolve函数,将p1的then方法放入微任务队列,遇到console立即打印process end!
    到这里执行栈已经执行完毕,开始检查微任务队列,首先执行p2的then方法,将其加入执行栈,执行完毕后打印4,p2 then出栈。
    接着执行微任务队列中p2的then方法,出队加入执行栈,执行后打印6,p1出栈。
    微任务队列检查为空后,检查宏任务队列,取出s1执行,遇到console打印1,遇到setTimeout,将s2挂起,返回结果后加入任务队列。
    遇到Promise p3立即执行其构造函数,遇到resolve方法后将其then方法加入微任务队列,p3执行完毕出栈。
    s1函数执行完毕,出栈,执行栈为空,检查微任务队列,发现p3的then方法,将其放入执行栈执行,打印11,p3 then出栈,微任务队列为空。
    检查宏任务队列,取出s3加入执行栈执行,打印2,遇到setTimeout将s4挂起,返回结果后加入任务队列。
    遇到Promise p4立即执行其构造函数,遇到resolve方法后将其then方法加入微任务队列,Promise p4执行完毕出栈。
    函数执行完毕,s3出栈,执行栈为空,检查微任务队列,发现p4的then方法,将其放入执行栈执行,打印12,微任务队列为空,p4 then出栈。
    继续检查宏任务队列,发现s6,将其加入执行栈执行,打印7,执行完毕出栈。
    执行栈为空检查微任务队列,微任务队列为空检查宏任务队列,发现s2,加入执行栈打印100,,执行完毕出栈。
    执行栈为空检查微任务队列,微任务队列为空检查宏任务队列,发现s4,加入执行栈打印200,执行完毕出栈。
    执行栈为空检查微任务队列,微任务队列为空检查宏任务队列,发现s5,加入执行栈打印3,执行完毕出栈,此时微任务队列宏任务队列都为空执行完毕!

到这里所有的过程就分析完毕了,聪明的的应该发现其中的机制来吧^_^,注意上文中提到“返回结果后....”,这里setTimeout不是说一上来就放入任务队列,比如setTimeout(fn,2000),是2000m后才把待执行任务fn放入任务队列,等待主线程执行,最后提一点关于node的事件循环,由上述代码中不难看出,不管是宏任务还是微任务都只有一个队列,但是node不同,其宏任务队列和微任务可以多个,如一个队列是setTimeout类型的,一个队列是setImmediate类型的。如果大家还想了解node的事件循环可以私下去了解这里不做解释。

文章到这里就结束了,第一次写可能存在很多“bug”,望大家谅解,也希望大家能提出宝贵的意见,也接受任何批评,如果感觉写的不错,那就给题主点个赞,你的赞将会是对题主最大的肯定。