Bootstrap

理解异步函数async和await的用法


定义

  • async 是异步的意思,await则可以理解为 async wait。所以可以理解async就是用来声明一个异步方法,而 await是用来等待异步方法执行
  • async作为一个关键字放在函数前面,表示该函数是一个异步函数,异步函数意味着该函数的执行不会阻塞后面代码的执行;而 await 用于等待一个异步方法执行完成;
  • await 等待一个 Promise 对象,如果 Promise的状态变成了 resolve 或者 rejcet,那么 async函数会恢复执行。并会阻塞该函数内后面的代码。
  • 使用 async/await 可以实现用同步代码的风格来编写异步代码,这是因为 async/await 的基础技术使用了生成器Promise,生成器是协程的实现,利用生成器能实现生成器函数的暂停和恢复。
  • 为了优化 .then 链而开发出来的。

async/await 出现的原因

Promise 的编程模型依然充斥着大量的 then 方法,虽然解决了回调地狱的问题,但是在语义方面依然存在缺陷,代码中充斥着大量的 then 函数,这就是 async/await 出现的原因。async/await 让代码更少,更简洁。

关于async

我们先来看看 async 到底是什么?根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。对 async 函数的理解,这里需要重点关注两个词:异步执行隐式返回 Promise 下面我们会通过例子来看如何 隐式返回 Promise。

async的用法,语法很简单,在函数前面加上async关键字,表示函数是异步的。

 async function timeout() {
     return 'hello world!'
 }

那怎么调用呢?async 函数也是函数,平时我们怎么使用函数就怎么使用它,直接加括号调用就可以了,为了表示它没有阻塞它后面代码的执行,我们在async 函数调用之后加一句console.log;

 async function timeout() {
     return 'hello world!'
 }
 timeout()
 console.log('我虽然在后面,但是先执行')

打印结果:
在这里插入图片描述
发现 timeout() 函数虽然调用了,但是没打印 hello world!; 先不要着急, 看一看timeout() 返回了什么? 把上面的 timeout() 语句改为console.log(timeout())

打印结果:
在这里插入图片描述

原来async 函数返回的是一个promise 对象,并且Promise还有state和result,如果async函数中有返回值,当调用该函数时,内部会调用Promise.resolve()方法把它转化成一个promise对象作为返回,但如果timeout函数内部抛出错误呢? 那么就会调用Promise.reject() 返回一个promise 对象

async function timeout() {
    throw new Error('rejected');
}
console.log(timeout());

就会调用Promise.reject() 返回一个promise 对象
 在这里插入图片描述
那么要想获取到async 函数的执行结果,就要调用promise的then 或 catch 来给它注册回调函数

继续修改代码

  async function timeout() {
      return 'hello world!'
    }
    timeout().then(val => {
      console.log(val)
    })
    console.log('我虽然在后面,但是先执行')

打印结果:
在这里插入图片描述
我们获取到了"hello world!', 同时timeout的执行也没有阻塞后面代码的执行,和 我们刚才说的一致。
 
如果async 函数执行完,返回的promise 没有注册回调函数,比如函数内部做了一次for 循环,你会发现函数的调用,就是执行了函数体,和普通函数没有区别,唯一的区别就是函数执行完会返回一个 promise 对象。

   async function timeout () {
      for (let index = 0; index < 3; index++) {
        console.log('async', +index)
      }
    }
    console.log(timeout())
    console.log('outer')

在这里插入图片描述

另外,async函数返回一个promise对象,下面两种方法是等效的

// 方法1
function f() {return Promise.resolve('TEST');
}// asyncF is equivalent to f!

// 方法2
async function asyncF() {return 'TEST';
}


关于await

1) await 到底在等啥?

async 关键字差不多了,最重要的就是async函数的执行会返回promise对象,并且把内部的值进行promise的封装。如果promise对象通过then或catch方法又注册了回调函数,async函数执行完以后,注册的回调函数就会放到异步队列中,等待执行。
如果只是async,和promise差不多,但有了await就不一样了,await关键字只能放到async函数里面,await是等待的意思,那么它等待什么呢?它后面跟着什么呢?其实await不仅仅用于等Promise对象,还可以等任意表达式,所以await后面实际是可以接普通函数调用或者直接量的,不过我们更多的是放一个返回promise 对象的表达式。他等待的是promise对象执行完毕,并返回结果。

//所以下面这个示例完全可以正确运行
    function getSomething () {
      return 'something'
    }
    async function testAsync () {
      return Promise.resolve('hello async')
    }
    async function test () {
      const v1 = await getSomething()
      const v2 = await testAsync()
      console.log(v1, v2)
    }
    test()

2) await 等到了要等的,然后呢?

await 等到了它要等的东西,一个 Promise 对象,或者其它值,然后呢?

  • 如果它等到的是一个Promise对象,await就忙起来了,它会阻塞函数后面的代码,等着Promise对象resolve/reject,然后得到resolve/reject的值,作为await表达式的运算结果。
  • 如果 await 等待的是一个非 Promise 对象,那么V8 会隐式地将该对象包装成一个已经 resolve 的 Promise 对象.

3) async/await 帮我们干了啥?

