JavaScript异步Promise

8 Aug

JavaScript里通常不建议阻塞主程序,尤其是一些代价比较昂贵的操作,如查找数据库,下载文件等操作,应该用异步API,如Ajax模式。在执行操作的的同时,主程序能继续处理后序代码,等操作结束后调用回调函数。

例如文件下载,同步方式的话,万一网络情绪不稳定,下载文件耗时很长,页面就卡住了,所以一定会用异步API:

downloadAsync("http://example.com/file.txt", function(text) {
    console.log(text);
});

但通常异步操作并非只有单一步骤,就以文件下载为例,如果下载3个有关联的文件,你要如何才能保证依次下载呢?你可能会用回调嵌套:

downloadAsync("a.txt", function(a) {
    downloadAsync("b.txt", function(b) {
        downloadAsync("c.txt", function(c) {
            console.log("Contents: " + a + b + c);
        }, function(error) {
            console.log("Error: " + error);
        });
    }, function(error) {
        console.log("Error: " + error);
    });
}, function(error) {
    console.log("Error: " + error);
});

回调嵌套本身没有什么错。它被诟病之处在于,这才3层,基本框架就很难看了。业务逻辑稍微多一点,即便你努力重构出子函数,也难以避免让主体框架陷入“回调地狱”,代码会难以阅读和维护。

这就是Promise诞生的原因,它并不是全新的数据类型,而是一种组织代码的方式,内部改进了回调函数,对外暴露出then方法。让开发者避免陷入“回调地狱”,写出更加清晰的,类似函数链的代码。ES6将Promise纳入了标准,本篇就介绍一下Promise。

  • Promise对象
  • then方法
  • catch方法
  • 静态方法(resolve,reject,all,race)
  • 例子

Promise对象

Promise既然是一种代码组织方式,它就需要为用户提供构造函数来创建一个Promise对象。构造函数原型:new Promise(function(resolve, reject) { … } );。参照MDN

构造函数用一个函数作为参数,该函数有两个参数,两个参数均是回调函数,由JS引擎提供,你不用自己部署了。第一个参数resolve,当异步操作成功时会调用,它有一个参数用于传递异步操作成功的结果。第二个参数reject,当异步操作失败时会调用,它有一个参数用于传递异步操作失败的信息。例如:

var myPromise = new Promise(function(resolve, reject) {
    ...  //异步操作
    if( success ) {
        resolve(value);
    } else {
        reject(error);
    }
});

上面生成了一个Promise对象,它代表着一个异步操作。有3种状态Pending,Resolved(MDN里又称Fulfilled),Rejected。看名字就知道分别表示异步操作中,操作成功,操作失败。

一旦调用构造函数开始生成Promise对象(如上面myPromise),就会立即执行异步操作。异步操作成功的话,myPromise状态会从Pending切换成Resolved。失败的话,状态从Pending切换成Rejected。状态改变后就固定了,永远不会再次改变,这就是Promise的语义,表示“承诺”。一旦承诺,海枯石烂都不会变。实例对象生成后,就能调用then方法了。

then方法

Promise.prototype.then()方法显然就是Promise的精华。函数声明:p.then(resolve, reject);。参照MDN

注意它不是静态方法(JS静态方法可以点这里),需要经由Promise实例对象来调用。

then方法有两个参数,第一个参数是Promise实例对象为Resolved状态时的回调函数,它的参数就是上面Promise构造函数里resolve传递过来的异步操作成功的结果。

第二个参数可选,是Promise实例对象为Rejected状态时的回调函数,它的参数就是上面Promise构造函数里reject传递过来的异步操作失败的信息。

例如:

var myPromise = new Promise(function(resolve, reject) {
    console.log("执行异步操作");
    resolve("成功");
    //reject ("失败啦");
});
console.log("后续操作");

myPromise.then(function(value) {
    console.log(value);
}, function(error) {
     console.log(error);
});
//执行异步操作
//后续操作
//成功

上面结果可以看出,一旦调用构造函数开始生成Promise对象,就会立即执行异步操作,打印出第一行log。由于是异步,会继续执行生成Promise对象外的代码,打印出第二行log。异步操作成功后,打印出then里resolve回调函数里的log。

由于通常异常会用下面介绍的catch方法捕捉,因此then方法的第二个参数通常会省略:

myPromise.then(function(value) {
    ...	//成功
}).catch(function(error) {
    ...	//失败
});

then方法最强大之处在于,它内部可以使用return或throw来实现链式调用。使用return或throw后的返回值是一个新的Promise实例对象(注意,不是原来那个Promise实例对象):

var myPromise = new Promise(function(resolve, reject) {
    resolve(1);
});

myPromise.then(function(value) {
    console.log("第" + value + "一次异步操作成功");  //第1次异步操作成功
    return value+1;
}).then(function(value) {
    console.log("第" + value + "一次异步操作成功");  //第2次异步操作成功
});

