听说你还没听过ES6的Generator函数?
Generator,生成器函数,ES6中最难理解的语法,没有之一。Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。
- 语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。
- 形式上,Generator 函数是一个普通函数,但是有两个特征 :
function
关键字与函数名之间有一个星号*
;- 函数体内部使用
yield
表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。
语法
只要给一个函数关键字后面添加一个星号
*
,那么这个函数就被称之为生成器generator函数
调用
调用生成器函数,不会立即执行函数体,而是会返回一个Iterator迭代器对象,调用
next()
方法这继续往后执行,碰到yield
关键字就暂停。
next
只是给yield
下指令,有了next
就执行一个yield
。yield
表达式后面的表达式,只有当调用next
方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的 “惰性求值” (Lazy Evaluation)的语法功能。
value
:表示每次执行时yield
后面的内容(每次调用都会从上一次停止的地方继续运行)done
:表示当前函数状态,是否执行完
1 | function* generator (){ |
1 | function* foo () { |
yield
没有返回值:undefined
1 | function* g() { |
调用的是异步代码(先执行同步代码再执行异步代码):
- 分步调用
next()
:
由运行结果可知:前四次调用中,返回{value: Promise, done: false}
是同步的先执行,而setTimeout
中的代码(打印)是异步,等同步代码执行完后再执行。而第五次调用时,返回{value: undefined, done: false}
和调用B()
打印b
都是同步代码,故会按顺序执行。
1 | function A(num) { |
- 一次性调用
next()
:
1 | function A(num) { |
执行结果:
分析:前四个yield
表达式后面都是setTimeout
异步代码,会按顺序加入任务队列;执行到第5个yield
,后面是同步代码,会打印出同步的b
,之后返回 {value: undefined, done: false}
,同步任务完成后再从任务队列中依次执行刚刚的异步任务。 注意:如果一次性调用的是6次g.next()
,则打印完同步的b
后返回的是 {value: undefined, done: true}
。
遍历
因为执行 Generator 函数会返回一个遍历器对象,所以可以对其进行遍历操作:返回所有的
value
,最后return
的值不会被返回,因为for of
遇到done
为true
就停止遍历了。for-of 循环只会遍历迭代器 done 的值为 false 的结果
1 | function* generator (){ |
与 Iterator 接口的关系
任意一个对象的
Symbol.iterator
方法,等于该对象的遍历器生成函数,调用该函数会返回该对象的一个遍历器对象。由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator属性,从而使得该对象具有 Iterator 接口。
1 | var myIterable = {}; |
Generator 函数执行后,返回一个遍历器对象。该对象本身也具有Symbol.iterator
属性,执行后返回自身。
1 | function* gen(){ |
next()
方法传参
next
方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值,也就是替换掉上一个整个yield
表达式。
1 | function* g() { |
Generator.prototype.throw()
Generator 函数返回的遍历器对象,都有一个
throw
方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。
1 | var g = function* () { |
上面代码中,遍历器对象i连续抛出两个错误:
- 第一个错误被 Generator 函数体内的
catch
语句捕获。 - 第二次抛出错误,由于 Generator 函数内部的
catch
语句已经执行过了,不会再捕捉到这个错误了,所以这个错误就被抛出了 Generator 函数体,被函数体外的catch
语句捕获。
throw方法可以接受一个参数,该参数会被catch语句接收,建议抛出Error对象的实例。
不要混淆遍历器对象的throw
方法和全局的throw
命令。throw命令与g.throw
方法是无关的,两者互不影响。 上面代码的错误,是用遍历器对象的throw
方法抛出的,而不是用throw
命令抛出的。全局的throw
命令只能被函数体外的catch
语句捕获。
1 | function* gen() { |
上面代码中,g.throw(1)
执行时, next
方法一次都没有执行过。这时,抛出的错误不会被内部捕获,而是直接在外部抛出,导致程序出错。
这种行为其实很好理解,因为第一次执行next
方法,等同于启动执行 Generator 函数的内部代码,否则 Generator 函数还没有开始执行,这时throw
方法抛错只可能抛出在函数外部。throw
方法被捕获以后,会附带执行下一条yield
表达式。也就是说,会附带执行一次next
方法。
一旦 Generator 执行过程中抛出错误,且没有被内部捕获,就不会再执行下去了 。如果此后还调用next方法,将 返回一个value属性等于undefined、done属性等于true的对象 ,即 JavaScript 引擎认为这个 Generator 已经运行结束了。
如下面的例子:
1 | function* g() { |
上面代码一共三次运行next
方法,第二次运行的时候会抛出错误,然后第三次运行的时候,Generator 函数就已经结束了,不再执行下去了。
Generator.prototype.return()
Generator 函数返回的遍历器对象,还有一个
return
方法,可以返回给定的值,并且终结遍历 Generator 函数。
1 | function* gen() { |
如果 Generator 函数内部有try...finally
代码块,且正在执行try
代码块,那么return
方法会导致立刻进入finally
代码块, finally
执行完以后,再次调用next()
方法会刚刚return
的参数,如果没传参就返回undefined
,至此整个函数才会结束。
1 | function* numbers () { |
next()、throw()、return() 的共同点
next()
、throw()
、return()
这三个方法本质上是同一件事,可以放在一起理解。它们的作用都是 让 Generator 函数恢复执行,并且使用不同的语句替换yield
表达式 。next()
是将yield
表达式替换成一个值。
1 | const g = function* (x, y) { |
上面代码中,第二个next(1)
方法就相当于将yield
表达式替换成一个值1
。如果next
方法没有参数,就相当于替换成undefined
。
1 | gen.throw(new Error('出错了')); // Uncaught Error: 出错了 |
throw()
是将yield表达式替换成一个throw语句。
1 | gen.return(2); // Object {value: 2, done: true} |
return()
是将yield表达式替换成一个return语句。
yield*
表达式
如果在 Generator 函数内部,调用另一个 Generator 函数。需要在前者的函数体内部,自己手动完成遍历。但如果有多个 Generator 函数嵌套,写起来就非常麻烦。ES6 提供了
yield*
表达式,作为解决办法,用来在一个 Generator 函数里面执行另一个 Generator 函数。
1 | function g1(){ |
作为对象属性的 Generator 函数
如果一个对象的属性是 Generator 函数,可以简写。
1 | let obj = { |
1 | let obj = { |
Generator 函数的this
Generator 函数总是返回一个遍历器,ES6 规定这个遍历器是 Generator 函数的实例,也继承了 Generator 函数的prototype对象上的方法。
1 | function* g() {} |
- 如果把g当作普通的构造函数,并不会生效,因为g返回的总是遍历器对象,而不是this对象。
1 | function* g() { |
- Generator 函数也不能跟
new
命令一起用,会报错。
1 | function* F() { |
- 让 Generator 函数返回一个正常的对象实例,既可以用
next
方法,又可以获得正常的this
:首先,生成一个空对象,使用call
方法绑定 Generator 函数内部的this
。这样,构造函数调用以后,这个空对象就是 Generator 函数的实例对象了。
下面代码中,首先是F内部的this
对象绑定obj
对象,然后调用它,返回一个 Iterator 对象。这个对象执行三次next
方法(因为F内部有两个yield
表达式),完成 F 内部所有代码的运行。这时,所有内部属性都绑定在obj
对象上了,因此obj
对象也就成了F的实例。
1 | function* F() { |
- 执行的是遍历器对象f,但是生成的对象实例是
obj
,将这两个对象统一呢:一个办法就是将obj
换成F.prototype
。
1 | function* F() { |
再将F改成构造函数,就可以对它执行new
命令了。
1 | function* gen() { |
Generator 与状态机
Generator 是实现状态机的最佳结构。
比如,下面的clock
函数就是一个状态机:
1 | let ticking = true; |
clock
函数一共有两种状态(Tick
和Tock
),每运行一次,就改变一次状态。这个函数如果用 Generator 实现:
1 | let clock = function* () { |
上面的 Generator 实现与 ES5 实现对比,可以看到少了用来保存状态的外部变量ticking,这样就更简洁,更安全(状态不会被非法篡改) 、更符合函数式编程的思想,在写法上也更优雅
Generator与协程(coroutine)
- 由于 JavaScript 是单线程语言,只能保持一个调用栈。引入协程以后,每个任务可以保持自己的调用栈。这样做的最大好处,就是抛出错误的时候,可以找到原始的调用栈。不至于像异步操作的回调函数那样,一旦出错,原始的调用栈早就结束。
- Generator 函数是 ES6 对协程的实现,但属于不完全实现 :
- Generator 函数被称为“半协程”(semi-coroutine),意思是只有 Generator 函数的调用者,才能将程序的执行权还给 Generator 函数。
- 如果是完全执行的协程,任何函数都可以让暂停的协程继续执行。
- 如果将 Generator 函数当作协程,完全可以将多个需要互相协作的任务写成 Generator 函数,它们之间使用yield表达式交换控制权。
Generator与上下文
- JavaScript 代码运行时,会产生一个全局的上下文环境(context,又称运行环境),包含了当前所有的变量和对象。
- 然后,执行函数(或块级代码)的时候,又会在当前上下文环境的上层,产生一个函数运行的上下文,变成当前(active)的上下文,由此形成一个上下文环境的堆栈(context stack)。
- 这个堆栈是“后进先出”的数据结构,最后产生的上下文环境首先执行完成,退出堆栈,然后再执行完成它下层的上下文,直至所有代码执行完成,堆栈清空。
Generator 函数不是这样,它执行产生的上下文环境,一旦遇到yield命令,就会暂时退出堆栈,但是并不消失,里面的所有变量和对象会冻结在当前状态。等到对它执行next命令时,这个上下文环境又会重新加入调用栈,冻结的变量和对象恢复执行。
1 | function* gen() { |
上面代码中,第一次执行g.next()
时,Generator 函数gen
的上下文会加入堆栈,即开始运行gen
内部的代码。等遇到yield 1
时,gen
上下文退出堆栈,内部状态冻结。第二次执行g.next()
时,gen
上下文重新加入堆栈,变成当前的上下文,重新恢复执行。
自动执行Generator函数
我们可以借助 Promise + Generator 可以手动控制异步代码和同步代码的执行顺序了,可是这么写目前依然存在一个很严峻的问题——类似于“回掉地狱”,代码层次不清晰等。
1 | g.next().value.then(() => { |
Thunk函数
Thunk 函数是一种用于延迟执行的函数包装器,通常用于处理异步操作或生成器函数的执行。Thunk 函数的目标是将一个函数的执行推迟到稍后的时间,以便在需要时再执行。在 JavaScript 中,Thunk 函数通常是一个带有回调函数的函数包装器。
如下例子:A函数执行完毕,递归进去执行 B函数,B函数执行完毕,递归进去执行 C函数。 这样一来,我们就通过打造一个 Thunk 函数和一个 Thunk 函数执行器(run
)来实现了让 Generator 函数自动执行下一次层的 next()
。
1 | // 异步函数 A、B 和 C,现在它们接受一个回调函数作为参数,并在异步操作完成后调用回调函数。 |
co模块
除了 Thunk 函数的方式,还有一个方法可以实现 Generator 的自动执行,就是 co 模块。
co 模块是大佬 TJ Holowaychuk封装的一个库,一个用于控制生成器函数执行的库,它允许你以同步的方式编写异步代码,使得生成器函数内部的异步操作看起来像同步代码一样。
co 模块的实现基于 Promise 和生成器函数的特性,它自动迭代生成器并处理 Promise 对象的返回值。
用法
原理
和Thunk函数一样同样是使用递归,不过 co 借助了 Promise中的
then
方法,所以需要使用者注意 yeild 后面的内容一定要返回一个 Promise 对象,当上一个yeild
执行完毕且状态变更为fulfilled
,then
才能执行,也就才能走进下一层的递归。
async/await
的实现就是由 Generator + Promise + co的手段 来封装的
Thunk和co的区别
co 和 Thunk 函数都有各自的用途和优势,选择使用哪个取决于你的具体需求和代码结构 :
- co 更适合复杂的异步控制流程
- 而 Thunk 函数更适合将异步操作封装成可延迟执行的函数。
当然,实际开发过程中,肯定是直接 async/awai
t呀!
1. 用途
- co 函数通常用于协调和管理异步操作的流程,使得异步操作看起来像同步代码一样执行。它通常与生成器函数结合使用。
- Thunk 函数主要用于封装异步操作,将其包装成一个函数,以便在需要时延迟执行。Thunk 函数通常用于构建异步流程控制工具。
2. 执行方式
- co 函数是一个库或工具,它使用 Promise 和生成器函数的协作来实现异步控制。co 内部会自动执行生成器函数,并管理异步操作的执行流程。
- Thunk 函数是一个函数包装器,它接受回调函数作为参数,并通常需要手动调用来执行。Thunk 函数的执行需要显式地调用,而不像 co 那样自动进行异步流程控制。
3. 使用场景
- co 函数适用于较复杂的异步流程控制,例如需要按顺序执行多个异步操作、处理错误等情况。它在管理多个异步任务时非常有用。
- Thunk 函数通常用于构建异步库或处理单一异步操作的情况,它更侧重于将异步操作封装成可延迟执行的函数,以便在需要时执行。
Generator应用
异步操作的同步化表达
1 | function* main() { |
上面代码的main
函数,就是通过 Ajax 操作获取数据。可以看到,除了多了一个yield
,它几乎与同步操作的写法完全一样。注意,makeAjaxCall
函数中的next
方法,必须加上response
参数,因为yield
表达式,本身是没有值的,总是等于undefined
。
1 | function* numbers() { |
控制流管理
1 | function* longRunningTask(value1) { |
作为数据结构
因为 Generator 函数可以返回一系列的值,这意味着它可以对任意表达式,提供类似数组的接口。
1 | function* doStuff() { |
等价于:
1 | function doStuff() { |
状态模式的语法糖
如果我们要自己去写状态模式的代码的话,会显得比较繁琐,相当于要自己去切换上下文,而有了Generator我们就可以直接利用Generator的能力为我们进行状态切换了。
频繁切换状态 :
1 | function* func() { |
作为迭代器的语法糖
请添加某些代码使得以下代码正常运行:
1 | const obj = { a: 1, b: 2 } |
这个题是要为obj
对象补迭代器 :
1 | obj[Symbol.iterator] = function () { |
1 | // 定义一个Generator,读取目标对象的 |