探索-JavaScript-ES2025-版--十-

探索 JavaScript(ES2025 版)(十)

原文:exploringjs.com/js/book/index.html

译者:飞龙

协议:CC BY-NC-SA 4.0

39 WeakSet(WeakSet)ES6(高级)

原文:exploringjs.com/js/book/ch_weaksets.html

  1. 39.1 WeakSet 与 Set 有何不同?

  2. 39.2 WeakSet 的使用场景:标记对象

    1. 39.2.1 示例:使用方法标记对象为安全使用
  3. 39.3 WeakSet API

39.1 WeakSet 与 Set 有何不同?

WeakSet 与 Set 类似,但有以下区别:

  • 它们可以持有对象,而不会阻止这些对象被垃圾回收。

  • 它们是黑盒:我们只有在拥有 WeakSet 和值的情况下才能从 WeakSet 中获取任何数据。支持的方法只有.add().delete().has()。请参阅 WeakMaps 作为黑盒部分,了解为什么 WeakSet 不允许迭代、循环和清除的原因。

39.2 WeakSet 的使用场景:标记对象

由于我们无法遍历 WeakSet 的元素,因此它们的使用场景并不多。

我们可以使用 WeakSet 来标记对象——例如:

const isSaved = new WeakSet();
{
  const obj = {};
  isSaved.add(obj); // (A)
  assert.equal(
    isSaved.has(obj), // (B)
    true
  );
}
// (C)

  • 在行 A 中,我们将obj标记为已保存。

  • 在行 B 中,我们检查obj是否已保存。

  • 在行 C 中,即使obj是数据结构isSaved的一个元素,它也可以被垃圾回收。

从某种意义上说,我们为obj创建了一个布尔属性,但将其外部存储。如果isSaved是一个属性,那么前面的代码将如下所示:

{
  const obj = {};
  obj.isSaved = true;
  assert.equal(
    obj.isSaved,
    true
  );
}

39.2.1 示例:使用方法标记对象为安全使用

以下代码演示了如何确保一个类只对其由其创建的实例应用其方法(基于Domenic Denicola 的代码):

const instancesOfSafeClass = new WeakSet();

class SafeClass {
  constructor() {
 instancesOfSafeClass.add(this);
 }

 method() {
 if (!instancesOfSafeClass.has(this)) {
 throw new TypeError('Incompatible object!');
 }
 }
}

const safeInstance = new SafeClass();
safeInstance.method(); // works

assert.throws(
 () => {
 const obj = {};
 SafeClass.prototype.method.call(obj); // throws an exception
 },
 TypeError
); 

39.3 WeakSet API

