JavaScript 高级程序设计(第 4 版)- 期约和异步函数

2023-05-17 15:09:25 浏览数 (2)

# 异步编程

  • 同步行为对应内存中顺序执行的处理器指令。在程序执行的每一步,都可以推断出程序的状态。
  • 异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。
  • 早期JS中,只支持定义回调函数来表明异步操作完成。串联多个异步操作需要深度嵌套回调函数(回调地狱)

# Promise

# Promise基础

代码语言:javascript复制
let p = new Promise(() => {});
setTimeout(console.log, 0, p); // Promise <pending>

Promise状态机

Promise是一个有状态的对象,且状态私有,不能直接通过JS检测到,也不能通过外部JS代码修改

  • pending:最初始状态,可以转为fulfilled或rejected,且转换不可逆
  • fulfilled(resolved):已经成功完成
  • rejected:没有成功完成

解决值、拒绝理由及用例

  • 可以抽象地表示一个异步操作,Promise的状态代表期约是否完成
  • Promise封装的异步操作也可以实际生成某个值,而程序期待Promise状态改变时可以访问这个值
  • 每个Promise只要状态切换为resolved,就会有一个私有的内部值(value),切换为rejected时会有一个私有的内部理由(reason)。二者可选,默认值为undefined

通过直线函数控制Promise状态

  • Promise状态私有,只能在内部操作。内部操作在Promise的执行器函数中完成。
  • 执行器函数职责:初始化期约的异步行为和控制状态的最终转换

Promise.resolve()

  • 可以实例化一个解决的期约
代码语言:javascript复制
// 下面两个期约实例实际上是一样的
let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();

Promise.reject()

  • 实例化一个拒绝的期约并抛出一个异步错误(该错误不能通过try/catch捕获,只能通过拒绝处理程序捕获)

同步/异步执行的二元性

代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法

# Promise的实例方法

ECMAScript 的 Promise 实现了 Thenable 接口,在ES暴露的异步结构中,任何对象都有一个then方法

Promise.prototype.then()

  • 为Promise实例添加处理程序的主要方法
  • 最多接收两个参数:onResolved处理程序和onRejected处理程序
代码语言:javascript复制
// Promise 只能转换为最终状态一次,所以这两个操作一定是互斥的
function onResolved(id) {
  setTimeout(console.log, 0, id, 'resolved');
}
function onRejected(id) {
  setTimeout(console.log, 0, id, 'rejected');
}
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'));
// p1 resolved
// p2 rejected

  • 两个处理程序参数都是可选的,传给then()的任何非函数类型的参数都会被静默忽略,如果想只提供onRejected参数,就要在onResolved参数位置传入undefined
  • Promise.prototype.then()返回一个新的Promise实例
    • 新Promise实例基于onResolved处理程序的返回值构建,即该处理程序的返回值会通过Promise.resolve()包装来生成新Promise
    • 如果没有提供处理程序,则Promise.resolve()就会包装上一个Promise解决之后的值
    • 如果没有显式的返回语句,则Promise.resolve()会包装默认的返回值undefined
    • 抛出异常会返回拒绝的Promise
    • 返回错误值不会触发拒绝行为,而会把错误对象包装在一个解决的期约中
代码语言:javascript复制
let p1 = new Promise(() => {});
let p2 = p1.then();
setTimeout(console.log, 0, p1); // Promise <pending>
setTimeout(console.log, 0, p2); // Promise <pending>
setTimeout(console.log, 0, p1 === p2); // false

  • onRejected处理程序返回的值也会被Promise.resolve()包装

Promise.prototype.catch()

  • 用于给Promise添加拒绝处理程序,只接收一个参数(onRejected处理程序)
  • 事实上,该方法就是一个语法糖,相当于Promise.prototype.then(null, onRejected)
代码语言:javascript复制
let p = Promise.reject();
let onRejected = function(e) {
  setTimeout(console.log, 0, 'rejected');
};
// 下面两种方式等效
p.then(null, onRejected); // rejected
p.catch(onRejected); // rejected

  • Promise.prototype.catch()返回一个新的Promise实例
代码语言:javascript复制
let p1 = new Promise(() => {});
let p2 = p1.catch();
setTimeout(console.log, 0, p1); // Promise <pending>
setTimeout(console.log, 0, p2); // Promise <pending>
setTimeout(console.log, 0, p1 === p2); // false

Promise.prototype.finally()

  • 用于给Promise添加onFinally处理程序,该处理程序在Promise转换为解决或拒绝状态时都会执行
  • 该方法可以避免onResolved和onRejected处理程序中出现冗余代码,无法知道期约的状态是解决还是拒绝
  • Promise.prototype.finally()方法返回一个新的期约实例
    • 该新实例不同于then()或catch()方法返回的实例,因为onFinallly被设计为一个状态无关的方法,所以大多数情况下它将表现父期约的传递
    • 如果返回的是一个待定的期约,或者onFinallly处理程序抛出了错误,则会返回相应的期约(待定或拒绝)

非重入期约方法

