# Promise

Promise 对象用于表示一个异步操作的最终完成 (或失败)及其结果值。

# 基础

  • 状态

一个 Promise 必然处于以下几种状态之一:

  • 待定(pending): 初始状态,既没有被兑现,也没有被拒绝。

  • 已兑现(fulfilled): 意味着操作成功完成。

  • 已拒绝(rejected): 意味着操作失败。

  • Promise.resolve()

可以把任何值都转换为一个 Promise 对象:

console.log(Promise.resolve(3)) // Promise {<fulfilled>: 3}
// 多余的参数会忽略
console.log(Promise.resolve(3, 4, 5)) //Promise {<fulfilled>: 3}

// 如果传入的参数本身也是一个 Promise 对象,那他的行为就类似于空包装
let p = Promise.resolve(7)
p === Promise.resolve(p) // true
p === Promise.resolve(Promise.resolve(p)) // true
  • Promise.reject()

如果传入的参数本身是一个 Promise,那么这个 Promise reject 返回的结果就是传入的 Promise 对象:

console.log(Promise.reject(Promise.resolve())) // Promise {<rejected>: Promise}
  • try catch 的捕获 try catch 是捕获不到 Promise.reject(new Error('bar')) 的错误,这里的同步代码之所以没有捕获到 Promise 抛出的错误,是因为他没有通过异步模式捕获错误。reject 的错误并没有抛到同步代码的线程中,而是通过浏览器异步消息队列来处理的。

# Promise 的实例方法

  • Promise.prototype.then()

这个 then() 方法接收最多两个参数: onResolved 处理程序和 onRejected 处理程序。如果只想传入 reject 参数,那就在 resolve 参数的位置上传入 undefined。

let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000))
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000))

p1.then(() => onResolved('p1'), () => onRejected('p1'))
p2.then(() => onResolved('p2'), () => onRejected('p2'))

// 不传 onResolved 处理程序的规范写法
p2.then(null, () => onRejected('p2'))
  • Promise.prototype.catch()

事实上,这个方法就是一个语法糖,调用它就相当于调用 Promise.prototype.then(null, onRejected)。

  • Promise.prototype.finally()

Promise.prototype.finally() 方法用于给 Promise 添加 onFinally 处理程序,这个处理程序在 Promise 转换为解决或拒绝状态时都会执行。这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道 Promise 的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

  • 非重入期约方法 onResolved / onRejected 处理程序、 catch() 处理程序和 finally() 处理程序中的回调函数,都是 JavaScript 事件执行机制中的微任务。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务,然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。

正常情况下,在通过 throw() 关键字抛出错误时,JavaScript 运行时的错误处理机制会停止执行抛出错误之后的任何指令:

throw Error('foo');
console.log('bar'); // 这一行不会执行
// Uncaught Error: foo

但是,在 Promise 中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令:

Promise.reject(Error('foo'));
console.log('bar');
// bar
// Uncaught (in promise) Error: foo

# Promise 连用以及合并请求

  • Promise 连用

把 Promise 逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个 Promise 实例的方法(then() 、catch() 和 finally())都会返回一个新的 Promise 对象,而这个新 Promise 又有自己的实例方法。这样连缀方法调用就可以构成所谓的“ Promise 连锁”。比如:

  let p = new Promise((resolve, reject) => {
  console.log('first');
  resolve();
});
p.then(() => console.log('second'))
.then(() => console.log('third'))
.then(() => console.log('fourth'));
// first
// second
// third
// fourth

这个实现最终执行了一连串同步任务。相当于下面代码:

(() => console.log('first'))();
(() => console.log('second'))();
(() => console.log('third'))();
(() => console.log('fourth'))();

要真正执行异步任务,可以改写前面的例子,让每个执行器都返回一个 Promise 实例。

let p1 = new Promise((resolve, reject) => {
  console.log('p1 executor');
  setTimeout(resolve, 1000);
});
p1.then(() => new Promise((resolve, reject) => {
  console.log('p2 executor');
  setTimeout(resolve, 1000);
}))
.then(() => new Promise((resolve, reject) => {
  console.log('p3 executor');
  setTimeout(resolve, 1000);
}))
.then(() => new Promise((resolve, reject) => {
  console.log('p4 executor');
  setTimeout(resolve, 1000);
}));
// p1 executor(1 秒后)
// p2 executor(2 秒后)
// p3 executor(3 秒后)
// p4 executor(4 秒后)

