async-await:用同步的方式写异步代码

引题

首先推荐先去看 Promise Promise 原理和使用 了解 JS 的 Promise 解决的异步回调编码风格。

Promise 的问题是,这种方式充满了 .then 方法,如果处理流程比较复杂,整段代码会充斥 then,语义化不明显,代码依然不是太容易阅读。

基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,使得代码逻辑更加清晰。可以参考下面这段改造示例代码:

// async/await 改造前
fetch('https://xxx.com').then(res1 => {
 console.log(res1)
  return fetch('https://kkk.com')
}).then(res2 => {
  console.log(res2)
}).catch(err => {
  console.log(err)
})
// async/await 改造后
async function foo() {
  try {
    let res1 = await fetch('https://xxx.com')
    console.log(res1)
    let res2 = await fetch('https://kkk.com')
    console.log(res2)
  } catch(err) {
    console.error(err)
  }
}

整段代码的异步处理逻辑都是使用同步代码的方式来实现的,而且还支持 try catch 来捕获异常,这就是完全在写同步代码,非常符合人的线性思维。

首先我们介绍生成器(Generator)是如何工作的,接着讲解 Generator 的底层实现机制——协程(Coroutine);因为 async/await 使用了 Generator 和 Promise 两种技术。

生成器 VS 协程

生成器

生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的,请看下面这段示例代码:

function* getDemo() {
  console.log('开始执行第一段')
  yield 'generator 1'
  console.log('开始执行第二段')
  yield 'generator 2'
  console.log('开始执行第三段')
  yield 'generator 3'
  console.log('执行结束')
  return 'generator finish.'
}

console.log('main')
let gen = getDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')
// 执行结果
main
demo.html:12 开始执行第一段
demo.html:24 generator 1
demo.html:25 main 1
demo.html:14 开始执行第二段
demo.html:26 generator 2
demo.html:27 main 2
demo.html:16 开始执行第三段
demo.html:28 generator 3
demo.html:29 main 3
demo.html:18 执行结束
demo.html:30 generator finish.
demo.html:31 main 4

观察输出结果发现,全局代码和 genDemo 函数是交替执行的。生成器函数内部执行一段代码,如果遇到 yield 关键字,JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行,外部函数可以通过 next 方法恢复函数的执行。

下面简单介绍下 JavaScript 引擎 V8 是如何实现一个函数的暂停和恢复的,这也有助于我们后面理解 async/await 的概念。首先了解协程的概念。

协程

协程是一种比线程更加轻量级的存在,你可以将其看成是跑在线程上的任务,一个线程中可以存在多个协程,但是同时只能执行一个协程。如果当前执行的是 A 协程,要启动 B 协程就需要 A 协程将主线程的控制权交给 B 协程,此时 A 协程暂停执行,B 协程恢复执行,我们将 A 协程称为 B 协程的父协程。

正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。更重要的是,协程不被操作系统内核管理,完全由程序控制。这样带来的好处是性能得到很大的提升,不会像切线程那样消耗资源。

对照上述生成器函数的协程的四点规则如下:

  1. 通过调用生成器函数 genDemo 来创建一个协程 gen,创建后,gen 协程并没有立即执行
  2. 要让 gen 协程执行,需要通过调用 gen.next
  3. 当协程正在执行时,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程
  4. 如果协程在执行期间,遇到了 return 关键字,JavaScript 引擎就会结束当前协程,并将 return 后的结果返回给父协程

关于父协程和 gen 协程的交互执行中,他们的调用栈切换需要关注以下两点:

  • gen 协程和父协程是在主线程上交互执行的,而不是并发执行的,它们之间的切换是通过 yieldgen.next 来配合完成的
  • gen 协程执行中调用 yield 时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息

所以,在 JavaScript 中,生成器就是协程的一种实现方式。下面是使用生成器和 Promise 来改造开头的那段 Promise 代码:

function* foo() {
  let res1 = yield fetch('https://xxx.com')
  console.log(res1)
  let res2 = yield fetch('https://kkk.com')
  console.log(res2)
}

let gen = foo()

