JavaScript异步Generator

作者: 张歆琳 | 来源:发表于2016-08-12 11:21 被阅读486次

    上一篇介绍了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有了更深入的了解。如果看过本篇觉得还行,推荐购买该书,比我写的好多了。

    相关文章

      网友评论

        本文标题:JavaScript异步Generator

        本文链接:https://www.haomeiwen.com/subject/rfnssttx.html