NodeJs使用Promise解决多层异步调用的简单学习

前言

第一次接触到Promise这个东西,是2012年微软发布Windows8操作系统后抱着作死好奇的心态研究用html5写Metro应用的时候。当时配合html5提供的WinJS库里面的异步接口全都是Promise形式,这对那时候刚刚毕业一点javascript基础都没有的我而言简直就是天书。我当时想的是,微软又在脑洞大开的瞎捣鼓了。

结果没想到,到了2015年,Promise居然写进ES6标准里面了。而且一项调查显示,js程序员们用这玩意用的还挺high。

讽刺的是,作为早在2012年就在Metro应用开发接口里面广泛使用Promise的微软,其自家浏览器IE直到2015年寿终正寝了都还不支持Promise,看来微软不是没有这个技术,而是真的对IE放弃治疗了。。。

现在回想起来,当时看到Promise最头疼的,就是初学者看起来匪夷所思,也是最被js程序员广为称道的特性:then函数调用链。

then函数调用链,从其本质上而言,就是对多个异步过程的依次调用,本文就从这一点着手,对Promise这一特性进行研究和学习。

 

Promise解决的问题

考虑如下场景,函数延时2秒之后打印一行日志,再延时3秒打印一行日志,再延时4秒打印一行日志,这在其他的编程语言当中是非常简单的事情,但是到了js里面就比较费劲,代码大约会写成下面的样子:

var myfunc = function() {   
    setTimeout(function() {
        console.log("log1");
        setTimeout(function() {
            console.log("log2");
            setTimeout(function() {
                console.log("log3");
            }, 4000);
        }, 3000); 
    }, 2000);
}

由于嵌套了多层回调结构,这里形成了一个典型的金字塔结构。如果业务逻辑再复杂一些,就会变成令人闻风丧胆的回调地狱。

如果意识比较好,知道提炼出简单的函数,那么代码差不多是这个样子:

var func1 = function() {
    setTimeout(func2, 2000);
};

var func2 = function() {
    console.log("log1");
    setTimeout(func3, 3000);
};

var func3 = function() {
    console.log("log2");
    setTimeout(func4, 4000);
};

var func4 = function() {
    console.log("log3");
};

这样看起来稍微好一点了,但是总觉得有点怪怪的。。。好吧,其实我js水平有限,说不上来为什么这样写不好。如果你知道为什么这样写不太好所以发明了Promise,请告诉我。

现在让我们言归正传,说说Promise这个东西。

Promise的描述

这里请允许我引用MDN对Promise的描述:

Promise 对象用于延迟(deferred) 计算和异步(asynchronous ) 计算.。一个Promise对象代表着一个还未完成,但预期将来会完成的操作。

Promise 对象是一个返回值的代理,这个返回值在promise对象创建时未必已知。它允许你为异步操作的成功或失败指定处理方法。 这使得异步方法可以像同步方法那样返回值:异步方法会返回一个包含了原返回值的 promise 对象来替代原返回值。

Promise对象有以下几种状态:

  • pending: 初始状态, 非 fulfilled 或 rejected。
  • fulfilled: 成功的操作。
  • rejected: 失败的操作。

pending状态的promise对象既可转换为带着一个成功值的fulfilled 状态,也可变为带着一个失败信息的 rejected 状态。当状态发生转换时,promise.then绑定的方法(函数句柄)就会被调用。(当绑定方法时,如果 promise对象已经处于 fulfilled 或 rejected 状态,那么相应的方法将会被立刻调用, 所以在异步操作的完成情况和它的绑定方法之间不存在竞争条件。)

更多关于Promise的描述和示例可以参考MDN的Promise条目,或者MSDN的Promise条目

尝试使用Promise解决我们的问题

基于以上对Promise的了解,我们知道可以使用它来解决多层回调嵌套后的代码蠢笨难以维护的问题。关于Promise的语法和参数上面给出的两个链接已经说的很清楚了,这里不重复,直接上代码。

我们先来尝试一个比较简单的情况,只执行一次延时和回调:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout call back");
});