function getGenPromise(gen) {
  return gen.next().value
}

getGenPromise(gen).then(res1 => {
  console.log(res1)
  return getGenPromise(gen)
}).then(res2 => {
  console.log(res2)
})

foo 函数是一个生成器函数,其内部实现了用同步代码形式来实现异步操作。我们将执行生成器的代码封装成一个函数,并将这个执行生成代码的函数称为执行器(参考 co 框架):

co(foo())

通过使用生成器配合执行器,就能实现使用同步的方式写异步代码了,大大加强代码可读性。

虽然执行器已经很好地满足我们的需求了,但是程序员的追求永无止境,ES7 引入了 async/await,这种方式能够彻底告别执行器和生成器,实现更加直观简洁的代码。

async

根据MDN定义,async 是一个通过异步执行隐式返回 Promise 作为结果的函数。例如下面这段代码

async function foo() {
  return 2
}
console.log(foo()) // Promise {<fulfilled>: 2}

await

下面结合协程来分析这段代码:

async function foo() {
  console.log(1)
  let a = await 100
  console.log(a)
  console.log(2)
}

console.log(0)
foo()
console.log(3)

执行流程如下:

  1. 首先,执行 console.log(0) ,输出 0
  2. 紧接着,执行 foo 函数,由于 foo 函数被 async 标记过,所以当进入该函数时,JavaScript 引擎会保存当前的调用栈等信息,然后执行 foo 函数中的 console.log(1) 并输出 1
  3. 接下来,执行 foo 函数中的 await 100
  4. 当执行到 await 100 时,JavaScript 引擎会默认创建一个 Promise 对象
    let promise_ = new Promise((resolve, reject) => {
      resolve(100)
    })
    
    引擎会将该 resolve 任务提交给微任务队列
  5. 然后 JavaScript 引擎会暂停当前协程执行,将主线程控制权交给父协程执行,同时会将 promise_ 对象返回给父协程
  6. 执行父协程流程 console.log(3) ,并输出 3
  7. 父协程结束,进入微任务的检查,执行微任务队列任务,发现有 resolve(100) 等待被执行,触发 promise_.then 中的回调函数
    promise_.then(value => {
      // 回调函数被激活,将主线程控制权交给 foo 协程,并将 value 值传给协程
    })
    
  8. foo 协程激活后会把值给变量 a,结束后将控制权归还给父协程

正因为 async/await 在背后为我们做了大量的工作,所以我们才能用优雅的同步方式写出异步代码来。

async/await

开始之前,需要知道 async-await的特点

  • await 后面跟的是 Promise 对象时,才会异步执行,其它类型的数据会同步执行
  • 返回的仍然是个 Promise 对象

一个函数如果加上 async ,那么该函数就会返回一个Promise,可以async 看成将函数 return 返回值使用 Promise.resolve() 包裹了下

async function Func() {
  return 'Func...'
}
console.log(Func())

在这里插入图片描述

Func().then(res => console.log(res)) // Func...

基本使用

async函数返回一个Promise对象,可以使用then方法添加回调函数。
当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

