深入研究ES6 Generators

总结

  以上就是generator函数的基本概念。如果你仍然觉得有点难以理解,也不用太担心,任何人刚开始接触generator函数时都会有这种感觉!

  你应该会很自然地想到generator函数能在自己的代码中起到什么样的作用,尽管我们会在很多地方用到它。我们刚刚只是接触到了一些皮毛,还有很多需要了解的,所以我们必须深入研究,才能发现它是如此的强大。

  尝试在Chrome nightly/canary或FireFox nightly或node
0.11+(使用–harmony参数)环境中运行本文的示例代码,并思考下面的问题:

  1. 如何处理异常?
  2. 在一个generator函数中可以调用另一个generator函数吗?
  3. 如何在generator函数中进行异步编程?

  接下来的文章会解答上述问题,并继续深入探讨有关ES6
generator函数的内容,敬请关注!

Generators委托

  你可以在一个generator函数体内调用另一个generator函数,不是通过普通的方式实例化一个generator函数,实际上是将当前generator函数的迭代控制委托给另一个generator函数。我们通过关键字yield
*
来实现。看下面的代码:

function *foo() {
    yield 3;
    yield 4;
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo(); // yield *将当前函数的迭代控制委托给另一个generator函数foo()
    yield 5;
}

for (var v of bar()) {
    console.log( v );
}
// 1 2 3 4 5

  注意这里我们依然推荐yield *foo()这种写法,而不用yield*
foo()
,我在前一篇文章中也提到过这一点(推荐使用function
*foo(){}
而不用function*
foo(){}
)。事实上,在很多其它的文章和文档中也都采用了前者,这种写法会让你的代码看起来更清晰一些。

  我们来看一下上面代码的运行原理。在for..of循环遍历中,通过隐式调用next()方法将表达式yield
1
yield
2
的值返回,这一点我们在前一篇文章中已经分析过了。在关键字yield
*
的位置,程序实例化并将迭代控制委托给另一个generator函数foo()。一旦通过yield
*
将迭代控制从*bar()委托给*foo()(只是暂时性的),for..of循环将通过next()方法遍历foo(),因此表达式yield
3
yield
4
将对应的值返回给for..of循环。当对*foo()的遍历结束后,委托控制又重新回到之前的那个generator函数,所以表达式yield
5
返回了对应的值。

  上面的代码很简单,只是通过yield表达式输出值。当然,你完全可以不通过for..of循环而手动通过next(..)方法并传入相应的值来进行遍历,这些传入的值也会通过yield
*
关键字传递给对应的yield表达式中。看下面的例子:

function *foo() {
    var z = yield 3;
    var w = yield 4;
    console.log( "z: " + z + ", w: " + w );
}

function *bar() {
    var x = yield 1;
    var y = yield 2;
    yield *foo(); // `yield*` delegates iteration control to `foo()`
    var v = yield 5;
    console.log( "x: " + x + ", y: " + y + ", v: " + v );
}

var it = bar();

it.next();      // { value:1, done:false }
it.next( "X" ); // { value:2, done:false }
it.next( "Y" ); // { value:3, done:false }
it.next( "Z" ); // { value:4, done:false }
it.next( "W" ); // { value:5, done:false }
// z: Z, w: W

it.next( "V" ); // { value:undefined, done:true }
// x: X, y: Y, v: V

  虽然这里我们只展示了一级委托,但理论上可以有任意多级委托,就是说上例中的generator函数*foo()中还可以有yield
*
表达式,从而将控制进一步委托给另外的generator函数,一级一级传递下去。

  还有一点就是yield
*
表达式允许接收被委托的generator函数的return返回值。

function *foo() {
    yield 2;
    yield 3;
    return "foo"; // 字符串"foo"会被返回给yield *表达式
}

function *bar() {
    yield 1;
    var v = yield *foo();
    console.log( "v: " + v );
    yield 4;
}

var it = bar();

it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // "v: foo"   { value:4, done:false }
it.next(); // { value:undefined, done:true }

  看上面的代码,通过yield
