🤱 保姆级教程 | 带你一次看懂事件循环(EventLoop)

915 阅读8分钟

Todo List

  • 为什么js在浏览器中有事件循环的机制
  • 你了解事件循环中的两种任务么?
  • 为什么要引入微任务的概念,只有宏任务可以么?
  • 描述一下浏览器中事件循环的具体机制执行流程
  • Node中的事件循环和浏览器中的事件循环有什么区别
  • async / await
  • 做题
  • 进阶:伪代码实现

为什么JS在浏览器中有事件循环的机制

关键词:单线程、多线程、同步阻塞、异步非阻塞

🤓   one thread 单线程
   = one call stack 一个调用栈
   = one thing at a time 同一时间只能做一件事

答:

JS是单线程的,单线程意味着同一时间只能做一件事情,当前的事情没有做完时线程就会被挂起,造成后续任务的阻塞。 比如一些耗时的脚本下载,它不应该对用户后续的行为造成阻塞。于是就提出了异步任务(回调函数)的概念,将一些需要时间的事件交给处理异步任务的线程管理,不对主线程执行后续任务造成阻塞

因为增加了多个线程并行之后,主线程需要知道其他线程的工作状态,“xx任务是否开始?xx任务是否执行完成?是否有异常情况?”等等。设想一下,如果有两个线程同时在操作一个dom节点,一个线程在删除dom节点,一个线程在操作dom节点,这两个操作就是互相冲突的,对于浏览器而言就会得不到我们想要的正确渲染结果。

主线程需要频繁的和多个线程协调任务、调度任务,于是浏览器又进一步引入了事件循环(EventLoop)的机制,来协调多个线程多个事件之间的工作

你了解事件循环中的两种任务么?

关键词:[宏任务 / 微任务] 是什么、及各自作用

🤓 事件循环机制将异步任务分为了两种类型,宏任务 + 微任务

答:

宏任务:诸如 整个script各种事件回调(dom 事件、I/O)setTimeoutsetInterval 等任务。

微任务:诸如 Promise.[then/finally/catch]MutationObserver (dom变化监听/前端回溯) 等任务。

为什么要引入微任务的概念,只有宏任务可以么?

关键词:事件循环机制中任务的优先级、时效性
如果这部分看不太明白,建议先看下一个内容再回头来理解 💁🏻‍♀️

答:

不可以,为了解决单线程同步阻塞问题,引入了异步任务,并有多个线程协作。又为了更好的调度任务、协调多个线程之间的工作,引入了事件循环机制。

因为将耗时的任务以异步任务的方式交给了其他线程处理,而为了保证多线程之间的有序工作,事件循环机制下只有主线程执行完调用栈内当前的所有同步任务,才会去询问有哪些异步任务可以传回主线程执行。

也就是说无论你的异步任务实际耗不耗时,也一定至少是下一轮事件循环(一个用来调度主线程与其他线程之间任务的机制),它的回调函数才会被推入调用栈执行。而这样就会影响到需要尽快处理的那些事件,因为你不知道你前面还排着多少异步任务回调未被推入调用栈。于是就此引入了微任务的概念,去处理异步任务里追求时效性、更高优先级的事情。

微任务的回调函数会在当前次事件循环结束前被推入调用栈执行。也就是说虽然是同一轮遇到的宏任务和微任务,但是宏任务的回调们会被放到后续的事件循环中执行,而微任务的回调们会在这一次事件循环结束前被全部执行。

描述一下浏览器中事件循环的具体机制执行流程

答:

[精简版]: 调用栈为空 -> 执行宏任务队列中最早的一个宏任务 x -> 执行 x 关联的微任务队列中的所有微任务 -> 调用栈为空 -> .... (重复如上动作) 这样重复的轮询机制,被称之为事件循环。

[叙述版]:

理解代码运行过程

加载JS,遇到任务(函数调用)会被推入调用栈,调用栈内的任务由主线程执行,执行完成后出栈,调用栈追踪JS的整个执行过程。

同步任务会直接在调用栈内完成执行,异步任务会在入栈后传递给其他线程处理,对应异步任务函数出栈。其他线程处理异步任务时,当这些任务满足了执行回调的条件,按照任务类型,分别塞入宏任务队列当前宏任务关联的微任务队列。等待被推入调用栈执行。

理解事件循环机制运作流程的前提是,要清除我们的代码是如何被执行的。下面会放一个最基础的例子,去演示代码被执行的过程。

代码如下:

function test() {
    console.log(1)
}

console.log(2)

test()

执行过程用简单的动画模拟了一下:

有mp4版的,有需要的可以留言 🙆🏻‍♀️

基础代码执行示例.gif

多线程之间的资源协调、任务调度

事件循环会持续不断的去监听调用栈,当调用栈空闲时(无可执行的任务,调用栈里只有全局执行环境),会读取宏任务队列中最早的一个任务,推入调用栈执行(运行流程同 1)。

