转载

异步流程控制

单线程与异步

Javascript是 单线程 运行、支持 异步 机制的语言。进入正题之前,我们有必要先理解这种运行方式。

以「起床上班」的过程为例,假设有以下几个步骤:

  • 起床(10min)
  • 洗刷(10min)
  • 换衣(5min)
  • 叫车(10min)
  • 上班(15min)

最简单最不容易出错的执行方式就是按顺序逐步执行,这样从起床到上班共需45分钟,效率较低。而高效的方式是,在「洗刷」之前先「叫车」,这样就可以节省10分钟的等车时间。

异步流程控制

这样一来「叫车」就成了异步操作。但为何只有「叫车」可以异步呢?因为车不需要自己开过来,所以自己处于空闲状态,可以先干点别的。

把上面的过程写成伪代码:

起床(); 叫车(function() {  上班(); }); 洗刷(); 换衣();

因为「上班」要在「叫车」之后才能执行,所以要作为「叫车」的回调函数。然而,「叫车」需要10分钟,「洗刷」也需要10分钟,「洗刷」执行完后刚好车就到了,此时会不会先执行「上班」而不是「换衣」呢?Javascript是单线程的语言,它会 先把当前的同步代码执行完再去执行异步的回调 。而异步的回调则是另一片同步代码,在这片代码执行完之前,其他的异步回调也不会被执行。所以「上班」不会先于「换衣」执行。

接下来考虑一种情况:手机没电了,要想叫车得先充电。很明显,充电的过程也可以异步执行。整个过程应该是:

异步流程控制

写成伪代码则是:

起床(); 充电(function() {  叫车(function() {   上班();  }); }); 洗刷(); 换衣();

充电、叫车、上班是异步串行(按顺序执行)的,所以要把后者作为前者的回调函数。可见,串行的异步操作越多,回调函数的嵌套就会越深,最终形成了回调金字塔(也叫回调地狱):

充电(function() {  叫车(function() {   其他事情1(function() {    其他事情2(function() {     其他事情3(function() {      上班();     });    });   });  }); });

这样的代码 极难阅读 ,也 极难维护 。此外,还有更复杂的问题:

  • 除了异步串行,还有异步并行,甚至是串行、并行互相穿插。
  • 异步代码的异常无法通过try...catch捕获,异常处理相当不方便。

可喜的是,随着异步编程的发展,上面提及的这些问题越来越好解决了,下面就给大家介绍三种解决方案。

Async库

Async 是一个异步操作的工具库,其中就包括流程控制。

async.series 」即为执行异步串行任务的方法。例如:

// 先加载a.txt再加载b.txt async.series([  function(next) {   $.ajax('a.txt', {    success: function(resultA) {     next(null, resultA);    },    error: function(err) {     next( new Error(err.statusText) );    }   });  },  function(next) {   $.ajax('b.txt', {    success: function(resultB) {     next(null, resultB);    },    error: function(err) {     next( new Error(err.statusText) );    }   });  } ], function(err, results) {  if (err) {   console.error(err);  } else {   console.dir(results); // 数组  } });

「async.series」的第一个参数是要执行的步骤(数组),每一个步骤都是一个函数。这个函数有一个参数「next」,异步操作完成后必须调用「next」:

  • 如果异步操作顺利完成,则执行「next」时的第一个参数为null,第二参数为执行结果;
  • 如果出现异常,则执行「next」时的第一个参数为异常信息。

「async.series」的第二个参数则是这些步骤全部执行完成后的回调函数。其中:

  • 第一个参数是异常信息,不为null时表示发生异常;
  • 第二个参数是由执行结果汇总而成的数组,顺序与步骤的顺序相对应。

async.waterfall 」是另一个用得更多的异步串行方法,它与「async.series」的区别是,直接把上一步的结果传给下一步,而不是汇总到最后的回调函数。例如:

// 简化代码,把ajax的调用封装为函数 function loadFile(path, next) {  return $.ajax(path, {   success: function(resultA) {    next(null, resultA);   },   error: function(err) {    next( new Error(err.statusText) );   }  }); }  // 假设a.txt的内容为下一个要加载的文件的路径 async.waterfall([  function(next) {   loadFile('a.txt', next);  },  function(resultA, next) {   console.log(resultA); // 'b.txt'   loadFile(resultA, next);  } ], function(err, result) {  if (err) {   console.error(error);  } else {   console.log(result); // b.txt的内容  } });