*foo()
表达式,程序将控制委托给generator函数*foo(),当函数foo()执行完毕后,通过return语句将值(字符串”foo“)返回给yield
*
表达式,然后在bar()函数中,这个值最终被赋值给变量v

  Yieldyield
*
之间有个很有趣的区别:在yield表达式中,接收的值是由随后的next(..)方法传入的参数,但是在yield
*
表达式中,它接收的是被委托的generator函数中return语句返回的值(此时通过next(..)方法将值传入的过程是透明的)。

  你也可以在yield *委托中进行双向错误处理:

function *foo() {
    try {
        yield 2;
    }
    catch (err) {
        console.log( "foo caught: " + err );
    }

    yield; // 暂停

    // 抛出一个错误
    throw "Oops!";
}

function *bar() {
    yield 1;
    try {
        yield *foo();
    }
    catch (err) {
        console.log( "bar caught: " + err );
    }
}

var it = bar();

it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }

it.throw( "Uh oh!" ); // 将会被foo()中的try..catch捕获
// foo caught: Uh oh!

it.next(); // { value:undefined, done:true }  --> 注意这里不会出现错误!
// bar caught: Oops!

  在上面的代码中,throw(“Uh oh!”)方法抛出一个错误,该错误被yield
*
委托的generator函数*foo()中的try..catch所捕获。同样地,*
foo()
中的throw
“Oops!”
语句将错误抛回给*bar(),然后被*bar()中的try..catch捕获。如果错误没有被捕获到,则会继续向上抛出。

 

 

错误处理

  ES6
generators设计中最牛逼的部分之一就是generator函数内部的代码是同步的,即使在generator函数外部控制是异步进行的。

  也就是说,你可以使用任何你所熟悉的错误处理机制来简单地在generator函数中处理错误,例如使用try..catch机制。

  来看一个例子:

function *foo() {
    try {
        var x = yield 3;
        console.log( "x: " + x ); // 有可能永远也不会运行到这儿!
    }
    catch (err) {
        console.log( "Error: " + err );
    }
}

  尽管函数会在yield
3
表达式的位置暂停任意长的时间,但是如果有错误被发回generator函数,try..catch依然会捕获该错误!你可以尝试在异步回调中调用上面的代码。

  那么,如何才能将错误精准地发回给generator函数呢?

var it = foo();

var res = it.next(); // { value:3, done:false }

// 这里我们不调用next(..)方法,而直接抛出一个异常:
it.throw( "Oops!" ); // Error: Oops!

  这里我们使用了另一个方法throw(..),它会在generator函数暂停的位置抛出一个错误,然后try..catch语句会捕获这个错误!

  注意:如果你通过throw(..)方法向generator函数抛出一个错误,但是该generator函数中并没有try..catch语句来捕获该错误,那么这个错误会被传回来(如果这个错误没有被其它代码捕获,则会被当作一个未处理的异常向上抛出)。所以:

function *foo() { }

var it = foo();
try {
    it.throw( "Oops!" );
}
catch (err) {
    console.log( "Error: " + err ); // Error: Oops!
}

  显然,反方向的错误处理也是可行的,看下面的代码:

function *foo() {
    var x = yield 3;
    var y = x.toUpperCase(); // 可能会引发类型错误!
    yield y;
}

var it = foo();

it.next(); // { value:3, done:false }

try {
    it.next( 42 ); // 42没有toUpperCase()方法
}
catch (err) {
    console.log( err ); // toUpperCase()引发TypeError错误
}

 

语法

  是时候介绍一下generator函数的语法了:

function *foo() {
    // ..
}

  注意这里的*了吗?这是一个新引入的运算符,对于学习C语言系的同学而言,可能会想到函数指针。不过这里千万不要把它和指针的概念混淆了,*运算符在这里只是用来标识generator函数的类型。

  你可能在其它的文章或文档中看到这种写法function*
