Callback、Promise、Generator、async/await 对比

7/2/2020 Javascript

JS 是单线程的语言。但是也有各种异步的方法(常见的就是 ajax 请求咯)。怎么处理好这些异步请求是一个非常重要的问题

假设一个场景:

现在有一个基础信息录入的表单,需要用户上传 4 张相关的图片和各种填写的资料

  • 上传图片是单独的接口,上传成功后会返回对应的文件名
  • 最后提交整体资料,图片的字段只要对应的图片文件名称,而不是文件的类型

# 石器时代的 Callback

既然要上传图片,还要拿对应的文件名。那就只好一张张上传,传完了才提交表单呗

// 先定义一个上传图片的方法
// 既然是callback的方式,那肯定就有回调函数传入进来了
function uploadImage(data, cb) {
  $.ajax({
    url: 'xxx',
    data: data,
    success: function(res) {
      // 假设图片文件名就在 res.data.file
      cb({
        status: 200,
        data: res.data.file
      })
    }
  })
}

// 定义一个提交数据的方法
// file 就是对应的图片文件,就忽略不写了~
function submit() {
  uploadImage({ name: 'img1', data: file }, function(img1_data) {
    uploadImage({ name: 'img2', data: file }, function(img2_data) {
      uploadImage({ name: 'img3', data: file }, function(img3_data) {
        uploadImage({ name: 'img4', data: file }, function(img4_data) {
          // 最后拿到所有的图片信息
          console.log(img1_data, img2_data, img3_data, img4_data)
          // 处理剩下的业务逻辑... 图片信息字段放入formData中
          // ajax 提交 formData数据
        })
      })
    })
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 优缺点

  • 优点

    • 这个快,并不是说开发效率的快。只是代码执行效率的快,可以看到中间基本没有中间环节,拿到数据,就调用回调函数返回对应的数据,JS 执行效率高
    • 逻辑简单,兼容性好 这兼容性好到什么底部?IE 老大哥都可以识别的代码,你说兼容性多好
  • 缺点

    • 回调地狱 Callback hell 上面的代码就有点回调地狱的味道,一层套一层,现在还只是上传 4 张图片,万一搞个 10 张,20 张,那嵌套的就太恐怖了
    • 可读性不好,毕竟代码是写给人看的。

# 铁器时代 Promise

Promise 的出现其实比较早,早在 JQ 就已经开始有实现,比如 JQ 的 1.5版本 就已经出现了 JQuery Deferred。这可以看作是 Promise 的一个原型了,不过可能用的人比较少,JQ 后来也出了 Promise 的版本。以至于后来也出现了各种 Promise 的库,还有我们熟悉的手写 Promise A+规范。直到近几年 Promise 终于被纳入了 JS 的规范

# JQuery Deferred

JQuery Deferred 也可以看做是 Promise 的前生了。语法其实和 Promise 还是略有相似

感兴趣的可以看下 `JQuery Deferred` 的用法
// 定义一个 Deferred
function waitHandle() {
  var dtd = $.Deferred() //创建一个deferred 对象
  var wait = function(dtd) {
    // 要求传入一个deferred 对象
    var task = function() {
      console.log('执行完成')
      dtd.resolve() // 表示异步任务已经完成
      // dtd.reject() //表示异步任务失败或出错
      setTimeout(task, 2000)
      return dtd // 要求返回deferred 对象
    }
    //注意,这里一定要有返回值
    return wait(dtd)
  }
}

// 使用的时候
var w = waitHandle()
w.then(
  function() {
    console.log('ok 1')
  },
  function() {
    console.log('error 1')
  }
).then(
  function() {
    console.log('ok 2')
  },
  function() {
    console.log('error 2')
  }
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

# 定义 Promise

还是根据一开始讲的案例继续写代码(上传 4 张图片然后提交表单)

来看下 Promise 对于这个的实现:

// 注意少了callback了。只传入了要上传图片的内咯
function uploadImage(data) {
  return new Promise(function(resove, reject) {
    $.ajax({
      url: 'xxx',
      data: data,
      success: function(res) {
        // 用 resolve 代表成功
        resolve({
          status: 200,
          data: res.data.file
        })
      },
      fail: function() {
        // reject 代表失败
        reject({
          status: 400,
          data: ''
        })
      }
    })
  })
}

// 定义一个提交数据的方法
// file 就是对应的图片文件,就忽略不写了~
function submit() {
  uploadImage({ name: 'img1', data: file })
    .then(res => {
      formData.img1 = res.data
      return uploadImage({ name: 'img2', data: file })
    })
    .then(res => {
      formData.img2 = res.data
      return uploadImage({ name: 'img3', data: file })
    })
    .then(res => {
      formData.img3 = res.data
      return uploadImage({ name: 'img4', data: file })
    })
    .then(res => {
      formData.img4 = res.data
      // 最后就是提交 formData 了
    })
    .catch(res => {
      // 当然也可以单独处理,写法很多
      console.log('上传图片失败后的统一处理')
    })
    .finally(res => {
      console.log('最后全部执行完成,不管有没有错误都会来这里')
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

小结

Promise 好像的确解决了回调地狱的问题,可是那么 then 还是看的眼花缭乱。明明都是差不多的方法,解决了回调地狱,可是又引来了新的链式调用过长的问题
而且,.then 方法中还需要特别注意,如果上一个方法没有 return 新的 Promise 对象,那.then还是拿的上一个方法的值(总的来说可能编码错误率相对高一些)。需要时刻注意 链式调用 的规则,返回新的对象~

# Promise.all

基于刚才说的 Promise 方法,其实我们上传图片的动作都是一致的,甚至不一致的方法也可以。为了避免写那么多的 .thenPromise.all 就出现了

作用 Promise.all 主要用于一次性执行多个异步方法,并且按传入顺序执行,用法如下

function uploadImage(data) {
  // 上传方法和Promise一样的了。就不重复写
}

Promise.all([
  uploadImage({ name: 'img1', data: file }),
  uploadImage({ name: 'img2', data: file }),
  uploadImage({ name: 'img3', data: file }),
  uploadImage({ name: 'img4', data: file })
])
  .then(res => {
    console.log(res) // 打印出来的res是个数组
    // res = [
    //   { data: 'xxx', code: 200 },
    //   { data: 'xxx', code: 200 },
    //   { data: 'xxx', code: 200 },
    //   { data: 'xxx', code: 200 }
    // ]
  })
  .catch(res => {
    console.log('如果有一个图片上传失败,都会进到.catch 方法中')
  })
  .finally(res => {
    console.log('最后全部执行完成,不管有没有错误都会来这里')
  })

// Promise.all 接受的是一个数组。所以上面的代码还能这样写:
// 如果是一致的操作。可以通过循环来调用方法。用 map 返回一个数组
var uploadList = [
  { name: 'img1', data: file },
  { name: 'img2', data: file },
  { name: 'img3', data: file },
  { name: 'img4', data: file }
]

Promise.all(uploadList.map(item => uploadImage(item))).then(res => {
  // ... 后面都一样了
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

uploadImage 部分可以换成其他的 Promise 封装的方法,他会执行这些 Promise 方法,统一拿到回调值。

# Promise.race

Promise.all 是把数组内的方法全部都执行完成才回调,而 Promise.race 则是哪个函数先执行完成,就会进回调,其他还没执行完成的函数则会被销毁









 
 
 



var fun1 = new Promise(function(resolve, reject) {
  setTimeout(resolve, 3000, 'fun1')
})

var fun2 = new Promise(function(resolve, reject) {
  setTimeout(resolve, 200, 'fun2')
})

// *注:这里使用的示例和promise.all用的示例类似,
// 可是没有用()执行,因为fun1就是一个promise对象,不需要执行
Promise.race([fun1, fun2]).then(function(result) {
  console.log(result) // 'fun2'   因为fun2比较早执行结束
})
1
2
3
4
5
6
7
8
9
10
11
12
13

# 优缺点

  • 优点
    • 代码可读性稍微提高(依旧还是一串的 then
    • 如果使用 Promise.all 方法都是同步执行的(比如 4 张图片都是同时上传,而无需等待其他图片)
    • Promise.race 更是添加了非常多的场景,例如同时烧两壶水,那壶先开就用那一壶,这就是典现的 Promise.race 应用场景。
  • 缺点
    • 刚才也说了,Promise.all 方法都是同步执行的。如果希望方法可以按顺序执行,我们还是要写很多的.then 回调,就像刚开始的 Promise 的例子一样
    • 而且当我们需要在某过程中需要停止执行(或者在中途返回了错误的值),还必须得层层判断后跳出,非常麻烦。

# 火器时代 Generator

Generator 函数是一种可以中途暂停、异步执行的函数,它的语法很简单

function uploadImage(data) {
  // 上传方法和Promise一样的了。就不重复写
  return new Promise()
}

function* upload() {
  yield uploadImage({ name: 'img1', data: file })
  yield uploadImage({ name: 'img2', data: file })
  yield uploadImage({ name: 'img3', data: file })
  yield uploadImage({ name: 'img4', data: file })
}

function submit() {
  var uploadFn = upload() // 执行后返回的是一个 Generator 对象
  formData.img1 = uploadFn.next().data
  formData.img2 = uploadFn.next().data
  formData.img3 = uploadFn.next().data
  formData.img4 = uploadFn.next().data

  // 最后提交formData表单
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 优缺点

  • 优点
    • 和 Promise 相比。Generator 在异常处理方向会显得更加又好,如果出错了,可以直接就把 submit 方法 return 掉。而不用等待其他方法执行回调
  • 缺点
    • 就是每次都要执行 next() 显得很麻烦

# 认知时代 async/await

Generator的出现,并不是异步解决的最终方案。并且由于 Generator 比较特殊的语法,*yield.next以至于其实很多人都没用过 Generator。直接转向了 async/await

async/await 只是 generator 的语法糖。但是使用 async 的函数会返回 Promise 对象。所以在 await后面的方法,只要是 Promise 对象都可以~

function uploadImage(data) {
  // 上传方法和Promise一样的了。就不重复写
  return new Promise()
}

// 按循序执行的例子
async function submit() {
  // 最后提交formData表单
  formData.img1 = await uploadImage({ name: 'img1', data: file }).data
  formData.img2 = await uploadImage({ name: 'img2', data: file }).data
  formData.img3 = await uploadImage({ name: 'img3', data: file }).data
  formData.img4 = await uploadImage({ name: 'img4', data: file }).data
}

// 既然 await 后面只要是Promise函数就可以,那我们可以改成这样
async function submit2() {
  var uploadList = [
    { name: 'img1', data: file },
    { name: 'img2', data: file },
    { name: 'img3', data: file },
    { name: 'img4', data: file }
  ]

  var imgList = await Promise.all(uploadList.map(item => uploadImage(item)))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

这样就万事大吉了吗?我们还忽略了异常处理的情况。既然 await 返回的是 Promise 对象,那就还得考虑错误的情况。这时候成功或者失败,都会返回到对应的对象/imgList 中。这并不是我们想看到的。

所以我们在定义一个 await 包装的方法

const awaitWrap = promise => {
  return promise.then(data => [data, null]).catch(err => [null, err])
}

// 然后基于上面的代码可以写出
async function submit() {
  const [img1, err1_msg] = await awaitWrap(uploadImage({ name: 'img1', data: file }))
  const [img2, err2_msg] = await awaitWrap(uploadImage({ name: 'img2', data: file }))
  const [img3, err3_msg] = await awaitWrap(uploadImage({ name: 'img3', data: file }))
  const [img4, err4_msg] = await awaitWrap(uploadImage({ name: 'img4', data: file }))
}
1
2
3
4
5
6
7
8
9
10
11

awaitWrap 接收 Promise 对象,也返回 Promise 对象(还记得一开始 Promise 的时候说的嘛,如果.then中没返回新的Promise,那就还是当前自己的对象)
返回的新对象里面,返回了一个数组,直接把成功的参数和错误的参数分为 2 部分,这样我们就不会把成功/失败混淆在一起了

# 优缺点

  • 优点
    • 语法更加清晰,基于 Generator 的语法糖,可以按步骤执行,也可以配合 Promise.all 多个函数同时执行
  • 缺点
    • 兼容性 其实从 Promise 开始,IE 就开始不好兼容,当然网上有非常多的pollyfill去完善这个兼容性
    • await 如果默认不处理的话成功和失败默认是不分开的,都在返回值里面,想优雅的处理还得自己封装下

# 总结

每个方法各有优缺点和用处。需要根据自身的场景,选择最合适的方法才是最好的

  • 如果只是简单的一些回调,那肯定用callbakc是最方便,最快的
  • 考虑到程序可能要按顺序加载/某个方法依赖另外一个异步方法执行的时候,优先使用Promise封装。这样拓展性目前是最好的(支持 async 和 Generator)
  • 最后如果要考虑到异步的执行顺序,我还是优先推荐 async/await。这个可 Promise.all 并发,也可以一个个等待执行。不过后面的几个方法兼容性目前还不太好,还需要引入非常多的 pollyfill。可是很大的提升了可读性和维护性
Last Updated: 5/9/2021, 10:45:03 PM