做个简单的比较
现在举例,用 setTimeout 模拟耗时的异步操作,先来看看不用 async/await 会怎么写

function takeLongTime () {
      return new Promise(resolve => {
        setTimeout(() =>
          resolve('long_time_value'), 1000
        )
      })
    }
takeLongTime().then(val => {
   console.log(val, 'val')
})

如果改用 async/await 呢,会是这样

    function takeLongTime () {
      return new Promise(resolve => {
        setTimeout(() =>
          resolve('long_time_value'), 1000
        )
      })
    }
     async function test () {
      let v = await takeLongTime()
      console.log(v, 'v')
    }
    test()

眼尖的已经发现 takeLongTime () 没有申明为async。实际上takeLongTime () 本身就返回Promise对象,加不加async结果都一样。

4) await 优势在于处理 then 链,使代码看起来像同步代码一样

现在写一个函数,让它返回promise 对象,该函数的作用是2s 之后让数值乘以2

// 2s 之后返回双倍的值
function doubleAfter2seconds (num) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve(num * 2)
        }, 2000)
      })
    }

现在再写一个async 函数,从而可以使用await 关键字, await 后面放置的就是返回promise对象的一个表达式,所以它后面可以写上 doubleAfter2seconds 函数的调用

async function testResult() {
    let result = await doubleAfter2seconds(30);
    console.log(result); //2s后打印60
}
testResult();

代码的执行过程
调用testResult 函数,它里面遇到了await, await 表示等待,代码就暂停到这里,不再向下执行了,它等待后面的promise对象执行完毕,然后拿到promise resolve 的值并进行返回,返回值拿到之后,它继续向下执行。具体到 我们的代码, 遇到await 之后,代码就暂停执行了, 等待doubleAfter2seconds(30) 执行完毕,doubleAfter2seconds(30) 返回的promise 开始执行,2秒 之后,promise resolve 了, 并返回了值为60, 这时await 才拿到返回值60, 然后赋值给result, 暂停结束,代码继续执行,执行 console.log语句。

就这一个函数,我们可能看不出async/await 的作用,如果我们要计算3个数的值,然后把得到的值进行输出呢?

async function testResult() {
    let first = await doubleAfter2seconds(30);
    let second = await doubleAfter2seconds(50);
    let third = await doubleAfter2seconds(30);
    console.log(first + second + third);
}
testResult()

6秒后,控制台输出220, 我们可以看到,写异步代码就像写同步代码一样了,再也没有回调地域了。

这里强调一下,当js引擎在等待promise.resolve的时候,他并没有真正的暂停工作,它可以处理其他的一些事情,如果我们在testResult函数后面继续执行其他代码,比如console.log一下,会发现console.log代码先执行。

async function testResult() {
    let first = await doubleAfter2seconds(30);
    let second = await doubleAfter2seconds(50);
    let third = await doubleAfter2seconds(30);
    console.log(first + second + third);
}
testResult()
console.log('我先执行!!!')

先输出 “我先执行!!!”,6s后输出计算结果。
在这里插入图片描述

应用举例

1)当遇到 await 时,会阻塞函数内部处于它后面的代码(而非整段代码),去执行该函数外部的同步代码;当外部的同步代码执行完毕,再回到该函数执行剩余的代码。并且当 await 执行完毕之后,会优先处理微任务队列的代码。

async function fn1 (){
  console.log(1)
  await fn2() // 遇到fn2,进入执行
  console.log(2) // 阻塞,加入微任务队列
}
async function fn2 (){
  console.log('fn2')
}
fn1()
console.log(3)

// 输出结果:1,fn2,3,2

上面的例子中,await 会阻塞它下面的代码(即加入微任务队列),先执行 async 外面的同步代码,同步代码执行完,再回到 async 函数中,再执行之前阻塞的代码。

2)

  async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
    setTimeout(() => {
      console.log('timer1')
    }, 0)
  }
  async function async2() {
    setTimeout(() => {
      console.log('timer2')
    }, 0)
    console.log('async2')
  }
  async1()
  setTimeout(() => {
    console.log('timer3')
  }, 0)
  console.log('start')
// async1 start => async2 => start => async1 end => timer2 => timer3 => timer1

代码的执行过程:

  1. 首先进入async1,打印出 async1 start
  2. 然后遇到async2,进入async2,遇到定时器 timer2,加入宏任务队列,之后打印 async2
  3. 由于 async2阻塞了后面的代码执行,将后面的代码加入微任务队列。所以执行后面的定时器 timer3,将其加入宏任务队列,之后打印同步代码 start
  4. 同步代码执行完毕,先执行微任务队列,打印出 async1 end,遇到定时器 timer1,将其加入宏任务队列。
  5. 最后再执行宏任务队列,宏任务队列有3个任务,先后顺序是 timer2timer3timer1,分别按顺序执行,任务队列按照先进先出原则执行。

3)

async function test() {
  console.log(100)
  let x = await 200
  console.log(x)
  console.log(200)
}
console.log(0)
test()
console.log(300)
//0 => 100 => 300 => 200 => 200

