JavaScript异步Generator

12 Aug

上一篇介绍了Promise异步编程,可以很好地回避回调地狱。但Promise的问题是,不管什么样的异步操作,被Promise一包装,看上去都是一堆then,语义方面还不够清晰。因此更好的异步编程解决方案是ES6的Generator。你可以从GitHub上获取本篇代码。

  • 基本概念和语法
  • next方法
  • return方法
  • throw方法
  • 嵌套Generator
  • 作为对象属性
  • this
  • 例子

基本概念和语法

Promise只是一种代码组织结构,但Generator具有全新的语法,参照MDN

function* gen() { 
    yield 1;
    yield 2;
    yield 3;
}
var g = gen();

console.log(g);         // Generator {}
console.log(g.next()); 	// { value: 1, done: false }
console.log(g.next()); 	// { value: 2, done: false }
console.log(g.next()); 	// { value: 3, done: true }
console.log(g.next()); 	// { value: undefined, done: true }

上面是一个最简单的Generator函数,和普通函数有两个区别:

首先是函数名前需要有个*星号,JS解释器读到function关键字后面接*星号就知道它是一个Generator函数了。

其次函数体内有yield关键字,有点像return关键字,都会返回后面的表达式的值。区别是return了函数就终止了,但yield表示函数暂时运行到这里,稍后继续运行。究其本质,yield是用来定义函数的内部状态的,调用Generator函数后,会返回得到一个遍历器对象,可以依次遍历Generator函数的内部状态。(Generator在英语中是生成器的意思,意味着将异步操作打包生成一个生成器)

注意上面说的,调用Generator只会得到一个遍历器对象,仅此而已。并不会执行Generator函数。上例中var g = gen();,变量g是一个遍历器对象,即一个指向内部状态的指针对象,用于之后遍历yield定义的内部状态。

有了遍历器对象g之后,就可以使用next方法使指针依次移向下一个状态,即让函数从开头或上一次暂停的地方开始执行,执行到下一个yield或return语句为止。虽然yeild和next本质上是遍历器对象和操作指针,但你使用时可以将它们简单地理解为:

Generator是分段执行的函数。yeild是暂停的标记。next用于继续执行。

看上面执行的结果:

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

next方法返回的是一个对象,有两个属性,分别是value和done。

value属性就是内部状态的值,即yield关键字后面的表达式结果。yield相当于return,都会返回后面的表达式结果。如果yield后面没有语句,那会和return一样默认返回undefined。它俩的区别是,return返回后函数调用就结束了,但yield返回后,只是暂停函数执行,等待下一次调用next方法来恢复执行剩余的函数代码。一个函数里,只能执行一次return,但可以执行多次yield。

done属性会检查遍历器对象指针是否已经指到最后,即是否已经遍历结束,结束了为true,尚未结束为false。如果遍历结束,done为true后,再执行next的话(如上面最后一行),value会得到undefined,done保持不变。对照着上面的例子代码,很容易理解。

上面说了,调用Generator只会得到一个遍历器对象,这就提供了两个特性:惰性求值,自动遍历。

惰性求值

function* add(n1, n2) {
    yield  n1 + n2;
}
var a = add(3, 4);
console.log(a.next());  // { value: 7, done: false }

如果是普通函数,执行var a = add(3, 4);语句后,变量a会赋值为7。但此处变量a只是一个遍历器对象,不会执行函数。直到调用next方法将指针移到yield语句处时才会去求值。

利用这个特性,你可以在Generator函数里不定义yield,来实现一个单纯的暂缓执行函数:

function* f() {console.log('执行了')}

var generator = f();
setTimeout(function () {
    generator.next();
}, 2000);

上例中,如果函数f是一个普通函数,变量generator赋值时就会执行。但是,将函数f写成Generator函数后,只有在显示地调用next方法后才会执行。

自动遍历

遍历器对象意味着,Generator函数的返回值可以被实现了遍历器接口的各种方法调用,例如for…of,Array.form,扩展运算符(…),解构赋值等。例如:

function* numbers () {
    yield 1;
    yield 2;
    return 3;
    yield 4;
}

//for...of
var gen1 = numbers();
for (let n of gen1) {
    console.log(n);    //1  2
}

//Array.from
console.log(Array.from(numbers()));  // [1, 2]

//扩展运算符(…)
console.log([...numbers()]);    // [1, 2]

//解构赋值
let [x, y] = numbers();
console.log(x);   //1
console.log(y);   //2

上面各方法都可以自动遍历Generator函数生成的遍历器对象,就不用你显示地写next语句了。隐式自动调用next方法,遍历到done为true为止结束。因此上例中numbers函数里的3和4不会被打印出来。

