JavaScript 之 Promise 简介

Promise 是最近比较热门的设计模式之一,大约从 2000 年起活跃于各大语言之间,是异步编程的一大利器。 JavaScript 中也有 bluebird, Q, when.js 等库支持 Promise 的使用。

目前,很多库已经加入 Promise 支持,新的 API (如 fetch 等)很多也基于 Promise 设计, Promise 开始在 JS 社区流行起来。而随着 ES2015 (ES6) 的普及, Promise 被标准化 ,也进入了 JavaScript 标准运行环境,相比 ES3, ES5 等环境可以省去加载库的代价和麻烦。

那么 Promise 究竟是什么,又如何在 JavaScript 日常开发中使用呢?请跟随青色旋律一起来了解吧。

基本概念

Promise 简单而言,是一种表示未来结果的对象。它代表一个正在进行的任务,当任务完成时会返回一个值或者任务失败时返回一个错误。有些语言或者库中, Promise 也称为 Future.

一个简单的类比是餐厅点餐的凭单。当您在快餐店点了一个汉堡的时候,收银员会给您一张小票,并提醒您稍后可以凭小票领餐。此处的凭单不是汉堡本身,但却是一个和汉堡可能有关的对象。您可以将其交给您的朋友,让其代替您领取。您也可以让服务员在汉堡准备完成的时候根据凭单号码通知您。当然,您也可以根据这个凭单来规划下一步行程,比如叫一辆车在吃完饭后来接您。但是任何对于未来的判断都具有不确定性——服务员可能会告诉您,由于餐厅缺少原料,您的汉堡恐怕没办法完成了,从而进行更改订单、投诉、退款等错误处理流程。

Promise 具有三种状态——等待中、已完成、或者已拒绝。 Promise 可以从等待变为已完成状态,或者是变为已拒绝。已完成和已拒绝是最终状态,不能再改变。已完成状态的 Promise 具有一个完成值,表示任务的最终结果,当然值也可以是 null 或者 undefined 来表示无返回值。已拒绝状态的 Promise 有一个拒绝值而没有返回值,拒绝值一般是错误对象。

Promise 本身是一个对象,可以到处传递。通过 Promise 对象可以注册回调函数等,可以做到当完成时调用此回调函数,并传入完成值,或者当拒绝时调用另外的回调函数,并传入拒绝值等等,有点像是事件机制。假如 Promise 已经是完成状态,新注册的回调函数仍然会被调用,而不会错过时机,拒绝同理。

为什么要使用 Promise?

首先, Promise 是对象,可以像其他对象一样存储、传递和赋值。相比之下,单纯的回调模式难以传递,事件机制可以传递目标对象本身但难以组合。

Promise 是一种规范化而可靠的机制。 Promise/A+ 规范(下述)的行为可预测,各种库的 Promise 可以互相兼容,且对于不完全遵守规范的实现有防御机制。 ES2015 规范在此基础上进一步定义了行为和工具,并将 Promise 引入标准运行环境。

Promise 在异步操作方面表达力极强,尤其是针对序列任务和各种执行模式,如并发、竞争等。 Promise 作为一种控制反转 (IoC) 机制,将监听和回调的任务转移给了调用方,允许更灵活的配置比如多回调函数、链式操作等。

越来越多的库支持 Promise, 新的 API 正在使用 Promise 制定。已有的回调函数等机制很容易转换为 Promise, 有库支持几乎自动的封装。 Promise 本身有大量第三方库实现,提供完善的工具集实现常用控制流。

此外, ES2015 (ES6) 的 yield 关键字使得书写基于 Promise 的控制流变得非常简单,语法几乎与同步操作等同。 ES2016 预计引入的 async/await 机制基于 Promise.

关于 Promise 与其他模式的对比,请参考本文附录。

在 JavaScript 中使用 Promise 对象

显而易见,要使用 Promise 模式,必须先获取 Promise 对象实例。在支持 ECMAScript 2016 (ES6) 的环境中, Promise 构造函数必须作为全局对象提供。使用 new Promise(executor) 构造函数可以创建一个可控的 Promise ,或者 Promise.resolve(value) 来创建一个初始完成的 Promise ,再或者使用 Promise.reject(err) 来创建一个初始拒绝的 Promise.