当期约进入落定状态时,与状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码会在处理程序之前执行。该特性由JS运行时保证,称为“非重入”特性

代码语言:javascript复制
let p = Promise.resolve();
p.then(() => console.log('onResolved handler'));

console.log('then() returns');

// then() returns
// onResolved handler

邻近处理程序的执行顺序

如果给Promise添加了多个处理程序,当Promise状态变化时,相关处理程序会按照它们的添加顺序依次执行。无论then()、catch()还是finally()添加的处理程序都是如此。

代码语言:javascript复制
let p1 = Promise.resolve();
let p2 = Promise.reject();

p1.then(() => setTimeout(console.log, 0, 1));
p1.then(() => setTimeout(console.log, 0, 2));
// 1
// 2

p2.then(null, () => setTimeout(console.log, 0, 3));
p2.then(null, () => setTimeout(console.log, 0, 4));
// 3
// 4

传递解决值和拒绝理由

到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可以进一步对这个值进行操作。在执行函数中,解决的值和拒绝的理由是分别作为 resolve()和 reject()的第一个参数往后传的。

代码语言:javascript复制
let p1 = new Promise((resolve, reject) => resolve('foo'));
p1.then((value) => console.log(value)); // 'foo'

let p2 = new Promise((resolve, reject) => reject('bar'));
p2.catch((reason) => console.log(reason)); // 'bar'

Promise.resolve()和 Promise.reject()在被调用时就会接收解决值和拒绝理由。同样地,它们返回的期约也会像执行器一样把这些值传给 onResolved 或 onRejected 处理程序

代码语言:javascript复制
let p1 = Promise.resolve('foo');
p1.then((value) => console.log(value)); // foo

let p2 = Promise.reject('bar');
p2.catch((reason) => console.log(reason)); // bar

拒绝期约与拒绝错误处理

绝期约类似于 throw()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。

代码语言:javascript复制
let p1 = new Promise((resolve, reject) => reject(Error('foo')));
let p2 = new Promise((resolve, reject) => { throw Error('bar'); });
let p3 = Promise.resolve().then(() => { throw Error('foo'); });
let p4 = Promise.reject(Error('foo'));

setTimeout(console.log, 0, p1); // Promise <rejected>: Error foo
setTimeout(console.log, 0, p2); // Promise <rejected>: Error foo
setTimeout(console.log, 0, p3); // Promise <rejected>: Error foo
setTimeout(console.log, 0, p4); // Promise <rejected>: Error foo

异步错误只能通过异步的 onRejected 处理程序捕获,这不包括捕获执行函数中的错误,在解决或拒绝期约之前,仍然可以使用 try/catch 在执行函数中捕获错误。

代码语言:javascript复制
let p = new Promise((resolve, reject) => {
  try {
    throw Error('foo');
  } catch (e) {
    resolve('bar');
  }
});
setTimeout(console.log, 0, p); // Promise <resolved>: bar

then()和 catch()的 onRejected 处理程序在语义上相当于 try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此, onRejected 处理程序的任务应该是在捕获异步错误之后返回一个解决的期约。

代码语言:javascript复制
new Promise((resolve, reject) => {
  console.log('begin asynchronous execution');
  reject(Error('bar'));
}).catch((e) => {
  console.log('caught error', e);
}).then(() => {
  console.log('continue asynchronous execution');
});
// begin asynchronous execution
// caught error Error: bar
// continue asynchronous execution

# 期约连锁与期约合成

期约连锁

因为每个Promise实例的方法(then()、catch()、finally())都会返回一个新的Promise对象,所以可以把Promise逐个串联起来。

代码语言:javascript复制
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

  • 其意义在于串行化异步任务,解决回调地狱问题
代码语言:javascript复制
// 将生成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
// p2 executor
// p3 executor
// p4 executor

期约图

一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。

代码语言:javascript复制
//  A
// / 
// B  C
// / /
// D E F G
let A = new Promise((resolve, reject) => {
  console.log('A');
  resolve();
});
let B = A.then(() => console.log('B'));
let C = A.then(() => console.log('C'));

B.then(() => console.log('D'));
B.then(() => console.log('E'));
C.then(() => console.log('F'));
C.then(() => console.log('G'));
// A
// B
// C
// D
// E
// F
// G

Promise.all()和Promise.race()

  • Promise.all()

Promise.all()静态方法创建的期约会在一组期约全部解决之后再解决。该方法接收一个可迭代对象,返回一个新期约

  • 合成的期约只会在每个包含的期约都解决之后才解决
  • 如果至少有一个包含的期约待定,则合成的期约待定。如果有一个包含的期约拒绝,则合成的期约也会拒绝
  • 如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,按照迭代器顺序
  • 如果有期约拒绝,最先拒绝的期约会将自己的理由作为合成期约的拒绝理由
  • Promise.race()

Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。该方法接收一个可迭代对象,返回一个新期约

  • 无论解决还是拒绝,最先落定的期约,Promise.race()就会包装其解决值或拒绝理由并返回新期约

串行期约合成