构造函数和WeakSet的三个方法与它们的Set等价物的工作方式相同[参考 ch_sets.html#quickref-sets]:

  • new WeakSet<T>(values?: Iterable<T>) ^(ES6)

  • .add(value: T): this ^(ES6)

  • .delete(value: T): boolean ^(ES6)

  • .has(value: T): boolean ^(ES6)

40 ES6 解构

原文:exploringjs.com/js/book/ch_destructuring.html

  1. 40.1 解构的第一印象

  2. 40.2 构造与提取

  3. 40.3 在哪里可以进行解构?

  4. 40.4 对象解构

    1. 40.4.1 属性值简写

    2. 40.4.2 剩余属性

    3. 40.4.3 语法陷阱:通过对象解构进行赋值

  5. 40.5 数组解构

    1. 40.5.1 数组解构与任何可迭代对象一起工作

    2. 40.5.2 剩余元素

  6. 40.6 解构的示例

    1. 40.6.1 数组解构:交换变量值

    2. 40.6.2 数组解构:返回数组的操作

    3. 40.6.3 对象解构:多个返回值

  7. 40.7 如果模式部分没有匹配到任何内容会发生什么?

    1. 40.7.1 对象解构和缺失的属性

    2. 40.7.2 数组解构和缺失的元素

  8. 40.8 哪些值不能解构?

    1. 40.8.1 我们无法对 undefinednull 进行对象解构

    2. 40.8.2 我们无法对非可迭代值进行数组解构

  9. 40.9 (高级)

  10. 40.10 默认值

    1. 40.10.1 数组解构中的默认值

    2. 40.10.2 对象解构中的默认值

  11. 40.11 参数定义与解构类似

  12. 40.12 嵌套解构

40.1 解构的第一印象

使用常规赋值,我们一次提取一个数据片段——例如:

const arr = ['a', 'b', 'c'];
const x = arr[0]; // extract
const y = arr[1]; // extract

使用解构,我们可以通过接收数据的位置上的模式同时提取多个数据片段。上一段代码中 = 的左侧是一个这样的位置。在以下代码中,行 A 中的方括号是一个解构模式:

const arr = ['a', 'b', 'c'];
const [x, y] = arr; // (A)
assert.equal(x, 'a');
assert.equal(y, 'b');

这段代码与上一段代码执行相同的操作。

注意模式“小于”数据:我们只提取所需的内容。

40.2 构造与提取

为了理解解构是什么,考虑 JavaScript 有两种相反的操作:

  • 我们可以通过设置属性和对象字面量来构造复合数据。

  • 我们可以从复合数据中提取数据,例如,通过获取属性。

数据构造看起来如下:

// Constructing: one property at a time
const jane1 = {};
jane1.first = 'Jane';
jane1.last = 'Doe';

// Constructing: multiple properties
const jane2 = {
  first: 'Jane',
  last: 'Doe',
};

assert.deepEqual(jane1, jane2);

数据提取看起来如下:

const jane = {
  first: 'Jane',
  last: 'Doe',
};

// Extracting: one property at a time
const f1 = jane.first;
const l1 = jane.last;
assert.equal(f1, 'Jane');
assert.equal(l1, 'Doe');

// Extracting: multiple properties (NEW!)
const {first: f2, last: l2} = jane; // (A)
assert.equal(f2, 'Jane');
assert.equal(l2, 'Doe');

行 A 的操作是新的:我们声明了两个变量f2l2,并通过解构(多值提取)来初始化它们。

行 A 的以下部分是一个解构模式

{first: f2, last: l2}

解构模式在语法上与用于多值构造的字面量相似。但它们出现在数据接收的地方(例如,在赋值的左侧),而不是数据创建的地方(例如,在赋值的右侧)。

40.3 在哪里可以进行解构?

解构模式可以在“数据接收位置”使用,例如:

  • 变量声明:

    const [a] = ['x'];
    assert.equal(a, 'x');
    
    let [b] = ['y'];
    assert.equal(b, 'y');
    
    
  • 赋值:

    let b;
    [b] = ['z'];
    assert.equal(b, 'z');
    
    
  • 参数定义:

    const f = ([x]) => x;
    assert.equal(f(['a']), 'a');
    
    

注意,变量声明包括constlet声明在for-of循环中:

const arr = ['a', 'b'];
for (const [index, element] of arr.entries()) {
    console.log(index, element);
}

输出:

0 a
1 b

在接下来的两个部分中,我们将更深入地探讨两种解构类型:对象解构和数组解构。

40.4 对象解构

对象解构 允许我们通过类似于对象字面量的模式批量提取属性值:

const address = {
  street: 'Evergreen Terrace',
  number: '742',
  city: 'Springfield',
  state: 'NT',
  zip: '49007',
};

const { street: s, city: c } = address;
assert.equal(s, 'Evergreen Terrace');
assert.equal(c, 'Springfield');

我们可以将模式视为放置在数据上的一张透明纸:模式键'street'在数据中有匹配项。因此,数据值'Evergreen Terrace'被分配给模式变量s

我们还可以对原始值进行对象解构:

const {length: len} = 'abc';
assert.equal(len, 3);

我们还可以对数组进行对象解构:

const {0:x, 2:y} = ['a', 'b', 'c'];
assert.equal(x, 'a');
assert.equal(y, 'c');

为什么这样工作?数组索引也是属性。

40.4.1 属性值缩写

对象字面量支持属性值缩写,对象模式也是如此:

const { street, city } = address;
assert.equal(street, 'Evergreen Terrace');
assert.equal(city, 'Springfield');

图标“练习”练习:对象解构

exercises/destructuring/object_destructuring_exrc.mjs

40.4.2 剩余属性

在对象字面量中,我们可以有扩展属性。在对象模式中,我们可以有剩余属性(必须放在最后):

const obj = { a: 1, b: 2, c: 3 };
const { a: propValue, ...remaining } = obj; // (A)

assert.equal(propValue, 1);
assert.deepEqual(remaining, {b:2, c:3});

一个剩余属性变量,如remaining(行 A),被分配了一个包含所有数据属性的对象,其键在模式中未提及。

remaining也可以被视为从obj中非破坏性地移除属性a的结果。

40.4.3 通过对象解构进行赋值:语法陷阱

如果我们在赋值时进行对象解构,我们将面临由语法歧义引起的陷阱——我们不能以大括号开始一个语句,因为这样 JavaScript 会认为我们正在开始一个块:

let prop;
assert.throws(
  () => eval("{prop} = { prop: 'hello' };"),
  {
    name: 'SyntaxError',
    message: "Unexpected token '='",
  });

图标“详情”为什么使用eval()

eval() 延迟解析(因此SyntaxError),直到assert.throws()的回调执行。如果我们不使用它,当这段代码被解析时,我们就会得到错误,而assert.throws()甚至不会被执行。

解决方案是将整个赋值放在括号中:

let prop;
({prop} = { prop: 'hello' });
assert.equal(prop, 'hello');

40.5 数组解构

数组解构允许我们通过类似于数组字面量的模式批量提取数组元素的值:

const [x, y] = ['a', 'b'];
assert.equal(x, 'a');
assert.equal(y, 'b');

我们可以通过在数组模式中放置空位来跳过元素:

const [, x, y] = ['a', 'b', 'c']; // (A)
assert.equal(x, 'b');
assert.equal(y, 'c');

行 A 中的数组模式以一个空位开始,这就是为什么索引 0 的数组元素被忽略。

40.5.1 数组解构与任何可迭代对象一起工作

数组解构可以应用于任何可迭代的值,而不仅仅是数组:

{ // Sets are iterable
  const [a, b] = new Set().add('fee').add('fi').add('fo');
  assert.equal(a, 'fee');
  assert.equal(b, 'fi');
}

{ // Maps are iterable
  const [a, b] = new Map().set('one', 1).set('two', 2);
  assert.deepEqual(
    a, ['one',1]
  );
  assert.deepEqual(
    b, ['two',2]
  );
}

{ // Strings are iterable
  const [a, b] = 'hello';
  assert.equal(a, 'h');
  assert.equal(b, 'e');
}

40.5.2 剩余元素

在数组字面量中,我们可以有扩展元素。在数组模式中,我们可以有剩余元素(必须放在最后):

const [x, y, ...remaining] = ['a', 'b', 'c', 'd']; // (A)

assert.equal(x, 'a');
assert.equal(y, 'b');
assert.deepEqual(remaining, ['c', 'd']);

剩余元素变量,如 remaining(行 A),被分配一个数组,其中包含尚未提到的解构值的所有元素。

40.6 解构示例

40.6.1 数组解构:交换变量值

我们可以使用数组解构来交换两个变量的值,而无需使用临时变量:

let x = 'a';
let y = 'b';

[x,y] = [y,x]; // swap

assert.equal(x, 'b');
assert.equal(y, 'a');

40.6.2 数组解构:返回数组的操作

当操作返回数组时,数组解构很有用,例如正则表达式的 .exec() 方法:

// Skip the element at index 0 (the whole match):
const [, year, month, day] =
  /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/
  .exec('2999-12-31');

assert.equal(year, '2999');
assert.equal(month, '12');
assert.equal(day, '31');

40.6.3 对象解构:多个返回值

如果一个函数返回多个值——无论是作为数组还是作为对象包装,解构都非常有用。

考虑一个 findElement() 函数,该函数在数组中查找元素:

findElement(array, (value, index) => «boolean expression»)

其第二个参数是一个函数,该函数接收元素的值和索引,并返回一个布尔值,指示这是否是调用者正在寻找的元素。

我们现在面临一个困境:findElement() 应该返回它找到的元素的值还是索引?一个解决方案是创建两个单独的函数,但这会导致代码重复,因为这两个函数非常相似。

以下实现通过返回一个包含找到的元素的索引和值的对象来避免重复:

function findElement(arr, predicate) {
  for (let index=0; index < arr.length; index++) {
    const value = arr[index];
    if (predicate(value)) {
      // We found something:
      return { value, index };
    }
  }
  // We didn’t find anything:
  return { value: undefined, index: -1 };
}

解构帮助我们处理 findElement() 的结果:

const arr = [7, 8, 6];

const {value, index} = findElement(arr, x => x % 2 === 0);
assert.equal(value, 8);
assert.equal(index, 1);

由于我们正在处理属性键,所以 valueindex 的提及顺序并不重要:

const {index, value} = findElement(arr, x => x % 2 === 0);

关键是,如果我们只对两个结果中的一个感兴趣,解构也能很好地为我们服务:

const arr = [7, 8, 6];

const {value} = findElement(arr, x => x % 2 === 0);
assert.equal(value, 8);

const {index} = findElement(arr, x => x % 2 === 0);
assert.equal(index, 1);

所有这些便利性结合起来,使得处理多个返回值的方式非常灵活。

40.7 如果模式部分不匹配会发生什么?

如果模式的一部分没有匹配,会发生什么?这与我们使用非批量操作时发生的情况相同:我们得到 undefined

40.7.1 对象解构和缺失属性

如果对象模式右侧没有匹配的属性,我们得到 undefined

const {prop: p} = {};
assert.equal(p, undefined);

40.7.2 数组解构和缺失元素

如果数组模式右侧没有匹配的元素,我们得到 undefined

const [x] = [];
assert.equal(x, undefined);

40.8 哪些值不能解构?

40.8.1 我们不能解构 undefinednull

对象解构只有在要解构的值是 undefinednull 时才会失败。也就是说,当通过点操作符访问属性会失败时,它也会失败。

> const {prop} = undefined
TypeError: Cannot destructure property 'prop' of 'undefined'
as it is undefined.

> const {prop} = null
TypeError: Cannot destructure property 'prop' of 'null'
as it is null.

因此,使用空对象模式解构值意味着“如果值是 undefinednull,则抛出异常”:

function throwIfUndefinedOrNull(value) {
  const {} = value;
}
assert.throws(
  () => throwIfUndefinedOrNull(undefined),
  TypeError
);
throwIfUndefinedOrNull(123); // OK

40.8.2 我们不能解构非可迭代值

数组解构要求解构的值是可迭代的。因此,我们不能解构 undefinednull。但我们也不能解构非可迭代的基本类型和对象:

> const [x] = 123
TypeError: 123 is not iterable
> const [y] = {}
TypeError: {} is not iterable

因此,使用空数组模式解构值意味着“如果值不可迭代,则抛出异常”:

function throwIfNotIterable(value) {
  const [] = value;
}
assert.throws(
  () => throwIfNotIterable(null),
  TypeError
);
assert.throws(
  () => throwIfNotIterable(true),
  TypeError
);
throwIfNotIterable('abc'); // OK: iterable primitive
throwIfNotIterable([]); // OK: iterable object

40.9 (高级)

所有剩余的部分都是高级的。

40.10 默认值

通常,如果模式没有匹配,相应的变量将被设置为 undefined

const {prop: p} = {};
assert.equal(p, undefined);

如果我们想使用不同的值,我们需要指定一个 默认值(通过 =):

const {prop: p = 123} = {}; // (A)
assert.equal(p, 123);

在行 A 中,我们指定 p 的默认值为 123。这个默认值被使用,因为我们正在解构的数据没有名为 prop 的属性。

40.10.1 数组解构中的默认值

这里,我们有两个默认值被分配给变量 xy,因为相应的元素不存在于解构的数组中。

const [x=1, y=2] = [];

assert.equal(x, 1);
assert.equal(y, 2);

数组模式中第一个元素的默认值是 1;第二个元素的默认值是 2

40.10.2 对象解构中的默认值

我们也可以为对象解构指定默认值:

const {first: f='', last: l=''} = {};
assert.equal(f, '');
assert.equal(l, '');

在解构的对象中,既不存在属性键 first 也不存在属性键 last。因此,使用了默认值。

使用属性值简写,这段代码变得更简单:

const {first='', last=''} = {};
assert.equal(first, '');
assert.equal(last, '');

40.11 参数定义与解构类似

考虑到我们在本章中学到的内容,参数定义与数组模式有很多共同之处(剩余元素、默认值等)。事实上,以下两个函数声明是等价的:

function f1(«pattern1», «pattern2») {
  // ···
}

function f2(...args) {
  const [«pattern1», «pattern2»] = args;
  // ···
}

40.12 嵌套解构

到目前为止,我们只在使用解构模式内部作为 赋值目标(数据汇)时使用了变量。但我们可以也将模式用作赋值目标,这使得我们可以将模式嵌套到任意深度:

const arr = [
  { first: 'Jane', last: 'Bond' },
  { first: 'Lars', last: 'Croft' },
];
const [, {first}] = arr; // (A)
assert.equal(first, 'Lars');

在行 A 的 Array 模式内部,存在一个索引为 1 的嵌套对象模式。

嵌套模式可能会变得难以理解,因此最好适度使用。

VIII 异步性

原文:exploringjs.com/js/book/pt_async.html

41 异步编程路线图

原文:exploringjs.com/js/book/ch_async-roadmap.html

  1. 41.1 下一章

  2. 41.2 同步函数

  3. 41.3 JavaScript 在单个进程中按顺序执行任务

  4. 41.4 基于回调的异步函数

  5. 41.5 基于 Promise 的异步函数

  6. 41.6 异步函数

这章是异步编程的简要路线图。

41.1 下一章

下一章将解释 JavaScript 中的异步编程:

  • “JavaScript 异步编程基础” (§42):

    • 我们将了解同步函数调用是如何工作的。

    • 我们还将探索 JavaScript 在单个进程中执行代码的方式,通过其 事件循环

    • 通过回调实现异步 在该章节中也有描述。

  • “异步编程的 Promises^(ES6)” (§43)

  • “Async 函数^(ES2017)” (§44)

  • “异步迭代^(ES2018)” (§45) 总结了这一系列关于异步编程的章节。异步迭代类似于 同步迭代,但迭代值是异步提供的。

本章的剩余部分将给你一些关于这一切意味着什么的初步想法。

阅读图标不必担心细节!

如果你现在还不完全理解,不要担心。这只是对即将发生的事情的一个快速预览。所有内容将在下一章中详细解释。

41.2 同步函数

正常函数是 同步的:调用者等待被调用者完成其计算。行 A 中的 divideSync() 是一个同步函数调用:

function main() {
 try {
 const result = divideSync(12, 3); // (A)
 assert.equal(result, 4);
 } catch (err) {
 assert.fail(err);
 }
}

41.3 JavaScript 在单个进程中按顺序执行任务

默认情况下,JavaScript 任务 是在单个进程中按顺序执行的函数。这看起来是这样的:

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

这个循环也被称为 事件循环,因为事件,例如点击鼠标,会将任务添加到队列中。

由于这种协作多任务处理风格,我们不希望任务在等待来自服务器的结果时阻塞其他任务的执行。下一小节将探讨如何处理这种情况。

41.4 基于回调的异步函数

如果 divide() 需要服务器来计算其结果呢?那么结果应该以不同的方式交付:调用者不需要(同步地)等待结果准备就绪;它应该在结果就绪时被通知(异步地)。异步交付结果的一种方式是给 divide() 提供一个回调函数,它使用该回调函数来通知调用者。

function main() {
 divideCallback(12, 3,
 (err, result) => {
 if (err) {
 assert.fail(err);
 } else {
 assert.equal(result, 4);
 }
 });
}

当有异步函数调用时:

divideCallback(x, y, callback)

然后发生以下步骤:

  • divideCallback() 向服务器发送请求。

  • 然后当前任务 main() 完成,其他任务可以执行。

  • 当服务器响应到达时,它要么是:

    • 一个错误 err:然后以下任务被添加到队列中。

      taskQueue.enqueue(() => callback(err));
      
      
    • 一个 result 值:然后以下任务被添加到队列中。

      taskQueue.enqueue(() => callback(null, result));
      
      

41.5 基于 Promise 的异步函数

Promise 是两件事:

  • 一种标准模式,使得使用回调函数变得更加容易。

  • 异步函数(下一小节的主题)所基于的机制。

调用一个基于 Promise 的函数看起来如下。

function main() {
 dividePromise(12, 3)
 .then(result => assert.equal(result, 4))
 .catch(err => assert.fail(err));
}

41.6 异步函数

观察异步函数的一种方式是将其视为基于 Promise 代码的更好语法:

async function main() {
 try {
 const result = await dividePromise(12, 3); // (A)
 assert.equal(result, 4);
 } catch (err) {
 assert.fail(err);
 }
}

在第 A 行调用的 dividePromise() 与前一部分中的相同,是基于 Promise 的函数。但现在我们有了一种看起来同步的语法来处理调用。await 只能在一种特殊类型的函数中使用,即 异步函数(注意 function 关键字前的 async 关键字)。await 会暂停当前的异步函数并从中返回。一旦等待的结果准备就绪,函数的执行将继续从上次停止的地方继续。

42 JavaScript 异步编程基础

原文:exploringjs.com/js/book/ch_async-js.html

  1. 42.1 事件循环

  2. 42.2 如何避免阻塞 JavaScript 进程

    1. 42.2.1 浏览器用户界面可能会被阻塞

    2. 42.2.2 如何避免阻塞浏览器?

    3. 42.2.3 通过 setTimeout() 调度新任务

    4. 42.2.4 完成到运行语义

  3. 42.3 异步结果传递模式

    1. 42.3.1 通过事件传递异步结果

    2. 42.3.2 通过回调传递异步结果

  4. 42.4 异步代码的缺点

  5. 42.5 资源

本章解释了 JavaScript 异步编程的基础。

42.1 事件循环

通常 JavaScript 在单个进程中运行 – 在网页浏览器和 Node.js 中都是如此。在这个单个进程中,任务 依次运行。一个 任务 是一段代码 – 想象一下没有参数的函数。任务通过队列进行管理:

  • 事件循环 在 JavaScript 进程内部持续运行。在每次循环迭代中,它从队列中取出一个任务(如果队列为空,则等待直到不为空)并执行它。任务完成后,控制权返回到事件循环,然后从队列中检索下一个任务并执行它。如此循环。

  • 任务来源 将任务添加到队列中。其中一些来源与 JavaScript 进程并发运行。例如,一个任务来源负责用户界面事件:如果用户点击某处并且注册了点击监听器,那么对该监听器的调用就会被添加到任务队列中。

以下 JavaScript 代码是事件循环的近似表示:

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

事件循环在 图 42.1 中表示。

图 42.1:任务来源 将要运行的代码添加到 任务队列 中,该队列由 事件循环 清空。

42.2 如何避免阻塞 JavaScript 进程

42.2.1 浏览器用户界面可能会被阻塞

浏览器的许多用户界面机制也在 JavaScript 进程中运行(作为任务)。因此,长时间运行的 JavaScript 代码可能会阻塞用户界面。让我们看看一个演示这一点的网页。我们可以通过两种方式尝试这个页面:

  • 我们可以在 在线运行它

  • 我们可以在存储库中打开以下文件进行练习:demos/async-js/blocking.html

以下 HTML 是页面的用户界面:

<a id="block" href="">Block</a>
<div id="statusMessage"></div>
<button>Click me!</button>

理念是点击“Block”,通过 JavaScript 执行一个长时间运行的循环。在循环期间,我们无法点击按钮,因为浏览器/JavaScript 进程被阻塞。

简化的 JavaScript 代码看起来像这样:

document.getElementById('block')
  .addEventListener('click', doBlock); // (A)

function doBlock(event) {
  // ···
  displayStatus('Blocking...');
  // ···
  sleep(5000); // (B)
  displayStatus('Done');
}

function sleep(milliseconds) {
  const start = Date.now();
  while ((Date.now() - start) < milliseconds);
}
function displayStatus(status) {
  document.getElementById('statusMessage')
    .textContent = status;
}

这些是代码的关键部分:

  • 行 A:我们告诉浏览器在点击 ID 为 block 的 HTML 元素时调用 doBlock()

  • doBlock() 显示状态信息,然后调用 sleep() 以阻塞 JavaScript 进程 5000 毫秒(行 B)。

  • sleep() 通过循环直到经过足够的时间来阻塞 JavaScript 进程。

  • displayStatus() 在 ID 为 statusMessage<div> 内显示状态消息。

42.2.2 如何避免阻塞浏览器?

我们如何防止长时间运行的操作阻塞浏览器?

  • 操作可以异步地提供其结果:一些操作,如下载文件,在 JavaScript 进程外部运行,并与它并发。如果我们调用这样的操作,我们向它提供一个回调。一旦操作完成,它将通过向队列中添加任务的方式调用回调并返回结果。这种提供结果的方式被称为异步,因为调用者等待结果时不会被阻塞:它可以做其他事情,并在结果准备好时得到通知。正常函数调用同步地提供其结果。我们的代码也可以异步地提供结果。我们很快就会了解更多关于异步代码的内容。

  • 操作可以在单独的进程中执行:这可以通过所谓的 Web Workers 实现。Web Worker 是一个重量级进程,它与主进程并发运行。它有自己的运行时环境(全局变量等)。它是完全隔离的;通信通过消息传递进行。有关更多信息,请参阅 MDN 网络文档

  • 操作可以暂停并给队列中的挂起任务运行的机会——这可以解除浏览器的阻塞。下一小节将解释这是如何实现的。

42.2.3 通过 setTimeout() 调度新任务

以下全局函数在延迟 ms 毫秒后执行其参数 callback(类型签名已简化 - setTimeout() 有更多功能):

function setTimeout(callback: () => void, ms: number): any

函数返回一个 句柄(一个 ID),可以用来 清除 超时(在回调执行之前取消执行)的以下全局函数:

function clearTimeout(handle?: any): void

setTimeout() 在浏览器和 Node.js 中都可用。我们可以将 setTimeout() 视为为稍后执行的任务进行调度:

console.log('First task starts');
setTimeout(
  () => { // (A)
    console.log('Second task starts');
  },
  0 // (B)
);
console.log('First task ends');

在第一个任务中,我们正在调度一个新的任务(从行 A 开始的回调)在零毫秒的延迟后运行(行 B)。

输出:

First task starts
First task ends
Second task starts

另一种看待发生情况的方式是:第一个任务暂停了一下,然后继续执行第二个任务

  • 第一个任务运行。在它运行的同时,可能会触发事件,如点击事件,并导致任务被添加到队列中。这些任务只能在当前任务完成后才能运行。

  • 第一个任务结束。现在其他任务可以运行。我们使用了零毫秒的延迟,所以所有在第二个任务之前添加到队列中的任务都将接下来运行。

  • 第二个任务运行,继续第一个任务的工作。

换句话说:任务休息了一下,给了其他任务运行的机会。

42.2.4 运行到完成语义

JavaScript 为任务提供保证:

每个任务总是在执行下一个任务之前完成(“运行到完成”)。

因此,任务在处理数据时不必担心数据被更改(并发修改)。这简化了 JavaScript 中的编程。

我们可以在前面的例子中观察到运行到完成:

console.log('First task starts');
setTimeout(
  () => {
    console.log('Second task starts');
  },
  0
);
console.log('First task ends');

第一个任务在下一个任务开始之前就结束了。

42.3 异步传递结果的模式

为了避免在等待长时间运行的操作完成时阻塞主进程,结果通常在 JavaScript 中以异步方式传递。以下是三种流行的实现方式:

  • Events

  • Callbacks

  • Promises

前两个模式将在接下来的两个小节中解释。Promises 将在下一章中解释。

42.3.1 通过事件传递异步结果

事件作为模式的工作方式如下:

  • 它们用于异步传递值。

  • 它们零次或多次这样做。

  • 在这个模式中有三个角色:

    • 事件(一个对象)携带要传递的数据。

    • 事件监听器是一个通过参数接收事件的函数。

    • 事件源发送事件并允许我们注册事件监听器。

在 JavaScript 的世界中,存在多种这种模式的变体。我们将在下面看三个例子。

42.3.1.1 事件:IndexedDB

IndexedDB 是内置在网页浏览器中的数据库。这是一个使用它的例子:

const openRequest = indexedDB.open('MyDatabase', 1); // (A)

openRequest.onsuccess = (event) => {
  const db = event.target.result;
  // ···
};

openRequest.onerror = (error) => {
  console.error(error);
};

indexedDB调用操作的方式不寻常:

  • 每个操作都有一个关联的方法来创建请求对象。例如,在行 A 中,操作是“open”,方法是.open(),请求对象是openRequest

  • 操作的参数通过请求对象提供,而不是通过方法参数提供。例如,事件监听器(函数)存储在属性.onsuccess.onerror中。

  • 通过方法(在行 A 中)将操作的调用添加到任务队列中。也就是说,我们在调用已经添加到队列之后配置操作。只有运行到完成语义才能让我们避免竞态条件,并确保操作在当前代码片段完成后运行。

42.3.1.2 事件:XMLHttpRequest

XMLHttpRequest API 允许我们在网页浏览器内进行下载。这是下载文件 http://example.com/textfile.txt 的方法:

const xhr = new XMLHttpRequest(); // (A)
xhr.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
  if (xhr.status == 200) {
    processData(xhr.responseText);
  } else {
    assert.fail(new Error(xhr.statusText));
  }
};
xhr.onerror = () => { // (D)
  assert.fail(new Error('Network error'));
};
xhr.send(); // (E)

