再也不怕Chrome和Node的事件循环啦

1,052 阅读11分钟

简单介绍

js的是单线程的,如果一个任务执行时间过长,那程序的执行就会被阻塞。

为了防止它执行的堵塞,js将任务分为两种

  • 同步任务
  • 异步任务

网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。

主线程会在执行完同步任务后去执行异步任务。

js的事件循环机制

转自掘金 ssssyoki

同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。

当指定的事情完成时,Event Table会将这个函数移入Event Queue。

主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。

上述过程会不断重复,也就是常说的Event Loop(事件循环)。

常见的 setTimeout,Promise,dom事件等都是异步任务。

setTimeout(fn)和Promise.resolve().then(fn) 仍然是异步执行的,它们在执行后立即将异步任务(执行回调函数)推入异步的队列中

宏任务与微任务

为什么要区分宏任务和微任务?

如果不将任务进行划分,按照队列方式执行,当大量任务执行时,某些任务的回调迟迟得不到执行(都在队尾),就会造成应用效果上的卡顿。

所以设计者将任务分为宏任务和微任务,微任务可以穿插在宏任务中执行。

宏任务和微任务有什么?

  • macro-task(宏任务):
    • 整体代码sript
    • I/O
    • setTimeout
    • setInterval
    • requestAnimationFrame (浏览器)
    • dom事件(浏览器)
  • micro-task(微任务):
    • Promise的方法及其派生
    • MutationObserver(浏览器)
    • process.nextTick(node)

process.nextTick不完全属于微任务,它在一轮微任务执行完后执行,(或按照语义,它总是在下一轮任务开始前执行)node内部维护着另一个队列而不是与Promise的微任务队列共享

你可能会疑惑为什么srcipt也被分入宏任务了,广义上讲script也属于宏任务,这里不区分同步和异步执行(本质是开启一个队列执行)。

宏任务与微任务的执行顺序

宏任务任务执行结束后,执行微任务直至微任务队列清空,一轮事件循环结束。新的循环开始检测是否有宏任务可以执行...

上述过程会不断重复,也就是常说的Event Loop(事件循环)。

可能人会认为,先执行完所有宏任务,再执行所有微任务,这在浏览器环境下是完全错误的。

正确的执行顺序:执行完一个宏任务后,就去执行微任务队列,直至微任务队列清空,接着执行下一个宏任务。

一般来说,微任务队列中的第一个任务,都是由前面的执行宏任务过程中添加的。

我们在微任务中可能添加新的微任务,那么应该怎么执行呢?

考虑以下代码

new Promise((resolve) => resolve()).then(() => {
   for(let i = 0;i< 10 ;i++) new Promise.resolve().then(()=>console.log('1'))
})
setTimeout(()=>console.log('2'))

// 1 * 10
// 2

微任务执行过程中添加的微任务还是会一直被执行直至清空,而不是放入下一个循环中,我们应该避免这种情况。

栗子

触发MutationObserver和Dom事件(chrome环境下)来源

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

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

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

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

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);


inner是内层的盒子,outer是外层盒子,他们都注册了事件,点击内层时会显示什么呢?

click
promise
mutate
click
promise
mutate
timeout
timeout

解释过程(下面表格的状态都是一次宏任务或微任务结束时的状态)

  1. 点击内层盒子,内层事件冒泡至外层

    宏任务队列 微任务队列
    内层click事件
    外层click事件

    console:

  2. 新的事件循环开始,第一个宏任务出队执行。执行过程中打印click,设置定时器宏任务,设置promise微任务,修改被MutationObserver监测的属性(添加MutationObserver微任务)

    宏任务队列 微任务队列
    外层click事件 promise
    定时器 MutationObserver

    console:click

  3. 宏任务执行结束,检查执行微任务队列 打印了promise 和 mutate

    宏任务队列 微任务队列
    外层click事件
    定时器

    console:click promise mutate

  4. 新的事件循环开始 因为同2,3步骤一样不再复述

    宏任务队列 微任务队列
    定时器
    定时器

    console:click promise mutate click promise mutate

  5. 最后两个定时器相继两轮宏任务

    • console:click promise mutate click promise mutate timeout timeout

