写点什么

Promise/A 的误区以及实践

2013 年 12 月 20 日

什么是 Promise

Promise 是一种让异步代码书写起来更优雅的模式,能够让异步操作代码像同步代码那样书写并且阅读,比如下面这个异步请求的例子:

复制代码
$.get("/js/script,js", function () {
// callback to do
})

就可以改写为 Promise 模式:

复制代码
var promise = $.get("/js/script");

返回值 promise 即代表操作的最终结果。返回值 promise 也可以作为“第一类对象”( First-class object )被当做参数传递。这个模式最大的优势就是避免了传统异步操作代码中,回调函数嵌套回调函数的糟糕情况。

如果你之前对 Promise 模式有所了解的话(可以参考 InfoQ 之前的这篇文章),谈到Promise,最先想到的一定是它的 then函数,的确它非常重要,在 Promise 模式的定义中 ( CommonJS Promises/A ) 中,then函数是这么被定义的:

(原文)A promise is defined as an object that has a function as the value for the property then: then(fulfilledHandler, errorHandler, progressHandler)

(译)一个 promise 被定义为一个拥有 then 属性的对象,并且此 then 属性的值为一个函数: then(fulfilledHandler, errorHandler, progressHandler)

也就是说每一个 promise 结果一定会自带一个 then 函数,通过这个then函数,我们可以添加 promise 转变到不同状态 (定义中 promise 只有三种状态,unfulfilled, fulfilled, failed. 这里说的状态转变即从 unfulfilled 至 fulfilled,或者从 unfulfilled 至 failed) 时的回调,还可以监听 progress 事件,拿上面的代码为例:

复制代码
var fulfilledHandler = function () {}
var errorHandler = function () {}
var progressHandler = function () {}
$.get("/js/script").then(fulfilledHandler, errorHandler, progressHandler)

这有一些类似于

复制代码
$.ajax({
error: errorHandler,
success: fulfilledHandler,
progress: progressHandler
})

这个时候你会感到疑惑了,上面两种方式看上去不是几乎一模一样吗?——但 promise 的重点并非在上述各种回调函数的聚合,而是在于提供了一种同步函数与异步函数联系和通信的方式。之所以感到相似这也是大部分人对 Promise 的理解存在的误区,只停留在then的聚合 (aggregating) 功能。甚至在一些著名的类库中也犯了同样的错误 (下面即以 jQuery 举例)。下面通过列举两个常见的误区,来让人们对 Promise 有一个完整的认识。

Promise/A 模式与同步模式有什么联系?

抛开 Promise,让我们看看同步操作函数最重要的两个特征

  • 能够返回值
  • 能够抛出异常

这其实和高等数学中的复合函数 (function composition) 很像:你可以将一个函数的返回值作为参数传递给另一个函数,并且将另一个函数的返回值作为参数再传递给下一个函数……像一条“链”一样无限的这么做下去。更重要的是,如果当中的某一环节出现了异常,这个异常能够被抛出,传递出去直到被catch捕获。

而在传统的异步操作中不再会有返回值,也不再会抛出异常——或者你可以抛出,但是没有人可以及时捕获。这样的结果导致必须在异步操作的回调中再嵌套一系列的回调,以防止意外情况的发生。

而 Promise 模式恰好就是为这两个缺憾准备的,它能够实现函数的复合与异常的抛出(冒泡直到被捕获)。符合 Promise 模式的函数必须返回一个 promise,无论它是 fulfilled 状态也好,还是 failed(rejected) 状态也好,我们都可以把它当做同步操作函数中的一个返回值:

复制代码
$.get("/user/784533") // promise return
.then(function parseHandler(info) {
var userInfo = parseData(JSON.parse(info));
return resolve(userInfo); // promise return
})
.then(getCreditInfo) // promise return
.then(function successHandler(result) {
console.log("User credit info: ", result);
}, function errorHandler(error) {
console.error("Error:", error);
})

