前端基础进阶(十二):深刻核心,详解事件循环机制

Event Loop

JavaScript的学习零散而庞杂,所以不少时候咱们学到了一些东西,可是却没办法感觉到本身的进步,甚至过了不久,就把学到的东西给忘了。为了解决本身的这个困扰,在学习的过程当中,我一直试图在寻找一条核心的线索,只要我根据这条线索,我就可以一点一点的进步。javascript

前端基础进阶正是围绕这条线索慢慢展开,而事件循环机制(Event Loop),则是这条线索的最关键的知识点。因此,我就快马加鞭的去深刻的学习了事件循环机制,并总结出了这篇文章跟你们分享。html

事件循环机制从总体上的告诉了咱们所写的JavaScript代码的执行顺序。可是在我学习的过程当中,找到的许多国内博客文章对于它的讲解浅尝辄止,不得其法,不少文章在图中画个圈就表示循环了,看了以后也没感受明白了多少。可是他又如此重要,以至于当咱们想要面试中高级岗位时,事件循环机制老是绕不开的话题。特别是ES6中正式加入了Promise对象以后,对于新标准中事件循环机制的理解就变得更加剧要。这就很尴尬了。前端

最近有两篇比较火的文章也表达了这个问题的重要性。

这个前端面试在搞事
80% 应聘者都不及格的 JS 面试题html5

可是很遗憾的是,大神们告诉了你们这个知识点很重要,却并无告诉你们为何会这样。因此当咱们在面试时遇到这样的问题时,就算你知道告终果,面试官再进一步问一下,咱们依然懵逼。java

在学习事件循环机制以前,我默认你已经懂得了以下概念,若是仍然有疑问,能够回过头去看看我之前的文章。node

  • 执行上下文(Execution context)
  • 函数调用栈(call stack)
  • 队列数据结构(queue)
  • Promise(我会在下一篇文章专门总结Promise的详细使用)
由于chrome浏览器中新标准中的事件循环机制与nodejs相似,所以此处就整合nodejs一块儿来理解,其中会介绍到几个nodejs有,可是浏览器中没有的API,你们只须要了解就好,不必定非要知道她是如何使用。好比process.nextTick,setImmediate

OK,那我就先抛出结论,而后以例子与图示详细给你们演示事件循环机制。web

  • 咱们知道JavaScript的一大特色就是单线程,而这个线程中拥有惟一的一个事件循环。
固然新标准中的web worker涉及到了多线程,我对它了解也很少,这里就不讨论了。
  • JavaScript代码的执行过程当中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另一些代码的执行。

队列数据结构

  • 一个线程中,事件循环是惟一的,可是任务队列能够拥有多个。
  • 任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。
  • macro-task大概包括:script(总体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
  • micro-task大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)
  • setTimeout/Promise等咱们称之为任务源。而进入任务队列的是他们指定的具体执行任务。
// setTimeout中的回调函数才是进入任务队列的任务
setTimeout(function() {
    console.log('xxxx');
})
// 很是多的同窗对于setTimeout的理解存在误差。因此大概说一下误解:
// setTimeout做为一个任务分发器,这个函数会当即执行,而它所要分发的任务,也就是它的第一个参数,才是延迟执行
  • 来自不一样任务源的任务会进入到不一样的任务队列。其中setTimeout与setInterval是同源的。
  • 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(总体代码)开始第一次循环。以后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),而后执行全部的micro-task。当全部可执行的micro-task执行完毕以后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,而后再执行全部的micro-task,这样一直循环下去。
  • 其中每个任务的执行,不管是macro-task仍是micro-task,都是借助函数调用栈来完成。

纯文字表述确实有点干涩,所以,这里咱们经过2个例子,来逐步理解事件循环的具体顺序。面试

// demo01  出自于上面我引用文章的一个例子,咱们来根据上面的结论,一步一步分析具体的执行过程。
// 为了方便理解,我以打印出来的字符做为当前的任务名称
setTimeout(function() {
    console.log('timeout1');
})

new Promise(function(resolve) {
    console.log('promise1');
    for(var i = 0; i < 1000; i++) {
        i == 99 && resolve();
    }
    console.log('promise2');
}).then(function() {
    console.log('then1');
})

console.log('global1');

首先,事件循环从宏任务队列开始,这个时候,宏任务队列中,只有一个script(总体代码)任务。每个任务的执行顺序,都依靠函数调用栈来搞定,而当遇到任务源时,则会先分发任务到对应的队列中去,因此,上面例子的第一步执行以下图所示。chrome