在不支持 ES2015 Promise 的环境中,也可以使用第三方库来构造 Promise. 比如 bluebird, Q, when.js, RSVP.js, 等等。以上的库都兼容 Promise/A+ 规范 ,产生的 Promise 对象都可以根据下述方式使用。但是,产生 Promise 对象的方式则各不相同,有的类似 ES6 使用构造函数,有的提供工厂方法,等等。

由于实现 Promise 对象本身并不需要先进的 JavaScript 语言特性,因此 Promise 的兼容性极好,上述的有些库甚至可以兼容 ES3 运行环境。如果您还在犹豫是否要使用 ES2015, 也无须犹豫使用使用 Promise, 因为 Promise 可以像常见的 jQuery 等库一样加载后开箱即用,无须编译流程或者运行环境的特殊支持。甚至,有一些框架本身也带有 Promise 的实现,比如 AngularJS 以及 YUI.

Promise/A+ 回调注册

Promise/A+ 规范 中, promise 是指具有 .then 函数的对象,且此函数的行为符合一定的规范。简而言之,只有 .then 函数本身是此规范的一部分,而对象的其他行为一概未定义,可以继承任何对象,拥有任何其他属性等等。符合 Promise/A+ 规范的各种对象可以互相兼容。

.then 接受两个可选参数, onFulfilledonRejected 函数。

onFulfilled 回调函数必须在 promise 已经完成后被调用,传入一个参数,此参数为 promise 的完成值。onRejectedpromise 已经被拒绝后被调用,传入一个参数表示拒绝值。两个回调函数中最多只会有一个被调用,而且只会被调用一次。实现的角度而言,这两个回调函数一定会异步被调用,不得同步调用。这样的设计使得执行顺序可控。理解 JS 运行机制的读者可以由此推断出, promise.then(A, B); C(); 的场合,一定是 C 函数先执行,然后再是异步执行 A 函数或者 B 函数其中之一,不可能是其他顺序。

一个简单的例子,假如 readFile('a.txt') 返回一个符合规范的 promise 对象,则有:

1
2
3
4
5
readFile('a.txt').then(function (contents) {
console.log('Got file contents: ', contents);
}, function (err) {
console.error('Error reading file: ', err);
});

(注:本文中所有代码如无特殊说明,均兼容 ES5 及以上。有 ES2015 (ES6) 支持的读者可自行使用 ES2015 语法,如箭头函数等,不影响代码实际效果。 promise 的回调函数本身并不需要依赖 this, 这里基本上不需要多考虑箭头函数对 this 的影响。)

这里实现了简单的成功流程处理和错误流程处理,无须使用事件机制等。如果只需要处理成功或者错误其中一种情况,则可以省略另外一个函数。注意省略完成回调的场合,必须以 .then(undefined, onRejected) 等形式调用,而不能只传递一个参数。由于这个写法实在是太丑了, ES2015 的 Promise 对象也提供了 .catch(onRejected) 这个额外的函数,其效果与上述写法相同。大部分上述的第三方库也实现了这一函数。要注意的是, .catch 并非 Promise/A+ 标准之一,所以兼容 Promise/A+ 的库也可能会不实现这个函数或者这个函数做的是其他的事情,具体请查阅库的文档。后文中青色旋律会使用 .catch(onRejected) 这一方法,如需兼容 Promise/A+ 请自行替换为 .then(undefined, onRejected).

.then 函数可以在一个 Promise 上执行多次,注册多个完成或者拒绝回调。回调的执行顺序与注册的顺序相同,比如:

1
2
3
4
5
6
7
8
9
var getConfig = readFileAsJson('config.json');
getConfig.then(function (config) {
doFoo(config.foo);
});
getConfig.then(function (config) {
doBar(config.foo);
});

Promise/A+ 链式用法

.then 函数本身也会返回一个 promise 对象,而这个新的 promise 行为和两个回调函数有关。(注意 promise 以外也可能触发此行为,见后述。)

首先,两个回调函数如果成功执行,返回的值将决定新 promise 的成功值,例如:

1
2
3
4
5
6
7
8
9
10
11
12
var readConfigFile = readFile('config.json');
var getConfig = readConfigFile.then(function (contents) {
return JSON.parse(contents);
}, function (err) {
console.error('Error reading file: ', err);
return null;
});
getConfig.then(function (config) {
console.log('Config is: ', config)
});