``` 上面的例子中,$.getgetCreditInfo都为异步操作,但在 Promise 模式下,(形式上)转化为了链式的顺序操作

$.get返回的 promise 由parseHandler进行解析,返回值“传入”getCreditInfo中,而getCreditInfo的返回值同时“传入”successHandler中。

之所以要在传入二字上注上引号,因为并非真正把 promise 当做值传递进入函数中,但我们完全可以把它理解为传入,并且改写为同步函数的形式,这样以来函数复合便一目了然:

复制代码
try {
var info = $.get("/user/784533"); //Blocking
var userInfo = parseData(JSON.parse(info));
var resolveResult = parseData(userInfo);
var creditInfo = getCreditInfo(resolveResult); //Blocking
console.log("User credit info: ", result);
} cacth(e) {
console.error("Error:", error);
}

但是在 jQuery1.8.0 版本之前,比如 jQuery1.5.0(jQuery 在 1.5.0 版本中引入 Promise,在 1.8.0 开始得到修正),存在无法捕获异常的问题:

复制代码
var step1 = function() {
console.log("------step1------");
var d = $.Deferred();
d.resolve('Some data');
return d.promise();
},
step2 = function(str) {
console.log("------step2------");
console.log("step2 recevied: ", str);
var d = $.Deferred();
// 故意在 fulfilled hanlder 中抛出异常
d.reject(new Error("This is failing!!!"));
return d.promise();
},
step3 = function(str) {
console.log("------step3------");
console.log("step3 recevied: ", str);
var d = $.Deferred();
d.resolve(str + ' to display');
return d.promise();
},
completeIt = function(str) {
console.log("------complete------");
console.log("[complete]------>", str);
},
handleErr = function(err) {
console.log("------error------");
console.log("[error]------>", err);
};
step1().
then(step2).
then(step3).
then(completeIt, handleErr);

上述代码在 jQuery-1.5.0 中运行的结果:

复制代码
------step1------
------step2------
step2 recevied: Some data
------step3------
step3 recevied: Some data
------complete------
[complete]------> Some data

在 step2 中,在解析 step1 中传递的值后故意抛出了一个异常,但是我们在最后定义的errorHandler却没有捕获到这个错误。

忽略捕获异常的错误,上面的结果还反映出另一个问题,最后一步completeHandler中处理的值应该是由 step3 中决定的,也就是 step3 中的

复制代码
d.resolve(str + ' to display');

最后应打印出的结果为

复制代码
some data to display

而在 jQuery-1.9.0 中异常是可以捕获的,运行结果为:

复制代码
------step1------
------step2------
step2 recevied: Some data
------error------
[error]------> Error {}

但是打印出的结果仍然有问题

注意到 step3 没有执行,因为 step3 中只定义了 fulfilled 的回调,异常只有在最后errorhandler才被捕获。

其实我们可以试试,在 step3 中添加处理异常的回调函数:

复制代码
step1().
then(step2).
then(step3, function (str) {
console.log("------[error] step3------");
console.log("step3 revecied: ", str);
var d = $.Deferred();
d.resolve(str + ' to display');
return d.promise();
}).
then(completeIt, handleErr);

运行结果如下:

复制代码
------step1------
------step2------
step2 recevied: Some data
------[error] step3------
step3 revecied: Error {}
------complete------
[complete]------> Error: This is failing!!! to display

虽然错误在 step3 被捕获了,但是由于我们将错误信息传递了下去,最后一步打印出的仍然是 error 消息

细节:返回 Promise

让我们继续看看 Promise/A 定义的第二段:

(原文)This function should return a new promise that is fulfilled when the given fulfilledHandler or errorHandler callback is finished. This allows promise operations to be chained together. The value returned from the callback handler is the fulfillment value for the returned promise. If the callback throws an error, the returned promise will be moved to failed state.

(译文)这样的函数应该返回一个新的 promise,该 promise 是被指定回调函数 (成功执行或者捕获异常) 解析之后的结果。如此一来 promise 之间的操作便能链式的串联起来。回调函数返回的值是解析返回的 promise 的结果。如果回调函数抛出了异常,返回的 promise 便会转化为异常状态

这段定义告诉我们两点:

  1. 无论返回值是 fulfilled 也好,还是被 rejected 也好,必须返回一个新的 promise;
  2. then关键字并非只是各个回调的填充器,在输入的同时它同时也输出新的 promise,以便形成链式 ;

同样以 jQuery 的代码为例:

复制代码
var step1 = function() {
console.log("------step1------");
var d = $.Deferred();
d.resolve('Some data');
return d.promise();
};
var step2 = function (result) {
console.log("------step2------");
console.log("step2 recevied: " + result);
var d = $.Deferred();
d.resolve("step2 resolve: " + result);
return d.promise();
}
var step3 = function (result) {
console.log("------step3------");
console.log("step3 recevied: " + result);
var d = $.Deferred();
d.reject(new Error("This is failing!!!"));
return d.promise();
}
var promise = step1();
var promise1 = promise.then(step2);
var promise2 = promise.then(step3);

var promise1 = promise.then(step2); var promise2 = promise.then(step3); ``` step1 返回的 promise 是 fulfilled 状态,但不同的是 step2 fulfilled 之后,返回一个仍然可被解析的 promise(1),而 step3 则抛出一个异常 (promise2)。

