Skip to content
大纲

EventLoop

  • 什么是事件循环?
  • 什么是消息队列?
  • 宏任务和微任务
    • 哪些属于宏任务?
    • 哪些属于微任务?
    • 事件循环,消息队列与宏任务、微任务之间的关系是什么?
    • 微任务添加和执行流程示意图

Event Loop

  1. 初始状态下,调用栈空。微任务队列空,宏任务队列里有且只有一个 script 脚本(整体代码)。这时首先执行并出队的就是 整体代码
  2. 整体代码作为宏任务进入调用栈,进行同步任务和异步任务的区分
  3. 同步任务直接执行并且在执行完之后出栈,异步任务进行微任务与宏任务的划分,分别被推入进入微任务队列宏任务队列
  4. 等同步任务执行完了(调用栈为空)以后,再处理微任务队列,将微任务队列压入调用栈
  5. 当调用栈中的微任务队列被处理完了(调用栈为空)之后,再将宏任务队列压入调用栈,直至调用栈再一次为空,一次轮回结束

js 线程的特点

  • js 的特点是单线程的,js 主要用途是用户交互,如果是多线程操作就容易产生冲突,那么单线程就意味着所有任务都需要排队,如果前一个任务耗时很长,后面的任务就不得不一直等着,所以就出现了同步、异步任务

同步和异步任务在 js 中是如何执行的

  • js 代码运行会有一个主线程和一个任务队列,主线程会自上而下的依次执行 js 代码,形成一个执行栈
  • 同步任务会被放到主线程中依次执行,而异步任务被放到任务队列中执行,执行完会在任务队列中打一个标记,形成一个对应的事件。promise 是何时被放入异步任务队列的?是 运行时遇到所有 then 或 catch,就直接放进任务队列了吗?
  • 主线程任务执行完毕,会从任务队列中提取对应的事件?谁负责提取?
  • EventLoop 是主线程重复从事件队列中取消息、执行的过程;事件队列遵循 FIFO 的原则

任务队列可以有多个,分为 macro-task(由宿主发起) 和 micro-task(由 js 自身发起)

  • macro-task 包括:
    • script(整体代码)
    • setTimeout
    • setInterval
    • setImmediate
    • I/O
    • UI rendering UI 渲染
    • 事件队列(未确定)
    • postMessage
    • MessageChannel(异步的宏任务)
  • micro-task 包括:
    • Promise(then 或 catch 等)
    • Object.observe(已废弃)
    • MutationObserver(html5 新特性)
    • process.nextTick()(Node 独有)
    • queueMicrotask(Chrome 官方 API,可节省实例化 Promise 的开销)
    • V8 的垃圾回收过程
  • 其他:
    • requestAnimationFrame(有争议)
    • requestIdleCallback

说明

  • requestAnimationFrame(有争议)、requestIdleCallback:是和宏任务性质一样的任务,但其既不是宏任务也不是微任务
  • requestIdleCallback 是在浏览器渲染后有空闲时间时执行,如果 requestIdleCallback 设置了第二个参数 timeout,则会在超时后的下一帧强制执行

我们看下浏览器里的一帧发生了什么

通常情况下,浏览器的一帧为 16.7ms。由于 js 是单线程,那么它内部的一些事件,比如 click 事件,宏任务,微任务,requestAnimatinFrame,requestIdleCallback 等等都会在浏览器帧里按一定的顺序去执行。具体的执行顺序如下:

browser-frame

浏览器一帧里回调的执行顺序为:

  1. 用户事件:最先执行,比如 click 等事件。
  2. js 代码:宏任务和微任务,这段时间里可以执行多个宏任务,但是必须把微任务队列执行完成。宏任务会被浏览器自动调控。比如浏览器如果觉得宏任务执行时间太久,它会将下一个宏任务分配到下一帧中,避免掉帧。
  3. 在渲染前执行 scroll/resize 等事件回调。
  4. 在渲染前执行 requestAnimationFrame 回调。
  5. 渲染界面:面试中经常提到的浏览器渲染时 html、css 的计算布局绘制等都是在这里完成。
  6. requestIdleCallback 执行回调:如果前面的那些任务执行完成了,一帧还剩余时间,那么会调用该函数。

事件循环,消息队列与宏任务、微任务之间的关系是什么?

  • 宏任务入队消息队列,可以将消息队列理解为宏任务队列
  • 每个宏任务内有一个微任务队列,执行过程中微任务入队当前宏任务的微任务队列
  • 宏任务微任务队列为空时才会执行下一个宏任务
  • 事件循环捕获队列出队的宏任务和微任务并执行