代码语言:javascript复制
function addTwo(x) { return x   2; }
function addThree(x) { return x   3; }
function addFive(x) { return x   5; }
function addTen(x) {
  return Promise.resolve(x)
    .then(addTwo)
    .then(addThree)
    .then(addFive);
}
addTen(8).then(console.log); // 18

function compose(...fns) {
  return (x) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x));
}
let addTen = compose(addTwo, addThree, addFive);
addTen(8).then(console.log); // 18

# 期约扩展

ES6 期约实现是很可靠的,但它也有不足之处。比如,期约取消和进度追踪。

  • 期约取消
代码语言:javascript复制
<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>

  • 期约进度通知
代码语言:javascript复制
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(notifyHandlers) {
    this.notifyHandlers.push(notifyHandlers);
    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);
});
p.notify((x) => setTimeout(console.log, 0, 'progress:', x));
p.notify((y) => setTimeout(console.log, 0, '当前进度:', y));
p.then(() => setTimeout(console.log, 0, 'complete'));
// progress: 80% remaining
// 当前进度: 80% remaining
// progress: 60% remaining
// 当前进度: 60% remaining
// progress: 40% remaining
// 当前进度: 40% remaining
// progress: 20% remaining
// 当前进度: 20% remaining
// complete

# 异步函数

异步函数,也称为async/await,是ES6期约模式在ECMAScript函数中的应用。async/await是ES8规范新增的,该特性从行为和语法上都增强了JS,让以同步方式写的代码能够异步执行。

# 异步函数

async

  • 用于声明异步函数,可以用在函数声明,函数表达式、箭头函数和方法上
  • 使用async关键字可以让函数有异步特征,但总体上其代码仍然是同步求值的
  • 异步函数如果使用return返回了值,该值会被Promise.resolve()包装成一个期约对象。异步函数始终返回期约对象
代码语言:javascript复制
async function foo() {
  console.log(1);
  return 3;
}
foo().then(console.log);
console.log(2);
// 1
// 2
// 3

  • 异步函数的返回值期待一个实现thenable接口的对象,但常规的值也可以
代码语言:javascript复制
// 返回一个原始值
async function foo() {
  return 'foo';
}
foo().then(console.log);
// foo

// 返回一个没有实现thenable接口的对象
async function bar() {
  return ['bar'];
}
bar().then(console.log);
// ['bar']

// 返回一个实现了thenable接口的非期约对象
async function baz() {
  const thenable = {
    then(callback) {
      callback('baz');
    }
  };
  return thenable;
}
baz().then(console.log);
// baz

// 返回一个期约
async function qux() {
  return Promise.resolve('qux');
}
qux().then(console.log);
// qux

  • 在异步函数中抛出错误会返回拒绝的期约,但拒绝期约的错误不会被异步函数捕获
代码语言:javascript复制
async function foo() {
  console.log(1);
  throw 3;
}
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3

async function bar() {
  console.log(1);
  Promise.reject(3);
}
bar().catch(console.log);
console.log(2);
// 1
// 2
// Uncaught (in promise): 3

await

  • 使用await关键字可以暂停异步函数代码的执行,等待期约解决
  • await关键字会暂停执行异步函数后面的代码,让出JS运行时的执行线程
  • await会尝试“解包”对象的值,然后将整个值传给表达式,再异步恢复异步函数的执行
  • await关键字与JS一元操作一样,可以单独使用,也可以在表达式中使用
代码语言:javascript复制
async function foo() {
  console.log(await Promise.resolve('foo'));
}
foo(); // foo

async function bar() {
  return await Promise.resolve('bar');
}
bar().then(console.log); // bar

async function baz() {
  await new Promise((resolve, reject) => setTimeout(resolve, 1000));
  console.log('baz');
}
baz(); // baz (1s 后)

  • await期待一个实现thenable接口的对象,但常规的值也可以。
    • 如果是实现thenable接口的对象,则这个对象可以由await来解包
    • 如果不是,则这个值就被当做已经解决的期约
  • 等待会抛出错误的同步操作,会返回拒绝的期约
代码语言:javascript复制
async function foo() {
  console.log(1);
  await (() => { throw 3; })();
}
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3

  • 单独的Promise.reject()不会被异步函数捕获,而会抛出未捕获错误。但对拒绝的期约使用await则会释放错误值(将拒绝期约返回)
代码语言:javascript复制
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 关键字必须在异步函数中使用,不能再顶级上下文中使用
  • 异步函数的特质不会扩展到嵌套函数,异步函数只能直接出现在异步函数的定义中

# 停止和恢复执行

JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了, JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。

  • 即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值

# 异步函数策略

  • 实现sleep
代码语言:javascript复制
async function sleep(delay) {
  return new Promise((resolve) => setTimeout(resolve, delay));
}
async function foo() {
  const t0 = Date.now();
  await sleep(1500);
  console.log(Date.now() - t0);
}
foo(); // 1514

  • 利用平行执行
  • 串行执行期约
  • 栈追踪与内存管理 期约与异步函数的功能有相当程度的重叠,但它们在内存中的表示则差别很大。

0 人点赞