浏览器 事件循环(Event Loop)

前言

学习JS,Event Loop是一个绕不开的点。JS 的异步执行逻辑依赖 Event Loop 机制,但是这套机制却是定义在 HTML 标准中的。因为 Event Loop 本身并不属于 ES 层面的功能,是宿主环境给脚本提供了这一机制,才让脚步有了异步执行的能力。根据JS宿主环境的不同,可以分为浏览器的事件循环和node的事件循环,两者之间会有一些不同。这里我们只讲浏览器的事件循环。

概念

  • 主线程
    这里可以简单理解为运行JavaScript的地方。MDN的解释是这样的:

主线程
主线程用于浏览器处理用户事件和页面绘制等。默认情况下,浏览器在一个线程中运行一个页面中的所有 JavaScript 脚本,以及呈现布局,回流,和垃圾回收。这意味着一个长时间运行的 JavaScript 会阻塞线程,导致页面无法响应,造成不佳的用户体验。
除非故意使用 web worker,比如 service worker,不然 JavaScript 只在线程中运行,所以脚本的运行时,很容易导致事件处理流程或绘制的延迟。主线程中运行的工作越少,就有越多的余地来处理用户事件,页面绘制和对用户保持响应。

  • 调用栈(Call stack)
    调用栈就是函数执行的地方,是主线程在运行JavaScript代码的过程中形成的,遵循后进先出的规则。正在调用栈中执行的函数如果还调用了其它函数,那么这些函数也将会被添加进调用栈并执行。当函数执行完毕之后,会被移出调用栈。

来看一段代码:

function eat() {
        console.log('吃东西啦')
        drink()
        console.log('吃饱喝足啦')
    }
    function drink() {
        console.log('饮茶啦')
    }
    eat()

这段代码的执行流程是这样的:
1、不管前面的函数声明,直到看到eat(),将eat函数添加到调用栈。
2、执行eat函数体内的所有代码,首先会打印‘吃东西啦’,然后看到drink函数,又将其添加到调用栈,然后执行drink,打印‘饮茶啦’,最后打印‘吃饱喝足啦’。
3、eat函数体内的代码执行完毕,将eat移除。

  • 执行栈(execution context stack)
    其实就是调用栈。

  • 同步任务与异步任务
    同步就是主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
    异步任务就是不立即执行,在未来某一刻执行的任务,不进入主线程,而是暂时挂起,等有结果时则会把它对应的回调函数添加到任务队列中。
    来看一段代码:

function eat() {
    console.log('吃东西啦')
    setTimeout(drink, 1000);
    console.log('吃饱喝足啦')
}
function drink() {
    console.log('饮茶啦')
}
eat()

这里的eat是同步任务,而setTimeout是异步任务,当执行到jsetTimieout时,会将它挂起,直到一秒后将它的回调函数drink添加到任务队列中等待执行。所以,这里的执行结果就会变成这样:
浏览器 事件循环(Event Loop)

  • task queue (任务队列)
    事件循环有一个或多个任务队列。任务队列是一组任务,而不是一个队列。因为事件循环处理模型的第一步是从任务队列中选择一个可运行的任务,而不是直接拿第一个任务。规范是这么写的:

Task queues are sets, not queues, because step one of the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.

看到这个规范我是有点迷的,以前一直认为任务队列就是一个队列,遵循先进先出的规则的,很多技术文章也是这么写的。这里如果有更好的理解,欢迎评论区留言。

接下来看一张大名鼎鼎的图:
浏览器 事件循环(Event Loop)
这是演讲菲利普·罗伯茨:到底什么是Event Loop呢里的一张图。(可以去看下这个演讲,有不忍吐槽的中文字幕,不过要个*)遗憾的是,里面并没有讲到宏任务和微任务。

看下代码:

console.log(1)
 setTimeout(cb, 2000)
 console.log(3)
 function cb() {
   console.log(2)
 }

