likes
comments
collection
share

JavaScript 高级深入浅出:async、await 与事件循环

作者站长头像
站长
· 阅读数 24

介绍

本文是 JavaScript 高级深入浅出的第 17 篇,本文将会介绍 async、await 与事件循环

正文

1. async/await

1.1 async

async 关键词声明一个异步函数:

  • asyncasynchronous 的缩写,意为 异步、非同步
  • syncsynchronous 的缩写,意为 同步
async function foo() {}

const bar = async () => {}

class Baz {
  async foo() {}
}

1.2 异步函数和普通函数的区别

返回值不同

async function foo() {
  return 'foo'
}

// 异步函数的返回值一定是一个 Promise 实例
const p = foo()
p.then(res => {
  console.log(res)
})

// 所以就可以返回一个 Promise 实例或者 thenable 了
async function bar() {
  return {
    then(resolve) {
      resolve('bar')
    },
  }
}
async function baz() {
  return new Promise(resolve => {
    resolve('baz')
  })
}

1.3 await

async 函数的另外一个特殊之处就是可以在它内部使用 await 关键字,普通函数是不可以的

await 后面可以是 Promise实例 、异步函数thenable、普通的值

async function foo() {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('foo execute')
      resolve()
    }, 2000)
  })
}

async function bar() {
  // 在 foo 代码执行完毕前,后面的代码都不会执行
  // 就像是所有的代码都是同步执行的
  await foo()
  console.log('bar execute')
}

bar()

如果 await 后面的 Promise 实例的状态是 rejected,那么将会作为 await 异步函数的 rejected 的值

async function foo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('foo execute')
      reject('error')
    }, 2000)
  })
}

async function bar() {
  await foo()
  // 下面这行就不会执行了,因为上面的代码是 rejected 的
  console.log('bar execute')
}

bar().catch(err => {
  // reject 的值将会被传递到这里
  console.log('err', err)
})

2. 浏览器的事件循环

2.1 进程与线程

进程和线程是操作系统中的两个概念:

  • 进程(process):计算机已经运行的程序,是操作系统管理程序的一种方式
  • 线程(thread):操作系统能够运行运算调度的最小单位,通常情况下它包含在进程中

通俗的说:

  • 进程:可以理解为启动一个应用程序,就会创建一个进程(或者多个进程)
  • 线程:每一个进程当中,都会至少创建一个线程用来执行程序中的代码,这个线程叫做主线程
  • 所以也可以说进程是线程的容器

2.2 操作系统的工作方式

操作系统是如何做到让多个进程同时工作的呢?

  • 这是因为 CPU 的运算速度非常快,它可以在多个进程之间迅速切换
  • 当我们进程中的线程获取到时间片时,就可以快速执行我们编写的代码
  • 对于用户来说是感受不到这种切换

如果你使用的 CPU 是单核 CPU ,那么在正常运行时,CPU 就会在多个进程中切换

2.3 浏览器中的 JS 线程

我们经常说 JS 是单线程的,但是 JS 的线程应该有自己的容器:浏览器进程或 Node 进程

而浏览器只有一个进程吗?

  • 现代浏览器中(特别是 Chrome),每个页面都有自己的渲染进程,每个进程中又会有很多线程,例如就会有执行 JS 代码的线程。

JS 的代码是在一个线程中执行的:

  • 也就是说在同一时间 JS 只能做一件事
  • 如果这件事是十分耗时的,那么就会产生线程阻塞

所以真正耗时的操作,都不是由 JS 线程去执行的:

  • 浏览器的每个进程都是多线程的,那么其他线程就可以来完成这个耗时操作
  • 比如 网络请求、定时器 等等,我们只需要执行回调即可

2.4 浏览器的事件循环

如果在执行 JS 代码的过程中,出现了异步操作呢?

  • 比如我们调用了 setTimeout 这个函数
  • 这个函数会被放到调用栈中,执行会立即结束,并不会阻塞后续代码的执行
  • 而计时的操作则是交给其他线程来执行,并在计时完成后,调用 setTimeout 传入的回调函数

