3月阅读周·你不知道的JavaScript | Promise理论,了解、使用&集成进自己的异步流
背景
去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。
没有计划的阅读,收效甚微。
新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。
这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。
《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。
已读完书籍:《架构简洁之道》、《深入浅出的Node.js》。
当前阅读周书籍:《你不知道的JavaScript(上卷)》、《你不知道的JavaScript(中卷)》。
Promise
了解它
Promise值
Promise的决议结果可能是拒绝而不是完成。拒绝值和完成的Promise不一样:完成值总是编程给出的,而拒绝值,通常称为拒绝原因(rejection reason),可能是程序逻辑直接设置的,也可能是从运行异常隐式得出的值。
通过Promise,调用then(..)实际上可以接受两个函数,第一个用于完成情况(如前所示),第二个用于拒绝情况:
add(fetchX(), fetchY()).then(
// 完成处理函数
function (sum) {
console.log(sum);
},
// 拒绝处理函数
function (err) {
console.error(err); // 烦!
},
);
如果在获取X或Y的过程中出错,或者在加法过程中出错,add(..)返回的就是一个被拒绝的promise,传给then(..)的第二个错误处理回调就会从这个promise中得到拒绝值。
Promise可以按照可预测的方式组成(组合),而不用关心时序或底层的结果。另外,一旦Promise决议,它就永远保持在这个状态。此时它就成为了不变值,可以根据需求多次查看。
Promise是一种封装和组合未来值的易于复用的机制。
完成事件
Promise的决议:一种在异步任务中作为两个或更多步骤的流程控制机制,时序上的this-then-that。
假定开发者现在需要实现一个业务场景:要调用一个函数foo(..)执行某个任务,想要知道foo(..)什么时候结束,然后进行下一个任务。
在典型的JavaScript风格中,如果需要侦听某个通知,你可能就会想到事件。因此,可以把对通知的需求重新组织为对foo(..)发出的一个完成事件的侦听。
function foo(x) {
// 开始做点可能耗时的工作
// 构造一个listener事件通知处理对象来返回
return listener;
}
var evt = foo(42);
evt.on('completion', function () {
// 可以进行下一步了!
});
evt.on('failure', function (err) {
// 啊,foo(..)中出错了
});
foo(..)显式创建并返回了一个事件订阅对象,调用代码得到这个对象,并在其上注册了两个事件处理函数。
这里没有把回调传给foo(..),而是返回一个名为evt的事件注册对象,由它来接受回调。在foo(..)完成的时候,它们都可以独立地得到通知。
Promise“事件”
new Promise( function(..){ .. } )模式下,传入的函数会立即执行(不会像then(..)中的回调一样异步延迟),它有两个参数,一般会将其分别称为resolve和reject。这些是promise的决议函数。resolve(..)通常标识完成,而reject(..)则标识拒绝。
所以,bar(..)和baz(..)的内部实现或许如下:
function bar(fooPromise) {
// 侦听foo(..)完成
fooPromise.then(
function () {
// foo(..)已经完毕,所以执行bar(..)的任务
},
function () {
// 啊,foo(..)中出错了!
},
);
}
// 对于baz(..)也是一样
具有then方法的鸭子类型
在Promise领域,一个重要的细节是如何确定某个值是不是真正的Promise。
识别Promise(或者行为类似于Promise的东西)就是定义某种称为thenable的东西,将其定义为任何具有then(..)方法的对象和函数。可以理解为,任何这样的值就是Promise一致的thenable。
根据一个值的形态(具有哪些属性)对这个值的类型做出一些假定。这种类型检查一般用术语鸭子类型来表示——“如果它看起来像只鸭子,叫起来像只鸭子,那它一定就是只鸭子”。
于是,对thenable值的鸭子类型检测就大致类似于:
if (p !== null && (typeof p === 'object' || typeof p === 'function') && typeof p.then === 'function') {
// 假定这是一个thenable!
} else {
// 不是thenable
}
使用恰好有then(..)函数的一个对象或函数值完成一个Promise,它会自动被识别为thenable,并被按照特定的规则处理。
var o = { then: function () {} };
// 让v [[Prototype]]-link到o
var v = Object.create(o);
v.someStuff = 'cool';
v.otherStuff = 'not so cool';
v.hasOwnProperty('then'); // false
上面的代码中,thenable鸭子类型检测会把v认作一个thenable。
Promise信任问题
一个回调传入工具foo(..)时可能出现如下问题:
- 调用回调过早;
- 调用回调过晚(或不被调用);
- 调用回调次数过少或过多;
- 未能传递所需的环境和参数;
- 吞掉可能出现的错误和异常。
Promise的特性就是专门用来为这些问题提供一个有效的可复用的答案。
调用过早
根据定义,Promise就不必担心这种问题,因为即使是立即完成的Promise(类似于new Promise(function(resolve){ resolve(42); }))也无法被同步观察到。
也就是说,对一个Promise调用then(..)的时候,即使这个Promise已经决议,提供给then(..)的回调也总会被异步调用。
不再需要插入setTimeout(..,0) hack, Promise会自动防止Zalgo出现。
调用过晚
Promise创建对象调用resolve(..)或reject(..)时,这个Promise的then(..)注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事件点上一定会被触发。
一个Promise决议后,这个Promise上所有的通过then(..)注册的回调都会在下一个异步时机点上依次被立即调用。这些回调中的任意一个都无法影响或延误对其他回调的调用。
p.then(function () {
p.then(function () {
console.log('C');
});
console.log('A');
});
p.then(function () {
console.log('B');
});
// A B C
这里,"C"无法打断或抢占"B",这是因为Promise的运作方式。
回调未调用
这个问题很常见,Promise可以通过几种途径解决:
1、没有任何东西(甚至JavaScript错误)能阻止Promise向你通知它的决议。如果对一个Promise注册了一个完成回调和一个拒绝回调,那么Promise在决议时总是会调用其中的一个。
2、如果回调函数本身包含JavaScript错误,那可能就会看不到期望的结果,但实际上回调还是被调用了。
3、如果Promise本身永远不被决议,Promise提供了一种解决方案,使用了一种称为竞态的高级抽象机制:
// 用于超时一个Promise的工具
function timeoutPromise(delay) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
reject('Timeout! ');
}, delay);
});
}
// 设置foo()超时
Promise.race([
foo(), // 试着开始foo()
timeoutPromise(3000), // 给它3秒钟
]).then(
function () {
// foo(..)及时完成!
},
function (err) {
// 或者foo()被拒绝,或者只是没能按时完成
// 查看err来了解是哪种情况
},
);
调用次数过少或过多
根据定义,回调被调用的正确次数应该是1,“过少”的情况就是调用0次。
对于“过多”的情况,Promise的定义方式使得它只能被决议一次。由于Promise只能被决议一次,所以任何通过then(..)注册的(每个)回调就只会被调用一次。
如果把同一个回调注册了不止一次(比如p.then(f); p.then(f);),那它被调用的次数就会和注册次数相同,响应函数只会被调用一次。
未能传递参数/环境值
Promise至多只能有一个决议值(完成或拒绝)。如果没有用任何值显式决议,那么这个值就是undefined。
如果使用多个参数调用resovle(..)或者reject(..),第一个参数之后的所有参数都会被默默忽略。
如果要传递多个值,你就必须要把它们封装在单个值中传递,比如通过一个数组或对象。
吞掉错误或异常
如果拒绝一个Promise并给出一个出错消息,这个值就会被传给拒绝回调。
如果在Promise的创建过程中或在查看其决议结果过程中的任何时间点上出现了一个JavaScript异常错误,比如一个TypeError或ReferenceError,那这个异常就会被捕捉,并且会使这个Promise被拒绝。
var p = new Promise(function (resolve, reject) {
foo.bar(); // foo未定义,所以会出错!
resolve(42); // 永远不会到达这里 :(
});
p.then(
function fulfilled() {
// 永远不会到达这里 :(
},
function rejected(err) {
// err将会是一个来自foo.bar()这一行的TypeError异常对象
},
);
foo.bar()中发生的JavaScript异常导致了Promise拒绝,你可以捕捉并对其作出响应。
是可信任的Promise吗
Promise并没有完全摆脱回调,它们只是改变了传递回调的位置。但是Promise比单纯使用回调更值得信任,因为Promise对这个问题已经有一个解决方案。包含在原生ES6 Promise实现中的解决方案就是Promise.resolve(..)。
如果向Promise.resolve(..)传递一个非Promise、非thenable的立即值,就会得到一个用这个值填充的promise。下面这种情况下,promise p1和promise p2的行为是完全一样的:
var p1 = new Promise(function (resolve, reject) {
resolve(42);
});
var p2 = Promise.resolve(42);
而如果向Promise.resolve(..)传递一个真正的Promise,就只会返回同一个promise:
var p1 = Promise.resolve(42);
var p2 = Promise.resolve(p1);
p1 === p2; // true
更重要的是,如果向Promise.resolve(..)传递了一个非Promise的thenable值,前者就会试图展开这个值,而且展开过程会持续到提取出一个具体的非类Promise的最终值。
链式流
可以把多个Promise连接到一起以表示一系列异步步骤。
这种方式可以实现的关键在于以下两个Promise固有行为特性:
- 每次你对Promise调用then(..),它都会创建并返回一个新的Promise,我们可以将其链接起来;
- 不管从then(..)调用的完成回调(第一个参数)返回的值是什么,它都会被自动设置为被链接Promise(第一点中的)的完成。
使链式流程控制可行的Promise固有特性:
- 调用Promise的then(..)会自动创建一个新的Promise从调用返回。
- 在完成或拒绝处理函数内部,如果返回一个值或抛出一个异常,新返回的(可链接的)Promise就相应地决议。
- 如果完成或拒绝处理函数返回一个Promise,它将会被展开,这样一来,不管它的决议值是什么,都会成为当前then(..)返回的链接Promise的决议值。
错误处理
错误处理最自然的形式就是同步的try..catch结构。遗憾的是,它只能是同步的,无法用于异步代码模式:
function foo() {
setTimeout(function () {
baz.bar();
}, 100);
}
try {
foo();
// 后面从 `baz.bar()` 抛出全局错误
} catch (err) {
// 永远不会到达这里
}
try..catch当然很好,但是无法跨异步操作工作。
Promise中的错误处理,其中拒绝处理函数被传递给then(..)。Promise没有采用流行的error-first回调设计风格,而是使用了分离回调(split-callback)风格。一个回调用于完成情况,一个回调用于拒绝情况:
var p = Promise.reject('Oops');
p.then(
function fulfilled() {
// 永远不会到达这里
},
function rejected(err) {
console.log(err); // "Oops"
},
);
值得注意的是,表面看来这种出错处理模式很合理,但是,实际上Promise的错误处理易于出错。
如果一开始就没能有效使用Promise API真正构造出一个Promise,那就无法得到一个被拒绝的Promise!
Promise API概述
new Promise(..)构造器
有启示性的构造器Promise(..)必须和new一起使用,并且必须提供一个函数回调。这个回调是同步的或立即调用的。这个函数接受两个函数回调,用以支持promise的决议。通常我们把这两个函数称为resolve(..)和reject(..):
var p = new Promise(function (resolve, reject) {
// resolve(..)用于决议/完成这个promise
// reject(..)用于拒绝这个promise
});
reject(..)就是拒绝这个promise;但resolve(..)既可能完成promise,也可能拒绝,要根据传入参数而定。如果传给resolve(..)的是一个非Promise、非thenable的立即值,这个promise就会用这个值完成。
Promise.resolve(..)和Promise.reject(..)
创建一个已被拒绝的Promise的快捷方式是使用Promise.reject(..)。
Promise.resolve(..)常用于创建一个已完成的Promise,使用方式与Promise.reject(..)类似。但是,Promise.resolve(..)也会展开thenable值(前面已多次介绍)。在这种情况下,返回的Promise采用传入的这个thenable的最终决议值,可能是完成,也可能是拒绝:
var fulfilledTh = {
then: function (cb) {
cb(42);
},
};
var rejectedTh = {
then: function (cb, errCb) {
errCb('Oops');
},
};
var p1 = Promise.resolve(fulfilledTh);
var p2 = Promise.resolve(rejectedTh);
// p1是完成的promise
// p2是拒绝的promise
then(..)和catch(..)
每个Promise实例都有then(..)和catch(..)方法,通过这两个方法可以为这个Promise注册完成和拒绝处理函数。Promise决议之后,立即会调用这两个处理函数之一,但不会两个都调用,而且总是异步调用。
then(..)接受一个或两个参数:第一个用于完成回调,第二个用于拒绝回调。如果两者中的任何一个被省略或者作为非函数值传入的话,就会替换为相应的默认回调。默认完成回调只是把消息传递下去,而默认拒绝回调则只是重新抛出其接收到的出错原因。
catch(..)只接受一个拒绝回调作为参数,并自动替换默认完成回调。换句话说,它等价于then(null, ..):
p.then(fulfilled);
p.then(fulfilled, rejected);
p.catch(rejected); // 或者p.then( null, rejected )
then(..)和catch(..)也会创建并返回一个新的promise,这个promise可以用于实现Promise链式流程控制。如果完成或拒绝回调中抛出异常,返回的promise是被拒绝的。如果任意一个回调返回非Promise、非thenable的立即值,这个值会被用作返回promise的完成值。如果完成处理函数返回一个promise或thenable,那么这个值会被展开,并作为返回promise的决议值。
Promise.all([ .. ])和Promise.race([ .. ])
ES6 Promise API静态辅助函数Promise.all([ .. ])和Promise.race([ .. ])都会创建一个Promise作为它们的返回值。这个promise的决议完全由传入的promise数组控制。
对Promise.all([ .. ])来说,只有传入的所有promise都完成,返回promise才能完成。如果有任何promise被拒绝,返回的主promise就立即会被拒绝。如果完成的话,你会得到一个数组,其中包含传入的所有promise的完成值。对于拒绝的情况,你只会得到第一个拒绝promise的拒绝理由值。
对Promise.race([ .. ])来说,只有第一个决议的promise(完成或拒绝)取胜,并且其决议结果成为返回promise的决议。
var p1 = Promise.resolve(42);
var p2 = Promise.resolve('Hello World');
var p3 = Promise.reject('Oops');
Promise.race([p1, p2, p3]).then(function (msg) {
console.log(msg); // 42
});
Promise.all([p1, p2, p3]).catch(function (err) {
console.error(err); // "Oops"
});
Promise.all([p1, p2]).then(function (msgs) {
console.log(msgs); // [42, "Hello World"]
});
总结
我们来总结一下本篇的主要内容:
- Promise解决了开发者因只用回调的代码而备受困扰的控制反转问题。
- Promise链也开始提供(尽管并不完美)以顺序的方式表达异步流的一个更好的方法,这有助于开发者的大脑更好地计划和维护异步JavaScript代码。
- Promise最本质的一个特征是:Promise只能被决议一次(完成或拒绝)。在许多异步情况中,只会获取一个值一次,所以这可以工作良好。
- 还有很多异步的情况适合另一种模式——一种类似于事件和/或数据流的模式。在表面上,目前还不清楚Promise能不能很好用于这样的用例,如果不是完全不可用的话。如果不在Promise之上构建显著的抽象,Promise肯定完全无法支持多值决议处理。
作者介绍
非职业「传道授业解惑」的开发者叶一一。
《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。
如果看完文章有所收获,欢迎点赞👍 | 收藏⭐️ | 留言📝。
- 点赞
- 收藏
- 关注作者
评论(0)