Promise & async_await 食用指南

755 阅读10分钟

Promise & async/await 初识

机缘

在JavaScript中很多动作都是异步的,比如 ajax ,或者下面这个函数:

function loadScript(src, callback) {
    let script = document.createElement('script')
    script.src = src
    
    script.onload = () => callback('success', srcipt)
    script.onerror = () => callback('failed')
    
    document.head.append(script)
}

上面的函数 loadScript 在传入脚本的地址后,然后创建 <script> 标签添加 src 属性。最后监听该脚本的 onloadonerror 事件将最后的结果通过调用 callback 的方式返回并传入参数。使用的时候如下:

loadScript('something.js', (result) => {
    if(result === 'success') {
        return '加载成功了,可以进行一系列的操作'
    }
    if(result === 'failed') {
        return '脚本运行失败,可以提示提示一下用户什么的'
    }
})

上面的函数就是一个异步的操作。因为脚本在加载时不会理解执行 onload 或者 onerror,而是一段时间后才会返回结果。之前的处理方式是通过 callback 回调的方式等待回应的结果。但是这样也有一些缺点,比如多个任务按队列请求时,我们就需要写上n个回调函数。

loadScript('something.js', function(script) {

  loadScript('something2.js', function(script) {

    loadScript('something3.js', function(script) {
      // ...在所有脚本被加载后继续操作
    })

  })
})

但如果嵌套变多,代码层次就会变深,维护难度也随之增加,尤其是如果我们有一个不是 ... 的真实代码,就会包含更多的循环,条件语句等。 这有时称为“回调地狱”或者“回调金字塔”。 那有没有什么更好的方式来处理这种情况呢,Promise就闪亮登场了⚡️⚡️

概念

想象一下,你是一个外卖老板,你的客人来订餐,会一直等着你做好外卖,并且不停的催着你好没有。你被烦的不行,餐厅生意日益惨淡。于是你急中生智,告诉客人你先坐着,我做好就通知你,你的生意越做越好。。。

在编程中,我们经常用现实世界中的事物进行类比: 1,“生产者代码” 会做一些事情,也需要事件。比如,它加载一个远程脚本。此时它就像“外卖店老板”。 2,“消费者代码” 想要在它准备好时知道结果。许多函数都需要结果。此时它们就像是“客人”。 3,promise 是将两者连接的一个特殊的 JavaScript 对象。就像是“列表”。生产者代码创建它,然后将它交给每个订阅的对象,因此它们都可以订阅到结果。

如果还没有理解可以先看一下 Promise 的语法:

let promise = new Promise(function(resolve, reject) {
  // executor (生产者代码,"cooker")
});

传递给 new Promise 的函数称之为 executor。当 promise 被创建时,它会被自动调用。它包含生产者代码,这最终会产生一个结果。与上文类比,executor 就是“老板”。

promise 对象有内部属性:

  • state —— 最初是 “pending”,然后被改为 “fulfilled” 或 “rejected”,
  • result —— 一个任意值,最初是 undefined。

当 executor 完成任务时,应调用下列之一:

  • resolve(value) —— 说明任务已经完成:
    • 将 state 设置为 "fulfilled",
    • sets result to value。
  • reject(error) —— 表明有错误发生:
    • 将 state 设置为 "rejected",
    • 将 result 设置为 error。

这是一个简单的 executor,可以把这一切都聚集在一起:

let promise = new Promise(function(resolve, reject) {
  // 当 promise 被构造时,函数会自动执行

  alert(resolve) // function () { [native code] }
  alert(reject)  // function () { [native code] }

  // 在 1 秒后,结果为“完成!”,表明任务被完成
  setTimeout(() => resolve("done!"), 1000)
})

我们运行上述代码后发现两件事:

1, 会自动并立即调用 executor(通过 new Promise)。 2, executor 接受两个参数 resolve 和 reject —— 这些函数来自于 JavaScipt 引擎。我们不需要创建它们,相反,executor 会在它们准备好时进行调用。

经过一秒钟的思考后,executor 调用 resolve("done") 来产生结果

这是“任务成功完成”的示例。

现在的是示例则是 promise 的 reject 出现于错误的发生:

let promise = new Promise(function(resolve, reject) {
  // after 1 second signal that the job is finished with an error
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

回到我们刚刚举的例子,我们现在要开始给客人订餐,并在完成后通知客人:

let cookPromise = (cooktime) => {
    return new Promise((resolve, reject) => {
        setTimeout(resolve, cooktime, '客官,饭菜做好啦!')
    })
}

// 上面的代码传入一个参数 cooktime 表示这个菜要做的时间(毫秒),使用如下
cookPromise(2000).then(alert)
// 2秒后弹出提示 客官,饭菜做好啦!

使用起来非常的简洁,当然我们也可以使用回调的方式去写:

let cookwait = (cooktime, callback) => {
    setTimeout(callback, cooktime, '客官,饭菜做好啦!')
}
cookwait(2000, alert)

如果做得饭菜不好失败了呢?我们可以通过 reject 修改 promise 的状态。

let cookPromise = () => {
    return new Promise((resolve, reject) => {
        if('饭做好啦') {
            resolve('客官,饭菜做好啦!')
        } else {
            reject('客官不好意思,饭菜做失败了。。。')
        }
    })
}

使用时如下:

cookPromise()
    .then(success => alert(success))
    .catch(failed => alert(failed))

// 你也可以使用then里传入2个参数的的方式,但是还是推荐catch来获取失败的信息,一个是因为语法看着更加明了,二是因为如果一开始就reject,第一个参数则会变成失败。
cookPromise()
    .then(success => alert(success), failed => alert(failed))

我们可以简单的总结一下,首先通过 new Promise() 新建一个 Promise 对象,告诉这是一个异步的程序,然后通过 resolve 或者 reject 来改变这个异步程序的状态。

那么我们继续回到之前说的回调地狱的问题,多个回调使用时,可以使用 Promise.then() 的链式调用:

new Promise((resolve, reject) => {
    resolve('step1 success')
}).then(() => 'step2 success')
  .then(() => 'step3 success')
  ....

此外如果有多个promise任务需要一起执行时,也可以使用 Promise.all() 方法:

let timeout = ms => new Promise((resolve, reject) => setTimeout(resolve, ms, 'done!'))

let timeout1 = timeout(1000)
let timeout2 = timeout(2000)
let timeout3 = timeout(3000)
let timeout4 = timeout(4000)

Promise.all([timeout1, timeout2, timeout3, timeout4]).then(() => {})

Promise.all() 方法接受一个由 Promise 组成的数组作为参数。并且自己也会返回一个 Promise 。所以可以使用 then 方法。

另外还有一个 Promise.race(),传参方式和 Promise.all(),区别在于后者需要等待所有的 Promise 都返回后才会执行后续的 then ,前者是只要有一个 Promise有结果后理解返回,其他的 Promise会被无视。

async

「async/await」是一种以更舒适的方式使用 promises 的特殊语法,同时它也更易于理解和使用。

语法

async 的写法很简单,直接在一个函数的 function 关键词前加一个 async 即可:

async function f() {
    return 1
}
f() // 返回一个 Promise 状态为 resolve
f().then(alert) // 弹出 1

观察上面的函数我们可以得出结论,async 会把函数包装成 Promise。 显式的返回一个 Promise,结果是一样的:

async function f() {
    return Promise.resolve(1)
}
f() // 返回一个 Promise 状态为 resolve
f().then(alert) // 弹出 1

所以说,async 确保了函数的返回值是一个 promise,也会包装非 promise 的值。很简单是吧?但是还没完。还有一个关键字叫 await,它只在 async 函数中有效,也非常酷。

await

语法如下:

let value = await promise
// 需要注意的是 await 只能和 async 配套,不能单独使用

下面是完整的使用:

async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("done!"), 1000)
  });

  let result = await promise; // 等待直到 promise 决议 (*)

  alert(result); // "done!"
}

f()

上面再执行 f() 后会在一秒后才会弹出 done! 。所以 alert 实际是在等待 (*) 行的promise返回一个状态后才执行。

同样我们也可以改写一下之前的链式调用的例子:

// 原例 Promise
new Promise((resolve, reject) => {
    resolve('step1 successed')
}).then(() => 'step2 successed')
  .then(() => 'step3 successed')
  ....
  
