JavaScript异步Thunk

18 Aug

在Generator一文中最后的例子,自动执行异步操作,还不够“自动”,毕竟每次调用异步API时,还需要手动指定resume回调函数以触发next。还有更“自动”的方式Thunk。感谢阮一峰提供的Thunk思路,可以参照这里。你可以从GitHub上获取本篇代码。

Thunk其实不是一个全新的概念,很早就有了,用于一个临时函数。什么意思呢?例如开发时会面临一个选择:什么时候开始求值?

var x = 1;
function f(m){
    return m * 2;
}
f(x + 5);    //12

对开发者来说这段代码非常正常,你不会多看它第二眼。问题是什么时候计算参数x + 5呢?

一种策略是立即计算,于是f(x + 5);会被转换成f(6);,然后进入函数f体内运行函数。

另一种策略是延迟计算,在进入函数f体之前并不计算参数值,而是进入函数体后才计算。因此函数体内return m * 2;会被转换成return (x + 5) * 2;,此时才开始计算。

如果不考虑开发成本,仅此例而言,延迟计算应该是比较好的。否则如果函数体内走了某if分支导致并没用到该参数,就白计算了,浪费性能。延迟到真正用到参数时才开始计算,这也是程序开发的一种流行的风格:越懒越好。

那如何实现延迟计算呢?可以生成一个临时函数(Thunk函数),将参数放进去里,上面代码等价于:

var x = 1;
var thunk = function () {   //Thunk函数
    return x + 5;
};
function f(tempFunc){
    return tempFunc() * 2;
};
f(thunk);   //12

上面这样的延迟计算,可能对效率控来说节省了一点理论上的性能(实际真节省了吗?未必),但从代码可读性,可维护性上来看,这样是得不偿失的。

再看看Thunk函数在JS里的应用,将多参的异步函数,转换成单参。通常异步函数的最后一个参数是回调函数。以NodeJS的核心模块File System的异步函数readFile为例

函数原型:fs.readFile(file[, options], callback)。支持3个参数,其中最后一个是回调函数。普通调用方式:

function someCallback(err, data) { 
    if (err) throw err;
    console.log(data); 
}
fs.readFile('./oranges.txt','utf8', someCallback);

用Thunk改造一下:

function someCallback(err, data) { 
    if (err) throw err;
    console.log(data); 
}
var Thunk = function (fileName, options){
    return function (callback){
        return fs.readFile(fileName, options, callback); 
    };
};

var readFileThunk = Thunk('./oranges.txt', 'utf8');
readFileThunk(someCallback);

看上去代码变复杂了。Thunk函数真正的作用是简化了参数,将原本多参的函数,简化成只接受回调函数做参数。即多参版本的异步函数,经由Thunk,变成了单参(参数为回调函数)函数。

现实中不必为每个异步函数定制一个Thunk函数,因此可以定义通用的Thunk函数:

var Thunk = function(fn){
    return function (){
        var args = Array.prototype.slice.call(arguments);
        return function (callback){
            args.push(callback);
            return fn.apply(this, args);
        }
    };
};

var readFileThunk = Thunk(fs.readFile);
readFileThunk('./oranges.txt', 'utf8')(someCallback);

可以把上面Thunk函数放到common位置,任何多参的异步函数(最后一个参数为回调函数),都可以调用上面的Thunk函数转换成单参版本。

其实上述Thunk函数等价于柯里化

var readFileThunk = fs.readFile.bind(null, './oranges.txt', 'utf8');
readFileThunk(someCallback);

如果不想自己造轮子来写Thunk函数,可以安装Thunkify模块:npm install thunkify。源代码和我们写的Thunk非常像。

上面举的Thunk函数的例子,无论是延迟计算,还是将多参异步函数转换成单参,其实都没什么卵用。所以在Generator函数出现之前,Thunk函数确实没什么卵用。真正让其发挥作用的是配合Generator函数实现自动化异步操作。以读取文件为例,Generator函数封装了两个异步操作:

var fs = require('fs');
var thunkify = require('thunkify');
var readFileThunk = thunkify(fs.readFile);

var gen = function* (){
    var r1 = yield readFileThunk('./apples.txt', 'utf8');
    console.log(r1);
    var r2 = yield readFileThunk('./oranges.txt', 'utf8');
    console.log(r2);
};

定义的异步操作很清晰(这也是Generator的优点,可以用同步化的方式定义异步操作步骤)。可以如下执行异步操作:

var g = gen();
var r = g.next();
r.value(function(err, data){	//r.value是一个function,等价于fs.readFile(callback)
    if (err) throw err;
    var r2 = g.next(data);
    r2.value(function(err, data){
        if (err) throw err;
        g.next(data);
    });
});

上面代码第二行执行next后,返回值r的value属性是Generator函数体内yield readFileThunk(‘./apples.txt’, ‘utf8’);语句的执行结果。即r的value属性是一个内部封装了[‘./apples.txt’, ‘utf8’]的单参数的fs.readFile函数。即r的value属性是fs.readFile(callback)函数。(再不明白,我也没有办法了…)

因此上面代码第三行r.value(function(err, data){…}等价于fs.readFile(function(err, data){…}。此时才开始正式执行异步函数读取文件内容。读取到的内容通过第5行next方法传递给Generator函数里的r1,打印出文件内容。之后就是重复上述套路。

显然开发者不想用这样嵌套的调用方法,太麻烦。所以参照Generator一文中例子的思路,可以定义一个run方法将上面的调用代码封装起来:

function run(genFunc) {
    var g = genFunc();
    function next(err, data) {
        var result = g.next(data);
        if (result.done) return;
        result.value(next);
    }
    next();
}
run(gen);

定义了run方法后,执行Generator函数就方便到令人发指。直接将Generator函数作为参数传给run就行了。然后会自动像多米诺骨牌一样依次执行Generator函数内的异步操作。当然,前提是每一个异步操作,都要是Thunk函数,即yield命令后面的必须是Thunk函数。

Thunk函数是自动执行Generator函数的一种选择,如果不习惯,或者觉得用Thunk函数并不会提高效率的话,可以像Generator一文中那样定义run,同样可以使Generator函数自动执行。

Leave a Reply

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