带你了解JavaScript的运行机制—Event Loop
Posted 铜板街技术
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了带你了解JavaScript的运行机制—Event Loop相关的知识,希望对你有一定的参考价值。
JS 是单线程的
首先,众所周知,JS 是单线程的,为什么这种低效的运行方式依旧没有被淘汰呢?这是由它的用途决定的;JS 主要用途是用户交互和 DOM 操作,举例来说假如 JS 同时有两线程,一个线程在某个 DOM 节点上添加内容,另一个线程却删除了这个节点,这时候浏览器就不知所措了,该以哪个线程为标准呢?(为了提高运行性能,新的 html5 里添加了web worker,其能在主线程内添加子线程,但是限制了其无法操作DOM。)
任务队列(task queue)
由于JS 是单线程,所以任务的执行就需要排队,一个一个执行,前一个任务结束了,下一个任务才能开始。但是当一个任务是异步任务时,浏览器就需要等待较长时间,才能得到它的返回结果继续执行,中间等待的时间cpu是空闲。JS 的应对方案是,将该任务暂时搁置,去执行其他任务。当有返回结果时再重新回来执行该任务。
这个暂时搁置,搁置于何处呢,答案就是任务队列。
同步任务是指在主线程上执行的任务,只有前一个任务执行完毕,下一个任务才能执行。 异步任务是指不进入主线程,而是进入任务队列(task queue)的任务,只有主线程任务执行完毕,任务队列的任务才会进入主线程执行。
执行栈(JS stack)
首先,我们先来了解一下堆(heap)和栈(stack)的概念。栈是用来静态分配内存的而堆是动态分配内存的,它们都是存在于计算机内存之中。栈是先进后出,堆是先进先出的。JS 的所有任务都是在 JS 执行栈中执行的。先进入栈的任务后执行,但是大部分时候 JS 执行栈内都只有一个任务。(下文会提及)
宏任务和微任务(task & Microtask)
上文说道异步任务不在主线程上执行,其实不单单是异步任务,所有的微任务都不在主线程上执行。由此其实我们可以将上文的任务队列称之为微任务队列。宏任务直接在主线程上自行,而微任务需要进入为任务队列,等待执行。
我们看一下代码(example1)
1console.log('script start');
2
3setTimeout(function() {
4 console.log('setTimeout');
5}, 0);
6
7Promise.resolve().then(function() {
8 console.log('promise1');
9}).then(function() {
10 console.log('promise2');
11});
12
13console.log('script end');
这个输出结果是什么呢?
1// 顺序为
2script start
3script end
4promise1
5promise2
6setTimeout
首先我们视整段代码为一个script标签,它作为一个宏任务,直接进入JS 执行栈中执行:
输出==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):
1<body>
2 <div class="outer">
3 <div class="inner"></div>
4 </div>
5</body>
6<script>
7 var outer = document.querySelector('.outer');
8 var inner = document.querySelector('.inner');
9
10 new MutationObserver(function() {
11 console.log('mutate');
12 }).observe(outer, {
13 attributes: true
14 });
15
16 function onClick() {
17 console.log('click');
18
19 setTimeout(function() {
20 console.log('timeout');
21 }, 0);
22
23 Promise.resolve().then(function() {
24 console.log('promise');
25 });
26
27 outer.setAttribute('data-random', Math.random());
28 }
29
30 inner.addEventListener('click', onClick);
31 outer.addEventListener('click', onClick);
32</script>
当我们点击inner这个div的时候会输出什么呢 ?
1顺序依次为:
2click
3promise
4mutate
5click
6promise
7mutate
8timeout
9timeout
为何是如此呢?
这里要说明的是一个click操作作为一个宏任务,当这个inner的click对应的监听函数执行完后,即视为一个任务的完成,此时执行微任务队列中的promise(then)和 mutationObserver的回调。这两个任务执行完成后微任务队列为空,然后再执行冒泡造成的outer的click。当outer的click任务和微任务都执行完后,才会再去找宏任务队列(主线程)中剩下的两个setTimeout的任务。并将其一个一个的压入执行栈。
超级进阶版(example3):
当我们在上面的js代码中加入下面这行代码时,会有什么不同吗?
1inner.click()
答案是:
1click
2click
3promise
4mutate
5promise
6timeout
7timeout
为何会有如此大的不同呢?下面我们来仔细分析:
上一个例子中两个微任务在两个 click 之间执行,而这个例子中,却是在两个 click 之后执行的;
首先 inner.click() 触发的事件作为一个任务压入执行栈,由此产生的 inner 的监听函数函数又做为一个任务压入执行栈,当这个回调函数产生的任务执行完毕后,输出了 click,且微任务队列里面增加 promise 和 mutate,那按上面的说法不是应该执行 promise 和 mutate 吗?然而并不是,因为此时JS 执行栈内的 inner.click() 还没有执行结束,所以继续 inner.click()的事件触发 outer 的监听函数,由此再输出 click,该回调结束后,inner.click() 这个任务才算是结束,此时才会去执行微任务队列中的任务。
简单来说,在这个例子中,由于我们调用 inner.click() ,使得事件监听器的回调函数和当前运行的脚本同步执行而不是异步,所以当前脚本的执行栈会一直压在 JS 执行栈 当中。所以在这个例子中的微任务不会在每一个 click 事件之后执行,而是在两个 click 事件执行完成之后执行。
Event Loop
JS 执行栈不断的从主线程中和微任务队列读取任务并执行,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)
注:本文所有运行结果皆基于chrome浏览器,其他浏览器或有出入。
参考文章:https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
作者简介
琦玉,铜板街前端开发工程师,2018年1月加入团队,目前主要负责大数据团队前端项目开发。
以上是关于带你了解JavaScript的运行机制—Event Loop的主要内容,如果未能解决你的问题,请参考以下文章
从 Event Loop 谈 JavaScript 的执行机制