而执行异步并行任务的方法则是「 async.parallel 」,用法与「async.series」类似,这里就不再详细说明了。

那先串行后并行又会是怎样的呢?

// 假设a.txt的内容是要下一步要并行加载的两个文件的路径 async.waterfall([  function(next) {   loadFile('a.txt', next);  },  function(resultA, next) {   console.log(resultA); // 'b.txt|c.txt'   resultA = resultA.split('|');   async.parallel([    function(next) { loadFile(resultA[0], next) },    function(next) { loadFile(resultA[1], next) }   ], next);  } ], function(err, results) {  if (err) {   console.error( new Error(err) );  } else {   console.dir(results);  } });

在先串行后并行的代码中,「async.parallel」嵌套在了「async.waterfall」的代码中。这样一来,如果串行和并行多穿插几次,会不会又出现回调金字塔的情况呢?答案是不会的,因为无论是增加一个串行步骤还是并行步骤,都可以直接加到「async.waterfall」的步骤数组中,也就是说最多只会嵌套一次。

Asycn库的优点是 符合Node.js的异步编程模式 (回调函数的第一个参数是异常信息,Node.js自带的异步接口都这样)。然而它的缺点也正是如此,回调函数中有一个异常信息参数,还占据了第一位,实在是太不方便了。

Promise

Promise是ES6标准的一部分,它提供了一种新的异步编程模式。但是ES6定稿比较晚,且旧的浏览器无法支持新的标准,因而有一些第三方的实现(比如 Bluebird )。顺带一提,Node.js 4.0+已经原生支持Promise。

那Promise究竟是什么玩意呢?Promise代表异步操作的 最终结果 ,跟Promise交互的主要方式是通过它的「 then 」方法注册回调函数去接收最终结果或者是不能完成的原因(异常)。

使用Promise首先要把异步操作Promise化:

function ajaxPromisify(url) {  return new Promise(function(resolve, reject) {   $.ajax({    url: url,    success: function(result) {     resolve(result);    },    error: function(err) {     reject( new Error(err.statusText) );    }   });  }); }

具体来说,就是创建一个Promise对象,创建时需要传入一个函数,这个函数有两个参数「 resolve 」和「 reject 」。操作成功时调用「resolve」,出现异常时调用「reject」。而想要获得异步操作的结果,正如前面提到的,需要调用Promise对象的「then」方法:

var promiseA = ajaxPromisify('a.txt'); promiseA.then(function(result) {  console.log(result); }, function(err) {  console.error(err); });

「then」方法有两个参数:

  • 第一个参数是操作成功时的回调;
  • 第二个参数是出现异常时的回调。

要注意的是, 创建Promise对象时传入的函数只会执行一次 ,即使多次调用了then方法,该函数也不会重复执行。这样一来,一个Promise实际上还缓存了异步操作的结果。

下面看一下基于Promise的异步串行是怎样的:

ajaxPromisify('a.txt').then(function(resultA) {  console.log(resultA);  return ajaxPromisify('b.txt'); }).then(function(resultB) {  console.log(resultB); }).catch(function(e) {  console.error(e); });

如果then的回调函数返回的是一个Promise对象,那么下一个「then」的回调函数就会在这个Promise对象完成之后再执行。所以多个步骤只需要通过「then」链式调用即可。此外,这段代码的「then」只有一个参数,而异常则由「 catch 」方法统一处理。

接下来看一下异步并行,需要用到Promise.all这个方法:

Promise.all([  ajaxPromisify('a.txt'),  ajaxPromisify('b.txt') ]).then(function(results) {  console.dir(results); }, function(err) {  console.error(err); });

最后是先串行后并行:

ajaxPromisify('a.txt').then(function(resultA) {  console.log(resultA); // 'b.txt|c.txt'  resultA = resultA.split('|');  return Promise.all([   ajaxPromisify(resultA[0]),   ajaxPromisify(resultA[1])  ]); }).then(function(results) {  console.dir(results); }).catch(function(e) {  console.error(e); });

可见,基于Promise的异步代码比Async库的要简洁,而且通过「then」的链式调用可以很好地控制执行顺序。但是由于现有的异步接口都不是基于Promise写的,所以要进行 二次封装

顺带一提,其实jQuery的「$.ajax」方法返回的就是一个不完全的Promise(没有实现Promise的所有接口):

$.ajax('a.txt').then(function(resultA) {  console.log(resultA);  return $.ajax('b.txt'); }).then(function(resultB) {  console.log(resultB); });

Generator Function

Generator Function,中文译名为 生成器函数 ,是ES6中的新特性。这种函数通过「 function * 」进行声明,函数内部可以通过「 yield 」关键字 暂停函数执行

这是一个生成器函数的例子:

function* Gen() {  console.log('begin');  var value = yield 'a';  console.log(value); // 'B'  return 'end'; }  var gen = Gen(); console.log(typeof gen); // 'object' var g1 = gen.next(); g1.value; // 'a' g1.done; // false var g2 = gen.next('B'); g2.value; // 'end' g2.done; // true

如果是普通的函数,执行「Gen()」后就会返回「end」,但生成器函数并不是这样。执行「Gen()」后,实际上是创建了一个生成器函数对象,此时函数内的代码不会执行。而调用这个对象(gen)的「next」方法时,函数开始执行,直到「yield」暂停。「next」方法的返回值则是一个对象,它有两个属性:

  • value: yield关键字后面的值 (如果为表达式,则为表达式的计算结果);
  • done:函数是否执行完毕。

第二次执行gen.next时,传入了一个参数值「B」。 「next」方法的参数值即为当前暂停函数的「yield」的返回值 ,所以函数内部value的值为「B」。然后函数继续执行,返回「end」。所以「g2.value」为的值「end」,此时函数执行完毕,「g2.done」的值为「true」。

那到底这玩意对异步编程有何助益呢?且看这段代码:

function* AJAXGen(url) {  try {   var result = yield ajaxPromisify(url);   console.log(result);  } catch (e) {   console.log('error');  } }  var gen = AJAXGen('a.txt'), promise = gen.next().value; promise.then(function(result) {  gen.next(result); }, function(err) {  gen.throw(err); });

其执行过程大概是:执行异步操作后就暂停了「AJAXGen」的执行,异步操作完成后通过「gen.next」把「result」回传到「AJAXGen」中;如果出现异常,就通过「gen.throw」抛出以便在「AJAXGen」里面捕获。

但是这样绕来绕去又有什么好处呢?仔细观察可以发现,「AJAXGen」函数内部的虽然执行的是异步操作,但完全就是同步的写法(连异常捕获都是),没有任何的回调函数。进一步思考,如果能把后面的细节封装起来,那真的就可以用同步的方式写异步的代码了。而后面的细节部分也是有规律可循的,封装起来并不是难事(只是有点绕):

function asyncByGen(Gen) {  var gen = Gen();   function nextStep(g) {   if (g.done) { return; }    if (g.value instanceof Promise) {    g.value.then(function(result) {     nextStep( gen.next(result) );    }, function(err) {     gen.throw(err);    });   } else {    nextStep( gen.next(g.value) );   }  }   nextStep( gen.next() ); }

借助这个函数,异步编程可以前所未有地简单:

// 异步串行 asyncByGen(function *() {  try {   console.log( yield ajaxPromisify('a.txt') );   console.log( yield ajaxPromisify('b.txt') );  } catch (e) {   console.error(e);  } });  // 异步并行 asyncByGen(function *() {  try {   console.dir( yield Promise.all([    ajaxPromisify('a.txt'),    ajaxPromisify('b.txt')   ]) );  } catch (e) {   console.error(e);  } });  // 先串行后并行 asyncByGen(function*() {  try {   var resultA = yield ajaxPromisify('a.txt');   console.log(resultA); // 'b.txt|c.txt'   resultA = resultA.split('|');   var results = yield Promise.all([    ajaxPromisify(resultA[0]),    ajaxPromisify(resultA[1])   ]);   console.dir(results);  } catch (e) {   console.error(e);  } });

生成器函数是一种比较新的特性,虽然Node.js 4.0+已经原生支持,但在旧版本浏览器上肯定无法运行。因此如果要在浏览器端使用还得通过编译器(如babel)编译成ES5的代码,这也是这种解决方案的最大缺点。

最后提一下 「co」库 。这个库的功能类似于「asyncByGen」,但它封装得更好,功能也更多,是用生成器函数写异步代码必不可少的利器。

正文到此结束
Loading...