因此我们就可以得知,一些异步的操作浏览器是如何处理的:

  • 为了不阻塞线程,一些可能会出现回调的函数/代码将会被立即执行
  • 而计时等等操作则是交给其他线程来操作的
  • 浏览器在内部维护着一个队列(queue),保存了正在进程异步操作的函数,这个队列就是事件队列
  • 最终结束后,将会调用相应的回调,例如 setTimeout 传入的 timer 参数
  • 而这一过程,就是事件循环。

2.5 宏任务和微任务

在 2.4 中,我们知道了浏览器内部维护着一个事件队列,其实事件队列分为 宏任务队列(MarcotaskQueue微任务队列(MircotaskQueue)

加入宏任务队列的操作:

  • 定时器(setTimeout、setInterval)
  • ajax
  • DOM(点击事件等)
  • UI 渲染操作
  • ......

加入微任务队列的操作:

  • queueMircotask
  • Promise.then
  • MutationObserver
  • ......

那么宏任务与微任务哪个会先执行呢?

  • 规范:在执行任何宏任务之前,都要保证微任务队列已经被清空
  • 也就是说在执行任何一个宏任务时,都要先执行微任务队列中的所有微任务

所以事件循环是这样的:

  • 执行 main script(主代码)
  • 将微任务队列中的任务加入 main script
  • 将宏任务中的队列加入 main script(在执行每个宏任务时,都要确认微任务队列是否清空,若未清空,在执行该宏任务之前执行所有的微任务)

3. 浏览器事件循环面试题

接下来,我们通过面试题来巩固宏任务与微任务

3.1 面试题一

setTimeout(() => {
  console.log('setTimeout1')

  new Promise(resolve => {
    resolve()
  }).then(() => {
    new Promise(resolve => {
      resolve()
    }).then(() => {
      console.log('then4')
    })
    console.log('then2')
  })
})

new Promise(resolve => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('then1')
})

setTimeout(() => {
  console.log('setTimeout2')
})

console.log(2)

queueMicrotask(() => {
  console.log('queueMicrotask1')
})

new Promise(resolve => {
  resolve()
}).then(() => {
  console.log('then3')
})
// 结果
// promise1 2 then1 queueMircotask1 then3 setTimeout1 then2 then4 setTiemout2

这道题还是很简单的,我们来看看解析:

  • 同步代码开始:执行第 16 行的 new Promise ,执行 executor,打印 promise1
  • 执行第 27 行的打印,打印 2
  • 执行第 33 行的 new Promise,执行 executor,未有打印代码,同步代码结束
  • 微任务队列开始清空:因为第 19 行的 Promise.then 是微任务,在执行第 1 行的 setTimeout 之前执行,打印 then1
  • 因为第 29 行的 queueMicrotask 是微任务,因此打印 queueMicrotask1
  • 因为第 36 行的 Promise.then 是微任务,在执行第 1 行的 setTimeout 之前执行,打印 then3微任务队列清空完毕
  • 开始执行每个宏任务:执行第一行的 setTimeout 的 timer 回调
  • 同步代码开始:执行第 2 行的打印,打印 setTimeout1
  • 执行第 4 行的 new Promise,执行 executor,未有打印代码,同步代码结束
  • 开始清空微任务队列:执行第 6 行的 Promise.then
  • **同步代码开始:**执行第 7 行的 new Promsie,执行 executor,未有打印代码
  • 执行第 12 行的打印,打印 then2同步代码结束
  • 开始清空微任务队列:执行第 9 行的 Promise.then,打印 then4微任务队列结束
  • 开始执行宏任务:执行第 23 行的宏任务,打印 setTimeout2
  • 宏任务队列清空完毕,代码执行完毕

3.2 面试题二

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

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

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise(resolve => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})
// 结果
// script start  async1 start  async2  promise1  async1 end  promise2  setTimeout

在看解析之前,我们先看一个知识点:

async function foo() {
  console.log('foo')
}

async function bar() {
  console.log('bar start')
  await foo()
  console.log('bar end')
}

