带你了解JavaScript的运行机制—Event Loop

JS 是单线程的。

首先,众所周知,JS 是单线程的,为何这种低效的运行方式依旧没有被淘汰那?这是由它的用途决定的;JS 主要用途是用户交互和DOM操做,举例来讲假如js同时有两线程,一个线程在某个DOM节点上添加内容,另外一个线程却删除了这个节点,这时候浏览器就不知所措了,该以哪一个线程为标准那?(为了提升运行性能,新的 html5 里添加了web worker,其能在主线程内添加子线程,可是限制了其没法操做DOM。)html

任务队列(task queue)

因为 JS 是单线程,因此任务的执行就须要排队,一个一个执行,前一个任务结束了,下一个任务才能开始。可是当一个任务是异步任务时,浏览器就须要等待较长时间,才能获得它的返回结果继续执行,中间等待的时间cpu是空闲。JS 的应对方案是,将该任务暂时搁置,去执行其余任务。当有返回结果时再从新回来执行该任务。前端

这个暂时搁置,搁置于何处那,答案就是任务队列html5

同步任务是指在主线程上执行的任务,只有前一个任务执行完毕,下一个任务才能执行。 异步任务是指不进入主线程,而是进入任务队列(task queue)的任务,只有主线程任务执行完毕,任务队列的任务才会进入主线程执行。web

执行栈(JS stack)

首先,咱们先来了解一下堆(heap)和栈(stack)的概念。栈是用来静态分配内存的而堆是动态分配内存的,它们都是存在于计算机内存之中。堆是先进后出,栈(堆栈)是先进先出的。js的全部任务都是在js执行栈中执行的。先进入栈的任务后执行,可是大部分时候js执行栈内都只有一个任务。(下文会说起)chrome

宏任务和微任务(task & Microtask)

上文说道异步任务不在主线程上执行,其实不仅仅是异步任务,全部的微任务都不在主线程上执行。由此其实咱们能够将上文的任务队列称之为微任务队列。宏任务直接在主线程上自行,而微任务须要进入为任务队列,等待执行。promise

咱们看一下代码(example1)浏览器

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
复制代码

这个输出结果是什么那?bash

顺序是:微信

script start
script end
promise1
promise2
setTimeout
复制代码

首先咱们视整段代码为一个 script 标签,它做为一个宏任务,直接进入js执行栈中执行:dom

输出==script start==;

遇到setTimeout,而0秒后setTimeout做为一个独立的宏任务加入到"宏任务队列"中。(注意这里说的是宏任务队列,也就是上文所说的主线程);

遇到promise,promise完成后的第一个then做为一个独立的微任务加入到“微任务队列”中,第二个then又作为一个微任务加入到微任务的队列中。

而后输出==script end==;

如今,咱们来理一下:script一整个宏任务执行完毕了,这时候js执行栈是空的,宏任务队列(主线程)中有一个setTimeout,而微任务队列中有两个promise(then)任务。先执行哪一个?回想咱们以前说的异步任务执行策略,就不难推测,下一个进入js执行栈就是第一个promise(then);

输出 ==promise1==;

而后此时再看宏任务队列和微任务队列。微任务队列还有一个promise(then),因此将这个微任务压入js执行栈执行;

输出==promise2==;

此时,微任务队列为空,因此再去执行宏任务队列中的任务,setTimeout;

输出==setTimeout==;

总结来讲,任务分为宏任务和微任务,对应宏任务队列(主线程)和微任务队列。微任务是在当前正在执行脚本结束以后当即执行的任务。当一个任务执行结束后,JS 执行栈空出来,这时候会首先去微任务队列中寻找任务,当微任务队列不为空时,将一个微任务加入到 JS 执行栈中。当当前的微任务队列为空时,再去执行宏任务队列中的任务。

如何区分微任务和宏任务:

宏任务(task):是严格按照时间顺序压栈和执行的,因此浏览器可以使得 JavaScript 内部任务与 DOM 任务可以有序的执行。当一个 task 执行结束后,在下一个 task 执行开始前,浏览器能够对页面进行从新渲染。每个 task 都是须要分配的,例如从用户的点击操做到一个点击事件,渲染HTML文档,同时还有上面例子中的 setTimeout。

setTimeout 的工做原理相信你们应该都知道,其中的延迟并非彻底精确的,这是由于 setTimeout 它会在延迟时间结束后分配一个新的 task 至 event loop 中,而不是当即执行,因此 setTimeout 的回调函数会等待前面的 task 都执行结束后再运行。这就是为何 'setTimeout' 会输出在 'script end' 以后,由于 'script end' 是第一个 task 的其中一部分,而 'setTimeout' 则是一个新的 task。