function processData(str) {
  assert.equal(str, 'Content of textfile.txt\n');
}

使用此 API,我们首先创建一个请求对象(行 A),然后配置它,然后激活它(行 E)。配置包括:

  • 指定要使用的 HTTP 请求方法(行 B):"GET"、"POST"、"PUT" 等。

  • 注册一个监听器(行 C),当可以下载某些内容时被通知。在监听器内部,我们仍然需要确定下载的内容是否包含我们请求的内容或是否通知我们错误。请注意,一些结果数据是通过请求对象 xhr 传递的。(我不是这种输入和输出数据混合的粉丝。)

  • 注册一个监听器(行 D),当发生网络错误时被通知。

42.3.1.3 事件:DOM

我们已经在 “浏览器用户界面的阻塞”(§42.2.1) 中看到了 DOM 事件的实际应用。以下代码也处理 click 事件:

const element = document.getElementById('my-link'); // (A)
element.addEventListener('click', clickListener); // (B)

function clickListener(event) {
  event.preventDefault(); // (C)
  console.log(event.shiftKey); // (D)
}

我们首先要求浏览器检索 ID 为 'my-link' 的 HTML 元素(行 A)。然后我们添加对所有 click 事件的监听器(行 B)。在监听器中,我们首先告诉浏览器不要执行其默认操作(行 C)——即前往链接的目标。然后我们记录当前是否按下了 shift 键(行 D)。

42.3.2 通过回调传递异步结果

回调是处理异步结果的一种模式。它们仅用于一次性结果,并且比事件更简洁。

例如,考虑一个名为 readFile() 的函数,它异步读取一个文本文件并返回其内容。如果它使用 Node.js 风格的回调,我们这样调用 readFile()

readFile('some-file.txt', {encoding: 'utf-8'},
  (error, data) => {
    if (error) {
      assert.fail(error);
      return;
    }
    assert.equal(data, 'The content of some-file.txt');
  });

有一个回调同时处理成功和失败。如果第一个参数不是 null,则发生了错误。否则,结果可以在第二个参数中找到。

练习图标 “exercise” 练习:基于回调的代码

以下练习使用异步代码的测试,这些测试与同步代码的测试不同。有关更多信息,请参阅 “Mocha 中的异步测试”(§12.2.2)。

  • 从同步到基于回调的代码:exercises/async-js/read_file_cb_exrc.mjs

  • 实现 .map() 的基于回调版本:exercises/async-js/map_cb_test.mjs

42.4 异步代码:缺点

在许多情况下,无论是在浏览器还是 Node.js 中,我们别无选择:我们必须使用异步代码。在本章中,我们看到了这种代码可以使用的一些模式。所有这些模式都有两个缺点:

  • 异步代码比同步代码更冗长。

  • 如果我们调用异步代码,我们的代码也必须变为异步。这是因为我们不能同步地等待异步结果。异步代码具有传染性。

第一个缺点在 Promise(下一章介绍)中变得不那么严重,而在异步函数(下一章介绍)中几乎消失。

然而,异步代码的传染性并没有消失。但通过异步函数,在同步和异步之间切换变得容易,这减轻了这种传染性。

42.5 资源

43 异步编程的 Promises ES6

原文:exploringjs.com/js/book/ch_promises.html

  1. 43.1 使用 Promises 的基础

    1. 43.1.1 使用基于 Promise 的函数

    2. 43.1.2 Promises 与事件

    3. 43.1.3 实现基于 Promise 的函数

    4. 43.1.4 Promise 的三个基本状态

    5. 43.1.5 通过 Promise.resolve()Promise.reject() 创建已解决和拒绝的 Promises

    6. 43.1.6 在 .then() 回调中返回和抛出

    7. 43.1.7 .catch() 及其回调

    8. 43.1.8 链式方法调用

    9. 43.1.9 Promise.try(): 开始一个 Promise 链^(ES2025)

    10. 43.1.10 Promise.prototype.finally()^(ES2018)

    11. 43.1.11 Promise.withResolvers()^(ES2024)

    12. 43.1.12 Promise 相对于普通回调的优势

  2. 43.2 示例

    1. 43.2.1 Node.js: 异步读取文件

    2. 43.2.2 浏览器:将 XMLHttpRequest 转换为 Promise

    3. 43.2.3 Fetch API

  3. 43.3 错误处理的技巧:不要混合拒绝和异常

  4. 43.4 基于 Promise 的函数同步开始,异步解决

  5. 43.5 Promise 组合函数:处理 Promise 数组

    1. 43.5.1 什么是 Promise 组合函数?

    2. 43.5.2 Promise.all()

    3. 43.5.3 Promise.race()

    4. 43.5.4 Promise.any()^(ES2021)

    5. 43.5.5 Promise.allSettled()^(ES2020)

    6. 43.5.6 短路 (高级)

  6. 43.6 并发和 Promise.all() (高级)

    1. 43.6.1 顺序执行与并发执行

    2. 43.6.2 并发技巧:关注操作何时开始

    3. 43.6.3 Promise.all() 是分叉-连接

  7. 43.7 链式 Promises 的技巧

    1. 43.7.1 链式错误:丢失尾部

    2. 43.7.2 链式错误:嵌套

    3. 43.7.3 链式错误:不必要的嵌套过多

    4. 43.7.4 并非所有嵌套都是不好的

    5. 43.7.5 链式错误:创建 Promise 而不是链式调用

  8. 43.8 Thenables (类似 Promise 的对象) (高级)

    1. 43.8.1 示例:已解决的 thenable

    2. 43.8.2 示例:被拒绝的 thenable

  9. 43.9 快速参考:Promise

    1. 43.9.1 new Promise()

    2. 43.9.2 Promise.*:创建 Promises

    3. 43.9.3 Promise.*:其他功能

    4. 43.9.4 Promise.*:Promise 组合器

    5. 43.9.5 Promise.prototype.*

