Javascript异步编程(三: Thunk函数)

前言

参数的求值策略

Thunk函数早在上个世纪60年代就诞生了。
那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是“求值策略”,即函数的参数到底应该何时求值。

const x = 1;

const f = (m) => m * 2;

console.log(f(x + 5));

上面代码先定义函数 f,然后向它传入表达式 x + 5 。请问,这个表达式应该何时求值?

传值调用

一种意见是“传值调用”(call by value),即在进入函数体之前,就计算 x + 5 的值(等于6),再将这个值传入函数 f 。C语言就采用这种策略。

f(x + 5)
// 传值调用时,等同于
f(6)

传名调用

另一种意见是“传名调用”(call by name),即直接将表达式 x + 5 传入函数体,只在用到它的时候求值。Hskell语言采用这种策略。

f(x + 5)
// 传名调用时,等同于
(x + 5) * 2

传值调用和传名调用,哪一种比较好?回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失。

const x = 1;

const fn = (a, b) => b;

console.log(fn(3 * x * x - 2 * x - 1, x));

上面代码中,函数 f 的第一个参数是一个复杂的表达式,但是函数体内根本没用到。对这个参数求值,实际上是不必要的。
因此,有一些计算机学家倾向于”传名调用”,即只在执行时求值。

Thunk 函数的含义

编译器的”传名调用”实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。

const x = 1;

const f = (m) =>  m * 2;

console.log(f(x + 5));

// 等同于

const x = 1;

const thunk = () => x + 5;

const f = (thunk) => thunk() * 2;

console.log(f(thunk));

上面代码中,函数 f 的参数 x + 5 被一个函数替换了。凡是用到原参数的地方,对 Thunk 函数求值即可。
这就是 Thunk 函数的定义,它是”传名调用”的一种实现策略,用来替换某个表达式。

JavaScript 语言的 Thunk 函数

JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。

在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。

以Ajax为列子

var url = 'https://api.github.com/users/github';

// 正常版本的Ajax(多参数版本)
$.get(url,function (data) {
    console.log(data);
});

// Thunk版本的Ajax(单参数版本)
var sendApply = Thunk(url);
sendApply(function (data) {
    console.log(data);
});

function Thunk(url){
    return function (callback){
        return $.get(url,function (data) {
            callback(data);
        });
    };
};

上面代码中,jquery 的 get 方法是一个多参数函数,两个参数分别为请求路径和成功回调函数。

经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 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);
    }
  };
};

使用上面的转换器,生成 ajax.get 的 Thunk 函数。

var sendApply = ThunkSimple($.ajax);
sendApply(url)(function (data) {
    debugger;
    console.log(data);
});
sendApply(url)().success(function (data) {
    console.log(data);
    $('div').html(JSON.stringify(data));
})

Thunkify 模块

生产环境的转换器,建议使用 Thunkify 模块。
首先是安装。

$ npm install thunkify -S

根据GitHub的Example。

const thunkify = require('thunkify');
const fs = require('fs');

const read = thunkify(fs.readFile);

read('package.json', 'utf8')((err, str) =>{
    console.log(err); // null
    console.error(str); // 输出package文件内容
});

Thunkify 的源码与上一节那个简单的转换器非常像。

function thunkify(fn){
  return function(){
    var args = new Array(arguments.length);
    var ctx = this;

    for(var i = 0; i < args.length; ++i) {
      args[i] = arguments[i];
    }

    return function(done){
      var called;

      args.push(function(){
        if (called) return;
        called = true;
        done.apply(null, arguments);
      });

      try {
        fn.apply(ctx, args);
      } catch (err) {
        done(err);
      }
    }
  }
};

它的源码主要多了一个检查机制,变量 called 确保回调函数只运行一次。这样的设计与下文的 Generator 函数相关。请看下面的例子。

const thunkify = require('thunkify');
const f = (a, b, callback) =>{
    let sum = a + b;
    callback.call(this,null,sum);
    sum++;
    callback.call(this,null,sum);
}


f(1,2,function(err,data){
    console.error(data); // 3 4
})

// 使用thunk函数
const ft = thunkify(f);
ft(1, 2)((data)=>{
    console.log(data);// 3
});

你会看到上面有两次输出,分别是3,4。
但是使用thunk函数后,只有一个输出,那就是3。

这就是上面那个called发挥的作用,它会让你sum函数定义的callback只执行一次,就算后面有更多的callback它也不管不问。

为什么会这样?主要是因为thunkify一般是和co配合用的,而co里面,你callback以后,它接收到数据之后就执行next函数了,如果你多次执行的话,会多次调用next,很明显会造成错误。

这是一个不得已的取舍。

所以你定义sum的时候,不要在callback之后再修改data的值进行第二次callback,养成这个习惯。

Generator 函数的流程管理

Thunk 函数可以用于 Generator 函数的自动流程管理。

依然以读取文件为例子。下面的 Generator 函数封装了两个异步操作。

const thunkify = require('thunkify');
const fs = require('fs');
const readFile = thunkify(fs.readFile);

const gen = function* () {
    const basic = yield readFile('thunk-basic.js','utf8');
    console.log(basic.toString());
    const package = yield readFile('package.json','utf8');
    console.log(package.toString());
}

上面代码中,yield 命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数。

这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数。为了便于理解,我们先看如何手动执行上面这个 Generator 函数。

const g = gen();
const doNext = g.next();

doNext.value((err,data)=>{
    if(err) throw err;
    const doNext2 = g.next(data);
    doNext2.value((err,data)=>{
        if(err) throw err;
        g.next(data);
    })
});

上面代码中,变量 g 是 Generator 函数的内部指针,表示目前执行到哪一步。next 方法负责将指针移动到下一步,并返回该步的信息(value 属性和 done 属性)。

仔细查看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入 next 方法的 value 属性。这使得我们可以用递归来自动完成这个过程。

Thunk 函数的自动流程管理

自己写执行函数是不是很烦?

体验下Thunk 函数真正的威力,自动执行 Generator 函数。
下面就是一个基于 Thunk 函数的 Generator 执行器。

const run = fn => {
    const gen = fn();

    const next = (err, data) => {
        const result = gen.next(data);
        if (result.done) return;
        result.value(next);
    };
    next();
};

run(gen);

上面代码的 run 函数,就是一个 Generator 函数的自动执行器。
内部的 next 函数就是 Thunk 的回调函数。
next 函数先将指针移到 Generator 函数的下一步(gen.next 方法),然后判断 Generator 函数是否结束(result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数(result.value 属性),否则就直接退出。

有了这个执行器,执行 Generator 函数方便多了。不管有多少个异步操作,直接传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数

var gen = function* (){
  var f1 = yield readFile('file1');
  var f2 = yield readFile('file2');
  // ...
  var fn = yield readFile('file98');
};

run(gen);

上面代码中,函数 gen 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。
这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk 函数并不是 Generator 函数自动执行的唯一方案。
因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。

回调函数可以做到这一点,Promise 对象也可以做到这一点。

下一篇,将介绍基于 Promise 的自动执行器co 函数。

你可能感兴趣的:(ECMAScript)