事件循环会不断地处理消息队列出队的任务,而宏任务指的就是入队到消息队列中的任务,每个宏任务都有一个微任务队列,宏任务在执行过程中,如果此时产生微任务,那么会将产生的微任务入队到当前的微任务队列中,在当前宏任务的主要任务完成后,会依次出队并执行微任务队列中的任务,直到当前微任务队列为空才会进行下一个宏任务。

事件运行机制

  • 执行一个宏任务(栈中没有就从事件队列中获取),执行过程中如果遇到微任务,就将它添加到微任务的任务队列中;
  • 宏任务执行完毕后,立即执行当前微任务队列的所有微任务;
  • 当前微任务执行完毕,开始检查渲染,如果需要渲染则 GUI 线程接管渲染;
    • 触发 resize、scroll 事件,建立媒体查询(执行一个任务中如果生成了微任务,则执行完任务该后就会执行所有的微任务,然后再执行下一个任务)。
    • 建立 css 动画(执行一个任务中如果生成了微任务,则执行完该任务后就会执行所有的微任务,然后再执行下一个任务)。
    • 执行 requestAnimationFrame 回调(执行一个任务中如果生成了微任务,则执行完该任务后就会执行所有的微任务,然后再执行下一个任务)。
    • 执行 IntersectionObserver 回调(执行一个任务中如果生成了微任务,则执行完该任务后就会执行所有的微任务,然后再执行下一个任务)。
  • 更新渲染屏幕
  • 浏览器判断当前帧是否还有空闲时间,如果有空闲时间,从 requestIdleCallback 回调函数队列中取第一个,执行它。执行微任务队列里的所有微任务,直到 requestIdleCallback 回调函数队列清空或当前帧没有空闲时间
  • 渲染完毕后,JS 线程继续接管,当前微任务队列的所有 Web Worker 任务,则执行
  • 开始下一个宏任务。

注意:

  1. requestAnimationFrame 和 requestIdleCallback 是和宏任务性质一样的任务,只是他们的执行时机不同而已
  2. 浏览器在每一轮 Event Loop 事件循环中不一定会去重新渲染屏幕,会根据浏览器刷新率以及页面性能或是否后台运行等因素判断的,浏览器的每一帧是比较固定的,会尽量保持 60Hz 的刷新率运行,每一帧中间可能会进行多轮事件循环
  3. requestAnimationFrame 是与浏览器是否渲染相关联的。它是在浏览器渲染前,在微任务执行后执行。
  4. requestIdleCallback 是在浏览器渲染后有空闲时间时执行,如果 requestIdleCallback 设置了第二个参数 timeout,则会在超时后的下一帧强制执行

不同宏任务与微任务队列之间的优先级

  1. 先执行 macrotasks:I/O -> UI 渲染-> requestAnimationFrame
  2. 再执行 microtasks:process.nextTick -> Promise -> MutationObserver ->Object.observe
  3. 再把 setTimeout、setInterval、setImmediate【三个货不讨喜】塞入一个新的 macrotasks, 依次:setTimeout-> setInterval -> setImmediate

具体代码执行分析

js
async function async1() {
  console.log('async1 start') //(2)
  await async2()
  console.log('async1 end') //(6)
}
async function async2() {
  console.log('async2') //(3)
}
console.log('script start') //(1)
setTimeout(function () {
  console.log('settimeout') //(8)
}, 0)
async1()
new Promise(function (resolve) {
  console.log('promise1') //(4)
  resolve()
}).then(function () {
  console.log('promise2') //(7)
})
console.log('script end') //(5)
  • 首先,事件循环从宏任务队列开始,读取整体代码,遇到相应的任务,会分发到对应任务队列中去
  • 我们看到定义了两个 async 函数,没有调用,继续遇到了 console 语句,则执行输出,遇到 setTimeout 将其分发到对应的任务队列中
  • 继续执行了 async1() 函数,其中 await 之前的代码是立即执行的,遇到 await,会将其表达式执行一遍,即执行了 async2() 函数,输出 'async2',紧接着把 await 后面的代码console.log('async1 end')加入到 microtask 中的 Promise 队列中,接着跳出 async1() 函数,执行后面的代码。
  • script 继续执行,遇到了 Promise 实例,立即执行其构造函数,后面的.then则被分发到 microtask 的 Promise 队列中,所以会先输出 promise1,然后执行 resolve,将 promise2 分配到对应队列。
  • script 任务继续往下执行,输出了 script end,至此,全局任务就执行完毕了。
  • 宏任务完毕后,开始清空微任务队列,微任务的Promise队列中有两个任务,即async1 endpromise2,按照先进先出的原则进行输出,微任务执行完毕,检查渲染,交给 GUI 线程
  • 开始第二轮的宏任务