按照图片的理解,这里就是这么运行的:
1、将console.log(1)压入调用栈中,执行完毕,然后弹出
2、setTimeout压入栈中,发现是异步任务,交给浏览器的其他线程,将他挂起
3、将console.log(3)压入栈中,执行完毕,弹出
4、这时候调用栈是空的,会轮询任务队列里面有没有任务,而如果第二步挂起的setTimeout的两秒还没有过,这时候任务队列就是空的,不执行。只有两秒到了,将cb函数压入任务队列中,事件轮询发现任务队列里面有任务,这时候cb才会被压入栈中执行。
5、以上过程不断重复,也就形成了事件循环。
所以打印的结果就是:
浏览器 事件循环(Event Loop)
我们知道,js是单线程的,同一时间能且只能做一件事件,那这些事件的挂起,轮询只能交给浏览器的其他线程去完成了。所以说,是宿主环境给了js异步执行的能力。

  • 宏任务(macrotask)
    macroTask,我并没有在规范中找到相关的介绍。不过其他的技术文章有两种说法,一个说法是将任务队列分为宏任务队列和微任务队列,另一个就是说宏任务队列就是任务队列。我个人更偏向于第二种说法,原因稍后说明。

  • 微任务(microtask)
    微任务队列不是任务队列,规范是这么说的:

The microtask queue is not a task queue.

这也是上面说的我偏向于第二种说法的原因。每个事件循环都有一个微任务队列,当调用栈为空的时候就会调用微任务队列里面的任务。事件循环一开始微任务队列是空的,过程中会有微任务添加进去,而当主线程为空时,就会执行微任务队列里面的任务。

那哪些宏任务,哪些是微任务呢?没有找到相关的规范文档。不过按照其他的技术文章,一般是这么分类的:

宏任务: script(整体代码),setTimeout, setInterval, setImmediate,I/O, UI rendering
微任务:  promise().then, Object.observe, MutationObserver

运行流程

我们来看一下代码:

<script>
  console.log(1)
  setTimeout(() => console.log(2), 200)
  setTimeout(() => {
    console.log(3)
    setTimeout(() => console.log(4), 50)
  }, 100)
  new Promise(resolve => {
    console.log(5)
    resolve()
    }).then(() => {
    console.log(6)
  })
  setTimeout(() => {
    new Promise(resolve => {
      console.log(7)
      resolve()
    }).then(() => {
      console.log(8)
    })
  })
  console.log(9)
</script>

1、首先,这段代码会作为宏任务而被添加到宏任务队列里面。这时候,宏任务队列只有一个任务,主线程为空,执行这个任务。
2、console.log(1)入栈,打印1,执行完毕,出栈
3、setTimeout入栈,挂起,等待200ms后将回调函数添加到宏任务队列
4、setTimeout入栈,挂起,等待100ms后将回调函数添加到宏任务队列
5、promise入栈,打印5,然后将then添加到微任务队列
6、setTimeout入栈,挂起,等待4ms后将回调函数加入到宏任务队列。这里提一嘴,HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒。
7、console.log(9)入栈,打印9,执行完毕,出栈
8、主线程为空,检查微任务队列是否有函数,发现有,入栈执行,打印6
到这里,不考虑其他的渲染什么的,本次事件循环就结束了,这时候的打印结果就是1 5 9 6 。
主线程又空了,事件轮询模块会一直轮询宏任务队列是否有任务可以执行。而很明显,在三个挂起的setTimeout里面,第六步的setTimeout是最快将
回调函数添加到宏任务队列的。这时候就可以进行下一个事件循环了。
9、循环开始,首先会打印7,然后将then添加到微任务队列,因为本次循环没有其他事做了,接着就执行微任务队列里的任务,打印8
10、继续轮询,而第四步比第三步更快,先打印3,又发现了setTimeout,继续挂起
而这时候第十步和地三步的哪个更快呢?这就要考虑在第三步到第十步之前有没有耗时的任务了。我们这里并没有什么耗时任务,所以第十步依然会比
第三步先执行。打印4,最后打印2 。整个代码就执行完毕了。
看下最终的打印结果:
浏览器 事件循环(Event Loop)

总结

到这里,事件循环就扒拉完了。而至于说微任务比宏任务先执行,其实在讲允许流程的时候已经可以看出来了,微任务队列会在当前事件循环结束之前清空,而宏任务只有在下一次事件循环的时候才会被执行。自然微任务就会比宏任务优先了。这里说的优先也只是在一个事件循环内。

参考

MDN

HTML标准

菲利普·罗伯茨:到底什么是Event Loop呢

上一篇:使用vuex实现增加、减少


下一篇:题目⑥ 说一下js的执行机制--事件循环EventLoop