阅读 230

浅析 Promise、Async/Await

这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战

Promise

基本用法

Promise 的简单封装与使用

// 封装
function 摇色子() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(Math.floor(Math.random() * 6) + 1)
    }, 3000)
  })
}

// 使用
摇色子().then(success1, failed1).then(success2, failed2)
复制代码

Ma Mi 任务模型

  • Ma 指 MacroTask(宏任务),Mi 指 MicroTask(微任务)
  • 先 Ma 再 Mi,即先执行宏任务再执行微任务
  • JavaScript 运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue),里面是各种需要当前程序处理的异步任务
  • 其实最初 JS 只存在一个任务队列,为了让 Promise 回调更早执行,强行又插入了一个异步的任务队列,用来存放 Mi 任务
  • 宏任务:setTimeout()、setInterval()、 setImmediate()、 I/O、UI渲染(常见的定时器,用户交互事件等等)
  • 微任务:Promise、process.nextTick、Object.observe、MutationObserver

Promise 的其他 API

Promise.resolve(result): 制造一个成功(或失败)

制造成功

function 摇色子() {
  return Promise.resolve(4)
}

// 等价于
function 摇色子() {
  return new Promise((resolve, reject) => {
    resolve(4)
  })
}

摇色子().then(n => console.log(n)) // 4
复制代码

制造失败

function 摇色子() {
  // 此处 Promise.resolve 接收的是一个失败的 Promise 实例(状态为 reject)
  return Promise.resolve(new Promise((resolve, reject) => reject()))
}

摇色子().then(n => console.log(n)) // 1 Uncaught (in promise) undefined
复制代码

关于 Promise.resolve 接收参数的问题,ECMAScript 6 入门里其实说得很清楚

如果参数是 Promise 实例,那么Promise.resolve将不做任何修改、原封不动地返回这个实例;如果参数是一个原始值,或者没有参数,Promise.resolve都会直接返回一个resolved状态的 Promise 对象。

Promise.reject(reason): 制造一个失败

Promise.reject('我错了')
// 等价于
new Promise((resolve, reject) => reject('我错了'))

Promise.reject('我错了').then(null, reason => console.log(reason)) // 我错了
复制代码

Promise.all(数组): 等待全部成功,或者有一个失败

全部成功,将所有成功 promise 结果组成的数组返回

Promise.all([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)])
  .then(values => console.log(values)) // [1, 2, 3]
复制代码

只要有一个失败,就结束,返回最先被 reject 失败状态的值

Promise.all([Promise.reject(1), Promise.resolve(2), Promise.resolve(3)])
  .then(values => console.log(values)) // Uncaught (in promise) 1
复制代码

Promse.all 在需要对多个异步进行处理时往往非常有用;

不过在某些特殊情况下,直接使用Promse.all就显得不那么方便了

举个例子,比如现在有 3 个请求,request1, request2 和 request3,我们需要对这 3 个请求进行统一处理,并且不管请求成功还是失败,都需要拿到所有的响应结果,如果这时候使用Promise.all([request1, request2, request3])的话,request1 请求失败了,后面的两个请求 request2, request3 就都不会执行了。

如何解决 Promise.all() 在第一个 Promise 失败就会中断的问题?

利用 .then() 后会返回一个状态为 resolved 的 Promise(即会自动包装成一个已resolved的promise)

// 3 个请求
const request1 = () => new Promise((resolve, reject) => {
 setTimeout(() => {
   reject('第 1 个请求失败')
 }, 1000)
})

const request2 = () => new Promise((resolve, reject) => {
 setTimeout(() => {
   reject('第 2 个请求失败')
 }, 2000)
})

const request3 = () => new Promise((resolve, reject) => {
 setTimeout(() => {
   resolve('第 3 个请求成功')
 }, 3000)
})

Promise.all([
  request1().then(value => ({ status: 'ok', value }), reason => ({ status: 'not ok', reason })),
  request2().then(value => ({ status: 'ok', value }), reason => ({ status: 'not ok', reason })),
  request3().then(value => ({ status: 'ok', value }), reason => ({ status: 'not ok', reason }))
]).then(result => console.log(result))
复制代码

可以把对每个请求的 .then 操作封装一下

const x = promiseList => promiseList.map(promise => promise.then(value => ({
  status: 'ok',
  value
}), reason => ({
  status: 'not ok',
  reason
})))

const xxx = promiseList => Promise.all(x(promiseList))

xxx([request1(), request2(), request3()])
  .then(result => console.log(result))
复制代码

打印结果如下:

image.png

Promise.allSettled(数组): 等待全部状态改变

Promise.allSettled([Promise.reject(1), Promise.resolve(2), Promise.resolve(3)])
  .then(result => console.log(result))
复制代码

打印结果如下:

image.png

可以看出 Promise.allSettled 的作用其实和上面我们实现的 xxx 函数的作用是一致的,因此针对上文提到场景,可以直接使用 Promise.allSettled,更加简洁。

Promise.race(数组): 等待第一个状态改变

Promise.race([request1(), request2(), request3()]).then((result) => {
  console.log(result)
}).catch((error) => {
  console.log(error) // 第 1 个请求失败
})
复制代码

Promise.race([request1, request2, request3])里面哪个请求最先响应,就返回其对应的结果,不管结果本身是成功状态还是失败状态(这里最先响应的请求是 request1)。

一般情况下用不到 Promise.race 这个 api,不过在某些场景下还是有用的。例如在多台服务器部署了同样的服务端代码,要从一个商品列表的接口拿数据,这时候就可以在 race 中写上所有服务器中的查询商品列表的接口地址,哪个服务器响应快,就优先从哪个服务器拿数据。

Promise 的应用场景

多次处理一个结果

摇色子().then(v => v1).then(v1 => v2)
复制代码

第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。

串行

  • 这里有一个悖论:一旦 promise 出现,那么任务就已经执行了
  • 所以不是 promise 串行,而是任务串行
  • 解法:把任务放进队列,完成一个再做下一个(用 Reduce 实现 Promise 串行执行

并行

Promise.allPromise.allSettledPromise.race都可以看作是并行地在处理任务

这里可能你会产生疑问,JS 不是单线程吗,怎么做到并行执行任务?

这里指的是并行地做网络请求的任务,而网络请求实际上是由浏览器来做的,并非是 JS 做的,就像 setTimeout 是浏览器的功能而不是 JS 的,setTimeout 只是浏览器提供给 JS 的一个接口。

Promise 的错误处理

自身的错误处理

promise 自身的错误处理其实挺好用的,直接在.then的第二个回调参数中进行错误处理即可

promise.then(s1, f1)
复制代码

或者使用.catch语法糖

// 上面写法的语法糖
promise.then(s1).catch(f1)
复制代码

建议总是使用catch()方法,而不使用then()方法的第二个参数,原因是第二种写法可以捕获前面then方法执行中的错误,也更接近同步的写法(try/catch

全局错误处理

以axios为例,Axios 作弊表

错误处理之后

  • 如果你没有继续抛错,那么错误就不再出现
  • 如果你继续抛错,那么后续回调就要继续处理错误

前端似乎对 Promise 不满

Async/Await替代Promise的6个理由,主要是以下 6 个方面:

  • 简洁
  • 错误处理
  • 条件语句
  • 中间值
  • 错误栈
  • 调试(在.then代码块中设置断点,使用 Step Over 快捷键,调试器不会跳到下一个.then,因为它只会跳过异步代码)

async / await

async / await 基本用法

最常见的用法

const fn = async() => {
  const temp = await makePromise()
  return temp + 1
}
复制代码

优点:完全没有缩进,就像是在写同步代码

封装一个 async 函数

async的封装和使用

function 摇色子() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(Math.floor(Math.random() * 6) + 1)
    }, 3000)
  })
}

async function fn() {
  const result = await 摇色子()
  console.log(result)
}

fn()
复制代码

try...catch进行错误处理

async function 摇色子() {
  throw new Error('色子坏了')
}

async function fn() {
  try {
    const result = await 摇色子()
    console.log(result)
  } catch (error) {
    console.log(error)
  }
}

fn()
复制代码

为什么需要 async

在函数前面加一个async,这看起来非常多余,await所在的函数就是async,不是吗?

理由之一:

在 ES 标准的 async/await 出来之前,有些人自己用函数实现了 await,为了兼容旧代码里普通函数的 await(xxx)(为了将旧代码里面的 await 和新的 ES 标准里的 async/await 区分开来),其实 async 本身并没有什么意义。

你可能会说,async函数会隐式地返回一个 Promise 对象呀,但这并不能成为必须要在函数前加async的理由,有兴趣的可以去看看知乎上关于async的讨论。

await 错误处理

用 try/catch 来同时处理同步和异步错误是很常见的做法

let response
try {
  response = await axios.get('/xxx')
} catch (e) {
  if (e.response) {
    console.log(e.response.status)
    throw e
  }
}
console.log(response)
复制代码