自动遍历的特性很重要,因为通常现实中我们不太显示地调用next方法,而是靠for…of来迭代它进行异步处理。

next方法

Generator.prototype.next()方法用于恢复执行。函数声明:gen.next(value)。参照MDN

返回值是包含value和done属性的对象,上面有介绍,不赘述。

上面介绍的next方法均没有参数,其实可以为它提供一个参数。参数会被当作上一个yield语句的返回值。如果没有参数,那默认上一个yield语句的返回值为undefined。

该参数意义重大。普通函数执行期间上下文是不变的,Generator函数也不例外,因此从暂停到下一次恢复都具有相同上下文。现在通过给next方法设参数,可以给Generator函数的上下文注入值。即,允许程序员在Generator函数运行的不同阶段,从外部向内部注入不同的值,来调整函数行为。例如:

function* myAdd(n) {
    var n1 = yield (n + 1);
    var n2 = yield (n1 * 2);
    return n1 + n2;
}

var a1 = myAdd(5);
console.log(a1.next());    // Object{value:6, done:false}
console.log(a1.next());    // Object{value:NaN, done:false}
console.log(a1.next());    // Object{value:NaN, done:true}

var a2 = myAdd(5);
console.log(a2.next());    // Object{value:6, done:false}
console.log(a2.next(10));  // Object{value:20, done:false}
console.log(a2.next(50));  // Object{value:60, done:true}

第一次调用next,n为5,所以结果value均为6。第二次调用next时,a1和a2的差异如下:

a1时,由于next方法没有参数,默认上一次yield的表达式值为undefined,即n1为undefined,导致n2为undefined * 2 = NaN。同理下一次再调用无参的next方法,n1和n2均为undefined,导致return了NaN。

a2时,由于next方法参数为10,表示上一次yield的表达式值为10,即n1为10,所以n2为10 * 2 = 20。同理下一次调用next(50),导致n2被改为50,所以return 10 + 50 = 60。

从语义上讲,next的参数表示上一次yield的表达式值,因此第一次调用next方法时,传递参数是没有意义的。如果第一次next方法带上参数,浏览器会直接无视该参数。

return方法

Generator.prototype.return()方法用于立即结束遍历,并返回给定的值。函数声明:gen.return(value)。参照MDN

Generator函数的返回值是遍历器对象,但你可以用return方法指定返回的值。参数就是返回值的value属性。用return方法后,done属性会被设为true,所以会立即终结遍历Generator函数。例如:

function* gen() {
    yield 1;
    yield 2;
    yield 3;
}

var g = gen();
console.log(g.next());         // { value: 1, done: false }
console.log(g.return('foo'));  // { value: "foo", done: true }
console.log(g.next());         // { value: undefined, done: true }

注意两个小细节:如果遍历尚未结束,即done为false的情况下,调用无参的return(),会将value设为undefined。如果已经遍历结束,即done已经为true的情况下,调用return(value)是没有意义的,参数也不会生效,value会固定为undefined。

throw方法

Generator.prototype.throw()方法用于抛出错误,然后在Generator函数体内捕获。函数声明:gen.throw(exception)。参照MDN

返回值是带有value和done属性的对象。参数是异常信息。例如:

function* gen() {
    try {
        yield;
    } catch (e) {
        console.log('内部捕获', e);
    }
    yield console.log('end');
};

try {
    var g = gen();
    g.next();
    
    if(true) {
        g.throw('a');
    }
    g.throw('b');
} catch (e) { 
    console.log('外部捕获', e);
}
// 内部捕获 a
// end
// 外部捕获 b

第一次throw被Generator函数内的catch语句捕获。需要注意的是,Generator函数内catch到异常后,JS认为异常已经被处理了,Generator函数仍旧会继续运行到yield为止,所以打印出end。由于Generator函数内的catch语句已经执行过了,第二次throw将没机会再次执行catch语句,于是错误就被抛到了Generator函数外,被外部catch语句捕获。

注意本节说的throw,本质上是Generator.prototype.throw()方法,需要由遍历器对象来调用。如果没用遍历器对象调用的话,是JS原生的throw,只会被Generator函数体外的catch捕捉。

例如上例中的g.throw(‘a’);,错写成throw(‘a’);的话,结果会打印出一行“外部捕获a”。后面的g.throw(‘b’);语句将没有机会被执行到。

还有一种情况,虽然写了g.throw(‘a’);,但Generator函数内部没有try…catch语句,那抛出的错误会被Generator函数体外的catch捕捉。与上面在内部被catch到的不同之处在于:在Generator函数内被catch到的话,JS认为异常已经被处理了,会继续运行到yield为止。但如果在Generator函数内未被catch到的话,JS认为函数出现故障,后续代码将不会继续执行下去了,如果继续调用next方法会得到value为undefined,done为true的对象。