myPromise.then(function(value) {
   console.log("第" + value + "一次异步操作成功");   //第1次异步操作成功
});

上面代码中,myPromise对象第一次调用then时,value为1,打印出log后return出一个匿名Promise对象。

你可能会疑惑,看代码return value+1;(相当于return 2;)只是返回了一个数字,并没有返回Promise对象呀。其实它会隐式调用下面介绍的Promise.resolve静态方法将转为一个Promise对象。具体怎么转的,请参照下面介绍的Promise.resolve静态方法。

如果then方法内部不是return,而是throw,会隐式调用下面介绍的Promise.reject静态方法返回一个Rejected状态的Promise对象。

由于then里用return或throw返回的是另一个Promise对象,这就实现了链式调用。

代码中返回得到的匿名Promise对象,由于状态是Resolved,立即调用链式中第二个then方法,value为2。(如果匿名Pormise对象里有异步API,那仍旧会等操作成功或失败后,再触发调用链中的then方法)

最后一次then方法由myPromise对象调用,因为Promise对象的状态改变后就固定了,永远不会再次改变,所以value值仍旧为1。

一个常见的错误是,忘记写return或throw,但却对then方法使用链式调用。如上例中将return语句删掉:

myPromise.then(function(value) {
    console.log("第" + value + "一次异步操作成功");  //第1次异步操作成功
}).then(function(value) {
    console.log("第" + value + "一次异步操作成功");  //第undefined次异步操作成功
});

结果发现链式调用后,下一个then方法得到的是undefined。因为如果不显式写return语句的话,JS里的函数会自动 return undefined。这样就相当于调用下面介绍的Promise.resolve(undefined)。虽然浏览器觉得这段代码合法,不会报错,但通常来说这不是你期待的结果。因此推荐:

确保处于调用链中间的then方法内部永远显式的调用return或者throw。

catch方法

Promise.prototype.catch()同样是实例方法,需要经由Promise实例对象来调用,用于Promise实例对象状态为Rejected的后续处理,即异常处理。函数声明:p.catch(reject);。参照MDN

catch方法本质上等价于then(null, reject),因此参数reject在上面介绍过了,是一个回调函数,它的参数就是Promise对象状态变为Rejected后,传递来的错误信息。

例如:

var myPromise = new Promise(function(resolve, reject) {
    throw "异步操作失败";
    resolve("成功");
});

myPromise.then(function(value) {
    console.log(value);
}).catch(function(e) {
    console.log(e);
});
//异步操作失败

异步操作时throw了异常,导致myPromise状态变成Rejected。由于then里未定义第二个可选的reject回调函数,所以跳过then方法,进入catch,打印出log。

有个细节要注意,上面异步操作里throw和resolve语句别写反了,写反了是不会捕捉异常的:

var myPromise = new Promise(function(resolve, reject) {
    resolve("成功");	//throw和resovle的顺序写反了
    throw "异步操作失败";
});

myPromise.then(function(value) {
    console.log(value);
}).catch(function(e) {
    console.log(e);
});
//成功

上面代码未能捕获异常,并不是throw语句未被执行,throw语句确实被执行了。但由于已经将状态改为Resolved了,Promise对象的状态一旦改变将永不再变。因此不会进入catch语句里。即throw异常的话,要保证Promise对象的状态为Rejected,否则即使throw了异常,也没有回调函数能捕捉该异常。

那如果catch之前的then里也定义了第二个可选的reject回调函数参数呢?究竟是then的reject捕捉还是catch捕捉?其实明白了catch是then(null, reject)的别名,就能推导出,应该被then的reject捕捉:

myPromise.then(function(value) {
    console.log(value);
},function(e) {
    console.log(e + ",由then的reject回调函数捕捉");
}).catch(function(e) {
    console.log(e);
});
//异步操作失败,由then的reject回调函数捕捉

上面代码等价于:

myPromise.then(function(value) {
    console.log(value);
},function(e) {
    console.log(e + ",由then的reject回调函数捕捉");
}).then(null, function(e) {
    console.log(e);
});
//异步操作失败,由then的reject回调函数捕捉

从结果可以看出,catch等价于then(null, reject),相当于调用链上全是then,自然是被第一个定义了reject回调的then捕捉。

当然使用catch看起来代码更清晰,所以通常会省略then方法的第二个参数reject回调,用catch来捕捉异常。

then的返回值是一个全新的Promise对象,catch也不例外,同样适用于链式调用。虽然通常catch都放在链式调用的最后,但没人强制规定必须这么做,catch后面完全可以继续接then。同样的,如果catch未放在调用链最后,而是在调用链中间的话,别忘了显示地写上return或throw语句。