此处 getConfig 是一个新的 promise ,假设文件读取成功,内容是合法 JSON 的情况下, getConfig 会以 JSON.parse 的返回值完成。假如文件读取失败,则 return null; 会导致 getConfignull 值完成。由于 JavaScript 本身的函数机制,函数不 return 相当于最后 return undefined;, 会使得新 promiseundefined 值完成。

但这里有一个特例,假如返回值是 promise, 那么新的 promise 结果和返回的 promise 相同。直接举个例子理解起来会比较快:

1
2
3
4
5
6
7
8
9
10
11
12
var getData = getConfig.then(function (config) {
return readFile(config.dataFile);
}, function (err) {
console.error('Error reading config, using default data file. ', err);
return readFile('default-data.txt');
});
getData.then(function (data) {
console.log('Data: ', data)
}, function (err) {
console.error('Error reading data file: ', err);
});

这里 getConfig 接上一段代码。可见,无论哪条执行路径,readFile 都会被调用,这里假设 readFile 返回一个 promise (这里称为 p1). 那么 getData 的结果会根据 p1 变化。如果 p1 完成则 getData 也完成,完成值相同。 p1 拒绝则 getData 也拒绝,拒绝值相同。这个特性被称为 Promise 链式使用 (chaining), 是 Promise 重要的特性之一。这一特性大大简化了异步序列的写法,例如:

1
2
3
4
5
6
7
8
9
readFile('config.json').then(function (contents) {
return JSON.parse(contents);
}).then(function (config) {
return readFile(config.dataFile);
}).then(function (data) {
return processData(data);
}).then(function (processedData) {
return writeFile('output.txt', processedData);
});

这其中涉及多个步骤,每个步骤可以是同步或者异步(返回 promise),按顺序执行。当同步操作变为异步的时候也无须修改代码结构。通常认为,这种写法与回调函数风格相比,避免了大量嵌套、管理多个回调函数等繁琐的编码,且可以进行灵活的异常处理(后述)。

注意对于返回值的判定实际上非常宽松,并不一定是 promise 才会触发以上特殊行为。事实上,任何具有 .then 属性且此属性为函数的对象(俗称 thenable )都会触发此行为,主要是为了兼容那些并不完全符合 Promise/A+ 规范的对象。这一行为在 Promise/A+ 规范 中有详细描述。

但是这也带来了一个问题,就是有 .then 属性且属性值为函数的对象可能无法正常在 Promise/A+ 机制下运作。本来认为是最终值的其他对象,仅仅是因为碰巧也有一个函数类型的 then 属性就被误判从而导致错误调用。(另一种可能,对象有一个 .then 的 getter, 然后 getter 抛出异常也会导致新 promise 被拒绝,拒绝理由和抛出异常相同,此类对象少见但并非不存在。)此行为不可避免,只能通过把这种对象装在数组里之类的方式绕过,比如 return [obj]; 然后处理的时候打开数组。如果您是一个库的作者,需要用到 promise,对于任何用户提供的对象,请务必小心。

任何正常的兼容 Promise/A+ 的库,基本上都不可能出现 Promise 中嵌套 Promise 的情况,包括 ES2015 规范规定的标准实现。事实上 Promise 里套 thenable 正常情况下都不可能。任何这种尝试基本上都会因为上面那个算法而解套。因为仔细考虑一下,其实 Promise 里套 Promise 是没有用的,正如未来的未来还是未来。当然,如果有一个对象,刚开始没有 .then 函数然后后面又有了的话……但愿正常人写代码没这么坑吧。

Promise/A+ 错误处理

假如 onFulfilled 或者 onRejected 执行时抛出异常,则新的 promise 会变为拒绝状态,且拒绝值为异常对象。这个特性可以用于错误处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
var readConfigFile = readFile('config.json');
var getConfig = readConfigFile.then(function (contents) {
console.log('Got file contents: ', contents);
var obj = JSON.parse(contents);
console.log('JSON parse result: ', config);
}, function (err) {
console.error('Error reading file: ', err);
});
getConfig.catch(function (err) {
console.error('Invalid JSON! ', err);
});

这个例子中,第一个 .then 调用返回一个 promise 对象,赋值给 getConfig.