bar()
console.log('main script end')
// 此时这里的 bar end 其实是一个微任务
// 因为在 async/await 的时候我们已经说了,异步任务一定会返回一个 Promise,而 
// await foo() 后面的代码要想在执行 foo 函数后面执行,那么就会加入在 foo 返回的 Promise
// 的 then 里面的
// 相当于
async function foo() {
  console.log('foo')
  return new Promise(resolve => {
    resolve()
  }).then(() => {
    console.log('bar end')
  })
}

只要了解了这个知识点,那么我们再看面试题还是简单的,看解析:

  • 同步代码开始:打印 11 行的 script start
  • 执行 async1 函数,打印第 2 行的 async1 start
  • 执行 async2 函数,打印第 8 行的 async2
  • 执行第 19 行的 new Promise 的 executor,打印 promise1同步代码结束
  • 开始清空微任务:执行第 4 行,打印 async1 end
  • 执行第 23 行,打印 promise2微任务队列清空完毕
  • 开始清空宏任务:执行第 13 行的 setTimeout 传入的回调,打印 setTimeout宏任务清空完毕
  • 代码执行完毕

3.3 面试题三

Promise.resolve()
  .then(() => {
    console.log(0)
    return Promise.resolve(4)
  })
  .then(res => {
    console.log(res)
  })

Promise.resolve()
  .then(() => {
    console.log(1)
  })
  .then(() => {
    console.log(2)
  })
  .then(() => {
    console.log(3)
  })
  .then(() => {
    console.log(5)
  })
  .then(() => {
    console.log(6)
  })

这个执行顺序很奇怪,但是我们改一下代码:

Promise.resolve()
  .then(() => {
    console.log(0)
    // 这里改了
    return 4
    // 相当于 resolve(4)
  })
  .then(res => {
    console.log(res)
  })

// 剩下的代码没动

// 就很容易推断出来结果是:
// 0 1 4 2 3 5 6

接下来,我们再改一下:

Promise.resolve()
  .then(() => {
    console.log(0)
    // 这里改了
    return {
      then(resolve) {
        resolve(4)
      }
    }
  })
  .then(res => {
    console.log(res)
  })

// 剩下的代码没动

// 这里的结果又变成了
// 0 1 2 4 3 5 6

为什么呢???这是因为执行 then 函数本身被放在了下一次的微任务中(在 then 中若返回的不是一个普通的值,例如返回了 thenable 或 Promise,那么就会被延迟到下一次微任务执行)。所以不难理解,由于本身 then 就推迟了一次微任务,因此 4 的顺序就又往后延了一个

最后让我们回到最开始,由于在 then 的时候 return 了一个非普通的值即 Promise.resolve(4) ,延迟了一次微任务,由于 Promise.resolve(4) 又延迟了一次微任务,因此一共延迟了两个微任务。

所以最终会执行 0 1 2 3 4 5 6

实际上,上述的操作在 Promises/A+ 中并没有类似的规范,因此在做类似的面试题时还是要实际操作一下。

为什么会有推迟这个操作呢?如果返回的是一个普通的值,那么就不会有复杂的情况,但是如果返回的是 thenable 或者 Promise,那么可能此函数中存在有大量的计算,立刻执行就有可能会阻塞下面的微任务的执行,因此就将其推迟到了下一次的微任务中。

4. Node 的事件循环

浏览器中的 EventLoop 是根据 HTML5 定义的规范来实现的,不同的浏览器可能有不同的实现,而 Node 的实现循环是根据 libuv 这个库所实现的。

这里我们看一下 Node.js 的架构图:

  • 我们发现 libuv 主要是维护了 EventLoop 和 worker threds(线程池)
  • EventLoop 负责调用系统的一些其他操作:文件的 IO、Network、child-processes 等

libuv 是一个跨平台的专注于异步 IO 的库,最初为 Node.js 开发,后面也被 Luvit、Julia、pyuv 等用在很多地方

JavaScript 高级深入浅出:async、await 与事件循环

4.1 Node 事件循环的阶段