另外Promise对象的异常同样具有冒泡特性,会一直向后传递,直到被捕捉到为止。因此then方法中抛出的异常,也能被catch捕捉。因为调用链里then抛出异常相当于将匿名Promise对象的状态改成Rejected,这样异常在调用链上传递,直到异常被捕捉到:

var myPromise = new Promise(function(resolve, reject) {
    resolve("成功");
});

myPromise.then(function(value) {
    console.log(value);
    throw "then的resolve里抛出异常";
}).catch(function(e) {
    console.log(e);
});
//成功
//then的resolve里抛出异常

上面代码中,异常操作成功,打印出log。但在then的resolve回调函数里又throw出了异常,该异常会被then返回的匿名Promise对象继续传递给catch。如果catch之前有多个then,无论哪里抛出异常,最终都会被catch捕捉到。

那如果异常未被捕捉到会怎么样呢?例如myPromise.then(…).catch(…).then(…);。最后的then里抛出异常,或者catch里抛出异常(没错,catch等价于then(null, reject),自然在catch方法之中,也能再抛出异常),会怎么样呢?

这就是Promise异常的冒泡和传统的try/catch代码块的异常冒泡的区别:如果Promise异常最终未被捕捉到,不会继续传递到外层代码,即不会有任何反应:

var myPromise = new Promise(function(resolve, reject) {
    resolve(x);   	//error,x未定义
});

myPromise.then(function(e) {
    console.log('成功');}
);

上面代码没有catch语句,因此异常未被捕捉到,外层代码也没有任何反应,仿佛什么都没发生(注意,试下来Chrome会抛出x未定义的错误)。忘记在最后加上catch,会导致珍贵的异常信息丢失,这对调试来说是异常痛苦的经历。要记住,通常程序员对自己写的代码会很自信,觉得不会抛出一个 error,但现实不同环境下,相同代码的执行结果会大相径庭。所以为了将来调试方便,建议:

在promise调用链最后添加catch。

myPromise().then(function () {
…
}).then(function () {
    …
}).catch(console.log.bind(console));

静态方法(resolve,reject,all,race)

这里介绍的4个都是静态方法,非实例方法,用Promise对象是无法调用的。

Promise.resolve将对象转为Promise对象。函数声明:Promise.resolve(value);。参照MDN

参数是一个对象,转换成Promise对象返回出去。你可以用该方法将jQuery的Deferred对象转换成一个Promise对象:

var myPromise = Promise.resolve($.ajax('/xx.json'));

参数分4种情况:

1.如果参数对象本身就是Promise对象,那就将该对象原封不动返回。

2.如果参数对象本身是thenable对象(即对象有then方法),转为Promise对象并返回后,立即执行thenable对象的then方法。例如:

var myPromise = Promise.resolve({ 
    then: function(resolve) { 
      resolve("成功"); 
    }
});
console.log(myPromise instanceof Promise);

myPromise.then(function(v) {
    console.log(v);
});
//true
//成功

上面代码中,Promise.resolve的参数里有then方法,因此myPromise对象生成后,立即执行then方法,根据异步操作的结果,调用then里resolve/reject回调函数

3.如果参数对象本身不是thenable对象(即对象没有then方法),例如一个数字数组等,那会返回一个状态固定为Resolved的全新的Promise对象:

var myPromise = Promise.resolve([1,2,3]);
myPromise.then(function(v) {
    console.log(v);  
});
//[1,2,3]

由于数组不是具有then方法的对象,返回Promise实例的状态固定为Resolved,触发调用then方法。原先的参数对象,会被传给then方法的resolve回调函数做为参数。

为什么呢?因为Promise.resolve([1,2,3]);等价于:

new Promise(function(resolve, reject) {
    resolve([1,2,3]);
});

4.如果没有参数,那会返回一个状态固定为Resolved的全新的空的Promise对象。虽然浏览器不会报错,但也没什么卵用。

Promise.reject将对象转换成一个状态为Rejected的全新的Promise对象。其他同Promise.resolve,不赘述。函数声明:Promise.reject(error);。参照MDN

该方法常用于调试,显示栈信息:

Promise.reject(new Error("fail")).then(function(error) {
    // not called
}, function(error) {
    console.log(error);   // Stacktrace
});

Promise.all方法用于将多个Promise实例对象,包装成一个新的Promise实例对象。函数声明:Promise.all(iterable);。参照MDN

参数是一个Promise对象数组。例如:

var p = Promise.all([p1, p2, p3]);

p1,p2,p3都是Promise对象,如果不是,就会先调用Promise.resolve方法,将参数转为Promise对象。(Promise.all方法的参数可以不是数组,但必须具有Iterator接口)

返回值是一个全新的Promise对象。它的状态由参数的状态决定:

当p1,p2,p3的状态都变成Resolved,p的状态才会变成Resolved。然后p1,p2,p3的返回值组成一个数组,传递给p的回调函数。