图标“阅读”推荐阅读

本章基于上一章的内容,介绍了 JavaScript 中的异步编程背景。

43.1 使用 Promise 的基本知识

43.1.1 使用基于 Promise 的函数

Promises 是一种异步交付结果的技术。而不是直接返回一个结果,基于 Promise 的函数返回一个Promise:一个最初为空的容器对象。如果函数最终完成,它会将结果或错误放入 Promise 中。

以下代码展示了如何使用基于 Promise 的函数addAsync()。我们很快就会看到该函数的实现方式。

const promise = addAsync(3, 4);
promise.then((result) => { // success
    assert.equal(result, 7);
  })
  .catch((error) => { // failure
    assert.fail(error);
  })
;

要访问 Promise 内部的内容(如果内部有内容的话),我们注册回调函数:

  • .then()方法注册了当有结果(如果有)时被调用的回调函数。

  • .catch()方法注册了当有错误(如果有)时被调用的回调函数。

Promises 的这个方面与事件模式类似。

43.1.2 Promise 与事件

Promises 与事件在两个方面有所不同:

  • 它们最多交付一个结果,并且针对该用例进行了优化:

    • 如果我们在 Promise 为空时注册.then()回调,当 Promise 收到结果时会通知我们。如果我们在 Promise 为空时注册.catch()回调,当 Promise 收到错误时会通知我们。

    • 一旦 Promise 收到结果或错误,该值就会被缓存。因此,如果我们在此之后注册回调,它会获取缓存的值(取决于它是否有资格接收它)。这意味着注册回调太晚的风险不存在。

    • Promise 接收到的第一个值永久性地解决该 Promise:之后接收到的值将被忽略。

  • 我们可以链式调用 Promise 方法.then().catch(),因为它们都返回 Promises。这有助于顺序调用多个异步函数。关于这一点,稍后还会详细介绍。

43.1.3 实现基于 Promise 的函数

这是一个基于 Promise 的函数实现,用于添加两个数字xy

function addAsync(x, y) {
  return new Promise( // (A)
    (resolve, reject) => { // (B)
      if (x === undefined || y === undefined) {
        reject(new Error('Must provide two parameters'));
      } else {
        resolve(x + y);
      }
    }
  );
}

addAsync()通过调用Promise构造函数立即创建并返回一个 Promise(行 A)。它只能通过将其传递给构造函数的回调(行 B)来改变新 Promise 的状态:

  • 回调参数resolve是一个函数,它将结果放入 Promise 中(在成功的情况下)。

  • 回调参数reject是一个函数,它将错误放入 Promise 中(在失败的情况下)。

一旦我们调用了这些函数,后续的调用就没有任何效果了。

43.1.3.1 揭示构造函数模式(高级)

Promise 构造函数使用揭示构造函数模式

const promise = new Promise(
  (resolve, reject) => {
    // ···
  }
);

引用 Domenic Denicola(JavaScript 的 Promise API 背后的其中一人)的话:

我称之为揭示构造函数模式,因为Promise构造函数揭示了其内部能力,但仅限于构建相关 Promise 的代码。解析或拒绝 Promise 的能力仅对构建代码可见,并且关键的是对任何使用 Promise 的人可见。所以如果我们把p传递给另一个消费者,比如说

doThingsWith(p);

那么,我们可以确信这个消费者无法干扰构造函数向我们揭示的任何内部机制。这与,例如,在p上放置resolvereject方法形成对比,任何人都可以调用这些方法。

43.1.4 Promise 的三个基本状态

图 43.1 描述了 Promise 可能处于的三个状态。

图 43.1:Promise 最初处于“挂起”状态。它后来可以转换到“已履行”或“已拒绝”状态(但它可能永远不会这样做)。如果一个 Promise 处于最终(非挂起)状态,它被称为已解决

43.1.4.1 一些 Promise 永远不会解决

这是一个永远不会解决且永远挂起的 Promise 的例子:

new Promise(() => {})

43.1.4.2 解析 Promise 和履行 Promise 之间的区别是什么?

一个 Promise 只能用非 Promise 值来履行。相比之下,我们可以用非 Promise 值或另一个 Promise 来解析一个 Promise。如果new Promise()的回调调用resolve(x),那么新创建的 Promise p的结果取决于x

  • 如果x是一个非 Promise 值,那么p会以x来履行。

  • 如果x是一个 Promise,那么p会采用x的状态(这基本上意味着x会替换p)。换句话说:

    • x处于挂起状态时,p也处于挂起状态。

    • 如果x被履行,p也会被履行。

    • 如果x被拒绝,p也会被拒绝。

换句话说:解析只决定了 Promise 的命运;它可能履行也可能不履行。这种行为有助于 Promise 方法的链式调用。关于这一点,稍后还会详细介绍。

43.1.5 通过Promise.resolve()Promise.reject()创建满足和拒绝的 Promise

如果x是一个非 Promise 值,则Promise.resolve(x)创建一个以该值满足的 Promise:

Promise.resolve(123)
  .then((x) => {
    assert.equal(x, 123);
  });

如果参数已经是 Promise,则返回不变:

const abcPromise = Promise.resolve('abc');
assert.equal(
  Promise.resolve(abcPromise), abcPromise
);

Promise.reject(err)接受一个值err(通常不是 Promise)并返回一个以它拒绝的 Promise:

const myError = new Error('My error!');
Promise.reject(myError)
  .catch((err) => {
    assert.equal(err, myError);
  });

这有什么用?

  • 一方面,我们可以使用Promise.resolve()将可能或可能不是 Promise 的值转换为保证是 Promise 的值。

  • 另一方面,我们可能想要创建一个以给定非 Promise 值来满足或拒绝的 Promise。然后我们可以使用Promise.resolve()Promise.reject() – 如以下示例所示。

function convertToNumber(stringOrNumber) {
  if (typeof stringOrNumber === 'number') {
    return Promise.resolve(stringOrNumber);
  } else if (typeof stringOrNumber === 'string') {
    return stringToNumberAsync(stringOrNumber);
  } else {
    return Promise.reject(new TypeError());
  }
}

43.1.6 .then()回调中的返回和抛出

.then()为 Promise 满足注册回调函数。它还返回一个新的 Promise。这样做使得方法链式调用成为可能:我们可以在结果上调用.then().catch(),并保持异步计算的进行。

.then()返回的 Promise 是如何解决的,取决于其回调函数内部发生的事情。让我们看看三个常见的情况。

43.1.6.1 从.then()回调返回非 Promise 值

首先,回调函数可以返回一个非 Promise 值(行 A)。因此,.then()返回的 Promise 以该值满足(如行 B 中检查的那样):

Promise.resolve('abc')
  .then((str) => {
    return str + str; // (A)
  })
  .then((str2) => {
    assert.equal(str2, 'abcabc'); // (B)
  });

43.1.6.2 .then()回调返回 Promise

其次,回调函数可以返回一个 Promise q(行 A)。因此,.then()返回的 Promise pq来满足。换句话说:p实际上被q替换了。

Promise.resolve('abc')
  .then((str) => {
    return Promise.resolve(123); // (A)
  })
  .then((num) => {
    assert.equal(num, 123);
  });

这有什么用?我们可以返回基于 Promise 的操作的结果,并通过一个“扁平”(非嵌套)的.then()来处理其满足值。比较:

// Flat
asyncFunc1()
  .then((result1) => {
    /*···*/
    return asyncFunc2();
  })
  .then((result2) => {
    /*···*/
  });

// Nested
asyncFunc1()
  .then((result1) => {
    /*···*/
    asyncFunc2()
    .then((result2) => {
      /*···*/
    });
  });

43.1.6.3 .then()回调中抛出异常

第三,回调函数可以抛出异常。因此,.then()返回的 Promise 以该异常拒绝。也就是说,同步错误被转换为异步错误。

const myError = new Error('My error!');
Promise.resolve('abc')
  .then((str) => {
    throw myError;
  })
  .catch((err) => {
    assert.equal(err, myError);
  });

43.1.7 .catch()及其回调

.then().catch()之间的区别在于,后者是由拒绝触发的,而不是由满足触发的。然而,两种方法都以相同的方式将回调函数的动作转换为 Promise。例如,在以下代码中,行 A 中.catch()回调函数返回的值成为满足值:

const err = new Error();

Promise.reject(err)
  .catch((e) => {
    assert.equal(e, err);
    // Something went wrong, use a default value
    return 'default value'; // (A)
  })
  .then((str) => {
    assert.equal(str, 'default value');
  });

43.1.8 链式调用方法

.then().catch()总是返回 Promise,使我们能够创建任意长的方法调用链:

function myAsyncFunc() {
 return asyncFunc1() // (A)
 .then((result1) => {
 // ···
 return asyncFunc2(); // a Promise
 })
 .then((result2) => {
 // ···
 return result2 ?? '(Empty)'; // not a Promise
 })
 .then((result3) => {
 // ···
 return asyncFunc4(); // a Promise
 });
}

由于链式调用,行 A 中的return返回了最后一个.then()的结果。

在某种程度上,.then()是同步分号异步版本:

  • asyncFunc1().then(asyncFunc2)

    依次执行异步操作asyncFunc1asyncFunc2

  • syncFunc1(); syncFunc2()

    依次执行同步操作syncFunc1syncFunc2

我们还可以添加.catch()到混合中,并让它同时处理多个错误源:

asyncFunc1()
  .then((result1) => {
    // ···
    return asyncFunc2();
  })
  .then((result2) => {
    // ···
  })
  .catch((error) => {
    // Failure: handle errors of asyncFunc1(), asyncFunc2()
    // and any (sync) exceptions thrown in previous callbacks
  });

43.1.9 Promise.try():启动 Promise 链(ES2025)

当 Promise 方法.then(cb)继续 Promise 链时,Promise.try(cb)开始一个 Promise 链——同时以类似的方式处理回调cb

  • 它调用cb

  • 如果cb抛出异常,Promise.try()会返回一个带有该异常的拒绝。

  • 如果cb返回一个值,Promise.try()将那个值解析为一个 Promise(如果没有嵌套,该值已经是 Promise)。

43.1.9.1 Promise.try()的使用场景:使用非纯异步代码启动 Promise 链

我们需要Promise.try()来启动一个包含同步和异步功能的代码的 Promise 链:

  • 为什么是混合的?如果代码完全是异步的,我们可以用它来启动一个 Promise 链。如果代码完全是同步的,则不需要 Promise。

  • 为什么在开始时?在 Promise 链的后期,Promise 函数如.then()是处理混合代码的好工具。

让我们看看一个例子:

function computeAsync() {
 return Promise.try(() => {
 const value = syncFuncMightThrow(); // (A)
 return asyncFunc(value); // (B)
 });
}

我们有一个同步功能(行 A)和异步功能(行 B)的混合。

为什么要在回调中包裹代码呢?如果我们在行 A 中调用的同步函数抛出异常,这会有帮助:然后Promise.try()捕获那个异常并将其转换为为我们提供的拒绝的 Promise。因此,前面的代码基本上等同于:

function computeAsync() {
 try {
 const value = syncFuncMightThrow();
 return asyncFunc(value);
 } catch (err) {
 return Promise.reject(err);
 }
}

43.1.9.2 为什么不使用异步函数?