当前宏任务内各任务(函数)均执行完成,即当前批宏任务结束前,读取当前宏任务关联的微任务队列中的任务,依次推入调用栈执行(运行流程同 1)。

调用栈空闲,推入最早的一个宏任务 -> 宏任务内部任务执行完成 -> 关联微任务队列全部执行完毕,这一连动作都做完调用栈又回到了空闲状态,这就是一轮事件循环。当JS引擎监听到调用栈空闲,并且宏任务队列上还有可以执行的任务,就会开启新一轮的事件循环,就这样一直重复轮询

文字相对生硬,可读性不那么高,这里整理了一下大致的事件循环过程,用图片的方式复刻了一下场景 🤯 (图片比较多,流量抱歉了 T.T

1.jpg 2.jpg 3.jpg 4.jpg 5.jpg 6.jpg 7.jpg 8.jpg 9.jpg 10.jpg 11.jpg

以上图片有需要的可以留言 🙆🏻‍♀️

Node中的事件循环和浏览器中的事件循环有什么区别

关键词:Node版本的不同情况

Node v10及以前:node.js 将事件循环机制分为了6个阶段,根据宏任务的作用,会分到不同阶段处理。而和浏览器事件循环机制不相同的是 —— v10 之前的node.js,在每一个阶段的所有宏任务全执行完成后,才会去查询一次这个阶段宏任务相关的所有微任务,依次执行。

Node v10以后:在 node.js v10 之后的事件循环机制和浏览器事件循环机制流程保持了统一,在一个宏任务内所有任务执行完成后(整个宏任务结束前),就会去执行与之相关联的所有微任务,然后再开始下一个宏任务。

如下代码运行后结果:

setTimeout(() => {
  console.log(1)
  Promise.resolve().then(() => console.log(2))
}, 0)

setTimeout(() => {
  console.log(3)
  Promise.resolve().then(() => console.log(4))
}, 0)

Node v10 及以前:1 -> 3 -> 2 -> 4
Node v10 以后:1 -> 2 -> 3 -> 4

做题

对于题目运行结果,如果有自己理解不通的可以留个言,我之后可以出个具体分析 🎸

async / await

讲到主线程和调用栈,就有一个不得不提的内容 async / await

async 函数会返回一个 Promise 对象,便于回调函数管理(支持链式 fn.then.then.blahblah)。

// before
async function fn() { 
  return executor
}

// after translate
function fn() { 
  return new Promise(executor)
}

await 是一个运算符,用于组成表达式,await xxx 的计算结果取决于 await 它等待的东西,也就是 xxx。如果它等待的不是一个 Promise,那么它的计算结果就是它等待的东西。

当代码遇到 await ,它会对当前 await 后面声明的代码进行阻塞,直到 等待的东西返回后&主线程其余同步任务执行完毕后 才会继续执行后续代码。如果 xxx 遇到了 error 且没有做异常捕获的话,那 await 之后的内容永远不会被执行。

执行流遇到 await functionXX(): Promise<any> 时,因为发生了函数调用,所以functionXX 会被压入调用栈,执行流会进入到函数内部,将 return Promise 之外的代码执行一遍(同步任务),Promise相关的异步操作会交由浏览器执行。

  1. 如下代码运行后结果

难度:✦✦✧✧✧

async function async1() {
  console.log(1)
  await async2()
  console.log(2)
}

async function async2() {
  console.log(3)
}

console.log(4)

setTimeout(function () {
  console.log(5)
}, 0)

async1()

new Promise(function (resolve) {
  console.log(6)
  resolve()
}).then(function () {
  console.log(7)
})

console.log(8)

答:

4
1
3
6
8
2
7
5
  1. 如下代码运行后结果

难度:✦✦✦✧✧

console.log(1)

// 1s 延时
setTimeout(() => {
  console.log(2)
}, 1000)

async function fn() {
  console.log(3)
  setTimeout(() => {
    console.log(4)
  }, 20)
  return Promise.reject()
}

async function run() {
  console.log(5)
  await fn()
  console.log(6)
}

run()

//需要执行 150ms 左右
for (let i = 0; i < 90000000; i++) {}

setTimeout(() => {
  console.log(7)

  new Promise((resolve) => {
    console.log(8)
    resolve()
  }).then(() => {
    console.log(9)
  })
}, 0)

console.log(10)

答:

1
5
3
10
4
7
8
9
2
  1. 如下代码运行后结果

难度:✦✦✦✦✧

function executor(resolve, reject) {
  let rand = Math.random()
  console.log('executor')
  if (rand > 0.5) resolve()
  reject()
}

const p0 = new Promise(executor)

const p1 = p0.then(() => {
  console.log(1)
  return new Promise(executor)
})

const p2 = p1.then(() => {
  console.log(2)
  return new Promise(executor)
})

p2.catch(() => {
  console.log('error')
})

console.log(2)

答:

// 待补充

THE END

感谢阅读 👏🏻 / 感谢阅读 🤙🏼 / 感谢阅读 👋🏽

再见吧朋友.jpg