js
function asyncGet(x) {
  return new Promise((resolve) =>
    // 将宏任务添加到宏任务列表
    setTimeout(() => {
      // 执行输出,并改变 Promise 状态
      console.log('a')
      resolve(x)
    }, 500)
  )
}

async function test() {
  console.log('b') //(2)
  const x = 3 + 5
  console.log(x) //(3)

  const a = await asyncGet(1) // 执行添加宏任务,等待,待状态改变再执行后续代码
  console.log(a) //resolve(1)

  const b = await asyncGet(2) // 执行添加宏任务,等待,待状态改变再执行后续代码
  console.log(b) //resolve(2)

  // 异步版本
  // const [a, b] = await Promise.all([
  //   asyncGet(1),
  //   asyncGet(2)
  // ])

  console.log('c') // 输出
  return a + b
}

const now = Date.now()
console.log('d') //(1)
test().then((x) => {
  console.log(x) // 收到返回结果 a + b 的值,输出
  console.log(`elapsed: ${Date.now() - now}`) // 输出
})
console.log('f') // (4)
js
// 返回一个 Promise 对象的数组,并不是我们期待的 value 数组
// await 只会暂停 map 的 callback,因此 map 完成时,不能保证 asyncGet 也全部完成
async function getAll(vals) {
  return vals.map(async (v) => await asyncGet(v))
}

执行过程详解

js
const promise1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('success')
  }, 1000)
})
const promise2 = promise1.then(() => {
  throw new Error('error!!!')
})
console.log('promise1', promise1)
console.log('promise2', promise2)
setTimeout(() => {
  console.log('promise1', promise1)
  console.log('promise2', promise2)
}, 2000)
  • 先执行第一个 new Promise 中的函数,碰到 setTimeout 将它加入下一个宏任务列表
  • 跳出 new Promise,碰到 promise1.then 这个微任务,但其状态还是为 pending,这里理解为先不执行
  • promise2 是一个新的状态为 pending 的 Promise
  • 执行同步代码 打印 promise1 的状态为 pending 与 promise2 的状态为 pending
  • 碰到第二个定时器,将其放入下一个宏任务列表
  • 第一轮宏任务执行结束,并且没有微任务需要执行,因此执行第二轮宏任务
  • 先执行第一个定时器里的内容,将 promise1 的状态改为 resolved 且保存结果并将之前的 promise1.then 推入微任务队列
  • 该定时器中没有其它的同步代码可执行,因此执行本轮的微任务队列,也就是 promise1.then,它抛出了一个错误,且将 promise2 的状态设置为了 rejected
  • 第一个定时器执行完毕,开始执行第二个定时器中的内容,打印 promise1 的状态为 resolved,打印 promise2 的状态为 rejected

async

  • 执行到 await fn() 语句时,会阻塞 fn() 后面代码的执行,因此会先去执行 fn() 中的同步代码后,跳出当前函数,继续执行其他代码,只有 fn() Promise 被 fulfill 或者 reject,再继续执行之后的代码。可以理解为「紧跟着 await 后面的语句相当于放到了 new Promise 中,下一行及之后的语句相当于放在 Promise.then 中」
js
async function async1() {
  console.log('async1 start')
  // 但 await 后面的 Promise 是没有返回值的,也就是它的状态始终是 pending 状态,因此相当于一直在 await,
  await new Promise((resolve) => {
    console.log('promise1')
  })
  console.log('async1 success')
  return 'async1 end'
}
console.log('srcipt start')
async1().then((res) => console.log(res))
console.log('srcipt end')
  • 写出执行结果
js
async function testSometing() {
  console.log('执行 testSometing')
  return 'testSometing'
}

async function testAsync() {
  console.log('执行 testAsync')
  return Promise.resolve('hello async')
}

async function test() {
  console.log('test start...')
  const v1 = await testSometing()
  console.log(v1)
  const v2 = await testAsync()
  console.log(v2)
  console.log(v1, v2)
}

test()

var promise = new Promise((resolve) => {
  console.log('promise start...')
  resolve('promise')
})
promise.then((val) => console.log(val))

console.log('test end...')
js
const async1 = async () => {
  console.log('async1')
  setTimeout(() => {
    console.log('timer1')
  }, 2000)
  // await 的 new Promise 要是没有返回值的话则不执行后面的内容
  await new Promise((resolve) => {
    console.log('promise1')
  })
  console.log('async1 end')
  return 'async1 success'
}
console.log('script start')
async1().then((res) => console.log(res))
console.log('script end')
Promise.resolve(1)
  .then(2)
  .then(Promise.resolve(3))
  .catch(4)
  .then((res) => console.log(res))
setTimeout(() => {
  console.log('timer2')
}, 1000)