只有当我们直接与 Promise 一起工作时,Promise.try()才是必需的。异步函数(在下一章中解释异步函数)已经提供了很好的支持来处理同步和异步代码的混合(任何地方)。

43.1.9.3 Promise.try()的替代方案

以下代码是Promise.try()的替代方案:

function countPlusOneAsync() {
 return Promise.resolve().then(
 () => countSyncOrAsync() // (A)
 )
 .then((result) => {
 return result + 1;
 });
}

Promise.resolve()创建一个被undefined实现的 Promise。这个结果对我们来说并不重要。重要的是我们刚刚启动了一个 Promise 链,并且可以将要尝试的代码放入行 A 的回调中。

Promise.try()相比,主要缺点是这种模式将在下一个 tick 上执行代码行 A(而不是立即执行)。

43.1.10 Promise.prototype.finally()(ES2018)

Promise 方法.finally()通常如下使用:

somePromise
  .then((result) => {
    // ···
  })
  .catch((error) => {
    // ···
  })
  .finally(() => {
    // ···
  })
;

.finally()回调始终执行——独立于somePromise.then()和/或.catch()返回的值。相比之下:

  • 只有当somePromise被实现时,.then()回调才会执行。

  • 只有在以下情况下,.catch()回调才会执行:

    • 要么somePromise被拒绝,

    • 或者 .then() 回调返回一个被拒绝的 Promise,

    • 或者 .then() 回调抛出异常。

如果回调返回一个非 Promise 值或一个已解决的 Promise,.finally() 将忽略该结果,并简单地传递在调用之前存在的结算:

Promise.resolve(123)
  .finally(() => {})
  .then((result) => {
    assert.equal(result, 123);
  });

Promise.reject('error')
  .finally(() => {})
  .catch((error) => {
    assert.equal(error, 'error');
  });

然而,如果 .finally() 回调抛出异常或返回一个被拒绝的 Promise,则 .finally() 返回的 Promise 将被拒绝:

Promise.reject('error (previously)')
  .finally(() => {
    throw 'error (finally)';
  })
  .catch((error) => {
    assert.equal(error, 'error (finally)');
  });

Promise.reject('error (previously)')
  .finally(() => {
    return Promise.reject('error (finally)');
  })
  .catch((error) => {
    assert.equal(error, 'error (finally)');
  });

43.1.10.1 .finally() 的用例:清理

.finally() 的一个常见用例类似于同步 finally 子句的常见用例:在完成资源使用后进行清理。这应该始终发生,无论一切是否顺利或出现错误 – 例如:

let connection;
db.open()
.then((conn) => {
  connection = conn;
  return connection.select({ name: 'Jane' });
})
.then((result) => {
  // Process result
  // Use `connection` to make more queries
})
// ···
.catch((error) => {
  // handle errors
})
.finally(() => {
  connection.close();
});

43.1.10.2 .finally() 的用例:在任何类型的结算之后首先执行某些操作

我们也可以在 .then().catch() 之前使用 .finally()。那么在 .finally() 回调中执行的操作总是先于其他两个回调执行。以下是一个示例函数 handleAsyncResult()

function handleAsyncResult(promise) {
  return promise
    .finally(() => {
      console.log('finally');
    })
    .then((result) => {
      console.log('then ' + result);
    })
    .catch((error) => {
      console.log('catch ' + error);
    })
  ;
}

这就是已解决的 Promise 发生的情况:

handleAsyncResult(Promise.resolve('fulfilled'));

输出:

finally
then fulfilled

这就是被拒绝的 Promise 发生的情况:

handleAsyncResult(Promise.reject('rejected'));

输出:

finally
catch rejected

43.1.11 Promise.withResolvers() (ES2024)

创建和解析 Promise 最常见的方式是通过 Promise 构造函数:

new Promise(
  (resolve, reject) => { ··· }
);

创建像那样的 Promise 的一个限制是,解决函数 resolvereject 只意味着在回调内部使用。有时我们想在它之外使用它们。这就是以下静态工厂方法有用的原因:

const { promise, resolve, reject } = Promise.withResolvers();

这就是使用那个工厂方法的样子:

{
  const { promise, resolve, reject } = Promise.withResolvers();
  resolve('fulfilled');
  assert.equal(
    await promise,
    'fulfilled'
  );
}
{
  const { promise, resolve, reject } = Promise.withResolvers();
  reject('rejected');
  try {
    await promise;
  } catch (err) {
    assert.equal(err, 'rejected');
  }
}

图标“问题”为什么叫 withResolvers?为什么不叫,例如,withSettlers

  • resolve() 可能不会解决 promise – 它只解决它。

  • ECMAScript 规范使用 “解析函数”的名称 来表示 resolvereject

图标“练习”练习:通过链表实现一个由 Promise 组成的异步队列,其元素是 Promise

exercises/promises/promise-queue_test.mjs

43.1.11.1 实现

我们可以如下实现 Promise.withResolvers()

function promiseWithResolvers() {
 let resolve;
 let reject;
 const promise = new Promise(
 (res, rej) => {
 // Executed synchronously!
 resolve = res;
 reject = rej;
 });
 return {promise, resolve, reject};
}

43.1.11.2 示例:单元素队列
class OneElementQueue {
  #promise = null;
  #resolve = null;
  constructor() {
 const { promise, resolve } = Promise.withResolvers();
 this.#promise = promise;
 this.#resolve = resolve;
 }
 get() {
 return this.#promise;
 }
 put(value) {
 this.#resolve(value);
 }
}

{ // Putting before getting
 const queue = new OneElementQueue();
 queue.put('one');
 assert.equal(
 await queue.get(),
 'one'
 );
}
{ // Getting before putting
 const queue = new OneElementQueue();
 setTimeout(
 // Runs after `await` pauses the current execution context
 () => queue.put('two'),
 0
 );
 assert.equal(
 await queue.get(),
 'two'
 );
} 

43.1.12 Promise 相对于普通回调的优点

这些是处理一次性结果时,Promise 相对于普通回调的一些优点:

  • 基于 Promise 的函数和方法的类型签名更简洁:如果函数是基于回调的,一些参数是关于输入的,而最后的那个或两个回调是关于输出的。使用 Promises,所有与输出相关的操作都通过返回值处理。

  • 连接异步处理步骤更加方便。

  • Promise 处理异步错误(通过拒绝)和同步错误:在 new Promise().then().catch() 的回调中,异常被转换为拒绝。相比之下,如果我们使用回调来处理异步性,通常不会为我们处理异常;我们必须自己处理。

  • Promise 是一个单一的标准,它正在逐渐取代几个相互不兼容的替代方案。例如,在 Node.js 中,现在许多函数都有基于 Promise 的版本。而新的异步浏览器 API 通常也是基于 Promise 的。

Promise 最大的优点之一是无需直接操作它们:它们是 async 函数 的基础,这是一种用于执行异步计算的同步外观语法。异步函数将在 下一章 中介绍。

43.2 示例

通过观察 Promise 的实际应用来理解它们很有帮助。让我们看看一些例子。

43.2.1 Node.js: 异步读取文件

考虑以下包含 JSON 数据 的文本文件 person.json

{
  "first": "Jane",
  "last": "Doe"
}

让我们看看两种读取此文件并将其解析为对象的代码版本。首先是一个基于回调的版本。其次是一个基于 Promise 的版本。

43.2.1.1 基于回调的版本

以下代码读取该文件的内容并将其转换为 JavaScript 对象。它基于 Node.js 风格的回调:

import * as fs from 'node:fs';
fs.readFile('person.json',
  (error, text) => {
    if (error) { // (A)
      // Failure
      assert.fail(error);
    } else {
      // Success
      try { // (B)
        const obj = JSON.parse(text); // (C)
        assert.deepEqual(obj, {
          first: 'Jane',
          last: 'Doe',
        });
      } catch (e) {
        // Invalid JSON
        assert.fail(e);
      }
    }
  });

fs 是 Node.js 用于文件系统操作的内置模块。我们使用基于回调的函数 fs.readFile() 来读取名为 person.json 的文件。如果我们成功,内容将通过参数 text 作为字符串传递。在行 C 中,我们将该字符串从基于文本的数据格式 JSON 转换为 JavaScript 对象。JSON 是一个具有消费和生成 JSON 方法的对象。它是 JavaScript 标准库的一部分,并在本书的 后续章节 中有文档说明。

注意,有两种错误处理机制:行 A 中的 if 负责处理由 fs.readFile() 报告的异步错误,而行 B 中的 try 负责处理由 JSON.parse() 报告的同步错误。

43.2.1.2 基于 Promise 的版本

以下代码使用 node:fs/promises 中的 readFile(),这是 fs.readFile() 的基于 Promise 的版本:

import {readFile} from 'node:fs/promises';
readFile('person.json')
  .then((text) => { // (A)
    // Success
    const obj = JSON.parse(text);
    assert.deepEqual(obj, {
      first: 'Jane',
      last: 'Doe',
    });
  })
  .catch((err) => { // (B)
    // Failure: file I/O error or JSON syntax error
    assert.fail(err);
  });

函数 readFile() 返回一个 Promise。在行 A 中,我们通过该 Promise 的 .then() 方法指定一个成功回调。then 的回调函数中的剩余代码是同步的。

.then() 返回一个 Promise,这使得在行 B 中可以调用 Promise 方法 .catch()。我们用它来指定一个失败回调。

注意,.catch() 允许我们处理 readFile() 的异步错误和 JSON.parse() 的同步错误,因为 .then() 回调中的异常成为拒绝。

43.2.2 Browsers: 将 XMLHttpRequest 转换为 Promise

我们之前已经看到了在网页浏览器中下载数据的基于事件的 XMLHttpRequest API。以下函数将这个 API 转换为 Promise:

function httpGet(url) {
  return new Promise(
    (resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.onload = () => {
        if (xhr.status === 200) {
          resolve(xhr.responseText); // (A)
        } else {
          // Something went wrong (404, etc.)
          reject(new Error(xhr.statusText)); // (B)
        }
      }
      xhr.onerror = () => {
        reject(new Error('Network error')); // (C)
      };
      xhr.open('GET', url);
      xhr.send();
    });
}

注意 XMLHttpRequest 的结果和错误是如何通过 resolve()reject() 处理的:

  • 成功的结果会导致返回的 Promise 被它本身(行 A)满足。

  • 错误会导致 Promise 被拒绝(行 B 和 C)。

这是使用 httpGet() 的方法:

httpGet('http://example.com/textfile.txt')
  .then((content) => {
    assert.equal(content, 'Content of textfile.txt\n');
  })
  .catch((error) => {
    assert.fail(error);
  });

Icon “exercise”练习:Promise 超时

exercises/promises/promise_timeout_test.mjs

43.2.3 Fetch API

大多数 JavaScript 平台支持 Fetch,这是一个基于 Promise 的 API,用于下载数据。将其视为 XMLHttpRequest 的基于 Promise 的版本。以下是该 API 的摘录API

interface Body {
  text() : Promise<string>;
  ···
}
interface Response extends Body {
  ···
}
declare function fetch(str) : Promise<Response>;

这意味着我们可以这样使用 fetch()

fetch('http://example.com/textfile.txt')
  .then(response => response.text())
  .then((text) => {
    assert.equal(text, 'Content of textfile.txt');
  });

fetch() 也在这里使用:“示例:.map() 与异步函数作为回调” (§44.3.3)。

Icon “exercise”练习:使用 fetch API

exercises/promises/fetch_json_test.mjs

43.3 错误处理的技巧:不要混合拒绝和异常

实现函数和方法的技巧:

不要混合(异步)拒绝和(同步)异常。

这使得我们的同步和异步代码更加可预测和简单,因为我们总能专注于单一的错误处理机制。

