使用 async 与 await 编写更优雅的异步代码

374 阅读5分钟

前言

在 jQuery 盛行的时代,我们最难避免的,就是回调地狱,尤其是当遇到了嵌套 ajax 请求的时候,我们的代码大都是这样的:

$.ajax({
  url:'xxx',
  success:function(data){
    $.ajax({
      url:'yyy' + data.xxxx,
      success:function(data){
        $.ajax({
          url:'zzz' + data.xxxx,
          success:function(data){
            //dosomething
          }
        })
      }
    })
  }
})

这样的代码,一是嵌套深了看起来难看,而是不利于调试,三是当一个回调里面的逻辑太多了的时候,一个方法的代码可能很长很长。于是,聪明的你肯定这样做过:

function fn1(data){
  $.ajax({
    url:'yyy' + data.xxxx,
    success:function(data){
      fn2(data)
    }
  })
}
function fn2(data){
  $.ajax({
    url:'zzz' + data.xxxx,
    success:function(data){
      //dosomething
    }
  })
}
$.ajax({
  url:'xxx',
  success:function(data){
    fn1(data)
  }
})

这样虽然看上去没上面那么糟糕了,但又却暴露出另外一个问题,各个 ajax 之间的依赖关系不是那么的明朗,对于后期维护代码的人而言,这是个很痛苦的事情。

后来随着 Promise 的出现,我们的回调地狱稍微有了改观,变成了下面这样:

new Promise((resolve) => {
  $.ajax({
    url:'xxx',
    success:function(data){
      resolve(data)
    }
  })
}).then((data) => {
  return new Promise((resolve) => {
    $.ajax({
      url:'yyy' + data.xxxx,
      success:function(data){
        resolve(data)
      }
    })
  })
}).then((data) => {
  $.ajax({
    url:'zzz' + data.xxxx,
    success:function(data){
      // dosomething
    }
  })
})

但还是没达到最优雅的姿势,直到 async 和 await 的出现,我们的代码总算变得更加清晰明了,各种异步之间的依赖关系看得一清二楚:

const ajax1 = function(){
    return new Promise((resolve,reject) => {
      $.ajax({
        url:'xxx',
        success:function(data){
          resolve(data)
        }
      })
      // 你可以用setTimeout 代替ajax进行异步模拟
      // setTimeout(function(){
      //   resolve('ajax1')
      // },1000)
    })
  }
 
  const ajax2 = function(data){
    return new Promise((resolve,reject) => {
      $.ajax({
        url:'yyy' + data.xxxx,
        success:function(data){
          resolve(data)
        }
      })
    })
  }
 
  const ajax3 = function(data){
    return new Promise((resolve,reject) => {
      $.ajax({
        url:'zzz' + data.xxxx,
        success:function(data){
          resolve(data)
        }
      })
    })
  }
 
  var fn = async function(){
    let ajax1Data = await ajax1()
    // ajax2 依赖ajax1的返回值
    let ajax2Data = await ajax2(ajax1Data)
    // ajax3 依赖ajax2的返回值
    let ajax3Data = await ajax3(ajax2Data)
    // 拿到了ajax3Data,做点别的
    dosomething()
  }
  fn()
}

接下来,我们就来谈谈 async 和 await 的 api 和 使用注意事项。

async

  1. async 函数返回的是一个 Promise 对象,如果结果是值,会经过 Promise 包装返回。
  2. async 函数中,如果有多个 await 关键字时,如果有一个 await 的状态变成了 rejected,那么后面的操作都不会继续执行。
  3. 如果在一个 async 方法中,有多个 await 操作的时候,程序会变成完全的串行操作,后一个会一直等到前一个执行完成才会执行。如果你的业务场景是多个异步操作之间不存在结果的依赖关系,请使用 promise.all。

async 函数声明方式:

// 普通的函数声明
async function fn(){}
 
// 声明一个函数表达式
let fn = async function(){}
 
// async形式的箭头函数
let fn = async () => {}

await

  1. await 只能存在与 async 方法内部,在其他地方不行。
  2. await 只能在 async 函数的当前作用域下执行,不能跨层级使用。
  3. await 命令后面可以是 Promise 对象或值,如果是值,会自动转成一个立即 resolve 的 Promise 对象。
  4. await 的返回结果是它后面所跟的 promise 的执行的结果,可能是 resolved 或者 rejected 的值。