下面可以不用看= =

/*不用看开始

不过有一个有趣的现象,如果在微任务中添加script呢(chrome浏览器环境下)?

// chrome环境下
new Promise((resolve) => resolve()).then(() => {
console.log('0')
let b = document.createElement('script')
new Promise((resolve)=> resolve()).then(()=>console.log('1'))
b.text = `
 setTimeout(()=>{ console.log('2') })
 console.log('3')
`
document.body.appendChild(b)
new Promise((resolve)=> resolve()).then(()=>console.log('4'))
console.log('5')
})

// 0
// 3
// 5
// 1
// 4
// Promise {<resolved>: undefined}
// 2
  1. 微任务执行
  2. 添加script进入html,直接暂停微任务的执行,直接跳去执行插入script的内容
  3. 执行完script内容后,没有清除微任务队列,而是返回暂停的微任务执行后面的语句
  4. 清空微任务队列,开启下一轮循环...
let b = document.createElement('script')
b.text = `
		let d = document.createElement('script')
		d.text = 'console.log(9)'
		console.log(10)
		document.body.appendChild(d)
		console.log(11)
`
document.body.appendChild(b)
// 10
// 9
// 11

上面的代码套了个娃,第一层添加为了模拟同步的执行环境。

虽然对原理不太清楚,不过我们发现无论是在执行同步还是异步的任务中,插入script后都会中断当前执行,去执行script中的代码。

不用看结束*/

node的宏任务执行

因为node有大量异步操作,它的宏任务也复杂得多,执行的优先级如下图

简单说下node宏任务执行的三个阶段

(按顺序)

  1. 定时器阶段,setTimeout,setInterval时间到了后回调被推入此队列
  2. 轮询(poll)阶段,文件I/O,网络I/O等异步操作完成后,会触发事件,这些事件的回调被推入此队列
  3. check阶段,setImmediate回调事件被推入此队列

补充

上面的阶段仍不完整

  • 在定时器阶段之后,会先处理I/O异常的回调,这里称它为异常阶段
  • 异常阶段结束后,又会进入定时器阶段(异常阶段可能有定时器到时间了,回调被推入队列),如此反复,直至既没有异常又没有定时器回调,才进入poll阶段
  • poll阶段队列可能为空,那么线程会等待callback加入,等待的时间有上限( 相当于阻塞了一段时间 ),过后自动进入check阶段
  • 在check阶段结束后,还有一个关闭事件阶段, socket 或句柄(handle)被突然关闭,如 socket.destroy,close回调事件被推入此队列

经过补充,我们列出最后的宏任务执行顺序

  1. timer阶段
  2. I/O异常回调阶段
  3. 空闲和准备阶段,如果有定时器时间到了回到第一阶段,如此反复直到没有定时器了
  4. poll阶段,如果没有事件,就会等待callback一段时间。到达时间上限后进入check阶段
  5. check阶段,有setImmediate回调则执行,后进入下一阶段
  6. 关闭事件阶段

Node微任务与宏任务执行顺序

node11及之后

每一次宏任务结束后,清空微任务队列,然后执行nextTick队列。

与chrome浏览器表现基本一致

node10及之前

  • 执行完一个阶段的所有任务
  • 执行完nextTick队列里面的内容
  • 然后执行完微任务队列的内容

process.nextTick(node11及之后的环境)

process.nextTick不完全属于微任务,它在一轮微任务执行完后执行,(或按照语义,它总是在下一轮任务开始前执行),node内部维护着另一个队列而不是与Promise的微任务队列共享

考虑以下执行

