1
0
mirror of https://github.com/ruanyf/es6tutorial.git synced 2025-05-24 18:32:22 +00:00
es6tutorial/docs/async.md
2015-06-15 11:29:50 +08:00

17 KiB
Raw Blame History

异步操作

异步编程对JavaScript语言太重要。JavaScript只有一根线程如果没有异步编程根本没法用非卡死不可。

ES6诞生以前异步编程的方法大概有下面四种。

  • 回调函数
  • 事件监听
  • 发布/订阅
  • Promise 对象

ES6将JavaScript异步编程带入了一个全新的阶段。

基本概念

异步

所谓"异步",简单说就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。

相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。

回调函数

JavaScript语言对异步编程的实现就是回调函数。所谓回调函数就是把任务的第二段单独写在一个函数里面等到重新执行这个任务的时候就直接调用这个函数。它的英语名字callback直译过来就是"重新调用"。

读取文件进行处理,是这样写的。

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

上面代码中readFile函数的第二个参数就是回调函数也就是任务的第二段。等到操作系统返回了/etc/passwd这个文件以后,回调函数才会执行。

一个有趣的问题是为什么Node.js约定回调函数的第一个参数必须是错误对象err如果没有错误该参数就是null原因是执行分成两段在这两段之间抛出的错误程序无法捕捉只能当作参数传入第二段。

Promise

回调函数本身并没有问题它的问题出现在多个回调函数嵌套。假定读取A文件之后再读取B文件代码如下。

fs.readFile(fileA, function (err, data) {
  fs.readFile(fileB, function (err, data) {
    // ...
  });
});

不难想象如果依次读取多个文件就会出现多重嵌套。代码不是纵向发展而是横向发展很快就会乱成一团无法管理。这种情况就称为“回调函数噩梦”callback hell

Promise就是为了解决这个问题而提出的。它不是新的语法功能而是一种新的写法允许将回调函数的横向加载改成纵向加载。采用Promise连续读取多个文件写法如下。

var readFile = require('fs-readfile-promise');

readFile(fileA)
.then(function(data){
  console.log(data.toString());
})
.then(function(){
  return readFile(fileB);
})
.then(function(data){
  console.log(data.toString());
})
.catch(function(err) {
  console.log(err);
});

上面代码中我使用了fs-readfile-promise模块它的作用就是返回一个Promise版本的readFile函数。Promise提供then方法加载回调函数catch方法捕捉执行过程中抛出的错误。

可以看到Promise 的写法只是回调函数的改进使用then方法以后异步任务的两段执行看得更清楚了除此以外并无新意。

Promise 的最大问题是代码冗余原来的任务被Promise 包装了一下,不管什么操作,一眼看去都是一堆 then原来的语义变得很不清楚。

那么,有没有更好的写法呢?

Generator函数

协程

传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做"协程"coroutine意思是多个线程互相协作完成异步任务。

协程有点像函数,又有点像线程。它的运行流程大致如下。

  • 第一步协程A开始执行。
  • 第二步协程A执行到一半进入暂停执行权转移到协程B。
  • 第三步一段时间后协程B交还执行权。
  • 第四步协程A恢复执行。

上面流程的协程A就是异步任务因为它分成两段或多段执行。

举例来说,读取文件的协程写法如下。

function asnycJob() {
  // ...其他代码
  var f = yield readFile(fileA);
  // ...其他代码
}

上面代码的函数asyncJob是一个协程它的奥妙就在其中的yield命令。它表示执行到此处执行权将交给其他协程。也就是说yield命令是异步两个阶段的分界线。

协程遇到 yield 命令就暂停等到执行权返回再从暂停的地方继续往后执行。它的最大优点就是代码的写法非常像同步操作如果去除yield命令简直一模一样。

Generator函数的概念

Generator函数是协程在ES6的实现最大特点就是可以交出函数的执行权即暂停执行

整个Generator函数就是一个封装的异步任务或者说是异步任务的容器。异步操作需要暂停的地方都用yield语句注明。Generator函数的执行方法如下。

function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代码中调用Generator函数会返回一个内部指针即遍历器g 。这是Generator函数不同于普通函数的另一个地方即执行它不会返回结果返回的是指针对象。调用指针g的next方法会移动内部指针即执行异步任务的第一段指向第一个遇到的yield语句上例是执行到x + 2为止。

换言之next方法的作用是分阶段执行Generator函数。每次调用next方法会返回一个对象表示当前阶段的信息value属性和done属性。value属性是yield语句后面表达式的值表示当前阶段的值done属性是一个布尔值表示Generator函数是否执行完毕即是否还有下一个阶段。

Generator函数的数据交换和错误处理

Generator函数可以暂停执行和恢复执行这是它能封装异步任务的根本原因。除此之外它还有两个特性使它可以作为异步编程的完整解决方案函数体内外的数据交换和错误处理机制。