然后,在 getConfig 上注册了一个拒绝回调来处理可能发生的错误。在执行时,假如 contents 不是合法 JSON, JSON.parse 会抛出 SyntaxError, 而此错误将会导致 getConfig 变为拒绝状态,最终导致最后一个函数运行,打印出 Invalid JSON 开头的信息。注意是 getConfig 这一 promise 变为拒绝,而不是 readConfigFile 变为拒绝状态。后者已经是完成状态了,状态不可能再改变。因此,第一个 .then 上注册的拒绝回调函数也不会运行。

假如拒绝回调函数没有注册的话,那么返回的 promise 会直接变为拒绝状态,且拒绝原因和第一个 promise 相同,这个也称为 promise 的错误传递。利用此原理可以统一处理错误:

1
2
3
4
5
6
7
8
9
doA().then(function () {
return doB();
}).then(function () {
return doC();
}).then(function () {
return doD();
}).catch(function (err) {
console.error('Something went wrong when doing A, B, C or D!', err);
});

此处中间任何一环出错,都会执行最后的错误处理函数。在编写 promise 风格的代码时,推荐在任何 promise 链的最后增加 .catch ,至少打印错误以便调试。(由于所有执行时抛出的错误都会被捕获,所以不存在全局异常的情况,不会导致进程退出等。但这也导致了执行环境通常不把这些当做错误来反馈。有的环境,如较新版本的 Chromium 和 Firefox ,会在控制台中打印出“可能未处理的拒绝”以方便调试。)

因此,通常来说 Promise 编程中,很少会使用到 .then(onFulfilled, onRejected) 这种写法,而是 .then(onFulfilled).catch(onRejected) 比较多,后一种写法可以顺便把 onFulfilled 代码中抛出的异常也处理了。没有 .catch 的情况下,也建议使用 .then(onFulfilled).then(undefined, onRejected). 当然,也可以 .catch(onRejected1).catch(onRejected2) ,假如 onRejected1 也可能会抛出异常的话,类似于嵌套 try-catch.

此处也要注意抛出异常不是唯一的拒绝方式。如上一节所述,如果返回值是一个 promise 则新的 promise 结果与其相同。这也意味着,如果 return promise1; 之后 promise1 拒绝,那么处理与上述相同。 ES2015 规范中提供函数 Promise.reject(val) 可以直接创建一个已经拒绝的 promise, 可以代替 throw val; 使用,例如:

1
2
3
4
5
doA().then(function() {
return Promise.reject(42);
}).catch(function(err) {
console.log(err); // prints number 42. It's not an Error!
});

Promise.resolve(val) 假如 val 是普通值,会直接返回一个已经完成的 promise. 如果 val 是 thenable, 返回一个行为和 val 状态相同的 promise, 具体算法与上述 thenable 算法相同。这两个特性都在 .then 的回调函数里没什么用(因为行为与 return 相同),但适合作为 Promise 链的起点。

ES2015 Promise 实用函数

除了以上提到的 Promise.rejectPromise.resolve 以外, ES2015 还提供不少好用的 Promise 处理函数。这些函数大部分常用 Promise 库也有实现,但名称和参数可能不同。

Promise.all(iterable) 接受一组 Promise, 返回一个新的 Promise. 如其名所示,假如所有 Promise 都完成,返回的 Promise 也会完成,且完成值是一个数组,每个元素是各自的完成值。假如其中任何一个拒绝,返回的 Promise 也会拒绝且拒绝值相同。这个函数可以用于聚合多个 Promise 的值,可以用于实现并发模式:

1
2
3
4
5
6
7
8
9
10
11
12
var files = ['foo.txt', 'bar.txt', 'baz.txt'];
var readFilePromises = files.map(readFile);
Promise.all(readFilePromises).then(function (contents) {
contents.forEach(function (content, i)) {
console.log('File: ', files[i], ' has content: ', content);
});
}, function (err) {
// We don't know which file has error here. To do so requires more info.
console.error('Error reading one of the files: ', err);
});

(青色旋律温馨提示:假如说 readFile 本身的实现在一个文件读取完成之前会阻塞第二个,那么代码仍然能运行,只是性能可能没有想像的那么好。同理,假如一个数据库操作函数内部使用的连接池容量有限,那么并发级别也不会是无限的。用了这个模式不代表就提高了并发,更不代表提高了性能。)

Promise.race(iterable) 也是接受一组 Promise, 但返回的 Promise 结果与第一个完成或者拒绝的 Promise 相同。正如其名,这一组 Promise 互相为竞争关系,第一个状态变为已完成或者已拒绝的 Promise 决定最终结果。这个可以用于实现竞争模式:

