JavaScript异步Promise

作者: 张歆琳 | 来源:发表于2016-08-08 15:16 被阅读1152次

    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

    注意它不是静态方法,需要经由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。

    相关文章

      网友评论

        本文标题:JavaScript异步Promise

        本文链接:https://www.haomeiwen.com/subject/avrrsttx.html