注意点

  1. 对于下面这段代码,打印结果并不是你期望的返回值 1,而是一个 promise。
function get(){
  return 1
}
 
async function getData(){
  let value = await get(); // 记住:await 后面的结果可以是值,也可以是 promise
  value++;
  return value;
}
 
var value = getData();
console.log(value)

因为 async 方法返回的永远是一个 promise,即使开发者返回的是一个常量,也会被自动调用 promise.resolve 方法转换为一个 promise。因此对于这种情况,上层调用方法也需要是 async 函数,所以你得像下面这样才能得到想要的结果:

(async function(){
  var value = await getData()
  console.log(value)
})();
  1. 如下代码也会报错,因为 await 和 async 中间跨了一层作用域。
async function fn1(){
  console.log('fn1 start')
}
async function fn2(){
  console.log('fn2 start')
  function cross(){
    await fn1()
  }
  cross()
}
fn2()

思考: 请看下面代码,并猜想一下执行结果的打印顺序。

async function fn1(){
  console.log('fn1 start')
  await fn2()
  console.log('fn1 end')
}
 
async function fn2(){
  console.log('fn2 start')
  await fn3()
  console.log('fn2 end')
}
 
async function fn3(){
  console.log('fn3')
}
 
fn1();
 
console.log('over')

正确结果如下:

fn1 start
fn2 start
fn3
over
fn2 end
fn1 end

跟你自己猜想的答案一样吗?如果不一样,那你可能得看看下面这段分析了。

就上面的代码而言,因为我们一开始调用了 fn1,所以第一个打印 fn1 start 毋庸置疑,但因为要等待 fn2 执行完,所以不会马上执行后面的代码;
接着 fn1 又调用了 fn2,所以会立即执行 fn2,打印了fn2 start,同 fn1 一样,由于要等待 fn3,所以不会马上执行后面的代码;
接着 fn2 又调用了 fn3,所以打印了fn3;
这个时候,已经没有异步代码了,直接执行最后一行代码,打印 over;
最后一行代码执行过后,已经没有同步代码了,所以开始等待异步执行;
这个时候,不防先思考一下我们的异步队列有哪些,分别是两次通过 await 添加的 fn2 和 fn3;
所以在 console.log(‘fn1 end’) 执行之前,要等待 fn2,因为 fn3 里已经没有异步了,所以直接打印 fn2 end;
最后等到 fn2 执行完成,直接打印 fn1 end。

其实,就上面的示例而言,抛开最后一行代码,你会发现,这跟 koa 的中间件差不多是一个逻辑。这种剥洋葱的模型在前端很多,最典型的就是 dom 事件的捕捉与冒泡。

实用场景举例

上文提到的ajax嵌套就是一个典型的例子。

再比如,react 的 setState 方法,很多人是像这样使用的:

changeState(){
  this.setState({
    value: newValue
  }, () => {
    console.log(this.state.value)
  })
}

用了 async 和 await 之后,就是下面这样:

async changeState(){
  await this.setState({
    value: newValue
  }
  console.log(this.state.value)
}

还有,如果你用 node 做过后端开发,当我们使用 mysql 时,可以这样封装我们的查询方法:

// 统一执行 sql 的函数
function exec(sql) {
    const promise = new Promise((resolve, reject) => {
        // con 是我们的链接对象
        con.query(sql, (err, result) => {
            if (err) {
                reject(err)
                return
            }
            resolve(result)
        })
    })
    return promise
}
 
module.exports = {
    exec,
    escape: mysql.escape
}

使用的时候就可以用 async 和 await 了:

const { exec } = require('./db')
 
// 这里的 async 返回了 promise
const getList = async (author, keyword) => {
  let sql = `select * from blogs where 1=1 `
  if (author) {
      sql += `and author='${author}' `
  }
  if (keyword) {
      sql += `and title like '%${keyword}%' `
  }
  sql += `order by createtime desc;`
 
  return await exec(sql)
}
 
getList.then(res=>{
  console.log(JSON.stringify(res))
})

OK,本文到此结束。附个人博客原文链接