首先script任务开始执行,全局上下文入栈

第二步:script任务执行时首先遇到了setTimeout,setTimeout为一个宏任务源,那么他的做用就是将任务分发到它对应的队列中。segmentfault

setTimeout(function() {
    console.log('timeout1');
})

宏任务timeout1进入setTimeout队列

第三步:script执行时遇到Promise实例。Promise构造函数中的第一个参数,是在new的时候执行,所以不会进入任何其余的队列,而是直接在当前任务直接执行了,然后续的.then则会被分发到micro-task的Promise队列中去。

所以,构造函数执行时,里面的参数进入函数调用栈执行。for循环不会进入任何队列,所以代码会依次执行,因此这里的promise1和promise2会依次输出。

promise1入栈执行,这时promise1被最早输出

resolve在for循环中入栈执行

构造函数执行完毕的过程当中,resolve执行完毕出栈,promise2输出,promise1页出栈,then执行时,Promise任务then1进入对应队列

script任务继续往下执行,最后只有一句输出了globa1,而后,全局任务就执行完毕了。

第四步:第一个宏任务script执行完毕以后,就开始执行全部的可执行的微任务。这个时候,微任务中,只有Promise队列中的一个任务then1,所以直接执行就好了,执行结果输出then1,固然,他的执行,也是进入函数调用栈中执行的。

执行全部的微任务

第五步:当全部的micro-tast执行完毕以后,表示第一轮的循环就结束了。这个时候就得开始第二轮的循环。第二轮循环仍然从宏任务macro-task开始。

微任务被清空

这个时候,咱们发现宏任务中,只有在setTimeout队列中还要一个timeout1的任务等待执行。所以就直接执行便可。

timeout1入栈执行

这个时候宏任务队列与微任务队列中都没有任务了,因此代码就不会再输出其余东西了。

那么上面这个例子的输出结果就显而易见。你们能够自行尝试体会。

这个例子比较简答,涉及到的队列任务并很少,所以读懂了它还不能全面的了解到事件循环机制的全貌。因此我下面弄了一个复杂一点的例子,再给你们解析一番,相信读懂以后,事件循环这个问题,再面试中再次被问到就难不倒你们了。

// demo02
console.log('golb1');

setTimeout(function() {
    console.log('timeout1');
    process.nextTick(function() {
        console.log('timeout1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout1_promise');
        resolve();
    }).then(function() {
        console.log('timeout1_then')
    })
})

setImmediate(function() {
    console.log('immediate1');
    process.nextTick(function() {
        console.log('immediate1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate1_promise');
        resolve();
    }).then(function() {
        console.log('immediate1_then')
    })
})

process.nextTick(function() {
    console.log('glob1_nextTick');
})
new Promise(function(resolve) {
    console.log('glob1_promise');
    resolve();
}).then(function() {
    console.log('glob1_then')
})

setTimeout(function() {
    console.log('timeout2');
    process.nextTick(function() {
        console.log('timeout2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout2_promise');
        resolve();
    }).then(function() {
        console.log('timeout2_then')
    })
})

process.nextTick(function() {
    console.log('glob2_nextTick');
})
new Promise(function(resolve) {
    console.log('glob2_promise');
    resolve();
}).then(function() {
    console.log('glob2_then')
})

setImmediate(function() {
    console.log('immediate2');
    process.nextTick(function() {
        console.log('immediate2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate2_promise');
        resolve();
    }).then(function() {
        console.log('immediate2_then')
    })
})

这个例子看上去有点复杂,乱七八糟的代码一大堆,不过不用担忧,咱们一步一步来分析一下。

第一步:宏任务script首先执行。全局入栈。glob1输出。

script首先执行

第二步,执行过程遇到setTimeout。setTimeout做为任务分发器,将任务分发到对应的宏任务队列中。

setTimeout(function() {
    console.log('timeout1');
    process.nextTick(function() {
        console.log('timeout1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout1_promise');
        resolve();
    }).then(function() {
        console.log('timeout1_then')
    })
})

timeout1进入对应队列

第三步:执行过程遇到setImmediate。setImmediate也是一个宏任务分发器,将任务分发到对应的任务队列中。setImmediate的任务队列会在setTimeout队列的后面执行。

setImmediate(function() {
    console.log('immediate1');
    process.nextTick(function() {
        console.log('immediate1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate1_promise');
        resolve();
    }).then(function() {
        console.log('immediate1_then')
    })
})