把生成 Promise 的代码提取到一个工厂函数中,就可以写成这样:

function delayedResolve(str) {
  return new Promise((resolve, reject) => {
    console.log(str);
    setTimeout(resolve, 1000);
  });
}

delayedResolve('p1 executor')
  .then(() => delayedResolve('p2 executor'))
  .then(() => delayedResolve('p3 executor'))
  .then(() => delayedResolve('p4 executor'))
// p1 executor(1 秒后)
// p2 executor(2 秒后)
// p3 executor(3 秒后)
// p4 executor(4 秒后)
  • Promise 合并请求

通过 Promise.all() 以及 Promise.race() 实现,Promise.all() 接收一个 Promise 对象组成的数组参数,如果其中一个 Promise 发生 reject(),那么就会报错,请求拒绝。

如果有 Promise 拒绝,则第一个拒绝的 Promise 会将自己的理由作为合成 Promise 的拒绝理由。之后再拒绝的 Promise 不会影响最终 Promise 的拒绝理由。不过,这并不影响所有包含 Promise 正常的拒绝操作。合成的 Promise 会静默处理所有包含 Promise 的拒绝操作,如下所示:

// 虽然只有第一个期约的拒绝理由会进入拒绝处理程序,第二个期约的拒绝也会被静默处理,不会有错误跑掉
let p = Promise.all([
  Promise.reject(3),
  new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
p.catch((reason) => setTimeout(console.log, 0, reason)); // 3
// 没有未处理的错误

Promise.race() 返回的结果是参数数组中,第一个执行 resolve() 或者 reject() 的 Promise 执行的结果。与 Promise.all() 类似,合成的 Promise 会静默处理所有包含 Promise 的拒绝操。

# Promise 的扩展

  • Promise 取消

可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。

class CancelToken {
  constructor(cancelFn) {
    this.promise = new Promise((resolve, reject) => {
      cancelFn(resolve);
    });
  }
}

这个类包装了一个 Promise,把解决方法暴露给了 cancelFn 参数。这样,外部代码就可以向构造函数传入一个函数,从而控制什么情况下可以取消 Promise。

<button id="start">Start</button>
<button id="cancel">Cancel</button>
<script>
  class CancelToken {
    constructor(cancelFn) {
      this.promise = new Promise((resolve, reject) => {
        cancelFn(() => {
          setTimeout(console.log, 0, "delay cancelled");
            resolve();
        });
      });
    }
  }
  const startButton = document.querySelector('#start');
  const cancelButton = document.querySelector('#cancel');
  function cancellableDelayedResolve(delay) {
    setTimeout(console.log, 0, "set delay");
    return new Promise((resolve, reject) => {
      const id = setTimeout((() => {
        setTimeout(console.log, 0, "delayed resolve");
        resolve();
      }), delay);
      const cancelToken = new CancelToken((cancelCallback) => cancelButton.addEventListener("click", cancelCallback));
      cancelToken.promise.then(() => clearTimeout(id));
    });
  }
  startButton.addEventListener("click", () => cancellableDelayedResolve(1000));
</script>

每次单击“Start”按钮都会开始计时,并实例化一个新的 CancelToken 的实例。此时,“Cancel” 按钮一旦被点击,就会触发令牌实例中的 Promise 解决。而解决之后,单击“Start”按钮设置的超时也会被取消。

  • Promise 执行进度

ECMAScript 6 期约并不支持进度追踪,但是可以通过扩展来实现。

class TrackablePromise extends Promise {
  constructor(executor) {
    const notifyHandlers = [];
    super((resolve, reject) => {
      return executor(resolve, reject, (status) => {
        notifyHandlers.map((handler) => handler(status));
      });
    });
    this.notifyHandlers = notifyHandlers;
  }
  notify(notifyHandler) {
    this.notifyHandlers.push(notifyHandler);
    return this;
  }
}

let p = new TrackablePromise((resolve, reject, notify) => {
  function countdown(x) {
    if (x > 0) {
      notify(`${20 * x}% remaining`);
      setTimeout(() => countdown(x - 1), 1000);
    } else {
      resolve();
    }
  }
  countdown(5);
});

# 异步函数

ES8 的 async/await 旨在解决利用异步结构组织代码的问题。为此,ECMAScript 对函数进行了扩展,为其增加了两个新关键字:async 和 await 。

# async

async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

async function foo() {}
let bar = async function() {};
let baz = async () => {};
class Qux {
  async qux() {}
}

拒绝 Promise 的错误不会被异步函数捕获:

async function foo() {
  console.log(1);
  Promise.reject(3);
}
// Attach a rejected handler to the returned promise
foo().catch(console.log);
console.log(2);
// 1
// 2
// Uncaught (in promise): 3

async 函数返回一个 Promise 对象。

# await

await 关键字只能在 async 函数中使用。await 后面跟一个异步的操作,如果不是异步操作,最终会通过 Promise.resolve() 包装返回。await 返回的是后面的异步对象执行的结果。

使用 await 关键字可以暂停异步函数代码的执行,等待 Promise 解决。

async function foo() {
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
  console.log(await p);
}
foo();
// 3

注意, await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。这个行为与生成器函数中的 yield 关键字是一样的。 await 关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。

await 关键字的用法与 JavaScript 的一元操作一样。它可以单独使用,也可以在表达式中使用,如:

// 异步打印"foo"
async function foo() {
  console.log(await Promise.resolve('foo'));
}
foo();
// foo
// 异步打印"bar"
async function bar() {
  return await Promise.resolve('bar');
}
bar().then(console.log);
// bar
// 1000 毫秒后异步打印"baz"
async function baz() {
  await new Promise((resolve, reject) => setTimeout(resolve, 1000));
  console.log('baz');
}
baz();
// baz(1000 毫秒后)

await 关键字期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如果是实现 thenable 接口的对象,则这个对象可以由 await 来“解包”。如果不是,则这个值就被当作已经解决的 Promise 对象。如下代码:

// 等待一个原始值
async function foo() {
  console.log(await 'foo');
}
foo();
// foo

// 等待一个没有实现 thenable 接口的对象
async function bar() {
  console.log(await ['bar']);
}
bar();
// ['bar']

// 等待一个实现了 thenable 接口的非 Promise 对象
async function baz() {
  const thenable = {
    then(callback) { callback('baz'); }
  };
  console.log(await thenable);
}
baz();
// baz


// 等待一个 Promise
async function qux() {
  console.log(await Promise.resolve('qux'));
}
qux();
// qux

等待会抛出错误的同步操作,会返回拒绝的 Promise:

async function foo() {
  console.log(1);
  await (() => { throw 3; })();
}
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3

如前面的例子所示,单独的 Promise.reject() 不会被异步函数捕获,而会抛出未捕获错误。

可以使用 await 捕获异常;

async function foo() {
  console.log(1);
  await Promise.reject(3);
  console.log(4); // 这行代码不会执行
}
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3

# await 的限制

await 关键字也只能直接出现在异步函数的定义中。在同步函数内部使用 await 会抛出 SyntaxError 。

// 不允许:await 出现在了箭头函数中
function foo() {
  const syncFn = () => {
    return await Promise.resolve('foo');
  };
  console.log(syncFn());
}

// 不允许:await 出现在了同步函数声明中
function bar() {
  function syncFn() {
    return await Promise.resolve('bar');
  }
  console.log(syncFn());
}

// 不允许:await 出现在了同步函数表达式中
function baz() {
  const syncFn = function() {
    return await Promise.resolve('baz');
  };
  console.log(syncFn());
}

// 不允许:IIFE 使用同步函数表达式或箭头函数
function qux() {
  (function () { console.log(await Promise.resolve('qux')); })();
  (() => console.log(await Promise.resolve('qux')))();
}

# 停止和恢复执行

JavaScript运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了,JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。 即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值。如下代码:

async function foo () {
  console.log(2)
  await null
  console.log(4)
}

console.log(1)
foo()
console.log(3)

// 1
// 2
// 3
// 4

如果 await 后跟一个 Promise 对象,此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。如下代码:

async function foo() {
  console.log(2);
  console.log(await Promise.resolve(8));
  console.log(9);
}
async function bar() {
  console.log(4);
  console.log(await 6);
  console.log(7);
}
console.log(1);
foo();
console.log(3);
bar();
console.log(5);
// 1
// 2
// 3
// 4
// 5
// 8
// 9
// 6
// 7

# 异步函数策略

  1. 实现 sleep()
async function sleep(delay) {
  return new Promise((resolve) => setTimeout(resolve, delay))
}

async function foo() {
  const t0 = Date.now()
  await sleep(1500) // 暂停 1500 毫秒
  console.log(Date.now() - t0)
}

foo()
// 1501
  1. 顺序执行

下面代码是顺序等待了 5 个随机的异步请求:

async function randomDelay(id) {
  // 延迟 0~1000 毫秒
  const delay = Math.random() * 1000;
  return new Promise((resolve) => setTimeout(() => {
    console.log(`${id} finished`);
    resolve();
  }, delay));
}
async function foo() {
  const t0 = Date.now();
  await randomDelay(0);
  await randomDelay(1);
  await randomDelay(2);
  await randomDelay(3);
  await randomDelay(4);
  console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed

用一个 for 循环重写,就是:

async function randomDelay(id) {
  // 延迟 0~1000 毫秒
  const delay = Math.random() * 1000;
  return new Promise((resolve) => setTimeout(() => {
    console.log(`${id} finished`);
    resolve();
  }, delay));
}
async function foo() {
  const t0 = Date.now();
  for (let i = 0; i < 5; ++i) {
    await randomDelay(i);
  }
  console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed
  1. 串行执行期约
function addTwo(x) {return x + 2;}
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}
async function addTen(x) {
  for (const fn of [addTwo, addThree, addFive]) {
    x = await fn(x);
  }
  return x;
}
addTen(9).then(console.log); // 19

也可以将这些函数改为 Promise 对象:

async function addTwo(x) {return x + 2;}
async function addThree(x) {return x + 3;}
async function addFive(x) {return x + 5;}

async function addTen(x) {
  for (const fn of [addTwo, addThree, addFive]) {
    x = await fn(x);
  }
  return x;
}
addTen(9).then(console.log); // 19
  1. 栈追踪与内存管理

在 Promise 中查看栈追踪:

function fooPromiseExecutor(resolve, reject) {
  setTimeout(reject, 1000, 'bar');
}
function foo() {
  new Promise(fooPromiseExecutor);
}
foo();
// Uncaught (in promise) bar
// setTimeout
// setTimeout (async)
// fooPromiseExecutor
// foo

我们可以看到错误信息包含嵌套函数的标识符,其实我们只需要最终指向错误的栈追踪。栈追踪信息应该相当直接地表现 JavaScript 引擎当前栈内存中函数调用之间的嵌套关系。在超时处理程序执行时和拒绝 Promise 时,我们看到的错误信息包含嵌套函数的标识符,那是被调用以创建最初 Promise 实例的函数。可是,我们知道这些函数已经返回了,因此栈追踪信息中不应该看到它们。

答案很简单,这是因为 JavaScript 引擎会在创建 Promise 时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。

可以使用异步函数减少栈追踪占用的内存:

function fooPromiseExecutor(resolve, reject) {
  setTimeout(reject, 1000, 'bar');
}
async function foo() {
  await new Promise(fooPromiseExecutor);
}
foo();
// Uncaught (in promise) bar
// foo
// async function (async)
// foo

这样一改,栈追踪信息就准确地反映了当前的调用栈。 fooPromiseExecutor() 已经返回,所以它不在错误信息中。但 foo() 此时被挂起了,并没有退出。JavaScript 运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样就不会像之前的例子那样带来额外的消耗,因此在重视性能的应用中是可以优先考虑的。

评 论:

更新: 12/27/2020, 4:59:16 PM