ES6 Promise 的最佳实践
处理 Promise 拒绝
保持“线性”
util.promisify是你最好的朋友
避免陷入顺序陷阱
注意:Promise 也可能阻塞事件循环。
考虑内存使用情况。
同步达成的承诺是多余的,也是不必要的。
过长的合约链应该会引起一些人的关注。
保持简单!
ES6 Promise 太棒了!它们是 JavaScript 异步编程不可或缺的结构,最终取代了以前臭名昭著的基于回调的模式,后者导致了代码深度嵌套(“回调地狱”)。
遗憾的是,Promise 的概念并不容易理解。在本文中,我将分享多年来我总结的最佳实践,这些实践帮助我充分利用了异步 JavaScript。
处理 Promise 拒绝
没有什么比未处理的 Promise 拒绝更令人沮丧的了。这种情况发生在 Promise 抛出错误,但没有Promise#catch相应的处理程序来优雅地处理它时。
在调试一个高并发应用程序时,由于随之而来的错误信息晦涩难懂(而且相当吓人),找到出错的 Promise 极其困难。然而,一旦找到问题 Promise 并确认其可复现,由于应用程序本身的并发性,确定应用程序的状态通常也同样困难。总而言之,这并非一件令人愉快的事情。
因此,解决方案很简单:Promise#catch无论可能性多么小,都要始终为可能被拒绝的 Promise 添加处理程序。
此外,在未来的 Node.js 版本中,未处理的 Promise 拒绝会导致 Node 进程崩溃。现在正是养成优雅错误处理习惯的最佳时机。
保持“线性”
在最近的一篇文章中,我解释了为什么避免嵌套 Promise 很重要。简而言之,嵌套 Promise 会让我们重新陷入“回调地狱”。Promise 的目标是为异步编程提供惯用的标准化语义。而嵌套 Promise 则让我们回到了 Node.js API 中那种冗长且繁琐的错误优先回调机制。
为了保持异步活动的“线性”,我们可以使用异步函数或正确链接的 Promise。
import { promises as fs } from 'fs';
// Nested Promises
fs.readFile('file.txt')
.then(text1 => fs.readFile(text1)
.then(text2 => fs.readFile(text2)
.then(console.log)));
// Linear Chain of Promises
const readOptions = { encoding: 'utf8' };
const readNextFile = fname => fs.readFile(fname, readOptions);
fs.readFile('file.txt', readOptions)
.then(readNextFile)
.then(readNextFile)
.then(console.log);
// Asynchronous Functions
async function readChainOfFiles() {
const file1 = await readNextFile('file.txt');
const file2 = await readNextFile(file1);
console.log(file2);
}
util.promisify是你最好的朋友
随着我们从错误优先回调过渡到 ES6 Promise,我们往往会养成“承诺一切”的习惯。
大多数情况下,用Promise构造函数包装旧的基于回调的 API 就足够了。一个典型的例子就是将“Promise”功能globalThis.setTimeout封装成一个sleep函数。
const sleep = ms => new Promise(
resolve => setTimeout(resolve, ms)
);
await sleep(1000);
然而,其他外部库未必能默认与 Promise 兼容。如果我们不小心,可能会出现一些意想不到的副作用,例如内存泄漏。在 Node.js 环境中,util.promisify有一个实用函数可以解决这个问题。
顾名思义,util.promisify该库用于修正和简化基于回调的 API 的封装。它假定给定的函数接受一个错误优先回调作为其最后一个参数,就像大多数 Node.js API 一样。如果存在特殊的实现细节¹,库作者还可以提供“自定义 Promise 器”。
import { promisify } from 'util';
const sleep = promisify(setTimeout);
await sleep(1000);
避免陷入顺序陷阱
在本系列的前一篇文章中,我详细讨论了调度多个独立 Promise 的强大功能。由于 Promise 链的顺序特性,其效率提升有限。因此,最大限度减少程序“空闲时间”的关键在于并发性。
import { promisify } from 'util';
const sleep = promisify(setTimeout);
// Sequential Code (~3.0s)
sleep(1000)
.then(() => sleep(1000));
.then(() => sleep(1000));
// Concurrent Code (~1.0s)
Promise.all([ sleep(1000), sleep(1000), sleep(1000) ]);
注意:Promise 也可能阻塞事件循环。
关于 Promise 最常见的误解或许是认为 Promise 可以实现“多线程”JavaScript 的执行。虽然事件循环给人一种“并行”的错觉,但这仅仅是一种错觉。本质上,JavaScript 仍然是单线程的。
事件循环的作用在于使运行时能够并发地调度、协调和处理程序中的各种事件。通俗地说,这些“事件”确实是并行发生的,但到时候它们仍然会按顺序处理。
在以下示例中,Promise 不会使用给定的执行器函数创建新线程。实际上,执行器函数总是在 Promise 构造完成后立即执行,从而阻塞事件循环。执行器函数返回后,顶层代码的执行才会恢复。解析后的值(通过处理程序)的使用会被延迟,直到当前调用栈执行完剩余的Promise#then顶层代码。
console.log('Before the Executor');
// Blocking the event loop...
const p1 = new Promise(resolve => {
// Very expensive CPU operation here...
for (let i = 0; i < 1e9; ++i)
continue;
console.log('During the Executor');
resolve('Resolved');
});
console.log('After the Executor');
p1.then(console.log);
console.log('End of Top-level Code');
// Result:
// 'Before the Executor'
// 'During the Executor'
// 'After the Executor'
// 'End of Top-level Code'
// 'Resolved'
由于 Promise 不会自动创建新线程,因此后续Promise#then处理程序中的 CPU 密集型工作也会阻塞事件循环。
Promise.resolve()
//.then(...)
//.then(...)
.then(() => {
for (let i = 0; i < 1e9; ++i)
continue;
});
考虑内存使用情况。
由于一些不可避免的堆内存分配,Promise 往往会占用相对较大的内存空间和计算成本。
除了存储有关Promise实例本身的信息(例如其属性和方法)之外,JavaScript 运行时还会动态分配更多内存来跟踪与每个 Promise 关联的异步活动。
此外,鉴于 Promise API 大量使用闭包和回调函数(两者都需要进行堆内存分配),单个 Promise 对象会占用相当多的内存,这令人惊讶。在热代码路径中,Promise 数组可能会造成严重的内存占用问题。
一般来说,每个新的 Promise 实例都Promise需要分配大量的堆内存来存储属性、方法、闭包和异步状态。我们使用的 Promise 越少,从长远来看就越有利。
同步达成的承诺是多余的,也是不必要的。
如前所述,Promise 并不会神奇地创建新线程。因此,完全同步的执行器函数(对于Promise构造函数而言)只会引入不必要的间接层。3
const promise1 = new Promise(resolve => {
// Do some synchronous stuff here...
resolve('Presto');
});
同样,将处理程序附加到Promise#then同步解析的 Promise 只会略微延迟代码的执行。4对于这种用例,最好改用其他方法。global.setImmediate
promise1.then(name => {
// This handler has been deferred. If this
// is intentional, one would be better off
// using `setImmediate`.
});
举例来说,如果执行函数不包含异步 I/O 操作,它就只是一个不必要的间接层,会带来上述的内存和计算开销。
因此,我个人不建议在项目中使用Promise.resolve`and` Promise.reject。这些静态方法的主要目的是将值封装在 Promise 中。鉴于生成的 Promise 会立即被处理,有人可能会认为根本没有必要使用 Promise(除非是为了 API 兼容性)。
// Chain of Immediately Settled Promises
const resolveSync = Promise.resolve.bind(Promise);
Promise.resolve('Presto')
.then(resolveSync) // Each invocation of `resolveSync` (which is an alias
.then(resolveSync) // for `Promise.resolve`) constructs a new promise
.then(resolveSync); // in addition to that returned by `Promise#then`.
过长的合约链应该会引起一些人的关注。
有时需要按顺序执行多个异步操作。在这种情况下,Promise 链是理想的抽象方案。
然而,必须指出的是,由于 Promise API 的设计初衷是链式调用,每次调用都会Promise#then构造并返回一个全新的Promise实例(并保留部分先前的状态)。考虑到中间处理程序还会构造额外的 Promise,过长的调用链可能会显著增加内存和 CPU 的使用量。
const p1 = Promise.resolve('Presto');
const p2 = p1.then(x => x);
// The two `Promise` instances are different.
p1 === p2; // false
应尽可能保持 Promise 链的简洁。一种有效的策略是禁止使用完全同步的Promise#then处理程序,链中的最后一个处理程序除外。
换句话说,所有中间处理程序都必须严格异步——也就是说,它们返回 Promise 对象。只有最终处理程序才有权运行完全同步的代码。
import { promises as fs } from 'fs';
// This is **not** an optimal chain of promises
// based on the criteria above.
const readOptions = { encoding: 'utf8' };
fs.readFile('file.txt', readOptions)
.then(text => {
// Intermediate handlers must return promises.
const filename = `${text}.docx`;
return fs.readFile(filename, readOptions);
})
.then(contents => {
// This handler is fully synchronous. It does not
// schedule any asynchronous operations. It simply
// processes the result of the preceding promise
// only to be wrapped (as a new promise) and later
// unwrapped (by the succeeding handler).
const parsedInteger = parseInt(contents);
return parsedInteger;
})
.then(parsed => {
// Do some synchronous tasks with the parsed contents...
});
如上例所示,完全同步的中间处理程序会导致 Promise 的冗余包装和解包。因此,强制执行最优链式调用策略至关重要。为了消除冗余,我们可以简单地将导致问题的中间处理程序的工作集成到后续处理程序中。
import { promises as fs } from 'fs';
const readOptions = { encoding: 'utf8' };
fs.readFile('file.txt', readOptions)
.then(text => {
// Intermediate handlers must return promises.
const filename = `${text}.docx`;
return fs.readFile(filename, readOptions);
})
.then(contents => {
// This no longer requires the intermediate handler.
const parsed = parseInt(contents);
// Do some synchronous tasks with the parsed contents...
});
保持简单!
如果不需要,就不要用。就这么简单。如果可以在不使用 Promise 的情况下实现抽象,那么我们应该始终优先选择这种方式。
Promise并非“免费”的。它们本身并不能在JavaScript中实现“并行”。它们只是用于调度和处理异步操作的一种标准化抽象。如果我们编写的代码本身并非异步的,那么就没有必要使用Promise。
不幸的是,很多时候,为了实现强大的功能,我们确实需要用到 Promise。因此,我们必须了解所有最佳实践、权衡取舍、陷阱和误解。目前,关键在于尽量减少 Promise 的使用——并非因为 Promise 本身“邪恶”,而是因为它们太容易被滥用。
但这并非故事的结局。在本系列的下一部分中,我将把最佳实践的讨论扩展到ES2017 异步函数(async/ await)。
-
这可能包括特定的参数格式、初始化操作、清理操作等等 。
-
本质上,这就是在“微任务队列”中调度“微任务”的含义。当前顶层代码执行完毕后,“微任务队列”会等待所有已调度的 Promise 完成。随着时间的推移,对于每个已完成的 Promise,“微任务队列”会调用相应的处理
Promise#then程序,并将已完成的值(由回调函数存储)传递给它resolve。 -
但由于需要额外调用一个 Promise,所以开销会更大 。↩
-
此外,还需要为每个链式处理程序构建一个新的 Promise,这增加了额外的开销 。↩