foo(){}
,而本文中我们使用这种写法function
*foo(){}
(区别仅仅是*的位置)。这两种写法都是正确的,不过我们推荐使用后者。

  我们来看看generator函数的内容。Generator函数在大多数方面就是普通的JS函数,因此我们需要学习的新语法不会很多。

  在generator函数体内部主要是yield关键字的应用,前面我们已经提到过它。注意这里的yield
___
被称之为yield表达式而不是语句,这是因为当我们重新启动generator函数时,我们会传入一个值,而不管这个值是什么,都会作为yield
___
表达式计算的结果。

  一个例子:

function *foo() {
    var x = 1 + (yield "foo");
    console.log(x);
}

  这里的yield
“foo”
表达式会在generator函数暂停时返回字符串“foo”,当下一次generator函数重新启动时,不管传入的值是什么,都会作为yield表达式计算的结果。这里会将表达式**1

  • 传入值的结果赋值给变量x**。

  从这个意义上来说,generator函数具有双向通信的功能。Generator函数暂停的时候返回了字符串“foo”,稍后(可能是立即,也可能是从现在开始一段很长的时间)重新启动的时候它会请求一个新值并将最终计算的结果返回。这里的yield关键字起到了请求新值的作用。

  在任何表达式中,你可以只用yield关键字而不带其它内容,此时yield返回的值是undefined。看下面的例子:

// 注意,这里的函数foo(..)不是一个generator函数!!
function foo(x) {
    console.log("x: " + x);
}

function *bar() {
    yield; // 暂停执行,返回值是undefined
    foo( yield ); // 暂停执行,稍后将获取到的值作为函数foo(..)的参数传入
}

  ES6 Generators系列:

  1. ES6
    Generators基本概念
  2. 深入研究ES6 Generators
  3. ES6
    Generators的异步应用
  4. ES6 Generators并发

  如果你还不知道什么是ES6 generators,请看我的前一篇文章“ES6
Generators基本概念”
。如果你已经对它有所了解,本文将带你深入了解ES6 generators的一些细节。

 

 

总结

  从代码语义层面来看,generator函数是同步执行的,这意味着你可以在yield语句中使用try..catch来处理错误。另外,generator遍历器还有一个throw(..)方法,可以在其暂停的地方抛出一个错误,这个错误也可以被generator函数内部的try..catch捕获。

  关键字yield
*
允许你在当前的generator函数内部委托并遍历另一个generator函数。我们可以将参数通过yield
*
传入到被委托的generator函数体中,当然,错误信息也会通过yield
*
被传回来。

  到目前为止我们还有一个最基本的问题没有回答,那就是如何在异步模式中使用generator函数。前面我们看到的所有对generator函数的遍历都是同步执行的。

  关键是要构造一种机制,能够使generator函数在暂停的时候启动一个异步任务,然后在异步任务结束时恢复generator函数的执行(通过调用next()方法)。我们将在下一篇文章中探讨在generator函数中创建这种异步控制的各种方法。敬请关注!

  ES6 Generators系列:

  1. ES6 Generators基本概念
  2. 深入研究ES6 Generators
  3. ES6
    Generators的异步应用
  4. ES6 Generators并发

  在JavaScript
ES6提供的诸多令人兴奋的新特性中,有一个新函数类型,叫generator。名字听起来很怪(我们姑且将它称之为生成器函数),而且行为更加让人觉得怪异。本文旨在解释generator函数的一些基本知识,用来说明它是如何工作的,并帮助你了解为什么它会让未来的JS变得如此强大。

 