事件循环就像是一个桥梁,是连接着应用程序的 JavaScript 和系统调用之间的通道:

  • 无论是我们的文件 IO、数据库、网络 IO、定时器、子进程,在完成相应的操作后,都会将对应的结果和回调函数放到事件循环(任务队列)中
  • 事件循环会不断的从**任务队列中取出对应的事件(回调函数)**来执行

一次完整的事件循环 Tick 分为很多个阶段:

  • 定时器(Timers):本阶段执行已经被 setTimeout()setIterval() 的调度回调函数
  • 待定回调(Pending Callback):对某些系统操作(如 TCP 错误类型)执行回调,比如 TCP 连接时接收到 ECONNREFUSED
  • idle, prepare:仅系统内部使用
  • 轮询(Poll):检索新的 I/O 事件,执行与 I/O 相关的回调
  • 检测(check)setImmediate() 回调函数在这里执行
  • 关闭的回调函数:一些关闭的回调函数,如 socket.on('close', ...)

4.2 Node 的宏任务与微任务

在 Node 的一次事件循环的 Tick 中我们发现,Node 的事件循环会更加复杂,在其实在 Node 中也分为宏任务与微任务:

  • 宏任务(macrotask)setTimeoutsetIntervalIO 事件setImmediateclose 事件
  • 微任务(microtask)Promise then 回调process.nextTickqueueMicrotask

但是 Node 中的事件循环不仅仅是为任务队列和宏任务队列:

  • 微任务队列:
    • next tick queue:process.nextTick
    • other queue:Promise.then 回调、queueMicrotask
  • 宏任务队列:
    • timer queue:setTimeout、setInterval
    • poll queue:IO 事件
    • check queue:setImmediate
    • close queue:close 事件

所以在每一次的事件循环的 tick 中,其实是按照下面的顺序执行的:

  • next tick mircotask queue
  • other microtask queue
  • timer queue
  • poll queue
  • check queue
  • close queue

5. Node 事件循环面试题

5.1 面试题一

async function async1() {
  console.log('async 1 start')
  await async2()
  console.log('async 1 end')
}

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

console.log('script start')

setTimeout(() => {
  console.log('setTimeout0')
}, 0)

setTimeout(() => {
  console.log('setTimeout2')
}, 300)

setImmediate(() => console.log('setImmediate'))

process.nextTick(() => console.log('nextTick1'))

async1()

process.nextTick(() => console.log('nextTick2'))

new Promise(resolve => {
  console.log('promise1')
  resolve()
  console.log('promise2')
}).then(() => {
  console.log('promise3')
})

console.log('script end')

// 执行顺序
// script start
// async 1 start
// async2
// promise1
// promise2
// script end
// nextTick1
// nextTick2
// async 1 end
// promise3
// setTimeout0
// setImmediate
// setTimeout2

解析

根据解析我们发现还是不难的:

  • 首先执行 main script:执行第 11 行,打印 script start
  • 执行 25 行的 async 1,执行第 2 行,打印 async 1 start
  • await 执行第 3 行的 async2,执行第 8 行,打印 async 2,而第 4 行的代码相当于是 Promise.then 的回调,所以现在不执行
  • 执行第 30 行打印 promise1,执行第 32 行打印 promise2
  • 执行第 37 行打印 script endmain script 完毕
  • 开始清空微任务队列:根据 4.2 中的顺序,执行 23 行,打印 nextTick1
  • 执行第 27 行,打印 nextTick2
  • 执行第 4 行,打印 async1 end
  • 执行第 34 行,打印 promise3微任务完毕
  • 开始清空宏任务队列:执行第 17 行的定时器,打印 setTimeout0
  • 执行第 21 行,打印 setImmediate宏任务完毕
  • 等待第 17 行的定时器完毕后,执行回调函数,再次执行新一轮的宏任务,打印 setTimeout2,宏任务完毕

总结

本篇重点介绍了浏览器与 Node 环境中的事件循环,我们知道了什么是宏任务与微任务,也知道了浏览器环境与 Node 环境下的宏任务与微任务的不同,通过一组面试题,巩固了任务队列的执行顺序

转载自:https://juejin.cn/post/7067780709548720136
评论
请登录