在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函数自动执行。