运行-停止-运行(Run-Stop-Run)

  ES6的generator函数允许在运行的过程中暂停一次或多次,随后再恢复运行。暂停的过程中允许其它的代码执行。

  如果你曾经读过有关并发或者线程编程方面的文章,你也许见到过”cooperative“(协作)一词,它说明了一个进程(这里可以将它理解为一个function)本身可以选择何时被中断以便与其它代码进行协作。这个概念与”preemptive“(抢占式。进程调度的一种方式。当前进程在运行过程中,如果有重要或紧迫的进程到达(其状态必须为就绪),则该进程将被迫放弃处理机,系统将处理机立刻分配给新到达的进程。)正好相反,它表明了一个进程或function可以被其自身的意愿打断。

  在ES6中,generator函数使用的都是cooperative类型的并发方式。在generator函数体内,通过使用新的yield关键字从内部将函数的运行打断。除了generator函数内部的yield关键字,你不可能从任何地方(包括函数外部)中断函数的运行。

  不过,一旦generator函数被中断,它不可能自行恢复运行,除非通过外部的控制来重新启动这个generator函数。稍后我会介绍如何实现这一点。

  基本上,按照需要,一个generator函数在运行中可以被停止和重新启动多次。事实上,你完全可以指定一个无限循环的generator函数(就像while(true){…}语句一样),它永远也不会被执行完。不过在一个正常的JS程序中,我们通常不会这样做,除非代码写错了。Generator函数足够理性,有时候它恰恰就是你想要的!

  而更重要的是,这种停止和启动不仅仅控制着generator函数的执行,它还允许信息的双向传递。普通函数在开始的时候获取参数,在结束的时候return一个值,而generator函数可以在每次yield的时候返回值,并且在下一次重新启动的时候再传入值。

 

 

for..of循环

  ES6还从语法层面上对遍历器提供了直接的支持,即for..of循环。看下面的例子:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
    yield 4;
    yield 5;
    return 6;
}

for (var v of foo()) {
    console.log( v );
}
// 1 2 3 4 5

console.log( v ); // 仍然是5,而不是6

  正如你所看到的,由foo()创建的遍历器被for..of循环自动捕获,然后自动进行遍历,每遍历一次就返回一个值,直到属性done的值为true。只要属性done的值为false,它就会自动提取value属性的值并将其传递给迭代变量(本例中为变量v)。一旦属性done的值为true,循环遍历就停止(而且不会包含函数的返回值,如果有的话。所以此处的return
6
不包括在for..of循环中)。

  如上所述,可以看到for..of循环忽略并抛弃了返回值6,这是因为此处没有对应的next()方法被调用,for..of循环不支持将值传递给generator函数迭代的情况,如在for..of循环中使用next(v)。事实上,在使用for..of循环时不需要使用next方法。

Generator遍历器

  “Generator遍历器”!乍一看,好像很难懂!

  遍历器是一种特殊的行为,实际上是一种设计模式,我们通过调用next()方法来遍历一组有序的值。想象一下,例如使用遍历器对数组[1,2,3,4,5]进行遍历。第一次调用next()方法返回1,第二次调用next()方法返回2,以此类推。当数组中的所有值都返回后,调用next()方法将返回nullfalse或其它可能的值用来表示数组中的所有元素都已遍历完毕。

  我们唯一可以从外部控制generator函数的方式就是构造和通过遍历器进行遍历。这听起来好像有点复杂,考虑下面这个简单的例子:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
    yield 4;
    yield 5;
}

  为了遍历generator函数*foo(),首先我们需要构造一个遍历器。怎么做?很简单!

var it = foo();

  事实上,通过普通的方式调用一个generator函数并不会真正地执行它。

  这有点让人难以理解。你可能在想,为什么不是var it = new foo().
背后的原理已经超出了我们的范围,这里我们不展开讨论。

  然后,我们通过下面的方法对generator函数进行遍历:

var message = it.next();

  这会执行yield 1表达式并返回值1,但不仅限于此。

console.log(message); // { value:1, done:false }

  事实上每次调用next()方法都会返回一个object对象,其中的value属性就是yield表达式返回的值,而属性done是一个boolean类型,用来表示对generator函数的遍历是否已经结束。

  继续看剩余的几个遍历:

console.log( it.next() ); // { value:2, done:false }
console.log( it.next() ); // { value:3, done:false }
console.log( it.next() ); // { value:4, done:false }
console.log( it.next() ); // { value:5, done:false }

  有趣的是,当value的值是5done仍然是false。这是因为从技术上来说,generator函数还没有执行完,我们必须再调用一次next()方法,如果此时传入一个值(如果未传入值,则默认为undefined),它会被设置为yield