对于基于 Promise 的函数和方法,这个规则意味着它们永远不应该抛出异常。然而,很容易不小心出错——例如:

// Don’t do this
function asyncFunc() {
 doSomethingSync(); // (A)
 return doSomethingAsync()
 .then((result) => {
 // ···
 });
}

问题在于,如果行 A 中抛出异常,那么 asyncFunc() 将抛出异常。该函数的调用者只期望拒绝,并没有准备好处理异常。我们可以通过三种方式来解决这个问题。

我们可以在函数的整个主体中包裹一个 try-catch 语句,并在抛出异常时返回一个拒绝的 Promise。

// Solution 1
function asyncFunc() {
 try {
 doSomethingSync();
 return doSomethingAsync()
 .then((result) => {
 // ···
 });
 } catch (err) {
 return Promise.reject(err);
 }
}

由于 .then() 将异常转换为拒绝,我们可以在 .then() 回调中执行 doSomethingSync()。为此,我们通过 Promise.resolve() 启动一个 Promise 链。我们忽略那个初始 Promise 的满足值 undefined

// Solution 2
function asyncFunc() {
 return Promise.resolve()
 .then(() => {
 doSomethingSync();
 return doSomethingAsync();
 })
 .then((result) => {
 // ···
 });
}

最后,new Promise() 也会将异常转换为拒绝。因此,使用这个构造函数与之前的解决方案类似:

// Solution 3
function asyncFunc() {
 return new Promise((resolve, reject) => {
 doSomethingSync();
 resolve(doSomethingAsync());
 })
 .then((result) => {
 // ···
 });
}

43.4 基于 Promise 的函数开始同步,解决异步

大多数基于 Promise 的函数都是这样执行的:

  • 它们的执行立即开始,同步(在当前任务中)。

  • 但它们返回的 Promise 保证会在异步(稍后的任务)中解决——如果有的话。

以下代码演示了这一点:

function asyncFunc() {
 console.log('asyncFunc');
 return new Promise(
 (resolve, _reject) => {
 console.log('new Promise()');
 resolve();
 }
 );
}
console.log('START');
asyncFunc()
 .then(() => {
 console.log('.then()'); // (A)
 });
console.log('END');

输出:

START
asyncFunc
new Promise()
END
.then()

我们可以看到,new Promise() 的回调在代码结束之前执行,而结果稍后才会传递(行 A)。

此方法的好处:

  • 从同步开始有助于避免竞争条件,因为我们依赖于基于 Promise 的函数开始时的顺序。在下一章中有一个例子在这里,其中文本被写入文件,并避免了竞争条件。

  • 在一个 Promise 解决之前,链式调用 Promise 不会使其他任务处理时间不足,因为在 Promise 解决之前,总会有一个中断,在这段时间内事件循环可以运行。

  • 基于 Promise 的函数总是异步返回结果;我们可以确信永远不会同步返回。这种可预测性使得代码更容易处理。

图标“外部”关于此方法的更多信息

“为异步设计 API” by Isaac Z. Schlueter

43.5 Promise 组合函数:处理 Promise 数组

43.5.1 Promise 组合函数是什么?

组合模式是函数式编程中用于构建结构的模式。它基于两种类型的函数:

  • 原始函数(简称:原始)创建原子部分。

  • 组合函数(简称:组合器)将原子和/或复合部分组合成复合部分。

当涉及到 JavaScript 的 Promise 时:

  • 原始函数包括:Promise.resolve()Promise.reject()

  • 组合器包括:Promise.all()Promise.race()Promise.any()Promise.allSettled()。在这些情况下:

    • 输入是零个或多个 Promise 的可迭代对象。

    • 输出是一个单独的 Promise。

接下来,我们将更详细地研究提到的 Promise 组合器。

43.5.2 Promise.all()

这是Promise.all()的类型签名:

Promise.all<T>(promises: Iterable<Promise<T>>): Promise<Array<T>>

Promise.all()返回一个 Promise,它是:

  • 如果所有promises都得到履行,则满足条件。

    • 然后,其履行值是一个包含promises履行值的数组。
  • 如果至少有一个 Promise 被拒绝,则拒绝。

    • 然后,其拒绝值是那个 Promise 的拒绝值。

这是一个输出 Promise 被履行的快速演示:

const promises = [
  Promise.resolve('result a'),
  Promise.resolve('result b'),
  Promise.resolve('result c'),
];
Promise.all(promises)
  .then((arr) => assert.deepEqual(
    arr, ['result a', 'result b', 'result c']
  ));

以下示例演示了如果至少有一个输入 Promise 被拒绝会发生什么:

const promises = [
  Promise.resolve('result a'),
  Promise.resolve('result b'),
  Promise.reject('ERROR'),
];
Promise.all(promises)
  .catch((err) => assert.equal(
    err, 'ERROR'
  ));

图 43.2 说明了Promise.all()的工作原理。

图 43.2:Promise 组合器Promise.all()

43.5.2.1 通过Promise.all()实现异步.map()

数组转换方法,如.map().filter()等,是为了同步计算而设计的。例如:

function timesTwoSync(x) {
  return 2 * x;
}
const arr = [1, 2, 3];
const result = arr.map(timesTwoSync);
assert.deepEqual(result, [2, 4, 6]);

如果 .map() 的回调是一个基于 Promise 的函数(将正常值映射到 Promise 的函数),那么 .map() 的结果是一个 Promise 数组。不幸的是,这不是普通代码可以处理的数据。幸运的是,我们可以通过 Promise.all() 来修复这个问题:它将 Promise 数组转换为解决为正常值数组的 Promise。

function timesTwoAsync(x) {
  return new Promise(resolve => resolve(x * 2));
}
const arr = [1, 2, 3];
const promiseArr = arr.map(timesTwoAsync);
Promise.all(promiseArr)
  .then((result) => {
    assert.deepEqual(result, [2, 4, 6]);
  });

43.5.2.2 一个更现实的 .map() 示例

接下来,我们将使用 .map()Promise.all() 从网络下载文本文件。为此,我们需要以下工具函数:

function downloadText(url) {
  return fetch(url)
    .then((response) => { // (A)
      if (!response.ok) { // (B)
        throw new Error(response.statusText);
      }
      return response.text(); // (C)
    });
}

downloadText() 使用基于 Promise 的 fetch API 将文本文件作为字符串下载:

  • 首先,它异步检索一个 response(行 A)。

  • response.ok(行 B)检查是否存在错误,例如“文件未找到”。

  • 如果没有,我们使用 .text()(行 C)来检索文件的内容作为字符串。

在以下示例中,我们下载了两个文本文件:

const urls = [
  'http://example.com/first.txt',
  'http://example.com/second.txt',
];

const promises = urls.map(
  url => downloadText(url));

Promise.all(promises)
  .then(
    (arr) => assert.deepEqual(
      arr, ['First!', 'Second!']
    ));

43.5.2.3 Promise.all() 的简单实现

这是 Promise.all() 的简化实现(例如,它不执行安全检查):

function all(iterable) {
  return new Promise((resolve, reject) => {
    let elementCount = 0;
    let result;

    let index = 0;
    for (const promise of iterable) {
      // Preserve the current value of `index`
      const currentIndex = index;
      promise.then(
        (value) => {
          result[currentIndex] = value;
          elementCount++;
          if (elementCount === result.length) {
            resolve(result); // (A)
          }
        },
        (err) => {
          reject(err); // (B)
        });
      index++;
    }
    if (index === 0) {
      // Resolution is normally done in line A
      resolve([]);
      return;
    }
    // Now we know how many Promises there are in `iterable`.
    // We can wait until now with initializing `result` because
    // the callbacks of .then() are executed asynchronously.
    result = new Array(index);
  });
}

结果 Promise 解决的两个主要位置是行 A 和行 B。在其中一个解决之后,另一个不能再改变解决值,因为 Promise 只能解决一次。

43.5.3 Promise.race()

这是 Promise.race() 的类型签名:

Promise.race<T>(promises: Iterable<Promise<T>>): Promise<T>

Promise.race() 返回一个 Promise q,它在 promises 中的第一个 Promise p 解决时立即解决。q 具有与 p 相同的解决值。

在下面的示例中,已解决的 Promise(行 A)的解决发生在拒绝的 Promise(行 B)的解决之前。因此,结果也是已解决的(行 C)。

const promises = [
  new Promise((resolve, reject) =>
    setTimeout(() => resolve('result'), 100)), // (A)
  new Promise((resolve, reject) =>
    setTimeout(() => reject('ERROR'), 200)), // (B)
];
Promise.race(promises)
  .then((result) => assert.equal( // (C)
    result, 'result'));

在下一个示例中,拒绝首先发生:

const promises = [
  new Promise((resolve, reject) =>
    setTimeout(() => resolve('result'), 200)),
  new Promise((resolve, reject) =>
    setTimeout(() => reject('ERROR'), 100)),
];
Promise.race(promises)
  .then(
    (result) => assert.fail(),
    (err) => assert.equal(
      err, 'ERROR'));

注意,Promise.race() 返回的 Promise 在其输入的 Promise 中第一个解决时立即解决。这意味着 Promise.race([]) 的结果永远不会解决。

图 43.3 展示了 Promise.race() 的工作原理。

图 43.3:Promise 组合器 Promise.race()

43.5.3.1 使用 Promise.race() 来超时一个 Promise

在本节中,我们将使用 Promise.race() 来超时 Promise。我们将使用以下辅助函数:

/**
 * Returns a Promise that is resolved with `value`
 * after `ms` milliseconds.
 */
function resolveAfter(ms, value=undefined) {
  return new Promise((resolve, _reject) => {
    setTimeout(() => resolve(value), ms);
  });
}

/**
 * Returns a Promise that is rejected with `reason`
 * after `ms` milliseconds.
 */
function rejectAfter(ms, reason=undefined) {
  return new Promise((_resolve, reject) => {
    setTimeout(() => reject(reason), ms);
  });
}

这个函数超时一个 Promise:

function timeout(timeoutInMs, promise) {
  return Promise.race([
    promise,
    rejectAfter(timeoutInMs,
      new Error('Operation timed out')
    ),
  ]);
}

timeout() 返回一个 Promise,其解决与以下两个 Promise 中首先解决的 Promise 相同。

  1. 参数 promise

  2. timeoutInMs 毫秒后拒绝的 Promise

为了产生第二个 Promise,timeout() 使用了这样一个事实:用拒绝的 Promise 解决挂起的 Promise 会导致前者被拒绝。

让我们看看 timeout() 的实际效果。在这里,输入 Promise 在超时之前被解决。因此,输出 Promise 被解决。

timeout(200, resolveAfter(100, 'Result!'))
  .then(result => assert.equal(result, 'Result!'));

在这里,超时发生在输入 Promise 被解决之前。因此,输出 Promise 被拒绝。

timeout(100, resolveAfter(200, 'Result!'))
  .catch(err => assert.deepEqual(err, new Error('Operation timed out')));

理解“Promise 超时”的真正含义很重要:

  • 如果输入 Promise 足够快地解决,其解决会被传递到输出 Promise。

  • 如果它没有足够快地解决,输出 Promise 会被拒绝。

也就是说,超时仅阻止输入 Promise 影响输出(因为 Promise 只能解决一次)。但它不会停止产生输入 Promise 的异步操作。

43.5.3.2 Promise.race() 的简单实现

这是一个 Promise.race() 的简化实现(例如,它不执行任何安全检查):

function race(iterable) {
  return new Promise((resolve, reject) => {
    for (const promise of iterable) {
      promise.then(
        (value) => {
          resolve(value); // (A)
        },
        (err) => {
          reject(err); // (B)
        });
    }
  });
}

结果 Promise 在 A 行或 B 行解决。一旦解决,解决值就不能再更改。