// async
(async () {
    let step1 = Promise.resolve('step1 successed')
    let step2 = Promise.resolve('step2 successed')
    let step3 = Promise.resolve('step3 successed')
    alert('all successed')
})()

如果想定义一个 async 的类方法,在方法前面添加 async 就可以了:

class Waiter {
  async wait() {
    return await Promise.resolve(1);
  }
}

new Waiter()
  .wait()
  .then(alert); // 1

这里的含义是一样的:它确保了方法的返回值是一个 promise 并且可以在方法中使用 await。在 Vue 中一些场景我们也经常会遇见,比如在 created() 方法中写异步的请求数据:

new Vue({
    el: '#app',
    async created () {
        let data = this.$http('getData.json')
        // 操作拿到的数据
    }
})

那如果是执行错误的返回呢,一共有两种写法可以返回 reject

// 第一种写法
async function f() {
  await Promise.reject(new Error("Whoops!"));
}

// 第二种写法
async function f() {
  throw new Error("Whoops!");
}

上面两种写法的结果都是一样的。在真实的环境下,promise 被拒绝前通常会等待一段时间。所以 await 会等待,然后抛出一个错误。 我们可以用 try..catch 来捕获上面的错误,就像对一般的 throw 语句那样:

async function f() {
  try {
    let response = await fetch('http://no-such-url')
  } catch(err) {
    alert(err) // TypeError: failed to fetch
  }
}

f()

如果我们不使用 try..catch,由 f() 产生的 promise 就会被拒绝。我们可以在函数调用后添加 .catch 来处理错误:

async function f() {
  let response = await fetch('http://no-such-url');
}

// f() 变为一个被拒绝的 promise
f().catch(alert); // TypeError: failed to fetch // (*)

微任务

Promise 的处理程序(handlers).then.catch.finally 都是异步的。

即便一个 promise 立即被 resolve.then.catch.finally 下面的代码也会在这些处理程序之前被执行。

let promise = Promise.resolve();

promise.then(() => alert("promise done"));

alert("code finished"); // 该警告框会首先弹出

上面的代码如果你运行它,你会首先看到 code finished,然后才是 promise done

这很奇怪,因为这个 promise 绝对在开头就被执行了。

为什么 .then 会在之后被触发?这是怎么回事?

微任务队列

异步任务需要适当的管理。为此,JavaScript 标准规定了一个内部队列 PromiseJobs,通常被称为 “微任务队列”(v8 术语)。

如规范中所述:

队列是先进先出的:首先进入队列的任务会首先运行。 只有在引擎中没有其它任务运行时,才会启动任务队列的执行。 或者,简单地说,当一个 promise 准备就绪时,它的 .then/catch/finally 处理程序就被放入队列中。但是不会立即被执行。当 JavaScript 引擎执行完当前的代码,它会从队列中获取任务并执行它。

这就是示例中的 “code finished” 会首先出现的原因。

Promise 处理程序总是被放入这个内部队列中。

如果有一个 promise 链带有多个 .then/catch/finally,那么它们中每一个都是异步执行的。也就是说,它会首先排入一个队列,只有当前代码执行完毕而且先前的排好队的处理程序都完成时才会被执行。

如果返回值的顺序对我们很重要该怎么办?我们怎么才能让 code finishedpromise done 之后出现呢?

很简单,只需要像下面这样把返回 code finished.then 处理程序放入队列中:

Promise.resolve()
  .then(() => alert("promise done!"))
  .then(() => alert("code finished"));

总结

函数前面的关键字 async 有两个作用:

  1. 让这个函数返回一个 promise
  2. 允许在函数内部使用 await。 这个 await 关键字又让 JavaScript 引擎等待直到 promise 完成,然后:

如果有错误,就会抛出异常,就像那里有一个 throw error 语句一样。 否则,就返回结果,并赋值。 这两个关键字一起用就提供了一个很棒的方式来控制异步代码,并且易于读写。

有了 async/await 我们就几乎不需要使用 promise.then/catch,但是不要忘了它们是基于 promise 的,所以在有些时候(如在最外层代码)我们就不得不使用这些方法。再有就是 Promise.all 可以帮助我们同时处理多个异步任务。

参考文献:现代 JavaScript 教程 - promise