1
2
3
4
5
6
7
8
9
10
11
Promise.race([downloadFile(), rejectAfter(3000)]).then(function (contents) {
contents.forEach(function (content, i)) {
console.log('Got file: ', content);
});
}, function (err) {
if (err instanceof TimeoutError) {
console.error('Download timeout!');
} else {
console.log('Error when downloading: ', err);
}
});

以上假设 rejectAfter(3000) 返回一个 Promise, 3000 毫秒后必定拒绝,且拒绝值满足 instanceof TimeoutError. 这段代码实现了下载文件,无论出错还是超时均认为是失败这个逻辑。青色旋律温馨提示,此逻辑第三方库里有更好用的工具,如 bluebird 有 downloadFile().timeout(3000) ,一般无须自己实现,此处仅为范例。

ES2015 Promise 构造函数

首先,业务代码中大部分 Promise 都不是用构造函数创建的。一般都会以某个库(例如数据库 JS 封装)或者 API (例如 fetch )返回的 Promise 作为起点,然后用 .then 来创建更多的 Promise 链。因为异步的来源一般是外部因素而不是内部因素。

如果您在和旧式的 API 打交道,而旧式的 API 还不支持 Promise, 而是使用 Node.js 风格回调等方式,那么也请先考虑使用第三方库等来把那些 API 封装为 Promise API. 例如, bluebird 库存在 Promisification 这个功能可以用于转换。链接也讲述了如何封装常见的几个库。手动转换往往面临考虑不够周全,一些边界条件下会出问题之类的陷阱。

但偶尔,可能也需要实现一些自定义的控制流,此时可能需要 new Promise 等。这个构造函数的常用方式是 new Promise(function (resolve, reject) { ... }), 返回一个 Promise, 当 reject(x) 被调用时以 x 拒绝,或者函数抛出异常的时候以异常值拒绝,当 resolve(y) 被调用且 y 不是 thenable 时以 y 完成,否则按照之前提到的 thenable 算法决定状态。可以看到, resolvereject 两个函数由 ES 执行环境提供,是用于手动控制 Promise 状态的手段。例如,可以手动实现前述的 rejectAfter 函数(假设 TimeoutError 已经定义):

1
2
3
4
5
6
7
8
function rejectAfter(timeout) {
return new Promise(function (_, reject) {
setTimeout(function () {
reject(new TimeoutError());
}, timeout);
});
}

(青色旋律温馨提示:此逻辑第三方库里一般已经有实现,如 bluebird 有 Promise.delay(3000).throw(new TimeoutError()), 此处仅为范例。)

或者手动将一个事件 API 转换为 Promise:

1
2
3
4
5
6
7
8
9
10
11
12
13
function myXhrRequest(method, url) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.addEventListener('load', function () {
resolve(xhr.response);
}, false);
xhr.addEventListener('error', function () {
reject(xhr.response);
}, false);
xhr.send();
});
}

(青色旋律温馨提示:如果浏览器支持,应直接使用原生支持 Promise 的 Fetch API 代替 XHR, 或在不支持的浏览器中使用 Fetch API Polyfill, 此处仅为范例,不推荐此类写法。)

第三方 Promise 库中也可能有类似的构造函数,或者工厂函数,以及 deferred 等其他 API, 详情请查阅您所使用的库的文档。

Promise 实战应用

第一个例子是需要读取三个文件,但此处要求串行读取,也就是说同时只能读取一个文件。这个例子的正确代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var files = ['foo.txt', 'bar.txt', 'baz.txt'];
var readAllFiles = Promise.resolve();
var contents = [];
files.forEach(function (file) {
readAllFiles = readAllFiles.then(function() {
return readFile(file);
}).then(function (content) {
contents.push(content);
return contents;
});
});
readAllFiles.then(...);

这里的麻烦之处是需要等上一步完成后再进行下一步,还要另外收集所有的结果。如果自己写的话,很可能会出问题,比如如下所述是一个错误写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var files = ['foo.txt', 'bar.txt', 'baz.txt'];
var promises = files.map(readFile); // WRONG: readFile called all at once!
var readAllFiles = Promise.resolve();
var contents = [];
promises.forEach(function (promise) {
readAllFiles = readAllFiles.then(function() {
return promise.then(function (content) {
contents.push(content);
return contents;
});
});
});

