Fork me on GitHub

ES6之Generator

在 ES6 中,对于异步问题的处理其实笼统的来说提供了三种解决方案,分别是PromiseGeneratorasync/await 。但这三种方案并不是各自独立,而是互相关联,互相依赖的。它们也都有各自的一些应用场景和优缺点。

这篇博客就主要来介绍一下Generator

OK,Let’s begin。

Generator 概述

首先,Generator是ES6中新推出的一种函数的叫法,它是一种全新的函数类型。

它与JS中其他函数的主要区别在于,它是一种可以在运行时暂停的函数。一般来说,在JS中,当一个函数运行起来时,我们是无法中断它的,归因于JS的单线程和事件循环机制,我们在以前是没法做到将一个正在运行的函数暂停去做一些其他事情,然后在需要时回来继续运行这个函数的。

Generator函数,正是为我们提供了这样一个实现,让我们可以在函数代码执行过程中,一次或多次暂停,并在将来的某个时刻继续执行。我们来仔细思考一下,对于普通的函数来说,我们在调用函数时向函数传入参数,并等待函数执行完毕为我们return一个最终值。那么对于Generator函数来说,我们在每次暂停时,都可以返回一个值,然后在启动时,再传入一个值。嗯,听起来还蛮振奋人心的函数内外部双向通信,用来处理异步问题,实在是再合适不过了啊。

generator 函数语法

首先,我们来看一下generator函数是如何书写的。

generator函数的声明语法跟普通函数的声明语法大致相同,不同的是,它多一个*号。如下:

JavaScript
1
2
3
4
5
6
7
8
/*以下两种generator函数声明都是合法的,但推荐使用第一种,比较明了*/
function *bar(){
//..
}

function* bar(){

}

这个*号,就是用来标示函数是一个generator函数的。

