掌握ECMAScript异步编程
发布在JS本子2017年4月12日view:1395HTML5前端开发ES6BrettBatPromise前端工程web
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

异步编程对于JavaScript的重要程度,从ES6,ES7一直在为解决异步问题而推出的各种新特性上可见一斑。ES6已经原生支持Promise,并引入了Generator函数。ES7又已经确定支持async/await。

看来如今在用JavaScript做异步编程的时候,回调函数已经变成了一个落伍的选择。为了能紧跟时代步伐,下面就来把这些与现代异步编程相关的知识点,一个个都了解清楚吧。

关键字

  • Promise
  • Generator函数
  • Thunk函数
  • Generator函数配合Thunk函数
  • Generator函数配合Promise
  • async/await

Promise

说到解决异步回调地狱的问题,很多人第一时间就会想到Promise。ES6已经原生支持Promise对象。在此之前也有许多库实现了Promise/A+规范,如bluebirdQ … 等。

我认为它的用法可以简单理解为把原本层层嵌套的回调函数铺开,变成排排站的链式结构。如:

// 使用回调
fs.readFile('a.txt', function() {
  fs.readFile('b.txt', function() {
    fs.readFile('c.txt', function() {
      console.log('done!');
    });
  })
});

// 使用Promise
readFilePromise('a.txt').then(function(res, rej) {
  return readFilePromise('b.txt');
}).then(function() {
  return readFilePromise('b.txt');
}).then(function() {
  console.log('done');
});

好处是可以将横向发展的代码变成纵向,一定程度上让代码可读性更强。但是缺点是有较多的代码冗余。

使用方法

创造一个Promise对象:

var promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

实例方法

  • then(…)
  • catch(…)

then()

then方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为Resolved时调用,第二个回调函数是Promise对象的状态变为Reject时调用。其中,第二个函数是可选的,不一定要提供。这两个函数都接受Promise对象传出的值作为参数。

promise.then(function(value) {
  // success
}, function(error) {
  // failure
});

catch()

Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch语句捕获。

getJSON('/post/1.json').then(function(post) {
  return getJSON(post.commentURL);
}).then(function(comments) {
  // some code
}).catch(function(error) {
  // 处理前面三个Promise产生的错误
});

上面代码中,一共有三个Promise对象:一个由getJSON产生,两个由then产生。它们之中任何一个抛出的错误,都会被最后一个catch捕获。

一般来说,不要在then方法里面定义Reject状态的回调函数(即then的第二个参数),总是使用catch方法。

静态方法

Generator 函数

Generator 函数,是一种特殊的函数,可以暂停执行。函数名之前要加星号,以示区别。函数内需要暂停的地方,使用 yield 语句注明。一个定义简单Generator函数的例子如下:

function* gen(x){
 console.log('1');
 var y = yield x + 2;
 console.log('2');
 return y;
}

Generator函数需要靠next方法进行推动执行。当执行第一次next方法时,函数才会开始执行,并在第一个yield关键字位置返回数据后暂停。

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

可以看到,调用next方法后,会从上次暂停的地方开始执行,直到下一个yield关键字后的表达式返回后。而next方法会返回一个object,有两项参数,value代表这次yield关键字所返回的值,done代表当前函数是否执行完成。 yield有点像是return关键字,因为它们都返回一个值,但是函数在yield之后会进入暂停状态。

当你调用一个generator时,它将返回一个迭代器对象。这个迭代器对象拥有一个叫做next的方法来帮助你重启generator函数并得到下一个值。

下面是一个无限计数器的例子:

function* ticketGenerator(){
    for(var i=0; true; i++){
        yield i;
    }
}   
var takeANumber = ticketGenerator();   
console.log(takeANumber.next().value); //0   
console.log(takeANumber.next().value); //1   

yield关键字

yield语句后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为JavaScript提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

function* gen() {
  yield  123 + 456;
}

上面代码中,yield后面的表达式123 + 456,不会立即求值,只会在next方法将指针移到这一句时,才会求值。

另外需要注意,yield语句只能用在 Generator 函数里面,用在其他地方都会报错

yield与return的区别

yield语句与return语句既有相似之处,也有区别。

相似之处在于: 都能返回紧跟在语句后面的那个表达式的值。

区别在于: 每次遇到yield,函数暂停执行。一个函数里面可以执行多个yield语句。

Generator函数可以返回一系列的值,因为可以有任意多个yield。 从另一个角度看,也可以说Generator生成了一系列的值,这也就是它的名称的来历(在英语中,generator这个词是“生成器”的意思)。

next方法

遍历器对象的next方法的运行逻辑如下:

(1)遇到yield语句,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。

(2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield语句。

(3)如果没有再遇到新的yield语句,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。

(4)如果该函数没有return语句,则返回的对象的value属性值为undefined。

参数

yield句本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield语句的返回值。

function* f() {
  for(var i = 0; true; i++) {
    var reset = yield i;
    if(reset) { i = -1; }
  }
}

var g = f();

g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }

上面代码先定义了一个可以无限运行的 Generator 函数f,如果next方法没有参数,每次运行到yield语句,变量reset的值总是undefined。当next方法带一个参数true时,变量reset就被重置为这个参数(即true),因此i会等于-1,下一轮循环就会从-1开始递增。

这个功能有很重要的语法意义。Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

Thunk 函数

Thunk函数的概念早在上个世纪60年代就诞生了,这里就不说别的,只谈谈Thunk函数在JavaScript下异步编程的相关知识点。

JavaScript下的Thunk函数