看起来和MSDN里的示例也没什么区别,执行结果如下:

$ node promisTest.js
1450194136374 start setTimeout
1450194138391 timeout call back

那么如果我们要再做一个延时呢,那么我可以这样写:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 2");
        setTimeout(res, 3000);
    }).then(function() {
        console.log(Date.now() + " timeout 2 call back");
    })
});

似乎也能正确运行:

$ node promisTest.js
1450194338710 start setTimeout 1
1450194340720 timeout 1 call back
1450194340720 start setTimeout 2
1450194343722 timeout 2 call back

不过代码看起来蠢萌蠢萌的是不是,而且隐约又在搭金字塔了。这和引入Promise的目的背道而驰。

那么问题出在哪呢?正确的姿势又是怎样的?

答案藏在then函数以及then函数的onFulfilled(或者叫onCompleted)回调函数的返回值里面。

首先明确的一点是,then函数会返回一个新的Promise变量,你可以再次调用这个新的Promise变量的then函数,像这样:

new Promise(...).then(...)
    .then(...).then(...).then(...)...

then函数返回的是什么样的Promies,取决于onFulfilled回调的返回值。

事实上,onFulfilled可以返回一个普通的变量,也可以是另一个Promise变量。

如果onFulfilled返回的是一个普通的值,那么then函数会返回一个默认的Promise变量。执行这个Promise的then函数会使Promise立即被满足,执行onFulfilled函数,而这个onFulfilled的入参,即是上一个onFulfilled的返回值。

而如果onFulfilled返回的是一个Promise变量,那个这个Promise变量就会作为then函数的返回值。

关于then函数和onFulfilled函数的返回值的这一系列设定,MDN和MSDN上的文档都没有明确的正面描述,至于ES6官方文档ECMAScript 2015 (6th Edition, ECMA-262)。。。我的水平有限实在看不懂,如果哪位高手能解释清楚官方文档里面对着两个返回值的描述,请一定留言指教!!!

所以以上为我的自由发挥,语言组织的有点拗口,上代码看一下大家就明白了。

首先是返回普通变量的情况:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    return 1024;
}).then(function(arg) {
    console.log(Date.now() + " last onFulfilled return " + arg);    
});

以上代码执行结果为:

$ node promisTest.js
1450277122125 start setTimeout 1
1450277124129 timeout 1 call back
1450277124129 last onFulfilled return 1024

有点意思对不对,但这不是关键。关键是onFulfilled函数返回一个Promise变量可以使我们很方便的连续调用多个异步过程。比如我们可以这样来尝试连续做两个延时操作:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 2");
        setTimeout(res, 3000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 2 call back");
});

执行结果如下:

$ node promisTest.js
1450277510275 start setTimeout 1
1450277512276 timeout 1 call back
1450277512276 start setTimeout 2
1450277515327 timeout 2 call back

如果觉得这也没什么了不起,那再多来几次也不在话下:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 2");
        setTimeout(res, 3000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 2 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 3");
        setTimeout(res, 4000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 3 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 4");
        setTimeout(res, 5000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 4 call back");
});
$ node promisTest.js
1450277902714 start setTimeout 1
1450277904722 timeout 1 call back
1450277904724 start setTimeout 2
1450277907725 timeout 2 call back
1450277907725 start setTimeout 3
1450277911730 timeout 3 call back
1450277911730 start setTimeout 4
1450277916744 timeout 4 call back

可以看到,多个延时的回调函数被有序的排列下来,并没有出现喜闻乐见的金字塔状结构。虽然代码里面调用的都是异步过程,但是看起来就像是全部由同步过程构成的一样。这就是Promise带给我们的好处。

如果你有把啰嗦的代码提炼成单独函数的好习惯,那就更加画美不看了:

function timeout1() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout1");
        setTimeout(res, 2000);
    });
}

function timeout2() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout2");
        setTimeout(res, 3000);
    });
}

function timeout3() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout3");
        setTimeout(res, 4000);
    });
}