只要p1,p2,p3中有一个变成Rejected,p的状态就变成Rejected,此时第一个Rejected的实例的返回值,会传递给p的回调函数。

var p1 = Promise.resolve(3);
var p2 = 1337;
var p3 = new Promise((resolve, reject) => {
    setTimeout(resolve, 1000, "foo");
}); 
Promise.all([p1, p2, p3]).then(values => { 
    console.log(values); 	//1秒后显示[3, 1337, "foo"] 
});

all方法用于哪里呢?一个非常常见场景是:程序员想用forEach,for,while等循环来处理他们的异步结果。例如删除所有临时文件:

db.allDocs({include_docs: true}).then(function (result) {
    result.rows.forEach(function (row) {		//用forEach删除所有临时文件
        db.remove(row.doc);
    });
}).then(function () {
    //我认为执行到这里时,所有临时文件都已经被删了。但是我错了…
});

上面代码,第一个then的回调里,期望用循环删除所有临时文件后,再进入第二个then。但事实上,由于第一个then里没有return语句(原因是删除文件是个异步动作,如果写了return语句,删文件执行之前就会return),所以返回的是undefined。因此第二个then并不是在所有文件均被删除后才执行的,实际上,第二个then的执行不会有任何延迟,会被立即执行。

如果第二个then里并不需要对临时文件进行任何操作,那这个bug可能会隐藏的很深。但如果在第二个then里刷新UI页面,那时不时UI页面上会出现一些还来不及删掉的文件,这取决于删文件的速度。程序员都知道,最讨厌的不是出bug,而是出了看运气才能再现的bug。

所以应该用Promise.all来写这种需要循环处理异步的操作,可以理解为异步的循环。

db.allDocs({include_docs: true}).then(function (result) {
    return Promise.all(result.rows.map(function (row) {
        return db.remove(row.doc);
    }));
}).then(function (arrayObject) {
    //我承诺执行到这里时,所有临时文件都已经被删掉了。
})

Promise.race方法和all方法类似,函数声明:Promise.race(iterable);,参照MDN

var p = Promise.race([p1,p2,p3]);

参数p1,p2,p3都是Promise对象,如果不是,就会先调用Promise.resolve方法,将参数转为Promise对象,这点和all相同,不赘述。

和Promise.all的区别是,race表示竞争。只要任意一个参数对象的状态发生改变,就会立即返回一个相同状态的Promise对象。

简单地说:all里全为Resolved才返回Resolved,有一个为Rejected就返回Rejected。race里有一个为Resolved/Rejected就返回Resolved/Rejected

race常用于竞争异步执行的结果场景,例如:指定时间内没有获得结果就Rejected:

var p = Promise.race([
    fetch('/resource'),
    new Promise(function (resolve, reject) {
        setTimeout(() => reject(new Error('request timeout')), 5000)
    })
]);
p.then(response => console.log(response))
  .catch(error => console.log(error));

上面代码中,如果5秒之内无法fetch到数据,p的状态就会变为Rejected,从而触发catch方法指定的回调函数。

例子

例如我们要做两次异步操作(用最简单的异步操作setTimeout为例),第一次异步操作成功后执行第二次异步操作:

function delay(time, callback){
    setTimeout(function(){
        callback("sleep "+time);
    },time);
}   

delay(1000,function(msg){
    console.log(msg);
    delay(2000,function(msg){
        console.log(msg);
    });
});
//1秒后打印出:sleep 1000
//再过2秒打印出:sleep 2000

上面用回调嵌套很容易实现,但也发现才两层(还没加上异常处理),代码就比较难看了。改成Promise试试:

function delay(time, callback){
    setTimeout(function(){
        callback("sleep "+time);
    },time);
} 

var p = new Promise(function (resolve, reject) {
    delay(1000, resolve);
});

p.then(function(value) {
    console.log(value);
    return new Promise(function (resolve, reject) {
        delay(2000, resolve);
    });
}).then(function(value) {
    console.log(value);
}).catch(console.log.bind(console));

因为才两个异步操作,所以代码行数上看,优势不明显。优势在于Promise让代码的逻辑流程变得更清晰。先执行第一次异步操作,成功后进入then,打印出结果,并执行第二次异步操作,成功后进入then,打印出结果。

总结

ES6 Promise的实现严格遵循了Promise/A+规范,用Promise可以写出更可读的异步编程代码,避免了回调地狱。需要注意的是,由于历史原因,有的库,例如jQuery的Deferred就不是Promise/ A+的规范,它是个工厂类,返回的是内部构建的deferred对象。本篇篇幅不够去介绍Deferred和Promise的区别,但ES6的Promise完全可以取代Deferred。

Leave a Reply

Your email address will not be published. Required fields are marked *