如果接触过python的话,会发现ES6generator函数和python的生成器函数十分相似,都是通过 yield 来在函数内部暂停函数的执行,并传递值到函数外去,再通过next()传递值给yield表达式,并继续函数的执行。(只是顺便一提,没了解过python的同学可以当做没看见。。

我们先来看一个简单的 generator 函数的例子,通过这个简单的例子来对generator函数做一个直观的认识。

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function *first(){
yield 1
yield 2
return 3
}

let f = first()

f
//Generator

f.next()
//{value: 1, done: false}

f.next()
//{value: 2, done: false}

f.next()
//{value: 3, done: true}

f.next()
//{value: undefined, done: true}

通过上面的函数,我们可以看到,我们定义的generator函数有以下几个特别的地方:

  • first()并不会执行first函数,也不会返回函数运行结果,而是返回一个指向函数内部状态的指针对象,也就是一个Generator对象。
  • 当我们执行调用generator函数返回的对象的next()方法后,函数才开始执行,执行到yield关键字时暂停,并返回一个对象,这个对象的value值为yield传递出的值,done属性是函数的状态,当函数执行完毕时,它会从false置为true

yield

大家generator函数的关键语句——yield。我们就来具体解释一下 yield

yield的汉语意思就是产出,从上面的例子中也很好理解,就是产出一个形如{value: xxx, done:boolean}的对象。

generator函数运行过程中,当遇到yield语句时,就会暂停函数,并将yield后的值作为next()所返回对象的value值。如果yield表达式不返回值的话,value就为undefined

再次调用next()方法,函数继续从上次暂停的地方执行,直到再次遇到yield,以此类推。

如果从上次暂停的位置后面没有yield,函数就会一直执行下去,直到函数结束将next返回对象的done值置为true

值得注意的是,如果函数有return语句的话,return语句的返回值会作为next()调用返回对象的最后一个value,并结束函数,后续的next调用,会一直返回undefined

关于yield,还有以下两点需要注意:

  • yield只能用在generator函数中,不能用在普通函数中,因此它也不能用在那些以函数作为参数的方法中,例如forEachmap等方法。

  • yield 如果用在表达式中参加运算,必须加括号。形如下:

    JavaScript
    1
    2
    3
    function *second(){
    let x = 1 + (yield 2)
    }

双向通信

在上面我们提到了,yield表达式可以暂停generator函数的运行,并将其后的值传递出去。那么我们如何向yield表达式传递值呢?也就是说,generator函数是如何实现函数内外部的双向通信的呢?

看下面例子:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function *foo(){
let x = 1 + (yield 200)
console.log('x: '+ x)
}

//首先,我们调用generator函数得到返回的generator对象。
let b = foo()

//我们调用next方法,执行函数。注意此时函数开始运行,首先对 x 赋值,从右到左,遇到了yield,暂停函数执行,返回 200
b.next()
//{value: 200, done: false}

//继续调用next()方法,函数从上次暂停的yield处继续开始,我们为next方法传递了一个参数1
b.next(1)
//'x: 2'
//{value: undefine, done: true}

从上面代码我们可以看到,x的值在函数结束时为2。

通过以上代码我们就可以清楚的知道generator函数如何实现双向通信了。

  • 遇到yield表达式时,暂停函数,并将其后的值传递出去
  • 在下一个next调用时,从暂停的yield处继续运行,并将next携带的参数作为此处yield表达式的最终计算结果值。
  • 如果 next()没有传递参数给yield表达式的话,默认yield表达式返回值为undefined

形象的来说,yield就像是在管道一样的函数上开了一个口子,在运行到它所在位置时,从这个口子会冒出一个东西,我们把冒出的东西拿到后,还可以通过这个口子,再往里面塞进去一个东西。而next,就是用来使我们能够合乎规则和秩序的取到口子传递出的东西再塞东西进去。

看明白上面的例子后,我们再通过下面这个复杂一些的例子,更详细的加深一下理解。

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function *bar(x){
let y = 2 * (yield x + 1)
let z = yield(y/3)
let h = x + y + z
console.log(h)
}

//首先,我们向函数传递一个参数 5
let b = bar(5)

//调用next方法。在这个next方法我们只是用来启动函数,到达第一个yield位置,传递出x+1的值,暂停
//这个next我们不需要传递任何参数,因为此时并没有处于暂停状态的yield表达式来接受我们传递的值。
b.next()
//{value: 6, done: false}

//继续调用next,此时函数在第一个yield暂停的位置继续运行,计算 y 的值。
//我们通过 next 传递进去一个值,将这个值作为 yield 表达式的计算结果值,求得y值为 48
//求得y 值之后,到函数下一行,计算 z 的值
//此时我们又遇到了yield,并将 y/3 的值传递出去,函数暂停,
b.next(24)
//{value: 16,done: false}

//继续调用next,此时函数在第二个yield暂停的位置继续往下运行,计算 z 的值。
//同上,我们传递一个值作为yield表达式的结果,得到 z 值为 1。
//函数继续运行,直到结束,没有再遇到yield和return传递值,因此返回对象value为undefined
//并最终输出 h 值为 5 + 58 + 1 ,即54
b.next(1)
//{value: undefined,done:false}

上面这个例子对于初次接触generator的同学可能有些难以理解,多看几次^O^.

异常捕捉

我们可以通过next()方法向generator函数内传递数据,那么当然,像promise对象的数据和错误处理配套的一样,我们也可以向generator中传递异常。

来看代码:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function *g(){
try{
yield 200
} catch(err) {
console.log('generator 内部捕获异常:' + err)
}
}

let f = gen()

//启动generator函数
f.next()

//使用generator对象的throw方法,向函数内部传递异常(注意区分此方法和全局throw方法,不是一个哦
f.throw('aError')
//generator 内部捕获异常:aError

当然,在实际使用中,我们最好应该向throw中传递一个Error对象的实例。此处传递一个字符串的行为不可取,只是为了方便演示。

generator对象的throw方法只能在generator函数启动后才能传递错误进去,很好理解,我们当然必须首先使用一次next方法启动函数才能抛出异常啊。抛出的异常如果在内部未被捕获,就会扩散到throw调用的位置,还没被捕获的话,程序就会被异常终止。

generator对象的return方法

generator对象,除了我们上面提到的nextthrow两个方法外,还有一个return方法。它的作用是向generator函数内部传递一个值,并结束generator函数的运行。

如下:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function *gen(){
yield 1
yield 2
yield 3
}

var g = gen()

//启动函数
g.next()
//{value: 1, done: false}

//向generator内部传递一个值100,并结束函数
g.return(100)
//{value: 100, done: false}

//可以看出函数已经结束了
g.next()
//{value: undefined, done: true}

我们在上面提到,generator函数内部的return会将函数结束,并将return作为最后一个非undefinedvalue返回值。generator对象的return方法,作用跟函数内部的return是一样的。

现在我们可以来总结一下nextthrowreturn这三个方法:

  • 这三个方法都是用于在generator函数从 上个暂停的yield处启动时,向yield表达式传递值作为其表达式结果的方法。
  • next方法传递一个普通值作为yield表达式的结果,相当于将yield表达式替换为一个普通值
  • throw方法传递一个抛出的错误作为yield表达式的结果,相当于将yield表达式替换为一个throw语句
  • return方法同样传递一个普通值作为yield表达式的结果,但它会结束函数的运行,相当于将yield表达式替换为一个return语句。

使用generator函数解决异步问题

上面讲了一大堆关于generator函数的特性和方法,但大家对于它具体怎么应用在一些异步操作中还是有些疑惑。

首先我们来思考一下,异步问题,归根结底就是有的操作,需要耗费比较长的时间,我们又不想在这些操作进行的时候一直等着它返回结果。我们想要的理想状态是,当这些操作进行时,我们可以先做一点别的事情,等操作完成了,我们再去得到结果就行。

generator函数就正好符合我们的要求。我们可以将异步操作包装在generator函数中,当异步操作进行时,函数暂停,我们可以去做其他的一些事情。当我们需要获得异步操作结果时,再使用next方法,取到结果并进行处理。这样看来,generator函数很适合来进行异步操作。

我们看下面的代码:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//假设我们有一个耗时的操作
function longTime(){
setTimeout(() => {
let a = 22
g.next(a)
},2000)
}

//我们通过generator函数来获取来处理这个异步操作
function *gen(){
let result = yield longTime()
console.log(result)
}

//获得generator对象
let g = gen()

//执行generator函数,yield会去执行longTime这个异步操作,并暂停generator函数
//在上面的异步操作中,我们在得到异步操作的结果后,通过在异步操作中调用next方法,将结果再交给generator函数
g.next()

可以看出,通过generator函数来进行异步处理,其实就是构造一个generator函数,使用yield来启动异步操作,并在异步操作中将结果再通过next()方法传递回generator函数,从而完成异步操作。

很多异步操作都是通过回调函数来传递操作结果的,那我们如果想使用generator函数来对他们进行处理,就必须在他们的回调函数里使用next方法,拿最常见的Ajax举例。

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
funciton req(){
$.get(url, function(data){
g.next(data)
})
}

function *gen(){
let result = yield req()
doSomeThing(result)
}

function doSomeThingx{
console.log(x)
}

那大家就可能有这样一个问题了,我为什么不直接在Ajax的回调函数里对结果进行处理,还要费那么大麻烦把结果传递回generator函数里干嘛。

额,单纯这样用generator函数是没什么用,在多个异步操作时,我们要在每个异步操作的回调里使用next传递结果,到处的回调里都是next,显得十分愚蠢。

于是就有了这样的一个解决方案,我们通过Thunk函数,将形如$.get(url, callback)类型的异步请求形式,转换为 aGet(url)(callback)形式的调用,这样,我们就可以在每次 next的返回值的value中自定义回调函数,从而简化代码。如下:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//首先通过thunk函数,将 $.get(url,cb) 转换为 aGet(url)(cb)的调用形式
let aGet = thunk($.get)

function *gen{
var r1 = yield aGet('urlA')
var r2 = yield aGet('urlB')
}

let g = gen()

//我们通过上面thunk的转换,使得next每次返回的对象value值可以直接接受一个回调函数作为参数,从而使我们更方便的在回调函数里使用next()来传递异步操作结果
g.next().value(function cb(data){
g.next(data).value(function cb(data){
g.next(data)
})
})

这种方式第一是写起来比较麻烦,难以理解,不够清晰明了。第二就是需要引入额外的thunk相关库来对各种有回调函数的异步API进行包装,增加了使用成本。

对于那些不使用回调函数,而是返回promise对象的异步API,使用generator函数来进行处理其实是一样的步骤,只不过将回调函数中的next调用换到了then中。

当然,generator函数有一些相关的库,例如co库,可以用来降低我们在回调函数和then中使用next来进行异步操作结果传递的麻烦程度,但同样的,也会增加我们的使用成本,降低多人协作开发中的容错率。引入相关的类库来改变项目中的所有异步操作处理步骤,可能对于一些大型项目来说,是需要慎重考虑的事情。

总结

相比于promise的原生支持和简明的用法,generator函数略显鸡肋,在ES7推出的async/await这个终极异步操作处理方案面前,generator函数就更有些尴尬了。

如果希望了解更多关于generator函数的知识,仍旧推荐阅读阮一峰老师的ES6入门,关于generator函数的一些细节和异步操作处理的更多应用,都介绍的比较详细。赠送飞机票一张

好了,对于generator函数的介绍,就到这里了。谢谢阅读。

----本文结束感谢阅读----