function timeout4() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout4");
        setTimeout(res, 5000);
    });
}

timeout1()
    .then(timeout2)
    .then(timeout3)
    .then(timeout4)
    .then(function() {
        console.log(Date.now() + " timout4 callback");
    });
$ node promisTest.js
1450278983342 start timeout1
1450278985343 start timeout2
1450278988351 start timeout3
1450278992356 start timeout4
1450278997370 timout4 callback

接下来我们可以再继续研究一下onFulfilled函数传入入参的问题。

我们已经知道,如果上一个onFulfilled函数返回了一个普通的值,那么这个值为作为这个onFulfilled函数的入参;那么如果上一个onFulfilled返回了一个Promise变量,这个onFulfilled的入参又来自哪里?

答案是,这个onFulfilled函数的入参,是上一个Promise中调用resolve函数时传入的值。

跳跃的有点大一时间无法接受对不对,让我们来好好缕一缕。

首先,Promise.resolve这个函数是什么,用MDN上面文邹邹的说法

用成功值value解决一个Promise对象。如果该value为可继续的(thenable,即带有then方法),返回的Promise对象会“跟随”这个value,采用这个value的最终状态;否则的话返回值会用这个value满足(fullfil)返回的Promise对象。

简而言之,这就是异步调用成功情况下的回调。

我们来看看普通的异步接口中,成功情况的回调是什么样的,就拿nodejs的上的fs.readFile(file[, options], callback)来说,它的典型调用例子如下

fs.readFile('/etc/passwd', function (err, data) {
  if (err) throw err;
  console.log(data);
});

因为对于fs.readFile这个函数而言,无论成功还是失败,它都会调用callback这个回调函数,所以这个回调接受两个入参,即失败时的异常描述err和成功时的返回结果data

那么假如我们用Promise来重构这个读取文件的例子,我们应该怎么写呢?

首先是封装fs.readFile函数:

function readFile(fileName) {
    return new Promise(function(resolve, reject) {
        fs.readFile(fileName, function (err, data) {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

其次是调用:

readFile('theFile.txt').then(
    function(data) {
        console.log(data);
    }, 
    function(err) {
        throw err;
    }   
);

想象一下,在其他语言的读取文件的同步调用接口的里面,文件的内容通常是放在哪里?函数返回值对不对!答案出来了,这个resolve的入参是什么?就是异步调用成功情况下的返回值。

有了这个概念之后,我们就不难理解“onFulfilled函数的入参,是上一个Promise中调用resolve函数时传入的值”这件事了。因为onFulfilled的任务,就是对上一个异步调用成功后的结果做处理的。

哎终于理顺了。。。

总结

下面请允许我用一段代码对本文讲解到的要点进行总结:

function callp1() {
    console.log(Date.now() + " start callp1");
    return new Promise(function(res, rej) {
        setTimeout(res, 2000);
    });
}

function callp2() {
    console.log(Date.now() + " start callp2");
    return new Promise(function(res, rej) {
        setTimeout(function() {
            res({arg1: 4, arg2: "arg2 value"});
        }, 3000);
    });
}

function callp3(arg) {
    console.log(Date.now() + " start callp3 with arg = " + arg);
    return new Promise(function(res, rej) {
        setTimeout(function() {
            res("callp3");
        }, arg * 1000);
    });
}

callp1().then(function() {
    console.log(Date.now() + " callp1 return");
    return callp2();
}).then(function(ret) {
    console.log(Date.now() + " callp2 return with ret value = " + JSON.stringify(ret));
    return callp3(ret.arg1);
}).then(function(ret) {
    console.log(Date.now() + " callp3 return with ret value = " + ret);
})

$ node promisTest.js
1450191479575 start callp1
1450191481597 callp1 return
1450191481599 start callp2
1450191484605 callp2 return with ret value = {"arg1":4,"arg2":"arg2 value"}
1450191484605 start callp3 with arg = 4
1450191488610 callp3 return with ret value = callp3
posted @ 2020-08-20 08:48  _成飞  阅读(1130)  评论(0编辑  收藏  举报