例如上例中gen函数内将try…catch语句去掉,结果只会打印出一行“外部捕获a”。 后面的g.throw(‘b’);语句将没有机会被执行到。

那如果Generator函数内部和外部均没有try…catch语句呢?那throw出的错误将一直冒泡,直到浏览器报错。

在回调函数实现异步操作里,要捕捉错误,你不得不给每个函数内部都写一个错误处理语句。用Promise的话,让多个then进行异步操作,最后用一个catch来捕捉所有错误。用Generator的话,可以用一个try…catch把多个yield语句包起来,简化了错误处理语句。

嵌套Generator

在Generater函数内部,调用另一个Generator函数的话,需要用yield*,即yield指针来实现,否则的话是没有效果的。例如:

function* foo() {
    yield 'a';
    yield 'b';
}
function* bar() {
    yield '1';
    foo();      //没有用yield*
    yield '2';
}
for (let v of bar()){
    console.log(v);
}
//1
//2

上面代码本意是想在bar内部调用另一个Generator函数foo,但由于没有用yield*,所以压根没效果。原因很简单,foo();返回的是一个遍历器对象,然后呢?就没然后了,所以没效果。

我们真正想要的是,让外层bar返回的遍历器对象,内部指针指向foo返回的遍历器对象,这样才能实现遍历。因此需要加上yield*,如下:

function* foo() {
    yield 'a';
    yield 'b';
}
function* bar() {
    yield '1';
    yield* foo();    //加上yield*,让指针指向内部Generator函数返回的遍历器对象
    yield '2';
}
for (let v of bar()){
    console.log(v);
}
//1
//a
//b
//2

yield*其实是个广义的概念,不是非要指向Generator函数的返回值,指向任意遍历器对象均可。例如指向数组:

function* gen(){
    yield* ["a", "b", "c"];
}
console.log(gen().next());    // { value:"a", done:false }

上面代码中,如果yield后面没有星号,得到的value是整个数组,加了星号就表示返回的是数组的遍历器对象。

作为对象属性

普通函数可以作为对象属性,Generator函数也不例外:

let obj = {
    gen: function* () { 
        yield 1; 
    }
};
let g = obj.gen();
console.log(g.next());    // { value:1, done:false }

你也可以简写成下面这种形式,效果是一样的:

let obj = {
    * gen() { 
        yield 1; 
    }
};

this

Generator函数返回的不是this对象,而是遍历器对象,内含指向内部状态的指针。因此它不是构造函数,不能用new来调用:

function* gen() {  
    yield this.a = 1;
}
let g = new gen();  //TypeError: gen is not a constructor

同理,由于返回的不是this对象,在Generator函数内部的this其实指向window。因此不建议在Generator函数内部使用this:

function* gen() {  
    yield this.a = 1;
}

let g = gen();
console.log(g.next());  //{value=1,  done=false}
console.log(g.a);       //undefined
console.log(window.a);  //1

上例中this指向的是全局对象window,结果给window添加了一个属性a。

总之,Generator函数的本意是用于流程控制,返回的是指向各流程步骤的指针。它并不是构造函数,不要试图像普通函数一样使用this

当然我经验尚浅,见识少。可能确实有需要用到this对象的场景,该怎么处理呢?可以像上面介绍的next,return,throw方法一样,在prototype上使用this:

function* gen() {}
gen.prototype.add1 = function () {
    if (typeof this.a === "undefined") {
        this.a = 1;
    } else {
        this.a++;
    }
};

let g = gen();
console.log(g.a);       //undefined
g.add1();
console.log(g.a);       //1
g.add1();
console.log(g.a);       //2
console.log(window.a);  //undefined

如果就是要在Generator函数内部使用this,且有期待的效果呢?似乎没什么好办法,只有一个变通的方法。先生成一个空对象,使用bind方法绑定Generator函数内部的this。这样,这个空对象就成Generator函数的实例对象了:

function* gen() {
    this.a = 1;
    yield this.b = 2;
    yield this.c = 3;
}
var obj = {};
var g = gen.call(obj);
g.next();
g.next();
g.next();

console.log(obj.a);      //1
console.log(obj.b);      //2
console.log(obj.c);      //3

console.log(g.a);       //undefined
console.log(g.b);       //undefined
console.log(g.c);       //undefined
console.log(window.a);  //undefined
console.log(window.b);  //undefined
console.log(window.c);  //undefined

上面代码可以看出,用call将空对象绑定到Generator函数内部的this上后,通过this添加的属性都被添加到了空对象obj上。之后可以通过obj正确取到属性值。

