Promise 是 JavaScript 中解决异步编程问题的一种重要工具 …… 哪怕你不知道 Promise 里面发生了什么,照抄 LLM 生成的代码也没错,但我想最好还是稍微有点探索精神,了解一下 Promise 的概念、用法及常见陷阱。

基础

下文使用 TypeScript 语法。

Promise 是一个对象,它代表了一个异步操作的最终完成(或失败)及其结果值。

在 TS 中你可以明确声明 Promise 的类型:Promise<T>,其中 T 是 Promise 成功时返回的值的类型(失败时返回的值的类型是 any)。

  • 也可以自动推导类型。
  • Promise<void> 表示没有返回值的 Promise。
1
2
3
4
5
const myPromise: Promise<string> = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Success");
}, 1000);
});

它有三种状态:

  • pending:初始状态。
  • fulfilled:操作成功。
  • rejected:操作失败。

只允许两种状态转换:

  • pendingfulfilled
  • pendingrejected

且一旦状态改变,就不能再改变了。

这个状态有什么意义呢?fulfilledrejected 状态下,Promise 对象视为已经完成,并立即调用相应的回调函数。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const trialPromise : Promise<number> = new Promise((resolve, reject) => {
if (Math.random() > 0.5) {
resolve(42);
} else {
reject(new Error("Rejected"));
}
}).then(
(result) => {
console.log("Success", result);
},
(error) => {
console.error("Failed", error);
}
);

其中,then(onFulfilled, onRejected) 方法接受两个参数,分别相当于 resolvereject 的回调函数。

不过一般不会见到 then 的第二个参数,因为 Promise 失败时会抛出异常,通常会使用 catch 方法来处理错误。

1
2
3
4
5
6
7
8
const trialPromise : Promise<number> = new Promise((resolve, reject) => {
if (Math.random() > 0.5) {
resolve(42);
} else {
reject(new Error("Rejected"));
}
}).then((result) => console.log("Success", result))
.catch((error) => console.error("Failed", error));

注意在 Promise 完成后,这个 Promise 变量可以作为一个值使用。假设实验成功,trialPromise 变量的值是 42;否则,trialPromise 变量的值是 Error 对象。

链式调用

由于 Promise 的 then 方法返回一个新的 Promise 对象,因此可以进行链式调用。

这有什么意义呢?

假设我们使用传统异步,将 PDF 文件转化为 base64 字符串然后进行 OCR 识别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const pdfToImages = async (pdfUrl: string, maxPages: number = 1) => {
const pdfjs = require('pdfjs-dist/webpack');
const pdfDoc = await pdfjs.getDocument(pdfUrl).promise;
const images = [];
const canvas = document.createElement('canvas');
const numPages = Math.min(maxPages, pdfDoc.numPages);
if (!numPages) {
throw new Error('Invalid PDF file');
}
for (let i = 1; i <= numPages; i++) {
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 3 });
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: context, viewport }).promise;
images.push(canvas.toDataURL('image/png'));
}
canvas.remove();
return images;
};

const OCR = async (image: string) => {
const result = await Tesseract.recognize(image, 'eng');
return result.data.text;
};

try {
const images = await pdfToImages('example.pdf');
const ocrResults = [];
for (const image of images) {
try {
const result = await OCR(image);
ocrResults.push(result);
} catch (error) {
console.error('OCR Error:', error);
}
}
} catch (error) {
console.error('Error:', error);
}

这样就有一个问题是:要处理的错误有很多种,可能是 PDF 文件本身的问题,也可能是 OCR 识别的问题。我们需要在每个 try...catch 块中处理错误。

如果我们使用 Promise,就可以将错误处理放在链的末尾:

1
2
3
4
5
const ocrResults : Promise<string[]> = pdfToImages('example.pdf')
.then((images) => {
return Promise.all(images.map((image) => OCR(image)));
})
.catch((error) => console.error('Error:', error));

这里有两级 Promise:

  • pdfToImages 返回一个 Promise,表示 PDF 转换为图片的结果。
  • Promise.all 返回一个 Promise,收集所有图片的 OCR 结果。

但只需要一个 catch 块来处理所有错误。

可能有人疑惑开头没有用到 new Promise((resolve, reject) => {}),因为异步函数本身就返回一个 Promise 对象。前面的例子只是没有明确写出来而已,实际上都是和 pdfToImages 相似的异步函数。