代码的执行过程:

  • 首先代码同步执行,打印出0;
  • 然后将 test() 压入执行栈,打印出100, 下面注意了,遇到了关键角色await
  • await 200,被 JS 引擎转换成一个 Promise :
let promise = new Promise((resolve,reject) => {
   resolve(200);
})

这里调用了 resolve,resolve的任务进入微任务队列

  • 然后,JS 引擎将暂停当前协程的运行,把线程的执行权交给父协程;
  • 回到父协程中,父协程的第一件事情就是对await返回的Promise调用then, 来监听这个 Promise 的状态改变;
promise.then(value => {
  // 相关逻辑,在resolve 执行之后来调用
})
  • 然后往下执行,打印出300。
  • 根据EventLoop机制,当前主线程的宏任务完成,现在检查微任务队列, 发现还有一个Promise的resolve,执行,现在父协程在then中传入的回调执行。我们来看看这个回调具体做的是什么。
promise.then(value => {
  // 1. 将线程的执行权交给test协程
  // 2. 把 value 值传递给 test 协程
})
  • 现在执行权到了test协程手上,test 接收到父协程传来的200, 赋值给 a ,然后依次执行后面的语句,打印200、200。

总结:

  1. Promise 的编程模型依然充斥着大量的 then 方法,虽然解决了回调地狱的问题,但是在语义方面依然存在缺陷,代码中充斥着大量的 then 函数,这就是 async/await 出现的原因。
  2. 使用 async/await 可以实现用同步代码的风格来编写异步代码,这是因为 async/await 的基础技术使用了生成器Promise,生成器是协程的实现,利用生成器能实现生成器函数的暂停和恢复。
  3. async 函数
    1)async 是一个通过异步执行隐式返回 Promise 作为结果的函数。
    2)Promise对象的结果由async函数执行的返回值决定
  4. await 表达式
    1)正常情况下,await右侧的表达式一般为 promise对象 , 但也可以是其它的值
    2)如果表达式是promise对象,await就忙起来了,它会阻塞函数后面的代码,等着Promise对象resolve/reject,然后得到resolve/reject的值,作为await表达式的运算结果。
    3)如果 await 等待的是一个非 Promise 对象,那么V8 会隐式地将该对象包装成一个已经resolve 的 Promise 对象。比如 上面案例 await 200,会默认创建一个 Promise 对象。
  5. 在使用await的时候我们只是暂停了函数,而非整段代码。这里经常会是容易犯错的地方。
  6. async和await是非阻塞的
  7. 仍然可以使用 Promise,例如Promise.all(p1, p2, p3).,接受一个数组作为参数,p1、p2、p3 都是 Promise 实例,如果不是,就会先调用 Promise .resolve方法,将参数转为 Promise 实例,再进一步处理。只要 p1、p2、p3 之中有一个被 rejected,整个状态就变成 rejected。
  8. 创建 Promise 时,并不会生成微任务,而是需要等到 Promise 对象调用 resolve 或者 reject 函数时,才会产生微任务。
    产生的微任务并不会立即执行,而是等待当前宏任务快要执行结束时再执行。
  9. 注意
    1)await必须写在async函数中, 但async函数中可以没有await
    2)如果await的promise失败了, 就会抛出异常, 需要通过try…catch来捕获处理

=========================== 2023-3-3更新 ===========================

async/await or promise使用时到底选择哪个呢?

大部分复杂情况下async/await 的确是最优解,个人觉得也不是所有情况下都是 async/await 写起来更爽,最不爽的就是他的错误处理,try...catch这个代码看起来就很奇怪(当然也有很多人喜欢这种错误处理方式)。所以我个人的习惯,当只有一个异步请求,且需要做错误处理的情况下,更倾向于使用 promise。比如

// promise
getInfo()
  .then(res => {
    //do somethings
  })
  .catch(err => {
    //do somethings
  })

// async/await
try {
  const res = await getInfo()
  //do somethings
} catch (error) {
  //do somethings
}

在有嵌套请求的情况下,肯定是 async/await 更直观的。

// promise
a(() => {
  b(() => {
    c()
  })
})

// async/await
await a()
await b()
await c()

当然代码写的好与不好还是取决于写代码的人的。
比如一个常见的业务场景:有两个并发的异步请求,在都完成后do something。但很多人会错误的用串行的方式实现了。

//错误
await a()
await b()
//这样变成了 a().then(() => b() )
// a 好了才会执行 b
done()

//正确
await Promise.all([a(), b()])
done()

还有一个小细节async/await打包后的代码其实会比 promise 复杂很多, 当然这个是一个忽略不计得问题。

总结:
我认为它们两个人并不是 or 的关系,在特定的业务场景下,选择相对而言代码可读性更好地解决方案。具体该怎么选择还是需要大家更具自己团队的风格或者自己的理解来判断。

其实围绕async/await,还有更深入的知识点,个人感觉还蛮有趣的,感兴趣的可以继续阅读我的其它文章。
理解JS的事件循环机制(Event Loop)
理解js中的同步和异步

学习过程中参考了:
用 async/await 来处理异步
vue中异步函数async和await的用法
理解 JavaScript 的 async/await
解释一下async/await的运行机制 - 神三元

;