Generator函数的返回值本身,即遍历器对象g,仍旧没有属性。包括原本this应该指向的window也同样没有属性。

例子

用Generator可以用同步的组织代码的方式,写出比Promise更清晰的异步操作代码。例如Promise一文中最后举的例子,我们要做两次异步操作(用最简单的异步操作setTimeout为例),第一次异步操作成功后执行第二次异步操作:

function delay(time, callback){
    setTimeout(function(){
        callback("sleep "+time);
    },time);
}   

delay(1000,function(msg){
    console.log(msg);
    delay(2000,function(msg){
        console.log(msg);
    });
});
//1秒后打印出:sleep 1000
//再过2秒打印出:sleep 2000

上面用回调嵌套很容易实现,但也发现才两层(还没加上异常处理),代码就比较难看了。改成Promise后,虽然异步操作扁平化了,但一眼看过去都是then。

使用Generator,我们可以在异步处理时,暂停函数运行,等异步处理完成,再恢复函数运行。这样就能用同步化的方式组织代码。代码流程看起来更加清晰。

用Generator函数写出来的同步化的代码,应该是这样的(为简单起见delay的回调函数参数暂时为空):

function* delayedMsg (){
    console.log(delay(1000,function(){}));
    console.log(delay(2000,function(){}));
}

上面这个还不完整,需要加上yield,实现进行异步操作时,暂停函数运行:

function* delayedMsg () { 
    console.log(yield delay(1000, function(){})); 
    console.log(yield delay(2000, function(){}));
}

上面说了Generator是惰性函数,我们需要给它一个原动力,推它一把,让它开始运行。通常例子里都会将Generator函数包装在名叫run或execute函数里,就是为了给它一个原动力,让懒汉开始工作:

function run(genFunc) { 
    var g = genFunc();
    g.next();
} 

run(function* delayedMsg () { 
    console.log(yield delay(1000, function(){})); 
    console.log(yield delay(2000, function(){}));
});

光给原动力还不够,还需要Generator函数内每个异步操作后调用next方法,执行下一步。由于next方法需要遍历器对象才能调用,因此定义到run方法内部,新建一个名叫resume的函数:

function run(genFunc) { 
    var g = genFunc(); 
    function resume(value) { 
        g.next(value);
    } 
    g.next();
}

resume函数的参数是上一次异步执行的结果,传递给next方法。resume函数就像一个推进器,推进着Generator函数前进。现在需要将resume和定义的Generator函数关联起来,完整代码如下:

function delay(time, callback){
    setTimeout(function(){
        callback("sleep "+time);
    },time);
}   

function run(genFunc) { 
    var g = genFunc(resume); 
    function resume(value) { 
        g.next(value);
    } 
    g.next();
} 

run(function* delayedMsg(resume) { 
    console.log(yield delay(1000, resume)); 
    console.log(yield delay(2000, resume));
});
//1秒后打印出:sleep 1000
//再过2秒打印出:sleep 2000

是不是有点复杂?总结一下:

首先,创建一个run函数,参数是自定义Gerenator函数。run函数内调用next方法提供原始推动力,让Generator函数运作起来。

其次,run函数内部创建一个resume函数,用于恢复yield暂停。参数为上一次异步操作的结果,传递给next方法。

最后,自定义Generator函数内,为每一个异步操作加上yield关键字和resume作为回调函数。这样异步操作完成后会调用resume,让Generator函数再推进一步。

通常run(有的库起名execute)和resume方法定义好后就一劳永逸,不用变了。如果上述过程实在搞不清,也不影响我们开发。因为我们只需根据业务需求,自定义Generator函数(即上面的delayedMsg),异步操作前加上yield,异步操作的回调函数设为resume,异步操作的结果传给resume做参数,就行了。

一个node.js用Generator读取多个文件的例子:

var fs = require('fs');

function run(gen) {
    var gen_obj = gen(resume);
    function resume() {
        var err = arguments[0];
        if (err && err instanceof Error) {
            return gen_obj.throw(err);
        }
        var data = arguments[1];
        gen_obj.next(data);
    }
    gen_obj.next();
}

run(function* gen(resume) {
    var ret;
    try {
        ret = yield fs.readFile('./apples.txt','utf8', resume);
        console.log(ret);
        ret = yield fs.readFile('./oranges.txt','utf8', resume);
        console.log(ret);
    } catch (e) {
        console.log(e);
    } finally {
        console.log('finally');
    }
});

总结

感谢阮一峰写的《ES6标准入门》一书,让我对Generator有了更深入的了解。如果看过本篇觉得还行,推荐购买该书,比我写的好多了。

Leave a Reply

Your email address will not be published. Required fields are marked *