按照定义所说,promise1 与 promise2 是相互不同的 promise,无论是被正确解析还是抛出异常,返回的都应该是一个独立的 promise。

为了验证产生的是否为独立的 promise,只需看他们的执行结果如何,接着给 promise1 和 promise2 定义 fulfilled 和 failed 回调函数:

复制代码
promise1.then(function (result) {
console.log("Success promise1: ", result);
}, function () {
console.log("Failed promise1: ", result);
})
promise2.then(function (result) {
console.log("Success promise2: ", result);
}, function (result) {
console.log("Failed promise2: ", result);
})

让我们看看在 jQuery-1.5.0 中执行的结果:

复制代码
Success promise1: Some data
Success promise2: Some data

虽然是一个被抛出的异常,但仍然可以被正确解析,并且解析使用的参数是上一个 promise 的返回值

在 jQuery-1.9.0 中:

复制代码
Success promise1: step2 resolve: Some
Failed promise2: Error {}

能被正常解析。

实践

完整认识了 promise 之后,我们可以用简单的代码实现一个 Promise 模式。

参照 jQuery 的Deferred,我们可以了解 Promise 的大致结构:

复制代码
var Promise = function () {}
Promise.prototype.when = function () {
// to do
}
Promise.prototype.resolve = function () {
// to do
}
Promise.prototype.rejected = function () {
// to do
}

Promise.prototype.rejected = function () { // to do } ``` 并且我们用最简单一个异步操作setTimeout来验证我们的 Promise 是否奏效:

复制代码
var delay = function (throwError) {
var promise = new Promise();
if (throwError) {
promise.reject(new Error("ERROR"));
return promise;
}
setTimeout(function () {
promise.resolve("some data");
}, 1000);
return promise;
}
delay().then(function (result) {
console.log(result);
}).then(function () {
console.log("This is the second successHandler");
}).then(function () {
console.log("This is the third successHandler");
})

首先我们要为每一个 Promise 准备一个队列来存储自己的回调函数

复制代码
function Promise() {
this.callbacks = [];
}

我们可以暂且把then()理解为往队列中填入回调的函数,并且为了能以链式的形式添加处理函数,最后必须返回当前 promise:

复制代码
Promise.prototype.then = function (successHandler, failedHandler) {
this.callbacks.push({
resolve: successHandler,
reject: failedHandler
});
return this;
}

} ``` 其实 resolve 和 reject 虽然名称不同,但是都是执行各自对应的回调函数,于是可以抽象出一个公共的complete方法:

复制代码
Promise.prototype = {
resolve: function (result) {
this.complete("resolve", result);
},
reject: function (result) {
this.complete("reject", result);
},
complete: function (type, result) {
// to do
}
}

complete 的工作非常显而易见,根据 type 不同执行回调函数出队,以 result 为参数,执行相应 type 的的函数:

复制代码
complete: function (type, result) {
this.callbacks.shift()[type](result);
}

但是这样只能执行队首回调函数,在链式的情况下,可能在callbacks中添加了多个回调函数,为了实现链式的执行,需要把callbacks中的回调全部出队,complete可以改进为:

复制代码
complete: function (type, result) {
while(this.callbacks.length) {
this.callbacks.shift()[type](result);
}
}

完整版如下:

复制代码
function Promise() {
this.callbacks = [];
}
Promise.prototype = {
resolve: function (result) {
this.complete("resolve", result);
},
reject: function (result) {
this.complete("reject", result);
},
complete: function (type, result) {
while(this.callbacks[0]) {
this.callbacks.shift()[type](result);
}
},
then: function (successHandler, failedHandler) {
this.callbacks.push({
resolve: successHandler,
reject: failedHandler
});
return this;
}
}

第一个版本即完成,可以看到测试上面开始例子的结果,能够顺利打印出信息。

接下来我们来完成处理异常部分

首先我们写一个能够故意抛出异常的测试用例

复制代码
delay(true).then(function (result) {
console.log(result);
}, function firstErrorHandler(error) {
console.error("First failedHandler catch: ", error);
var promise = new Promise();
promise.resolve("some data");
return promise;
}).then(function secondSucHandler(result) {
console.log("Second successHandler recevied: ", result);
}, function (error) {
console.error("Second failedHandler catch: ", error);
})

我们在 delay 中抛出异常,希望在firstErrorHandler捕获异常后,返回一个能 fulfilled 的 promise,并且用secondSucHandler顺利解析、

如果直接用上面版本执行,会发现没有任何结果,为什么?

** 因为在执行delay()时,第一个reject也同时被执行,但此时then函数还没执行,也就是处理 reject 的 handler 还没有被定义。** 当然也就不会有任何结果了。反过来也能想通,也就能说通 resolve 能被执行。

那么我们只要在 reject 函数中加上一定的延时即可:

复制代码
...
reject: function (result) {
var _this = this;
setTimeout(function () {
_this.complete("reject", result);
});
},
...

执行测试代码结果如下:

复制代码
First failedHandler catch: Error
Second failedHandler catch: Error

虽然错误被捕获了,但错误被一直传递一下去了,这也就是我们之前说的 jQuery 无法返回新的 promise,接下来要解决这个问题。

我们来写一个更复杂的测试用例,来验证下面的解决方案:

复制代码
delay()
// ------Level 1------
.then(function FirstSucHandler(result) {
console.log("First successHandler recevied: ", result);
var p = new Promise();
p.reject(new Error("This is a test"));
return p;
}, function FirstErrorHandler(error) {
console.error("Second failedHandler catch: ", error);
})
// ------Level 2------
.then(function SecondSucHandler(result) {
console.log("Second successHandler recevied: ", result);
})
// ------Level 3------
.then(function ThirdSucHandler(result) {
console.log("Third successHandler recevied: ", result);
}, function ThirdErrorHandler(error) {
console.error("Third failedHandler catch: ", error);
})
// ------Level 4------
.then(function FourSucHandler(result) {
console.log("Fourth successHandler recevied: ", result);
})

正确的执行顺序应该是FirstSucHandlerfulfilled 之后抛出异常,略过SecondSucHandler,异常被ThirdErrorHandler捕获,并且返回一个新的 promise,由FourSucHandler解析。

接下来还要修复并且考虑这些问题 1. 当异常被捕获之后将阻止异常往下传递 2. 定义中描述在 fulfilled 之后必须返回一个新的 promise,但如果没有返回新的 promise,或者是返回其他的值,应该作何处理?

对于第二点,我们暂且处理规则是:

  1. 如果没有返回值,那么下一个回调函数将继续解析上一 promise
  2. 如果返回值存在,但返回值不为 promise,默认调用 resolve handler,并且返回值作为回调函数的参数传入

大致流程图如下所示

复制代码
push
|{1}
[successHandler, errorHandler]
...
------->[successHandler, errorHandler]
| |
| shift
| |
| execute
| succussHandler or errorHandler
| |
| |
| ----------------------
| | |
| return return
----Auto--promise nothing or not promise
| |
<--------------- Manual --------|

``` 从上图中可以看出,promise 模式是一个周而复始执行 resolve 或者 reject 过程,每一轮必须执行二者之一,必然导致一组回调函数出队。如果抛出异常,但这组回调函数中没有异常处理函数errorHandler,那么这组回调函数便作废,直到找到下一个能捕获异常的回调函数。直到队列中回调函数全部出队。看来我们有必要写一个“直到找到我们需要的函数”的函数:

复制代码
getCallbackByType: function (type) {
if (callbacks.length) {
var callback = callbacks.shift()[type];
while (!callback) {
callback = callbacks.shift()[type];
}
}
return callback;
}

从上图中可以看出所有的 promise 可以共用一个callbacks队列,并且考虑到需要判断返回值是否为 promise 类型,我们最好还需要一个标志位,做如下修改:

复制代码
var callbacks = [];
function Promise() {
this.isPromise = true;
}
...

根据以上描述,这类似于一个递归的过程

复制代码
promise --> resolve/reject ---> promise ---> resolve/reject

注意在上面 Level 1 的FirstSucHandler中,新返回的 promise 执行了 reject,这会自动使队列一组回调函数出队并执行。但面对一些没有返回值的情况应该怎么办,那么就应该遵循我上面说的标准,要么执行上一个 promise,要么默认执行下一个 resolve:

复制代码
...
executeInLoop: function (promise,result) {
// 1. 如果回调队列还没有被清空
// 2. 或者没有返回值,
// 3. 或者有返回值但不是 promise
if ((promise && !promise.isPromise || !promise) && callbacks.length) {
// 默认执行 resolve
var callback = this.getCallbackByType("resolve");
if (callback) {
var promise = callback(promise? promise: result);
this.executeInLoop(promise, promise? promise: result);
}
}
},
...

最后 Complete 函数也要做相应修改:

复制代码
...
complete: function (type, result) {
var callback = this.getCallbackByType(type);
if (callback) {
var promise = callback(result);
this.executeInLoop(promise, promise? promise: result);
}
},
...