43.5.4 Promise.any() (ES2021)

这是 Promise.any() 的类型签名:

Promise.any<T>(promises: Iterable<Promise<T>>): Promise<T>

Promise.any() 返回一个 Promise p。它如何解决取决于参数 promises(它指的是一个 Promise 的可迭代对象):

  • 如果第一个 Promise 被解决,p 就会使用那个 Promise 解决。

  • 如果所有 Promise 都被拒绝,p 会使用包含所有拒绝值的 AggregateError 实例被拒绝。

图 43.4 展示了 Promise.any() 的工作原理。

图 43.4:Promise 组合器 Promise.any()

43.5.4.1 AggregateError (ES2021)

这是 AggregateError 的类型签名(Error 类的子类):

class AggregateError extends Error {
  // Instance properties (complementing the ones of Error)
  errors: Array<any>;

  constructor(
 errors: Iterable<any>,
 message: string = '',
 options?: ErrorOptions // ES2022
  );
}
interface ErrorOptions {
  cause?: any; // ES2022
}

43.5.4.2 两个初始示例

如果一个 Promise 被解决会发生这种情况:

const promises = [
  Promise.reject('ERROR A'),
  Promise.reject('ERROR B'),
  Promise.resolve('result'),
];
Promise.any(promises)
  .then((result) => assert.equal(
    result, 'result'
  ));

如果所有 Promise 都被拒绝会发生这种情况:

const promises = [
  Promise.reject('ERROR A'),
  Promise.reject('ERROR B'),
  Promise.reject('ERROR C'),
];
Promise.any(promises)
  .catch((aggregateError) => assert.deepEqual(
    aggregateError.errors,
    ['ERROR A', 'ERROR B', 'ERROR C']
  ));

43.5.4.3 Promise.any()Promise.all()

Promise.any()Promise.all() 可以从两种方式进行比较:

  • 它们是彼此的逆:

    • Promise.all():第一个输入拒绝会拒绝结果 Promise 或其解决值是一个包含输入解决值的数组。

    • Promise.any():第一个输入解决会解决结果 Promise 或其拒绝值是一个包含输入拒绝值的数组(在错误对象内部)。

  • 它们有不同的焦点:

    • Promise.all() 关注的是 所有 解决。相反的情况(至少一个拒绝)会导致拒绝。

    • Promise.any() 关注第一个解决。相反的情况(只有拒绝)会导致拒绝。

43.5.4.4 Promise.any()Promise.race()

Promise.any()Promise.race() 也有关联,但关注点不同:

  • Promise.race() 对解决感兴趣。首先解决的 Promise,“获胜”。换句话说:我们想知道哪个异步计算首先终止。

  • Promise.any() 对实现感兴趣。首先实现的 Promise,“获胜”。换句话说:我们想知道哪个异步计算首先成功。

.race() 的主要 – 相对罕见 – 用例是超时 Promise。.any() 的用例更广泛。我们将在下一节中查看它们。

43.5.4.5 Promise.any() 的用例

当我们有多个异步计算并且只对第一个成功的一个感兴趣时,我们使用 Promise.any()。从某种意义上说,我们让计算相互竞争,并使用最快的那个。

以下代码演示了在下载资源时的样子:

const resource = await Promise.any([
  fetch('http://example.com/first.txt')
    .then(response => response.text()),
  fetch('http://example.com/second.txt')
    .then(response => response.text()),
]);

同样的模式使我们能够使用下载速度更快的模块:

const mylib = await Promise.any([
  import('https://primary.example.com/mylib'),
  import('https://secondary.example.com/mylib'),
]);

为了比较,如果次要服务器只是作为后备 – 以防主服务器失败,我们会使用以下代码:

let mylib;
try {
  mylib = await import('https://primary.example.com/mylib');
} catch {
  mylib = await import('https://secondary.example.com/mylib');
}

43.5.4.6 我们如何实现 Promise.any()

Promise.any() 的简单实现基本上是 Promise.all() 实现的镜像版本。

43.5.5 Promise.allSettled() (ES2020)

这次,类型签名稍微复杂一些。您可以自由地跳到第一个演示,它应该更容易理解。

这是 Promise.allSettled() 的类型签名:

Promise.allSettled<T>(promises: Iterable<Promise<T>>)
  : Promise<Array<SettlementObject<T>>>

它返回一个 Promise,其元素具有以下类型签名:

type SettlementObject<T> = FulfillmentObject<T> | RejectionObject;

interface FulfillmentObject<T> {
  status: 'fulfilled';
  value: T;
}

interface RejectionObject {
  status: 'rejected';
  reason: unknown;
}

Promise.allSettled() 返回一个 Promise out。一旦所有 promises 都已解决,out 就会通过一个数组来实现。该数组中的每个元素 e 都对应于 promises 中的一个 Promise p

  • 如果 p 被实现,实现值为 v,那么 e

    { status: 'fulfilled', value:  v }
    
    
  • 如果 p 被拒绝,拒绝值为 r,那么 e

    { status: 'rejected',  reason: r }
    
    

除非在迭代 promises 时出现错误,否则输出 Promise out 从不会被拒绝。

图 43.5 阐述了 Promise.allSettled() 的工作原理。

图 43.5:Promise 组合器 Promise.allSettled()

43.5.5.1 Promise.allSettled() 的第一个演示

这是对 Promise.allSettled() 如何工作的快速初步演示:

Promise.allSettled([
  Promise.resolve('value'),
  Promise.reject('ERROR'),
])
.then(arr => assert.deepEqual(arr, [
  { status: 'fulfilled', value: 'value' },
  { status: 'rejected',  reason: 'ERROR' },
]));

43.5.5.2 Promise.allSettled() 的更长时间示例

以下示例类似于 .map()Promise.all() 的示例(我们从其中借用函数 downloadText()):我们正在下载存储在数组中的多个文本文件。然而,这次我们不希望在出现错误时停止,我们希望继续进行。Promise.allSettled() 允许我们这样做:

function downloadText(url) {
  return fetch(url)
    .then((response) => {
      if (!response.ok) {
        throw new Error(response.statusText);
      }
      return response.text();
    });
}

const urls = [
  'http://example.com/exists.txt',
  'http://example.com/missing.txt',
];

const result = Promise.allSettled(
  urls.map(url => downloadText(url))
);
result.then(
  (arr) => {
    assert.deepEqual(
      arr,
      [
        {
          status: 'fulfilled',
          value: 'Hello!',
        },
        {
          status: 'rejected',
          reason: new Error('Not Found'),
        },
      ]
    )
  }
);

43.5.5.3 Promise.allSettled() 的简单实现

这是 Promise.allSettled() 的简化实现(例如,它不执行任何安全检查):

function allSettled(iterable) {
  return new Promise((resolve, reject) => {
    let elementCount = 0;
    let result;

    function addElementToResult(i, elem) {
      result[i] = elem;
      elementCount++;
      if (elementCount === result.length) {
        resolve(result);
      }
    }

    let index = 0;
    for (const promise of iterable) {
      // Capture the current value of `index`
      const currentIndex = index;
      promise.then(
        (value) => addElementToResult(
          currentIndex, {
            status: 'fulfilled',
            value
          }
        ),
        (reason) => addElementToResult(
          currentIndex, {
            status: 'rejected',
            reason
          }
        )
      );
      index++;
    }
    if (index === 0) {
      // Resolution is normally triggered by addElementToResult()
      resolve([]);
      return;
    }
    // Now we know how many Promises there are in `iterable`.
    // We can wait until now with initializing `result` because
    // the callbacks of .then() are executed asynchronously.
    result = new Array(index);
  });
}

图标“练习”练习:Promise 组合方法

  • 获取 Promise 可迭代对象中的最高满足值:exercises/promises/get-highest-fulfillment_test.mjs

  • 实现 Promise.anySettled()exercises/promises/promise-any-settled_test.mjs

43.5.6 短路(高级)

对于 Promise 组合器,短路意味着输出 Promise 提前解决——在所有输入 Promise 解决之前。以下组合器会短路:

  • Promise.all():只要有一个输入 Promise 被拒绝,输出 Promise 就会被拒绝。

  • Promise.race():只要有一个输入 Promise 被解决,输出 Promise 就会被解决。

  • Promise.any():只要有一个输入 Promise 被满足,输出 Promise 就会被满足。

再次强调,提前解决并不意味着忽略的 Promises 后面的操作停止。它只是意味着它们的解决被忽略。

43.6 并发和 Promise.all()(高级)

43.6.1 顺序执行与并发执行

考虑以下代码:

const asyncFunc1 = () => Promise.resolve('one');
const asyncFunc2 = () => Promise.resolve('two');

asyncFunc1()
 .then((result1) => {
 assert.equal(result1, 'one');
 return asyncFunc2();
 })
 .then((result2) => {
 assert.equal(result2, 'two');
 }); 

以这种方式使用 .then() 会顺序执行基于 Promise 的函数:只有当 asyncFunc1() 的结果解决后,asyncFunc2() 才会执行。

Promise.all() 有助于更并发地执行基于 Promise 的函数:

Promise.all([asyncFunc1(), asyncFunc2()])
  .then((arr) => {
    assert.deepEqual(arr, ['one', 'two']);
  });

43.6.2 并发技巧:关注操作开始的时间

确定异步代码“并发”程度的技巧:关注异步操作开始的时间,而不是它们 Promise 的处理方式。

例如,以下每个函数都是并发执行 asyncFunc1()asyncFunc2(),因为它们几乎同时开始。

function concurrentAll() {
 return Promise.all([asyncFunc1(), asyncFunc2()]);
}

function concurrentThen() {
 const p1 = asyncFunc1();
 const p2 = asyncFunc2();
 return p1.then(r1 => p2.then(r2 => [r1, r2]));
} 

另一方面,以下两个函数都是顺序执行 asyncFunc1()asyncFunc2()asyncFunc2() 只有在 asyncFunc1() 的 Promise 被满足后才会被调用。

function sequentialThen() {
 return asyncFunc1()
 .then(r1 => asyncFunc2()
 .then(r2 => [r1, r2]));
}

function sequentialAll() {
 const p1 = asyncFunc1();
 const p2 = p1.then(() => asyncFunc2());
 return Promise.all([p1, p2]);
} 

43.6.3 Promise.all() 是分叉-合并

Promise.all() 与并发模式“分叉-合并”松相关。让我们回顾一下我们之前遇到的例子 之前:

Promise.all([
    // (A) fork
    downloadText('http://example.com/first.txt'),
    downloadText('http://example.com/second.txt'),
  ])
  // (B) join
  .then(
    (arr) => assert.deepEqual(
      arr, ['First!', 'Second!']
    ));

  • 分叉:在行 A 中,我们正在分叉两个异步计算并并发执行它们。

  • 合并:在行 B 中,我们将这些计算合并成一个“线程”,一旦它们都完成,这个线程就会启动。

43.7 链式 Promise 的技巧

本节提供了链式 Promise 的技巧。

43.7.1 链式错误:丢失尾部

问题:

// Don’t do this
function foo() {
 const promise = asyncFunc();
 promise.then((result) => {
 // ···
 });

 return promise;
}

计算从 asyncFunc() 返回的 Promise 开始。但之后,计算继续,并通过 .then() 创建另一个 Promise。foo() 返回前一个 Promise,但应该返回后一个。这是如何修复它的:

function foo() {
 const promise = asyncFunc();
 return promise.then((result) => {
 // ···
 });
}

43.7.2 链式错误:嵌套

问题:

// Don’t do this
asyncFunc1()
  .then((result1) => {
    return asyncFunc2()
    .then((result2) => { // (A)
      // ···
    });
  });

行 A 中的.then()是嵌套的。一个扁平的结构会更好:

asyncFunc1()
  .then((result1) => {
    return asyncFunc2();
  })
  .then((result2) => {
    // ···
  });