next方法返回值的value属性是Generator函数向外输出数据next方法还可以接受参数这是向Generator函数体内输入数据。

function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

上面代码中第一个next方法的value属性返回表达式x + 2的值3。第二个next方法带有参数2这个参数可以传入 Generator 函数作为上个阶段异步任务的返回结果被函数体内的变量y接收。因此这一步的 value 属性返回的就是2变量y的值

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){
    console.log(e);
  }
  return y;
}

var g = gen(1);
g.next();
g.throw'出错了';
// 出错了

上面代码的最后一行Generator函数体外使用指针对象的throw方法抛出的错误可以被函数体内的try ...catch代码块捕获。这意味着出错的代码与处理错误的代码实现了时间和空间上的分离这对于异步编程无疑是很重要的。

异步任务的封装

下面看看如何使用 Generator 函数,执行一个真实的异步任务。

var fetch = require('node-fetch');

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

上面代码中Generator函数封装了一个异步操作该操作先读取一个远程接口然后从JSON格式的数据解析信息。就像前面说过的这段代码非常像同步操作除了加上了yield命令。

执行这段代码的方法如下。

var g = gen();
var result = g.next();

result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

上面代码中首先执行Generator函数获取遍历器对象然后使用next 方法第二行执行异步任务的第一阶段。由于Fetch模块返回的是一个Promise对象因此要用then方法调用下一个next 方法。

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

Thunk函数

参数的求值策略

Thunk函数早在上个世纪60年代就诞生了。

那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是"求值策略",即函数的参数到底应该何时求值。

var x = 1;

function f(m){
  return m * 2;
}

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

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

function f(a, b){
  return b;
}

f(3 * x * x - 2 * x - 1, x);

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

Thunk函数的含义

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

function f(m){
  return m * 2;
}

f(x + 5);

// 等同于

var thunk = function () {
  return x + 5;
};

function f(thunk){
  return thunk() * 2;
}

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

JavaScript语言的Thunk函数

JavaScript语言是传值调用它的Thunk函数含义有所不同。在JavaScript语言中Thunk函数替换的不是表达式而是多参数函数将其替换成单参数的版本且只接受回调函数作为参数。

// 正常版本的readFile多参数版本
fs.readFile(fileName, callback);

// Thunk版本的readFile单参数版本
var readFileThunk = Thunk(fileName);
readFileThunk(callback);

var Thunk = function (fileName){
  return function (callback){
    return fs.readFile(fileName, callback);
  };
};

上面代码中fs模块的readFile方法是一个多参数函数两个参数分别为文件名和回调函数。经过转换器处理它变成了一个单参数函数只接受回调函数作为参数。这个单参数版本就叫做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);
    }
  };
};

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

var readFileThunk = Thunk(fs.readFile);
readFileThunk(fileA)(callback);

Thunkify模块

生产环境的转换器建议使用Thunkify模块。

首先是安装。

$ npm install thunkify

使用方式如下。

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

var read = thunkify(fs.readFile);
read('package.json')(function(err, str){
  // ...
});

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函数相关。请看下面的例子。

function f(a, b, callback){
  var sum = a + b;
  callback(sum);
  callback(sum);
}

var ft = thunkify(f);
ft(1, 2)(console.log);
// 3

上面代码中由于thunkify只允许回调函数执行一次所以只输出一行结果。

Generator 函数的流程管理

你可能会问, Thunk函数有什么用回答是以前确实没什么用但是ES6有了Generator函数Thunk函数现在可以用于Generator函数的自动流程管理。

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

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

var gen = function* (){
  var r1 = yield readFile('/etc/fstab');
  console.log(r1.toString());
  var r2 = yield readFile('/etc/shells');
  console.log(r2.toString());
};

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

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

var g = gen();

var r1 = g.next();
r1.value(function(err, data){
  if (err) throw err;
  var r2 = g.next(data);
  r2.value(function(err, data){
    if (err) throw err;
    g.next(data);
  });
});

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

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

Thunk函数的自动流程管理

Thunk函数真正的威力在于可以自动执行Generator函数。下面就是一个基于Thunk函数的Generator执行器。

function run(fn) {
  var gen = fn();

  function next(err, data) {
    var 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('fileA');
  var f2 = yield readFile('fileB');
  // ...
  var fn = yield readFile('fileN');
};

run(gen);

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

Thunk函数并不是Generator函数自动执行的唯一方案。因为自动执行的关键是必须有一种机制自动控制Generator函数的流程接收和交还程序的执行权。回调函数可以做到这一点Promise 对象也可以做到这一点。

co函数库

如果并发执行异步操作可以将异步操作都放入一个数组跟在yield语句后面。

co(function* () {
  var values = [n1, n2, n3];
  yield values.map(somethingAsync);
});

function* somethingAsync(x) {
  // do something async
  return y
}

上面的代码允许并发三个somethingAsync异步操作等到它们全部完成才会进行下一步。