四种 Promise 方法

Promise.all

上一节最后一个例子中使用了 Promise.all,它接受一个 Promise 数组(每一页都是一个 Promise),组合成一个新的 Promise 对象(数组)。

具体来说,在所有 Promise 都成功时,返回一个新的 Promise 对象,值是一个数组,包含所有 Promise 的结果;如果有一个 Promise 失败,则返回一个失败的 Promise 对象,值是第一个失败的 Promise 的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const timeoutPromise = (ms: number) => {
if (ms < 0) {
return Promise.reject(new Error(`Timeout cannot be negative: ${ms}`));
}
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Resolved after ${ms} ms`);
resolve(ms);
}, ms);
});
};

const promises = [
timeoutPromise(100),
timeoutPromise(10),
timeoutPromise(1),
];

const start = Date.now();
Promise.all(promises)
.then((values) => {
console.log(`All promises resolved in ${Date.now() - start} ms`);
console.log("Values:", values);
})
.catch((error) => {
console.error("Error:", error);
});

输出结果:

1
2
3
4
5
Resolved after 1 ms
Resolved after 10 ms
Resolved after 100 ms
All promises resolved in 103 ms
Values: [ 1, 10, 100 ]

所以 Promise.all 干了两件事:

  • (理论上)并行执行所有 Promise。
  • 等待所有 Promise 完成,收集其返回值,将这个数组作为一个 Promise 返回。(数组的顺序和传入的 Promise 顺序一致)

再试一下:

1
2
3
4
5
6
const promises = [
timeoutPromise(10),
timeoutPromise(-1000),
timeoutPromise(-100),
timeoutPromise(1)
];

输出结果:

1
2
3
Error: Timeout cannot be negative: -1000
Resolved after 1 ms
Resolved after 10 ms

所以要注意:

如果任何有一个 Promise 失败,Promise.all 会立即返回一个失败的 Promise。此时,其他 Promise 仍然会继续执行,但由于 Promise.all 的状态已经转为 rejected

  • 成功的 Promise 的结果不会被收集,then 方法不会被调用。
  • 仅第一个失败的 Promise 的结果会被传递给 catch 方法。
  • 单个 Promise 的失败仍然不会影响其他 Promise 的执行。

Promise.allSettled

如果你希望所有 Promise 都执行完毕后再处理结果,可以使用 Promise.allSettled 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const promises = [
timeoutPromise(10),
timeoutPromise(-1000),
timeoutPromise(-100),
timeoutPromise(1)
];
const start = Date.now();
Promise.allSettled(promises)
.then((results) => {
console.log(`All promises settled in ${Date.now() - start} ms`);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Promise ${index} resolved with value: ${result.value}`);
} else {
console.error(`Promise ${index} rejected with reason: ${result.reason}`);
}
});
});

输出结果:

1
2
3
4
5
6
7
Resolved after 1 ms
Resolved after 10 ms
All promises settled in 13 ms
Promise 0 resolved with value: 10
Promise 1 rejected with reason: Error: Timeout cannot be negative: -1000
Promise 2 rejected with reason: Error: Timeout cannot be negative: -100
Promise 3 resolved with value: 1

要注意,allSettled 方法返回的 Promise 对象的值是一个数组,包含每个 Promise 的结果对象。

每个结果对象都有两个属性:

  • status:表示 Promise 的状态,可以是 fulfilledrejected
  • value:如果 Promise 成功,包含成功的值;否则为 undefined
  • reason:如果 Promise 失败,包含失败的原因;否则为 undefined

allSettled 本身只有一种结果:fulfilled,即所有 Promise 都执行完毕,所以只需要一个 then 方法来处理结果。

Promise.race

Promise.racePromise.all 类似,但它只返回第一个完成的 Promise 的结果,无论是成功还是失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const promises = [
timeoutPromise(100),
timeoutPromise(10),
timeoutPromise(1),
];
const start = Date.now();
Promise.race(promises)
.then((value) => {
console.log(`First promise resolved in ${Date.now() - start} ms`);
console.log("Value:", value);
})
.catch((error) => {
console.error("Error:", error);
});

输出结果:

1
2
3
4
5
Resolved after 1 ms
First promise resolved in 4 ms
Value: 1
Resolved after 10 ms
Resolved after 100 ms