console.log('0')
setTimeout(() => {
  console.log('1')
  for(let i = 0; i <3;i++){
    new Promise.resolve().then(() => console.log('2'))
    process.nextTick(() => console.log('3'))
  }
})

如果你的答案是 0 1 2 3 2 3 2 3 ,那么你没有理解process.nextTick的解释

正确的答案是 0 1 2 2 2 3 3 3

process.nextTick的任务会一直放在微任务执行完后才执行

拓展阅读:vue的异步批量更新

异步批量更新流程

简单提及Vue更新的流程

  1. 数据被修改 this.xx = '被修改了'

  2. 数据被修改触发setter函数,修改被Watcher收集了,Watcher把自己放入待更新的数组

  3. 在次任务中的调用了this.$nextTick中的回调函数被收集入回调的数组中

  4. 宏任务结束后,nextTick回调数组在微任务执行。nextTick回调数组中的第一个执行的函数就是Watcher数组去通知更新dom(下图的flushBatcherQueue函数),之后就按顺序执行被收集的nextTick回调。

    我们定义nextTick回调总是在更新dom之后执行,所以一定可以保证回调是在dom的更改后执行

    img

这样做有什么好处呢?如果是同步更新数据,就无法做到优化了。

如果是异步批量更新,可以将一次宏任务中的数据更新收集起来,进行patch和去重等操作,尽可能少地更新dom。

微任务中执行更新和nextTick回调

Vue在异步选择的策略为以下优先级

  • Promise.resolve().then(nextTickHandler)

  • new MutationObserver(flushCallbacks)

MutationObserver在上面的例子已经演示怎么使用了。此处Vue不是为了监听dom的变化(textNode根本没被插入dom中),而是利用MutationObserver的特性创建一个微任务。

let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  • setImmediate

  • setTimeout

后面两种不是微任务的情况了,是浏览器不兼容迫不得已的情况。

如果宏任务更新,因为会被放入队尾,需要等待前面的任务完成,这可能会导致dom更新的延迟;

而采用微任务,在一次宏任务完成后立即更新dom,就不会存在延迟。

拓展阅读:包含渲染过程的浏览器事件循环顺序

转载: segmentfault.com/a/119000000…

来源:segmentfault 作者:ma63d

第一步,从多个task queue中的一个queue里,挑出一个最老的task。(因为有多个task queue的存在,使得浏览器可以完成我们前面说的,优先、高频率的执行某些task queue中的任务,比如UI的task queue)。 然后2到5步,执行这个task。
第六步, Perform a microtask checkpoint. ,这里会执行完microtask queue中的所有的microtask,如果microtask执行过程中又添加了microtask,那么仍然会执行新添加的microtask,当然,这个机制好像有限制,一轮microtask的执行总量似乎有限制(1000?),数量太多就执行一部分留下的以后再执行?这里我不太确定。

第七步,Update the rendering:
7.2到7.4,当前轮次的event loop中关联到的document对象会保持某些特定顺序,这些document对象都会执行需要执行UI render的,但是并不是所有关联到的document都需要更新UI,浏览器会判断这个document是否会从UI Render中获益,因为浏览器只需要保持60Hz的刷新率即可,而每轮event loop都是非常快的,所以没必要每个document都Render UI。
7.5和7.6 run the resize steps/run the scroll steps不是说去执行resize和scroll。每次我们scoll的时候视口或者dom就已经立即scroll了,并把document或者dom加入到 pending scroll event targets中,而run the scroll steps具体做的则是遍历这些target,在target上触发scroll事件。run the resize steps也是相似的,这个步骤是触发resize事件。
7.8和7.9 后续的media query, run CSS animations and send events等等也是相似的,都是触发事件,第10步和第11步则是执行我们熟悉的requestAnimationFrame回调和IntersectionObserver回调(第十步还是挺关键的,raf就是在这执行的!)。
7.12 渲染UI,关键就在这了。

第九步 继续执行event loop,又去执行task,microtasks和UI render。

参考阅读