这个看起来好像是对的,但其实在 files.map(readFile) 的时候,所有的 readFile 调用就已经执行了,结果其实是实现了并发模式。同理,永远不可能做出来一个函数,实现 serialExecution(promises) 这种签名然后返回序列化的结果,因为任务指令已经发出了才会返回一个 Promise. Promise 本身是任务结果的代表,而不是任务执行的控制器。

青色旋律在这里向大家推荐 bluebird 库的 .mapSeries 工具 。使用这一工具函数,代码可简化为:

1
2
3
var files = ['foo.txt', 'bar.txt', 'baz.txt'];
var readAllFiles = bluebird.mapSeries(files, readFile);

类似地,还有 .map(elements, mapper, {concurrency: 3}) 这样的工具来实现按指定的并发量来运行。

关于其他常见 Promise 任务,青色旋律会陆续在此进行一些介绍。如果大家好奇用 Promise 如何做某件事,也可以在评论区留言来提问。

总结

以上就是 Promise 的介绍和常见用法啦。大家在做新项目的时候,不妨试试看哦~对了,以防有人不看上面的内容直接跳到总结,青色旋律有必要再次提醒大家,使用 Promise 无须 ES2015 (ES6) 支持,只要找个合适的库即可,技术投资并不算太大。

如果您在计划一个新的库,不妨提供 Promise API, 然后使用 各种方式兼容 Node.js 样式的回调,这样两派人士都能开心地使用。内部逻辑可以完全基于 Promise 实现。

如果您有一些陈旧的代码需要重写,其中异步的逻辑非常混乱,也不妨试试看使用 Promise 重新理清思路。

如果您碰巧正在使用支持 ES2015 的环境,那么您也可以使用 yield 来配合 Promise 改善代码流,但需要一个支持 coroutine 的库,比如 bluebird. 或者您也可以走在时代的最前沿,使用 async/await 关键字来改善流控制。不过后者目前尚未收录进规范中,实现基本上也只有 babel 等。

那么,青色旋律这次的介绍就到这里啦。各位下回再见~

附: Promise vs 事件机制

Promise 的一个重要的特性就是如果完成,所有完成回调函数一定会被执行。如果拒绝,则所有拒绝函数一定会被执行,哪怕调用 .then 时状态转移已经结束。例如:

1
2
3
4
5
6
7
var readFilePromise = readFile('a.txt');
setTimeout(function () {
readFilePromise.then(function (contents) {
console.log('File contents: ' + contents);
});
}, 10000);

假如文件读取足够快,可能用不到 10 秒,readFilePromise 就已经是完成状态了,但之后再调用 .then 也仍然能够继续执行代码,而不会像事件一样,晚注册的就不会收到事件了。使用事件机制的 API 受到这个影响,很可能需要额外地检查属性等来判断任务是否已经完成,影响事件处理代码的表现力。

Promise 的缺点在于无法表达可能重复发生的事情,因为 Promise 最多只能经历一次状态转换。另外一个设计模式 Obserable 提供这样的支持。

总而言之,青色旋律认为 Promise 适合表达“任务”,而非发生的“事情”。事件机制则相反,各有不同的用途。当某个任务来自于事件,则应按照上述的方式将其封装为 Promise.

事件机制对于多步操作或者控制流并无支持,一般而言编写代码较为尴尬。

附: Promise vs 回调模式

首先, Promise 是回调模式的一种,其 API 是基于回调的。一般而言说 Promise 和回调模式对比,是指比较自由的回调模式,或者 Node.js 风格的回调模式。至少也是指用于监听任务处理完成的那种回调,与 array.forEach 之类的回调没有任何可比性。

Promise 是一种“严格管理”的回调。 onFulfilledonRejected 只能执行其中一个,且最多只能执行其中一次,就是一个很好的限制。回调模式下,如果代码有问题会导致多次调用从而导致难以调试的 bug, 例如:

1
2
3
4
5
6
7
function readFile(filename, callback) {
nativeReadFileAPI(function (ret) {
if (ret.err) callback(ret.err);
// Do some decoding here...
callback(null, contents);
});
}