又或者

1
2
3
4
5
const promises = [
timeoutPromise(100),
timeoutPromise(-10),
timeoutPromise(-1),
];

输出结果:

1
2
Error: Timeout cannot be negative: -10
Resolved after 100 ms

因为 resolve 有 100ms 延迟,reject 立即返回,这里肯定是 timeoutPromise(-10) 先完成,所以 Promise.race 返回的 Promise 是失败的。

Promise.race 被 rejected 后的行为与 Promise.all 一样,其他 Promise 仍然会继续执行,但 then 方法不会被调用,只有第一个失败的 Promise 的结果会被传递给 catch 方法。

可以再给失败加个延时:

1
2
3
4
5
6
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const promises = [
timeoutPromise(100),
delay(10).then(() => timeoutPromise(-10)),
timeoutPromise(1),
];

输出结果:

1
2
3
4
Resolved after 1 ms
First promise resolved in 4 ms
Value: 1
Resolved after 100 ms

我们可以用同样的道理分析:在 timeoutPromise(1) resolved 之后,整个 Promise 的状态就变成了 fulfilled,所以:

  • 成功的 Promise 的结果不会被收集,只有第一个成功的 Promise 的结果会被传递给 then 方法。
  • 失败的 Promise 的结果被完全忽略。

Promise.race 有一个典型的应用场景,就是实现超时控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const timeout = (ms: number) => new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Timeout after ${ms} ms`));
}, ms);
});

const OCRWithTimeout = (image: string, timeoutMs: number) => {
return Promise.race([
OCR(image),
timeout(timeoutMs)
]).then((result) => result.data.text)
.catch((error) => {
console.error('OCR Error:', error);
throw error;
});
};

const ocrResults : Promise<string[]> = pdfToImages('example.pdf')
.then((images) => {
return Promise.all(images.map((image) => OCRWithTimeout(image, 5000)));
})
.catch((error) => console.error('Error:', error));

这样我们用一个 catch 同时处理了 OCR 失败和超时的错误。

Promise.any

any 比较少见,但其实你不难通过字面意思理解它的作用和行为。


可以想想看这个关键字在 Python 中的用法:

1
2
3
4
5
6
if all([x > 0 for x in arr]):
return max(arr)
elif any([x < 0 for x in arr]):
return min(arr)
else:
return 0

毫无疑问,Promise.any 的行为和 Promise.all 完全对立。

具体来说:

  • 只要有一个成功的 Promise,立即 resolve,返回这个 Promise 的结果。
  • 如果所有 Promise 都失败,返回一个失败的 Promise,值是一个 AggregateError 对象,包含所有失败的 Promise 的错误信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const promises = [
timeoutPromise(100),
timeoutPromise(-10),
timeoutPromise(-1),
];
const start = Date.now();
Promise.any(promises)
.then((value) => {
console.log(`First promise resolved in ${Date.now() - start} ms`);
console.log("Value:", value);
})
.catch((error) => {
console.error("Error:", error);
for (const err of error.errors) {
console.error("Error:", err);
}
});

输出结果:

1
2
3
Resolved after 100 ms
First promise resolved in 103 ms
Value: 100

再试一下:

1
2
3
4
5
const promises = [
timeoutPromise(-100),
timeoutPromise(-10),
timeoutPromise(-1),
];

输出结果:

1
2
3
4
Error: All promises were rejected
Error: Timeout cannot be negative: -100
Error: Timeout cannot be negative: -10
Error: Timeout cannot be negative: -1

总结

四种 Promise 方法的区别整理如下:

方法成功条件成功返回值失败条件失败返回值
.all全部成功[结果数组]任意失败最先失败的值
.allSettled全部完成[结果/错误数组]--
.race任意成功最先成功的值任意失败最先失败的值
.any任意成功最先成功的值全部失败AggregateError 对象

额外注意:

  • .all.allSettled 中,结果数组是按照传入的 Promise 顺序排列的。
  • .all(提前失败)、.race(提前成功或失败)和 .any(提前成功)后,未完成的 Promise 仍然会继续执行,但不会影响结果。
  • Promise 数组中每个 Promise 都不会影响其他 Promise 的执行,状态独立。

React 中的 Promise

TODO

常见陷阱

TODO