5
表达式计算的结果,然后generator函数才算执行完毕。

  因此:

console.log( it.next() ); // { value:undefined, done:true }

  所以,最终的结果是我们完成了generator函数的调用,但是最后一次的遍历并没有返回任何值,这是因为所有的yield表达式都已经被执行完了。

  你或许在想,我们可以在generator函数中使用return语句吗?如果可以的话,那value属性的值会被返回吗?

答案是肯定的:

function *foo() {
    yield 1;
    return 2;
}

var it = foo();

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

运行-完成(Run-To-Completion)

  首先我们要讨论的是generator函数和普通函数在运行方式上有什么区别。

  不论你是否已经意识到了,对于函数而言,你总是会假定一个原则:一旦函数开始运行,它就会在其它JS代码运行之前运行到结束。这句话怎么理解呢?看下面的代码:

setTimeout(function(){
    console.log("Hello World");
},1);

function foo() {
    // NOTE: don't ever do crazy long-running loops like this
    for (var i=0; i<=1E10; i++) {
        console.log(i);
    }
}

foo();
// 0..1E10
// "Hello World"

  这里的for循环需要一个比较长的时间来执行完,显然超过1毫秒。在foo()函数运行过程中,上面的setTimeout函数不会被运行直到foo()函数运行结束。

  那如果事情不是这样的会怎么样?如果foo()函数的运行会被setTimeout打断呢?是不是我们的程序将会变得不稳定?

  在多线程运行的程序中,这的确会给你带来噩梦,好在JavaScript是单线程运行的(同一时间只有一条命令或函数会被运行),因此这一点你不必担心。

  注意,Web开发允许JS程序的一部分在一个独立的线程里运行,该线程可以与JS主线程并行运行。但这并不意味着我们可以在JS程序中引入多线程操作,因为在多线程操作中两个独立的线程之间是可以通过异步事件相互通信的,它们彼此之间通过事件轮询机制(event-loop)一次一个地来运行。

 

但是:

  依赖generator函数中return语句返回的值并不值得提倡,因为当使用for..of循环(下面会介绍)来遍历generator函数时,最后的return语句可能会导致异常。

  我们来完整地看一下在遍历generator函数时信息是如何被传入和传出的:

function *foo(x) {
    var y = 2 * (yield (x + 1));
    var z = yield (y / 3);
    return (x + y + z);
}

var it = foo( 5 );

// 注意这里在调用next()方法时没有传入任何值
console.log( it.next() );       // { value:6, done:false }
console.log( it.next( 12 ) );   // { value:8, done:false }
console.log( it.next( 13 ) );   // { value:42, done:true }

  你可以看到我们在构造generator函数遍历器的时候仍然可以传递参数,这和普通的函数调用一样,通过语句foo(5),我们将参数x的值设置为5。

  第一次调用next()方法时,没有传入任何值。为什么呢?因为此时没有yield表达式来接收我们传入的值。

  如果在第一次调用next()方法时传入一个值,也不会有任何影响,该值会被抛弃掉。按照ES6标准的规定,此时generator函数会直接忽略掉该值(注意:在撰写本文时,Chrome和FireFox浏览器都能很好地符合该规定,但其它浏览器可能并不完全符合,而且可能会抛出异常)。

  表达式yield(x +
1)
的返回值是6,然后第二个next(12)12作为参数传入,用来代替表达式**yield(x

  • 1),因此变量y的值就是12 × 2,即24。随后的yield(y /
    3)(即yield(24 /
    3))返回值8。然后第三个next(13)13作为参数传入,用来代替表达式yield(y
    / 3),所以变量z的值是13**。

  最后,语句return (x + y + z)return (5 + 24 +
13)
,所以最终的返回值是42

  多重温几次上面的代码,开始的时候你会觉得很难懂,只要理解了generator函数执行的过程,掌握起来并不难。