在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。 文字解释起来确实很奇怪,代码一看就明白了:

// 一个接收回调函数的普通函数 的调用
fs.readFile('fileName', callbackFn);

// 转为Thunk函数后的调用
readFileThunk('fileName')(callbackFn);

转换方法

把一个函数转成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);
    }
  };
};

更可靠一些,可以使用一些第三方库,如Thunkify 模块。原理上与上文的简单转换器相同,多了限制回调函数只能调用一次的机制。

Generator函数与Thunk函数的配合

说了这么多Thunk函数的相关内容,但是这东西能用来干嘛呢?没错,就是可以与Generator函数配合。

前面说了Generator函数内可以通过yield关键字暂停执行,调用next方法继续执行。然而没有一个机制可以自动控制Generator函数的暂停和执行的话,这些功能好像就没有什么猫用了。

因为就算当个异步操作完成后,并没有什么办法能让这个函数自动继续下去。而在外部手动执行next的话,并不知道这个异步操作是否执行完成。

这时如果用到Thunk函数的话就完美解决了,可以通过yield返回的value值调用回调函数,在回调函数里调用next方法。

下面就是通过使用Generator函数配合Thunk函数,完成异步操作的示例:

// 包含异步操作的代码
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());
};

// 执行
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);
  });
});

虽然这里编写异步的代码已经几乎像同步代码一样清晰了,但是负责执行的代码似乎还是有一些乱。我们可以把负责执行的部分封装成一个函数,将Generator函数传入进去,就让他自动执行。

封装一个run方法:

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

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

  next();
}

再来重新写一下刚才的代码,就清晰多啦!

// 程序逻辑部分
var gen = function* (){
  var r1 = yield readFile('/etc/fstab');
  console.log(r1.toString());
  var r2 = yield readFile('/etc/shells');
  console.log(r2.toString());
};
// 执行程序
run(gen);

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

Generator函数与Promise的配合

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

先把一步的方法封装成一个Promise对象

var fs = require('fs');

var readFile = function (fileName){
  return new Promise(function (resolve, reject){
    fs.readFile(fileName, function(error, data){
      if (error) reject(error);
      resolve(data);
    });
  });
};

var gen = function* (){
  var f1 = yield readFile('a.txt');
  var f2 = yield readFile('b.txt');
  console.log(f1.toString());
  console.log(f2.toString());
};

然后,手动执行上面的 Generator 函数。

var g = gen();

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

手动执行其实就是用 then 方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。

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

  function next(data){
    var result = g.next(data);
    if (result.done) return result.value;
    result.value.then(function(data){
      next(data);
    });
  }

  next();
}

run(gen);

上面代码中,只要 Generator 函数还没执行到最后一步,next 函数就调用自身,以此实现自动执行。

co函数库

co 就是使用Promise与Generator的配合,与上面的方法类似,它的源码也比较简单。

co的并发操作

co 支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。只需要把并发的操作都放在数组或对象里面。

// 数组的写法
co(function* () {
  var res = yield [
    Promise.resolve(1),
    Promise.resolve(2)
  ];
  console.log(res); 
}).catch(onerror);

// 对象的写法
co(function* () {
  var res = yield {
    1: Promise.resolve(1),
    2: Promise.resolve(2),
  };
  console.log(res); 
}).catch(onerror);

async/await

目前最优雅的方式

看了前面说的几种异步编程解决方案,虽然在原始的方案上有所进步。但不管哪种方式,总归还是觉得有些多余的东西。而在ES7下的新特效async/await应该算是目前最优雅的方案了,看看下面的异步代码你就明白了。

async function runReadFile() {
  var f1 = await readFilePromise('a.txt');
  console.log(f1);
  var f2 = await readFilePromise('b.txt');
  console.log(f2);
}
runReadFile();

代码确实简洁不少吧!不过怎么感觉和用Generator函数这么像呢,而且还是需要用到Promise?

确实,实际上async 函数就是 Generator 函数的语法糖。而它的好处在于:

  1. 自动执行。不需要再去编写执行的函数,也不需要再去导入co等函数库。你只要运行它,它就会自动执行。
  2. *有更好的语义性。*async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。

基本用法

规则

  • async 表示这是一个async函数,await只能用在这个函数里面。
  • await 表示在这里等待promise返回结果了,再继续执行。
  • await 后面跟着的应该是一个promise对象(也允许其他数据类型,然而没有什么意义)

await返回值

await等待的虽然是promise对象,但不必写.then(..),直接可以得到返回值。

var sleep = function (time) {
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            // resolve传递 'ok'
            resolve('ok');
        }, time);
    })
};

var start = async function () {
    let result = await sleep(3000);
    console.log(result); // 收到 'ok'
};

错误捕捉

既然then(...)不用写了,那么catch(...)也不用写,可以直接用标准的try catch语法捕捉错误。

var sleep = function (time) {
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            // 模拟出错了,reject返回 'error'
            reject('error');
        }, time);
    })
};

var start = async function () {
    try {
        console.log('start');
        await sleep(3000); // 这里得到了一个返回错误

        // 所以以下代码不会被执行了
        console.log('end');
    } catch (err) {
        console.log(err); // 这里捕捉到错误 'error'
    }
};

值得注意的是,和yield只能在Generator函数中一样,await也是只能存在与async函数的上下文中的。

(结束啦)

参考内容:

评论
发表评论
暂无评论
WRITTEN BY
PUBLISHED IN
JS本子

JS上遇到的问题,技巧和知识点

我的收藏