43.7.3 链式错误:不必要的嵌套过多

这又是另一个可避免嵌套的例子:

// Don’t do this
asyncFunc1()
  .then((result1) => {
    if (result1 < 0) {
      return asyncFuncA()
      .then(resultA => 'Result: ' + resultA);
    } else {
      return asyncFuncB()
      .then(resultB => 'Result: ' + resultB);
    }
  });

我们可以再次得到一个扁平的结构:

asyncFunc1()
  .then((result1) => {
    return result1 < 0 ? asyncFuncA() : asyncFuncB();
  })
  .then((resultAB) => {
    return 'Result: ' + resultAB;
  });

43.7.4 并非所有嵌套都是不好的

在以下代码中,我们实际上受益于嵌套:

db.open()
  .then((connection) => { // (A)
    return connection.select({ name: 'Jane' })
      .then((result) => { // (B)
        // Process result
        // Use `connection` to make more queries
      })
      // ···
      .finally(() => {
        connection.close(); // (C)
      });
  })

我们在行 A 中接收异步结果。在行 B 中,我们进行了嵌套,以便在行 C 中访问回调内部的变量connection

43.7.5 链式错误:创建 Promise 而不是链式

问题:

// Don’t do this
class Model {
  insertInto(db) {
    return new Promise((resolve, reject) => { // (A)
      db.insert(this.fields)
        .then((resultCode) => {
          this.notifyObservers({event: 'created', model: this});
          resolve(resultCode);
        }).catch((err) => {
          reject(err);
        })
    });
  }
  // ···
}

在行 A 中,我们创建了一个 Promise 来传递db.insert()的结果。这是不必要的冗长,可以简化:

class Model {
  insertInto(db) {
    return db.insert(this.fields)
      .then((resultCode) => {
        this.notifyObservers({event: 'created', model: this});
        return resultCode;
      });
  }
  // ···
}

关键思想是我们不需要创建一个 Promise;我们可以返回.then()调用的结果。一个额外的优点是我们不需要捕获并重新拒绝db.insert()的失败。我们只需将它的拒绝传递给.insertInto()的调用者。

43.8 Thenables (Promise-like objects) (高级)

当在 ES6(2015 年)中向 JavaScript 的标准库添加 Promise 时,几个 Promise 库很受欢迎并且被广泛使用。为了使这些库与内置 API 互操作,TC39 定义了一个最小的接口,用于 Promise-like 对象,该接口与大多数这些库兼容。尽可能多的情况下,API 不需要对象是 Promise – 它们只需要是 Promise-like。如果需要,API 会透明地将 Promise-like 对象转换为 API Promise。

那么什么最小接口描述了 Promise 的本质?它只需要一个.then()方法,让我们可以注册回调:

const promiseLikeObject = {
  then(onFulfilled, onRejected) {
    // ···
  },
};

这是 TypeScript 中 Promise-like 对象的类型的一个简化版本:

interface PromiseLike<T> {
  then<TResult1, TResult2>(
    onFulfilled?: (value: T) => TResult1 | PromiseLike<TResult1>,
    onRejected?: (reason: any) => TResult2 | PromiseLike<TResult2>
  ): PromiseLike<TResult1 | TResult2>;
}

这个接口是足够的,因为.catch()实际上只是调用.then()(我们之前忽略了其第二个参数)的一个方便方式 – 以下两个调用是等效的:

promise.catch(onRejected)
promise.then(undefined, onRejected)

因为 Promise-like 对象只有一个.then()方法,所以它们也被称为thenables

43.8.1 示例:一个已解决的 thenable

下面的对象是一个已解决的 thenable:

const fulfilledThenable = {
  then(onFulfilled, onRejected) {
    onFulfilled('Success!');
  },
};

如果我们将 thenable 传递给Promise.resolve(),它将其转换为 Promise:

const promise = Promise.resolve(fulfilledThenable);
assert.equal(
  promise instanceof Promise, true
);

从回调中返回 thenable 相当于返回一个 Promise:

Promise.resolve()
  .then(() => fulfilledThenable)
  .then((value) => {
    assert.equal(value, 'Success!');
  });

我们也可以用 thenable 解决一个新的 Promise:

new Promise((resolve) => {
  resolve(fulfilledThenable);
}).then((value) => {
  assert.equal(value, 'Success!');
});

43.8.2 示例:一个被拒绝的 thenable

以下代码演示了一个被拒绝的 thenable:

const rejectedThenable = {
  then(onFulfilled, onRejected) {
    onRejected('Error!');
  },
};

Promise.resolve(rejectedThenable)
  .catch((reason) => {
    assert.equal(reason, 'Error!');
  });

Promise.resolve()
  .then(() => rejectedThenable)
  .catch((reason) => {
    assert.equal(reason, 'Error!');
  });

new Promise((resolve) => {
  resolve(rejectedThenable);
}).catch((reason) => {
  assert.equal(reason, 'Error!');
});

43.9 快速参考:Promise

43.9.1 new Promise()

  • new Promise(executor) ES6

    new Promise<T>(
      executor: (
     resolve: (value: T | PromiseLike<T>) => void,
     reject: (reason?: any) => void
     ) => void
    ): Promise<T>
    
    

    这个构造函数创建了一个新的 Promise。它将函数传递给其回调,这样 Promise 就可以被解决或拒绝:

    // Create a Promise and resolve it
    new Promise((resolve, reject) => {
      resolve('Result');
    }).then((value) => {
      assert.equal(value, 'Result');
    });
    
    // Create a Promise and reject it
    new Promise((resolve, reject) => {
      reject('Error');
    }).catch((reason) => {
      assert.equal(reason, 'Error');
    });
    
    

43.9.2 Promise.*: 创建 Promise

  • Promise.withResolvers() ES2024

    Promise.withResolvers<T>(): PromiseWithResolvers<T>;
    interface PromiseWithResolvers<T> {
      promise: Promise<T>;
      resolve: (value: T | PromiseLike<T>) => void;
      reject: (reason?: any) => void;
    }
    
    

    此方法创建一个 Promise,并返回一个包含该 Promise 以及用于解决或拒绝它的函数的对象。

  • Promise.resolve(value?) ES6

    创建一个 Promise,用value解决它并返回它:

    Promise.resolve('Yes')
    .then((value) => {
      assert.equal(value, 'Yes');
    });
    
    
  • Promise.reject(reason?) ES6

    创建一个 Promise,用value拒绝它并返回它:

    Promise.reject('No')
    .catch((reason) => {
      assert.equal(reason, 'No');
    });
    
    

43.9.3 Promise.*: 附加功能

  • Promise.try(callback, ...args) ES2025

    通过将callback视为.then()回调来创建 Promise:

    • 它使用零个或多个参数调用callback

    • 如果callback抛出异常,Promise.try()将其转换为拒绝的 Promise 并返回它。

    • 如果callback返回一个值,Promise.try()将其解决为 Promise 并返回它。

    此方法的用例是使用非纯异步代码开始 Promise 链 - 例如:

    function computeAsync() {
     return Promise.try(() => {
     const value = syncFuncMightThrow();
     return asyncFunc(value);
     });
    }
    
    

43.9.4 Promise.*: Promise 组合器

术语表:

  • 短路:在某些情况下,输出 Promise 可以提前解决(在所有输入 Promise 解决之前)。这被称为短路。

这些是 Promise 组合器:

  • Promise.all(promises) ES6

    Promise.all<T>(
      promises: Iterable<Promise<T>>
    ): Promise<Array<T>>
    
    
    • P 的解决:如果所有输入 Promise 都已解决。

      • 值:包含输入 Promise 的解决值的数组
    • P 的拒绝:如果有一个输入 Promise 被拒绝。

      • 值:输入 Promise 的拒绝值
    • 短路:是

    • 用例:使用 Promise 处理数组(拒绝会终止处理)

  • Promise.race(promises) ES6

    Promise.race<T>(
      promises: Iterable<Promise<T>>
    ): Promise<T>
    
    
    • P 的解决:如果第一个输入 Promise 已解决。

      • 值:输入 Promise 的解决值
    • 短路:是

    • 用例:对多个 Promise 中的第一个解决做出反应

  • Promise.any(promises) ES2021

    Promise.any<T>(
      promises: Iterable<Promise<T>>
    ): Promise<T>
    
    
    • P 的解决:如果有一个输入 Promise 被解决。

      • 值:输入 Promise 的解决值
    • P 的拒绝:如果所有输入 Promise 都被拒绝。

      • 值:包含输入 Promise 拒绝值的AggregateError
    • 短路:是

    • 用例:在多个异步计算中,我们只对第一个成功的结果感兴趣。也就是说,我们尝试了多种方法,最快的一个应该获胜。

    这是AggregateError的类型签名(省略了一些成员):

    class AggregateError {
      constructor(errors: Iterable<any>, message: string);
      get errors(): Array<any>;
      get message(): string;
    }
    
    
  • Promise.allSettled(promises) ES2020

    Promise.allSettled<T>(
      promises: Iterable<Promise<T>>
    ): Promise<Array<SettlementObject<T>>>
    
    
    • P 的解决:如果所有输入 Promise 都已解决。

      • 值:包含每个输入 Promise 的一个解决对象的数组。解决对象包含解决类型和解决值。
    • P 的拒绝:如果在迭代输入 Promise 时发生错误。

    • 短路:否

    • 用例:使用 Promise 处理数组(拒绝不会终止处理)

    这是SettlementObject的类型签名:

    type SettlementObject<T> = FulfillmentObject<T> | RejectionObject;
    
    interface FulfillmentObject<T> {
      status: 'fulfilled';
      value: T;
    }
    
    interface RejectionObject {
      status: 'rejected';
      reason: unknown;
    }
    
    

43.9.5 Promise.prototype.*

  • Promise.prototype.then(onFulfilled?, onRejected?) ES6

    interface Promise<T> {
      then<TResult1, TResult2>(
        onFulfilled?: (value: T) => TResult1 | PromiseLike<TResult1>,
        onRejected?: (reason: any) => TResult2 | PromiseLike<TResult2>
      ): Promise<TResult1 | TResult2>;
    }
    
    

    为 Promise 的实现值和/或拒绝值注册回调。通常,只使用第一个参数 onFulfilled.catch() 提供了一个更具描述性的替代方案,用于使用第二个参数 onRejected

    Promise.resolve('Yes')
    .then((value) => {
      assert.equal(value, 'Yes');
    });
    
    
  • Promise.prototype.catch(onRejected) ES6

    interface Promise<T> {
      catch<TResult>(
        onrejected?: (reason: any) => TResult2 | PromiseLike<TResult2>
      ): Promise<T | TResult>;
    }
    
    

    为 Promise 的拒绝值注册回调。这是使用 .then() 实现该目的的一个更具描述性的替代方案——以下两种调用是等效的:

    promise.catch(onRejected)
    promise.then(undefined, onRejected)
    
    

    示例:

    Promise.reject('No')
    .catch((reason) => {
      assert.equal(reason, 'No');
    });
    
    
  • Promise.prototype.finally(onFinally) ES2018

    interface Promise<T> {
      // Returning a rejected Promise from onFinally does have an effect!
      finally(onFinally?: () => void);
    }
    
    

    通常如下使用:

    somePromise
      .then((result) => {
        // ···
      })
      .catch((error) => {
        // ···
      })
      .finally(() => {
        // ···
      })
    ;
    
    

    .finally() 回调始终会被执行——无论 somePromise 的结果如何,以及 .then() 和/或 .catch() 返回的值。回调只有在其返回一个拒绝的 Promise 或抛出异常时才会产生效果。然后最终的 Promise 会因为拒绝值或异常而被拒绝。

posted @ 2025-12-12 18:01  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报