——轻易说“回调地狱”的,就是人云亦云。
Promise 是 ES6 中一个非常棒的 API。是有别于 ES5 回调的、具有突破性的异步编程解决方案。(关于异步编程方案,搭配 这里 学习可能更完整哦)
核心 API
最开始接触 Promise 时,总是使劲的去理解 promise
(“承诺”)传达的意思,什么是对未来的一个承诺......其实这种抽象概念,反而增加了对 Promise 的理解难度。
拒绝抽象概念,请讲代码。一个最简单的用法是这样的:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => {
resolve({msg: 'done'});
}, 1000)
})
我们来分层看 Promise API 是怎样的。
第一层:构造函数 Promise
接收一个函数作为参数,这个函数是一个进行异步操作的函数。再简写一下是这样的:
let promise = new Promise(fn);
第二层:这个异步函数fn
必须形式化的提供 2 个参数:(resolve, reject)
,它们在函数体内,由用户根据需要调用。
function fn(resolve, reject) {
setTimeout(() => {
resolve({msg: 'done'});
}, 1000)
}
第二层仅仅是规定了 fn
的书写要求。换个角度看,fn
与 Promise
原本半毛钱关系都没有,但要想和Promise
挂钩,那就得在这个函数体内,去调一下resolve
或reject
,仅此而已。
通观第一层、第二层,总结起来就是这两点:
- 从形式上看,Promise API 就是这三个元件:
Promise, resolve, reject
- 从使用角度看,Promise API 只不过是一套用来包裹和改造异步代码的套件。
再举一个常见的例子,来强化下这种结构认识:
let promise = new Promise( (resolve, reject) => {
$.get('/myUrl', (data, status) => {
if(status === 'success') {
resolve(data);
} else {
reject(data);
}
})
})
一方面,resolve
和reject
就像两个需要带话回去
的使徒,又像是潜藏在敌方的线人,起到获取fn
内部情报信息的作用。
另一方面,原本的$.get
异步方法被进一步改造,安插了两个线人
。
我们知道,一个promise
实例就包含一个状态机,能改变状态机状态的只有resolve
按钮和reject
按钮。
因此,一个promise
就只有三种状态:
-
pending
(悬而未决的初始等待状态) -
fulfilled
(已成功,按下resolve
按钮导致的) -
rejected
(已失败,按下reject
按钮导致的)
引入状态机
使得Promsie
异步方案和传统方案有着本质的不同,状态机不用关心细节处理(当然细节还是得我们自己处理),它只管划定了几种状态,我们编程时向状态
看齐,这样编程思路就更清晰。
此外,Promsie
是一套声明式编程的API,无论是new Promsie()
,.then()
,还是.catch()
,都要求传入函数作为参数,强调做什么,而不是聚焦怎么做。
一句话说, Promise 就是用状态机
和侵入式方法
封装异步操作
的一套声明式的 API 规范。
扁平化
扁平化是Promise
为我们呈现的一个非常凸显的理念。
说到扁平化,我们年轻人都欢迎扁平化,喜欢扁平化的、无层级、消息直达的组织。相比很多传统公司,很多互联网公司就采用扁平化管理……扯远了,一个是写代码,一个是组织结构管理......
但从某种角度说,写代码也是在搭结构,也要减少嵌套层级。Promise API 凸显了这样的结构,所以不得不叹服它的设计之妙。鉴于此,这给了我们很好的启示,我们编写代码也应尽量扁平化。
回到代码,我们来看看Promise
怎么让我们轻松的实现扁平化风格。
首先,依据上述 Promise 改造异步代码 这一思路,来改造一个常用的ajax
方法:
let $get = function(url) {
return new Promise((resolve, reject) => {
$.get(url, (data, status) => {
if(status === 'success') {
resolve(data);
} else {
reject(data);
}
});
})
}
如果,我们有一连串ajax
请求a, b, c...
,后面的请求依赖于前面请求的结果。
let promise = new Promise((resolve, reject) =>{
//发起 a 请求
return $get('/url_a');
}).then((res) => {
handler_a(res);
// 拿着 a 请求的结果,发起 b 请求
return $get('/url_b?name='+res.name);
})
.then((res) => {
handler_b(res);
// 拿着 b 请求的结果,发起 c 请求
return $get('/url_c?name='+res.name);
})
.then((res) => {
// 处理 c 请求回调
handler_c(res);
})
把我们的目光从函数的具体实现中抽离出来,将函数压缩成一个函数名,每一个异步函数处理自身业务,多个异步函数 “一” 字排开。
let promise = new Promise(fn_a)
.then(fn_b)
.then(fn_c)
.then(callback_c);
没有对比,就没有伤害。我们来看看传统 ES5 的回调办法。
$.get('/url_a', (data, status) => {
handler_a(data);
$.get('/url_b?name='+data.name, (data, status) => {
handler_b(data);
$.get('/url_c?name='+data.name, (data, status) => {
handler_c(data);
});
});
});
这种缩进和层层嵌套
的方式,非常容易造成上下文代码混乱,我们不得不非常小心翼翼处理内层函数与外层函数的数据,一旦内层函数使用了上层函数的变量,这种混乱程度就会加剧......总之,这种层叠上下文
的层层嵌套方式,着实增加了神经的紧张程度。
相比起来,Promise
的then()
方法很好的隔离了每个异步操作,异步之间只有结果数据的传递,不会有上下文
的重叠所产生的干扰。
值得注意的是,对于这种层层嵌套的方式,很多人或文章人云亦云的称之为 “回调地狱”。但个人觉得这种说法真的不够准确,因为回调本身是清晰的,并没有给我们带来理解和阅读上的麻烦,反而是嵌套——嵌套本身,造成了数据、结构层叠的麻烦,所以个人觉得叫做 “嵌套迷宫” 才合适。这篇文章 甚至对这种错误理用语于愤慨。
最后,通过比较,我们知道了Promise
的扁平化设计理念,也领略了这种上层设计
带来的好处。
错误机制
Promsie
的错误处理,是一大亮点,得益于Promsie
出色的设计,让错误处理变得轻松和方便无比。
错误机制的 API 就是reject()
方法和.catch()
方法,前者负责发起一个错误、并往下游传递,后者负责捕获传从上游递下来的错误。可以说,它俩共同担当了错误机制的建设。
首先看一段简写的示例:
let promise = new Promise((resolve, reject) => {
//setup a async operation
reject('error');
})
.then(resolve, reject)
.catch((error) => {
// handle the error
})
上述代码,从源头发起的错误,会依次流经.then()
和.catch()
。其中错误流经.then(resolve, reject)
时,可能出现三种境况:
-
then
只提供了reject
方法处理回调逻辑,没有提供reject
方法处理错误 -
then
提供reject
方法处理了错误 -
then
中的代码执行本身又出了新的错误
三种情况不难用上述代码模拟出结果来,总结起来就是:只要错误被提供的reject
方法处理了,下游将不会有这个错误出现;只要存在错误,并且不曾被方法处理,最终都会被.catch()
捕获。
Promsie
的错误机制初次看起来,规则种种,其实稍加梳理,是非常好理解的。
借助Promsie
的错误机制,我们写异步代码时就能规范而统一的处理错误问题,特别在处理复杂异步问题时,不至于被绕晕。
比如,一个异步函数,可能出现的错误大概是这样的:
let $get = function(url) {
return new Promise((resolve, reject) => {
$.get(url, (data, status) => {
if(status === 'success') {
if(parseDataError) {
reject('local parsed error');
}
resolve(data);
} else {
reject('many kinds of failures');
}
});
})
}
$get('http://myfault.com/')
.then((data) => {})
.catch((error) => {
console.log(error);
});
一个健壮的程序,是要考虑很多错误的情况的。如果能充分的挖掘错误点,就能让后期 debug 省事不少。上述典型的异步封装,错误点大概有以下:
- 解析错误:成功后对响应数据的解析出错
- 请求失败:请求失败的各种情况,归结成一条错误
- 系统错误:
$.get
方法或者其使用出现错误
以上种种错误,统统会流到下游,只需一个.catch()
方法就能轻松掌握。而且,如果.then()
回调中再有错误,那也是回调中的事情,这样能与源头很好的区分开来。
试想,如果有一串异步依次调用,那Promsie
的错误机制则更显示出它的优越性;而 “层层嵌套” 那种传统异步回调处理这些错误,将如同制造出一团乱麻出来(哦,是意大利面条......)。
批量异步操作
以上三个话题,都是关于 Promise API 本身的梳理,比较无聊。而本话题——批量异步操作,则是从笔者实际的使用经历中提炼出来的,仍然不得不感叹 Promise API 的设计非常到位。
比如一个复杂的电商网站,一般一个页面会有多个模块,比如A、B、C,每个都是从后台拉取数据然后渲染在页面上的。如果要求三个模块必须同时显示出来(任何一个出错了,都不显示),将怎样做?
当然现在的办法很多可以用 React.js 处理模块和数据问题,但在 JQuery + require.js 时代(不远,也就在去年前年),处理批量操作全部完成问题,就得同时发出三个请求,每个请求的回调函数都去做一次check
,检查是否三个异步都已完成了。
$.get('url_a', function(data){
if(checkAllWith('a')) {
mainRender(data);
}
})
$.get('url_b', function(data){
if(checkAllWith('b')) {
mainRender(data);
}
})
$.get('url_c', function(data){
if(checkAllWith('c')) {
mainRender(data);
}
})
这样的代码是可以解决问题的——如果三个请求都正常返回了,那么排在最后才响应的那个请求,其检验一定会通过,它的回调就会启动全部的渲染。最后一次性将三个模块同时显示出来。反之,任何一个请求无响应,最后都不会启动主渲染。
传统方法处理起来也并不复杂,但要是碰上 Promise,那就更简单了。对于相关问题,Promise 提供了两个 API 处理它们。
- promise.all
- promise.race
// $get 是上文中用过的 $.get() 方法的 promise 封装
// $getB、$getC 依次类推
let $getA = function( ) {
return $get('a').then(callback_a);
}
let all = Promise.all([$getA, $getB, $getC]);
// 3 个 promse 全部完成后,才会触发回调
all.then((data) => {
mainRender(data);
})
新旧方法比较起来,相当于Promise.all
自己维护了checkAllWith()
方法。
在批量异步竞争问题上,Promise.race
则可以轻松胜任。
在更多复杂异步编程中,Promise.all
, Promise.race
它们可以相互配合,最终给出优雅的解决方案。可以毫不夸张的说, Promise 就是异步编程专家。