微任务(Microtask):一般来讲就是须要在当前 task 执行结束后当即执行的任务,例如须要对一系列的任务作出回应,或者是须要异步的执行任务而又不须要分配一个新的 task,这样即可以减少一点性能的开销。microtask 任务队列是一个与 task 任务队列相互独立的队列,microtask 任务将会在每个 task 任务执行结束以后执行。每个 task 中产生的 microtask 都将会添加到 microtask 队列中,microtask 中产生的 microtask 将会添加至当前队列的尾部,而且 microtask 会按序的处理完队列中的全部任务。microtask 类型的任务目前包括了 MutationObserver 以及 Promise 的回调函数。

每当一个 Promise 被决议(或是被拒绝),便会将其回调函数添加至 microtask 任务队列中做为一个新的 microtask 。这也保证了 Promise 能够异步的执行。因此当咱们调用 .then(resolve, reject) 的时候,会当即生成一个新的 microtask 添加至队列中,这就是为何上面的 'promise1' 和 'promise2' 会输出在 'script end' 以后,由于 microtask 任务队列中的任务必须等待当前 task 执行结束后再执行,而 'promise1' 和 'promise2' 输出在 'setTimeout' 以前,这是由于 'setTimeout' 是一个新的 task,而 microtask 执行在当前 task 结束以后,下一个 task 开始以前。


进阶版,带你深刻task & Microtask(example2):

<body>
    <div class="outer">
      <div class="inner"></div>
    </div>
</body>
<script>
    var outer = document.querySelector('.outer');
    var inner = document.querySelector('.inner');

    new MutationObserver(function() {
      console.log('mutate');
    }).observe(outer, {
      attributes: true
    });

    function onClick() {
      console.log('click');
    
      setTimeout(function() {
        console.log('timeout');
      }, 0);

      Promise.resolve().then(function() {
        console.log('promise');
      });

      outer.setAttribute('data-random', Math.random());
    }

    inner.addEventListener('click', onClick);
    outer.addEventListener('click', onClick);
</script>
复制代码

当咱们点击inner这个div的时候会输出什么那?

顺序是:

click
promise
mutate
click
promise
mutate
timeout
timeout
复制代码

为什么是如此那?

这里要说明的是一个click操做做为一个宏任务,当这个inner的click对应的监听函数执行完后,即视为一个任务的完成,此时执行微任务队列中的promise(then)和 mutationObserver的回调。这两个任务执行完成后微任务队列为空,而后再执行冒泡形成的outter的click。当outter的click任务和微任务都执行完后,才会再去找宏任务队列(主线程)中剩下的两个setTimeout的任务。并将其一个一个的压入执行栈。


超级进阶版(example3):

当咱们在上面的js代码中加入下面这行代码时,会有什么不一样吗?

inner.click()
复制代码

答案是:

click
click
promise
mutate
promise
timeout
timeout
复制代码

为什么会有如此大的不一样那?下面咱们来仔细分析:

上一个例子中两个微任务在两个click之间执行,而这个例子中,倒是在两个click以后执行的;

首先inner.click()触发的事件做为一个任务压入执行栈,由此产生的inner的监听函数函数又作为一个任务压入执行栈,当这个回调函数产生的任务执行完毕后,输出了 click,且微任务队列里面增长promise和mutate,那按上面的说法不是应该执行promise和mutate吗?然而并非,由于此时 JS 执行栈内的inner.click()尚未执行结束,因此继续inner.click()的事件触发outter的监听函数,由此再输出click,该回调结束后,inner.click()这个任务才算是结束,此时才会去执行微任务队列中的任务。

简单来讲,在这个例子中,因为咱们调用 inner.click() ,使得事件监听器的回调函数和当前运行的脚本同步执行而不是异步,因此当前脚本的执行栈会一直压在 JS 执行栈 当中。因此在这个例子中的微任务不会在每个 click 事件以后执行,而是在两个 click 事件执行完成以后执行。

Event Loop

JS 执行栈不断的从主线程中和微任务队列读取任务并执行,这个过程是循环不断的,因此整个的这种运行机制又称为Event Loop(事件循环)

:本文全部运行结果皆给予chrome浏览器,其余浏览器或有出入

参考文章jakearchibald.com/2015/tasks-…

做者简介

琦玉,铜板街前端开发工程师,2018年1月加入团队,目前主要负责大数据团队前端项目开发。

更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。
相关文章
相关标签/搜索