function timeout(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

async function asyncPrint(value, ms) {
  await timeout(ms)
  console.log(value)
}

asyncPrint('hello world', 2000);
//hello world (2s后)

上面代码指定2000毫秒以后,输出hello world。同时函数asyncPrint()执行结果返回了一个promise对象。

async

async用来表示函数是异步的,定义的函数会返回一个promise对象,可以使用then方法添加回调函数,而async函数内部return语句返回的值,就会成为then方法回调函数的参数。针对上面的asyncPrint例子来看,如果我们直接使用then会发现什么都没有

function timeout(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

async function asyncPrint(value, ms) {
  await timeout(ms)
  console.log(value)
}

asyncPrint('hello world', 2000).then((value)=>{console.log(value)})
//hello world (2s后)
//undefined

因为函数asyncPrint内部没有return语句返回值,那我们加上return试一下

function timeout(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

async function asyncPrint(value, ms) {
  await timeout(ms)
  console.log(value)
  return 'done'
}

asyncPrint('hello world', 2000).then((value)=>{console.log(value)})
//hello world (2s后)
//done

其实就是相当于执行了Promise.resolve('done'),如果没有return就相当于Promise.resolve()

await

await后面可以跟任何JS表达式,虽然await可以跟很多类型的东西,但是最主要的意图是用来等待Promise对象的状态被resolved如果await的是promise对象会造成异步函数停止执行并等待promise解决,如果等到的是正常的表达式就立即执行,还是开头的那个例子,如果await的不是promise对象会怎么样

function timeout(ms) {
  setTimeout(()=>{}, ms)
}

async function asyncPrint(value, ms) {
  await timeout(ms)
  console.log(value)
}

asyncPrint('hello world', 2000);
//hello world (立即)

因为await后面不是promise对象了,也就不需要等待结果返回了,所以到这就直接往下执行了。
再来一个例子

function sleep(second) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('enough sleep~');
        }, second);
    })
}
function normalFunc() {
    console.log('normalFunc');
}
async function awaitDemo() {
    await normalFunc();
    console.log('something, ~~');
    let result = await sleep(2000);
    console.log(result);// 两秒之后会被打印出来
}
awaitDemo();
// normalFunc
// something, ~~
// ---2s后---
// enough sleep~

第一个await后面是一个正常表达式所以直接执行后继续往下执行,然后再遇到第二个await,这个await后面是一个异步操作,所以需要等待结果返回并执行后再继续往下执行,也就是过了2s以后再打印最后的值。

回调实例

异步回调依赖

举例说明,假设有三个请求需要发生,第三个请求是依赖第二个请求的解析,第二个请求是依赖第一个请求的解析
如果要用ES5实现就会有三个回调
如果用Promise就会至少有三个then
一个是横向代码很长,一个是纵向代码很长,如果用async-await来实现呢?

//我们仍然使用 setTimeout 来模拟异步请求
    function sleep(second, param) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(param);
            }, second);
        })
    }

    async function test() {
        let result1 = await sleep(1000, 'req01');
        console.log(result1)
        let result2 = await sleep(1000, 'req02' + result1);
        console.log(result2)
        let result3 = await sleep(1000, 'req03' + result2);
        console.log(result3)
    }

    test();
    //req01 --1s后--
    //req02req01 --2s后--
    //req03req02req01 --3s后--

难怪说async-awaitpromise的语法糖了,其实还是promise,只是代码阅读起来让你觉得它好像是一个同步请求,不用那么多回调或者then了。amazing!!!

并行处理

如果有多个await命令后面的异步操作之间不存在相互依赖的关系,那我们当然就不能使用上面实例中的方法来使用async-await了
例如有三个异步请求需要发送,但是相互之间没有关联,要做的其实就是当所有异步请求结束后清除界面上的loading,如果我们像上面那样使用当最后一个请求结束后清除loading,其实等待的时间就是三个请求时间之和:

function sleep(second) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('sth');
        }, second);
    })
}

async function clearLoad() {
    await sleep(1000);
    await sleep(1000);
    await sleep(1000);
    console.log('清除loading啦');
}

clearLoad();
// 清除loading啦 --3s后--

其实这里真正的需求是当最慢的那个请求发送结束了,就可以清除loading了,所以改良后是这样:

function sleep(second) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('sth');
        }, second);
    })
}

async function clearLoad() {
    let p1 = sleep(1000);
    let p2 = sleep(1000);
    let p3 = sleep(1000);
    await Promise.all([p1, p2, p3]);
    console.log('清除loading啦');
}

clearLoad();
// 清除loading啦 --1s后--

所以,以上可以得出:async-awaitpromise的语法糖,让我们书写代码更加流畅,增强代码的可读性,它是建立在promise机制之上的,并不能取代promise

思考题

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

console.log('script start')
setTimeout(() => {
  console.log('setTimeout')
}, 0)
bar()
new Promise(function(resolve) {
  console.log('promise executor')
  resolve()
}).then(function() {
  console.log('promise then')
})
console.log('script end')
  • 26
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值