但其实还有更好的写法,就像下面这样

const errorHandler = error => {
  console.log(error)
  // 注意这里要抛出一个错误
  throw error
}

// 只用一句代码就可以处理成功和失败
const response = await axios.get('/xxx').then(null, errorHandler)

// 或者使用 catch 语法糖
const response = await axios.get('/xxx').catch(errorHandler)
复制代码

需要注意的是,errorHandler函数中不要直接return一个值,一定要抛出一个错误(打断程序的运行)。因为在请求调用失败的情况下,会直接把errorHandlerreturn的值直接赋值给 response(通俗的说法就是“Promise 会吃掉错误”),在errorHandler中抛出一个错误能够保证在请求成功的情况下才会有 response,请求失败的情况下一定是会进入errorHandler函数中的

下面是一个实际的例子

const ajax = function() {
  return new Promise((resolve, reject) => {
    reject('这是失败后的提示')
    // resolve('这是成功后的结果')
  })
}

const error = (error) => {
  console.log('error:', error)
  return Promise.reject(error)
}

async function fn() {
  const response = await ajax().then(null, error)
  console.log('response:', response)
}

fn()
复制代码

可以看到,我们仅仅只用了一句代码就可以同时处理 Promise 成功和失败的情况了,绝大多数的 ajax 调用都是可以用这样的方式来处理的。

所以,对于async/await,并不是一定需要使用try/catch来做错误处理的。

之前我常常陷入一个误区:就是认为await.then是对立的,始终觉得用了await后就不应该再出现.then

但其实并非如此,说到底async/await也只不过是.then的语法糖而已。就像上面的例子一样,.thenawait完全是可以结合在一起使用的,在.then中进行错误处理,而await左边只接受成功结果。

另外,我们还可以把 4xx/5xx 等常见错误用拦截器全局处理,errorHandler也可以放在拦截器里。

await 的传染性

代码:

console.log(1)
await console.log(2)
console.log(3) // await 会使这句代码变成异步的,如果想要让他立即执行,放到 await 前面即可
复制代码

分析:

  • await会使得所有它左边的和下面的代码变成异步代码
  • console.log(3)变成异步任务了
  • Promise 同样有传染性(同步变异步),放到.then回调函数中的代码会变成异步的,不过相比于await.then下面的代码并不会变成异步的
  • 回调没有传染性

await 的应用场景

多次处理一个结果

const r1 = await makePromise()
const r2 = handleR1(r1)
const r3 = handleR2(r2)
复制代码

串行

天生串行(多个await并排时,从上到下依次执行,后面的会等前面执行完了再执行)

await promise1
await promise2
await promise3
...
复制代码

并行

同 Promise,await Promise.all([p1, p2, p3])await Promise.allSettled([p1, p2, p3])await Promise.race([p1, p2, p3]) 都是并行的

循环的时候存在 bug

正常情况下,即便在循环中,await也应当是串行执行的。

例如 for 循环中的 await 是串行的(后面等前面)

async function runPromiseByQueue(myPromises) {
  for (let i = 0; i < myPromises.length; i++) {
    await myPromises[i]();
  }
}

const createPromise = (time, id) => () =>
  new Promise((resolve) =>
    setTimeout(() => {
      console.log("promise", id);
      resolve();
    }, time)
  );
runPromiseByQueue([
  createPromise(3000, 4),
  createPromise(2000, 2),
  createPromise(1000, 1)
]);

// 4 2 1
复制代码

但是在某些循环中,如 forEach 和 map 中,await 会并行执行(后面不等前面)

async function runPromiseByQueue(myPromises) {
  myPromises.forEach(async (task) => {
    await task();
  });
}

const createPromise = (time, id) => () =>
  new Promise((resolve) =>
    setTimeout(() => {
      console.log("promise", id);
      resolve();
    }, time)
  );
runPromiseByQueue([
  createPromise(3000, 4),
  createPromise(2000, 2),
  createPromise(1000, 1)
]);

// 1 2 4
复制代码

后面 JS 又出了一个新的东西 for await...of 来弥补这个 bug

async function runPromiseByQueue(myPromises) {
  // 异步迭代
  for await (let item of myPromises) {
    console.log('promise', item);
  }
}

const createPromise = (time, id) =>
  new Promise((resolve) =>
    setTimeout(() => {
      resolve(id);
    }, time)
  );

runPromiseByQueue([
  createPromise(3000, 4),
  createPromise(2000, 2),
  createPromise(1000, 1)
]);

// 4 2 1
复制代码

Reference

文章分类
前端
文章标签