这个例子中,虽然粗看并没有什么问题,但假如 ret.err 为真值, callback 被调用后未返回,会导致接下来的代码继续执行,说不定会出错,也说不定 callback 会再被执行一次,导致难以调试的错误。 Promise 模式无此问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function readFile(filename) {
return new Promise(function (resolve, reject) {
nativeReadFileAPI(function (ret) {
if (ret.err) {
reject(ret.err);
} else {
resolve(ret);
}
});
}).then(function (ret) {
// Do some decoding here...
return contents;
});
}

首先,通过多步操作,将非 Promise 的代码隔离在比较小的区域内,很难出现拒绝了还可以继续执行的问题,或者忘记之前已经调用过回调的问题。其次,哪怕真的调用了多次 resolvereject, 甚至都调用了, Promise 库只会处理第一次,调用者不会被这个问题困扰。当然,假如库的维护者不写单元测试, Promise 当然也拯救不了他们……

此外, Promise 机制自动保证 onFulfilled 等回调异步执行,有效解决了 Zalgo 的问题。

总结而言,任何 API 必须同步返回或者异步返回,不能有时这样而有时那样,否则代码会非常难以调试。 Promise 的 .then 方法总是异步返回,在这一点上非常安全。此外, Promise 链每一环都是异步执行,即使上一次的 return x;x 不是 thenable, 也避免了很多问题。Promise.resolve(x) 可以把任何是或者不是 Promise 的东西转换为 Promise, 也可以用作统一作为异步处理。当然,青色旋律个人认为,最重要的是 Promise 写异步代码不需要花费太多力气,是一种默认行为,所以不会因为忘记 process.nextTick 或者嫌麻烦从而导致 Zalgo 被释放。

最后, Promise 有助于解决过度嵌套问题。和很多人相信的不一样,此问题并非回调风格独享,而是广泛存在的问题, Promise 也不能回避这个问题。真正的解决方案不是换哪一种风格,而是将代码拆分成较小可维护的块,并解除嵌套。 Promise 的优势在于表达链式的操作较为简便,而链式操作在回调模式中常常是过度嵌套的源泉,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
doA(function (err, val1) {
if (err) handleError(err);
doB(val1, function (err, val2) {
if (err) handleError(err);
doC(val2, function (err, val3) {
if (err) handleError(err);
doD(val3, function (err, val4) {
if (err) handleError(err);
handleSuccess(val4);
});
});
});
});

这里最不动脑子的办法就是这样写,所以才会有人这样写。如果适当进行设计和重构,完全可以在不涉及 Promise 的情况下解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
doA(function (err, val1) {
if (err) handleError(err);
doB(val1, function (err, val2) {
if (err) handleError(err);
handleBVal(val2);
});
});
function handleBVal(val2) {
doC(val2, function (err, val3) {
if (err) handleError(err);
doD(val3, function (err, val4) {
if (err) handleError(err);
handleSuccess(val4);
});
});
}

假设 A/B 逻辑相关, C/D 逻辑相关,则这个已经拆成不错的分块了,同时嵌套层数显著减少。相比,使用 Promise 的话,最不动脑子的方式也没什么太大的问题:

1
2
3
4
5
6
7
8
9
10
11
doA().then(function (val1) {
return doB(val1);
}).then(function (val2) {
return doC(val2);
}).then(function (val3) {
return doD(val3);
}).then(function (val4) {
handleSuccess(val4);
}).catch(function (err) {
handleError(err);
});

至少代码已经天然拆分为 4 个组件了,而且随时可以在任何地方打断成两个函数而无须太大的调整。缩进的级别也还算可以接受,默认为扁平化。此外,假如错误需要默认统一处理,也无须太多重复代码。假如需要重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function processAAndB() {
return doA().then(function (val1) {
return doB(val1);
});
}
function processCAndD(val2) {
return doC(val2).then(function (val3) {
return doD(val3);
});
}
processAAndB().then(function (val2) {
return processCAndD(val2);
}).then(function (val4) {
handleSuccess(val4);
}).catch(function (err) {
handleError(err);
});

重构任务也只是进行了一下分组,调用结构大体不变。此外,在其中加入一环等重构也比回调模式更为简便,插入异常处理的灵活性也没有损失。因此, Promise 模式有助于避免“回调地狱”带来的维护难题。


知识共享许可协议 本作品是青色旋律原创作品,采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。转载请注明来自 青色旋律,链接至 此页面 并使用相同授权协议。如需更宽松的授权协议,请联系青色旋律。