进入setImmediate队列

第四步:执行遇到nextTick,process.nextTick是一个微任务分发器,它会将任务分发到对应的微任务队列中去。

process.nextTick(function() {
    console.log('glob1_nextTick');
})

nextTick

第五步:执行遇到Promise。Promise的then方法会将任务分发到对应的微任务队列中,可是它构造函数中的方法会直接执行。所以,glob1_promise会第二个输出。

new Promise(function(resolve) {
    console.log('glob1_promise');
    resolve();
}).then(function() {
    console.log('glob1_then')
})

先是函数调用栈的变化

而后glob1_then任务进入队列

第六步:执行遇到第二个setTimeout。

setTimeout(function() {
    console.log('timeout2');
    process.nextTick(function() {
        console.log('timeout2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout2_promise');
        resolve();
    }).then(function() {
        console.log('timeout2_then')
    })
})

timeout2进入对应队列

第七步:前后遇到nextTick与Promise

process.nextTick(function() {
    console.log('glob2_nextTick');
})
new Promise(function(resolve) {
    console.log('glob2_promise');
    resolve();
}).then(function() {
    console.log('glob2_then')
})

glob2_nextTick与Promise任务分别进入各自的队列

第八步:再次遇到setImmediate。

setImmediate(function() {
    console.log('immediate2');
    process.nextTick(function() {
        console.log('immediate2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate2_promise');
        resolve();
    }).then(function() {
        console.log('immediate2_then')
    })
})

nextTick

这个时候,script中的代码就执行完毕了,执行过程当中,遇到不一样的任务分发器,就将任务分发到各自对应的队列中去。接下来,将会执行全部的微任务队列中的任务。

其中,nextTick队列会比Promie先执行。nextTick中的可执行任务执行完毕以后,才会开始执行Promise队列中的任务。

当全部可执行的微任务执行完毕以后,这一轮循环就表示结束了。下一轮循环继续从宏任务队列开始执行。

这个时候,script已经执行完毕,因此就从setTimeout队列开始执行。

第二轮循环初始状态

setTimeout任务的执行,也依然是借助函数调用栈来完成,而且遇到任务分发器的时候也会将任务分发到对应的队列中去。

只有当setTimeout中全部的任务执行完毕以后,才会再次开始执行微任务队列。而且清空全部的可执行微任务。

setTiemout队列产生的微任务执行完毕以后,循环则回过头来开始执行setImmediate队列。仍然是先将setImmediate队列中的任务执行完毕,再执行所产生的微任务。

当setImmediate队列执行产生的微任务所有执行以后,第二轮循环也就结束了。

你们须要注意这里的循环结束的时间节点。

当咱们在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,可是该任务就得等到下一轮事件循环执行了。例子中没有涉及到这么复杂的嵌套,你们能够动手添加或者修改他们的位置来感觉一下循环的变化。

OK,到这里,事件循环我想我已经表述得很清楚了,能不能理解就看读者老爷们有没有耐心了。我估计不少人会理解不了循环结束的节点。

固然,这些顺序都是v8的一些实现。咱们也能够根据上面的规则,来尝试实现一下事件循环的机制。

// 用数组模拟一个队列
var tasks = [];

// 模拟一个事件分发器
var addFn1 = function(task) {
    tasks.push(task);
}

// 执行全部的任务
var flush = function() {
    tasks.map(function(task) {
        task();
    })
}

// 最后利用setTimeout/或者其余你认为合适的方式丢入事件循环中
setTimeout(function() {
    flush();
})

// 固然,也能够不用丢进事件循环,而是咱们本身手动在适当的时机去执行对应的某一个方法

var dispatch = function(name) {
    tasks.map(function(item) {
        if(item.name == name) {
            item.handler();
        }
    })
}

// 固然,咱们把任务丢进去的时候,多保存一个name便可。
// 这时候,task的格式就以下
demoTask =  {
    name: 'demo',
    handler: function() {}
}

// 因而,一个订阅-通知的设计模式就这样轻松的被实现了

这样,咱们就模拟了一个任务队列。咱们还能够定义另一个队列,利用上面的各类方式来规定他们的优先级。

须要注意的是,这里的执行顺序,或者执行的优先级在不一样的场景里因为实现的不一样会致使不一样的结果,包括node的不一样版本,不一样浏览器等都有不一样的结果。

前端基础进阶系列目录

clipboard.png

相关文章
相关标签/搜索