最后贴上完整版代码:

复制代码
var callbacks = [];
function Promise() {
this.isPromise = true;
}
Promise.prototype = {
resolve: function (result) {
this.complete("resolve", result);
},
reject: function (result) {
var _this = this;
setTimeout(function () {
_this.complete("reject", result);
});
},
executeInLoop: function (promise,result) {
// 如果队列里还有函数 并且( 要么 没有返回一个值 或者 (有返回值但不是 promise 类型))
if ((promise && !promise.isPromise || !promise) && callbacks.length) {
var callback = this.getCallbackByType("resolve");
if (callback) {
var promise = callback(promise? promise: result);
this.executeInLoop(promise, promise? promise: result);
}
}
},
getCallbackByType: function (type) {
if (callbacks.length) {
var callback = callbacks.shift()[type];
while (!callback) {
callback = callbacks.shift()[type];
}
}
return callback;
},
complete: function (type, result) {
var callback = this.getCallbackByType(type);
if (callback) {
var promise = callback(result);
/*
1. 有返回值,promise 类型
2. 有返回值,其他类型
3. 无返回值
*/
this.executeInLoop(promise, promise? promise: result);
}
},
then: function (successHandler, failedHandler) {
callbacks.push({
resolve: successHandler,
reject: failedHandler
});
return this;
}
}

并附上执行结果:

复制代码
First successHandler recevied: some data
Third failedHandler catch: Error {}
Fourth successHandler recevied: Error {}

参考文献

作者简介

李光毅,新晋前端工程师,现就职于爱奇艺,热于前端技术分享。联系邮箱:juststayinvegas@gmail.com

2013 年 12 月 20 日 05:592856

评论

发布
暂无评论
发现更多内容

Zoom的加密算法,到底有什么问题?

范学雷

算法 编码习惯 产品设计 安全 编程语言

漫谈哲学与编程

keelii

编程 哲学

随手记备忘录的好习惯

changyou

在谈判中,你有哪些属于自己的独特的方法和技巧?

Yolanda

软件世界中的个人英雄与团队协作

王泰

团队管理 软件工程 团队协作

如何阅读源码?

武培轩

Java 源码 面试 进阶 后端

Java并发编程系列——线程

孙苏勇

Java 并发编程 线程

加班能解决交付的期望么?

拖地先生

项目管理 领导力 管理 时间管理

很不幸,自动化测试永远只能是必要非充分条件

刘华Kenneth

DevOps 敏捷 自动化 测试 金字塔

知乎开发了一个搜索引擎

红泥小壶

搜索引擎 百度 知乎

「超级右键」

非著名程序员

macos 程序员 效率工具 软件 Mac

理性主义和实证主义

王泰

理性主义 实证主义 哲学 软件工程

Facebook在用户增长到5亿时的扩容策略

Rayjun

团队管理 扩容

最近的一些人生感悟

小智

人生 哲学

浅谈负载均衡

Damon

Java 负载均衡 Kubernetes 微服务架构 Spring Cloud

敏捷开发 | 张三与需求管理

Worktile

敏捷开发 需求管理

程序员陪娃漫画系列——修龙头

孙苏勇

生活 程序员人生 陪伴 漫画

我敢说 80% 的程序员都掉进了「老鼠赛跑」的陷阱

非著名程序员

读书笔记 程序员 程序人生 提升认知

终极 Shell

池建强

Linux Shell

回"疫"录(2):不知者无畏

小天同学

疫情 回忆录 现实纪录

揭秘|为何程序员们能一直保持高收入?

丁长老

学习 程序员 写作 高薪

有关Kotlin Companion 我们需要了解到的几个知识点

王泰

Java 编程 kotlin 编程语言

基于Kubernetes的多云和混合云

倪朋飞

云计算 架构 Kubernetes 微服务架构 Service Mesh

Disruptor为何这么快

Rayjun

Java Disruptor

敏捷(组织)转型的6个准备条件

Bob Jiang

团队管理 敏捷 组织转型

死磕Java并发编程(6):从源码分析清楚AQS

七哥爱编程

Java Java并发 并发编程 AQS

写作平台使用感受

小天同学

产品 体验 反馈

小论互联网项目管理

南方

项目管理 互联网 个人成长 碧海潮生曲

克制文章长度

changyou

聊聊苹果公司技术部门的宫斗和冷战

赵钰莹

程序员 外包 apple

软件工程的史前时代 -- Therac-25 事件

王泰

质量管理 软件工程 软件危机 软件测试

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

Promise/A的误区以及实践-InfoQ