???? 大家好,我是小丞同学,本文将会带你理解 ES6 中的生成器。
写在前面
在上篇文章中,我们深入了理解了迭代器的原理和作用,这一篇我们来深扒与迭代器息息相关的生成器。
关于生成器有这样的描述
红宝书:生成器是 ES6 新增的一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力
阮一峰老师:Generator 函数是 ES6 提供的一种异步编程解决方案
从上面的两段话中,我们可以知道生成器有着至少两个作用:
打破完整运行,拥有暂停和启动的能力
解决异步操作
下面我们来看看生成器是如何实现这些功能的
一个例子了解生成器
我们先来看一个例子
下面是一个 for 循环的例子,会在每次循环中输出当前的 index ,这段代码很也是简单的生成了 0-5 这些数字
for (let i = 0; i <= 5; i++) { console.log(i); } // 输出 0 1 2 3 4 5
我们再来看看利用生成器函数是怎么实现的
function* generatorForLoop(num) { for (let i = 0; i <= num; i ++) { yield console.log(i); } } const gen = generatorForLoop(5); gen.next(); // 0 gen.next(); // 1 gen.next(); // 2 gen.next(); // 3 gen.next(); // 4 gen.next(); // 5
我们可以看到,只有调用 next 方法,才会向下执行,而不会一次产生所有值。这就是一个最简单的生成器了。在某些场景下,这种特性就成为了它的杀手锏 基本概念 1. 函数声明 生成器的形式是一个函数,函数名称前面加一个星号 * 表示它是一个生成器。
// 函数声明 function * generator () {} // 函数表达式 let generator = function *() {}
在定义一个生成器时,星号的位置在函数名前,但是位置没有明确的要求,不需要考虑挨着谁,都可以
只要是可以定义函数的地方,就可以定义生成器。
需要特别注意的是:箭头函数不能用来定义生成器
2. yield 表达式
函数体内部使用yield表达式,定义不同的内部状态,我们来看一段代码
function* helloWorld() { yield 'hello'; yield 'world'; return 'ending'; }
在上面的代码中定义了一个生成器函数 helloWorld ,内部有两个 yield 表达式,三个状态:hello,world 和 return 语句
作为生成器的核心,单纯这么解释可能还是不能明白 yield 的作用以及它的使用方法
下面我们来展开说说 yield 关键字
首先它和 return 关键字有些许的类似,return 语句会在完成函数调用后返回值,但是在 return 语句之后无法进行任何操作
可以看到在编译器中第一个 return 语句之后的代码变灰了,说明了没有生效。但是yield的工作方式却不同,我们再来看看 yield 是如何工作的
注意:yield 关键字只能在生成器函数内部使用,其他地方使用会抛出错误
首先生成器函数会返回一个遍历器对象,只有通过调用 next 方法才会遍历下一个状态,而 yield 就是一个暂停的标志
在上面的代码中,首先声明了一个生成器函数,利用 myR 变量接收生成器函数的返回值,也就是上面所说的遍历器对象,此时遍历器对象处于暂停状态。
当调用 next 方法时,开始执行,遇到 yield 表达式,就暂停后面的操作,将 yield 后面的表达式的值,作为返回的对象的 value 值,因此第一个 myR.next() 中的 value 值为 8
再次调用 next 方法时,再继续向下执行,遇到 yield 再停止,后续操作一致
需要注意的是,yield 表达式后面的表达式,只有当调用next方法,内部指针指向该语句时才会执行
function* gen() { yield 123 + 456; }
就例如上面的代码中,yield后面的表达式 123 + 456 ,不会立即求值,只会在 next 方法将指针移到这一句时,才会求值。
因此可以理解为 return 是结束, yield 是停止
3. 一定需要 yield 语句吗?
其实在生成器函数中也可以没有yield表达式,但是生成器的特性还在,那么它就变成了一个单纯的暂缓执行函数,只有在调用该函数的遍历器对象的 next 方法才会执行
function* hello() { console.log('现在执行'); } // 生成遍历器对象 let generator = hello() setTimeout(() => { // 开始执行 generator.next() }, 2000)
4. 注意 yield 表达式如果用在另一个表达式中,必须放在圆括号里
console.log('Hello' + (yield 123)); // OK
yield 表达式用作函数参数可以不加括号
foo(yield 'a')
如何理解 Generator 函数是状态机?
在阮一峰老师的 ES6 书籍上有着对生成器函数这样的理解
Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。
书上说,Generator 函数是状态机,这是什么意思呢,状态机又怎么理解呢?
这个和 JavaScript 的状态模式有些许关联
状态模式:当一个对象的内部状态发生改变时,会导致其行为的改变,这看起来像是改变了对象
看到这些定义的时候,显然每个字都知道是什么意思,合起来却不知所云
先不要慌,我们先来看看状态模式是个什么东西,写个状态机就明白了
我们用一个洗衣机的例子,按一下电源键就打开,再按一下就关闭,我们先来实现这个
let switches = (function () { let state = "off"; return function () { if (state === "off") { console.log("打开洗衣机"); state = "on"; } else if (state === "on") { console.log("关闭洗衣机"); state = "off"; } } })();
在上面的代码中,通过一个立即执行函数,返回一个函数,将状态 state 保存在函数内部,每次按下电源键调用 switches 函数即可。
这样看起来很完美,下面我们改变一下需求,洗衣机上有一个调整模式的按钮,每按一下换一个模式,假设有快速、洗涤、漂洗、拖水怎么实现
同样的我们还是可以采用 if-else 语句实现
let switches = (function () { let state = "快速"; return function () { if (state === "快速") { console.log("洗涤模式"); state = "洗涤"; } else if (state === "洗涤") { console.log("漂洗模式"); state = "漂洗"; } else if (state === "漂洗") { console.log("脱水模式"); state = "脱水"; } else if (state === "脱水") { console.log("快速模式"); state = "快速"; } } })();
越来越复杂了,当模式再增多时,if-else 语句会越来越多,代码会难以阅读,你可能会说可以采用 switch-case 语句来实现,当然也可以,但是治标不治本。我们可不可以不采用判断语句实现呢。回到我们刚开始的定义
状态模式:当一个对象的内部状态发生改变时,会导致其行为的改变,这看起来像是改变了对象
咦,想想,洗衣机不正是需要实现状态改变,行为改变吗?那这正可以采用状态模式来实现呀,这里我们就直接引出我们的 generator 函数,通过控制状态来改变它的行为
利用原型来实现的方法太过于复杂和冗余了,就不展示了
const fast = function () { console.log("快速模式"); } const wash = function () { console.log("洗涤模式"); } const rinse = function () { console.log("漂洗模式"); } const dehydration = function () { console.log("脱水模式"); } function* models() { let i = 0, fn, len = arguments.length; while (true) { fn = arguments[i++] yield fn() if (i === len) { i = 0; } } } const exe = models(fast, wash, rinse, dehydration); //按照模式顺序排放
在上面的代码中我们只需要在每次按下时调用 next 方法即可切换下一个状态
说了这么多 generator 为什么说是状态机呢?我的理解是:当调用 Generator 函数获取一个迭代器时,状态机处于初态。迭代器调用 next 方法后,向下一个状态跳转,然后执行该状态的代码。当遇到 return 或最后一个 yield 时,进入终态。同时采用 Generator 实现的状态机是最佳的结构。
next 传递参数
生成器的另一强大之处在于内建消息输入输出能力,而这一能力仰仗于 yield 和 next 方法
yield 表达式本身没有返回值,或者说总是返回 undefined 。 next 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。
从语义上讲,第一个 next 方法用来启动遍历器对象,所以不用带有参数。
来看一个例子
function* foo(x) { let y = x * (yield) return y } const it = foo(6) it.next() let res = it.next(7) console.log(res.value) // 42
在上面的代码中,调用 foo 函数返回一个遍历器对象 it ,并将 6 作为参数传递给 x ,调用遍历器对象的 next 方法,启动遍历器对象,并且运行到第一个 yield 位置停止,
再次调用 next 方法传入参数 7 ,作为上一个 yield 表达式的返回值也就是 x 的乘项 (yield) 的值,运行到下一个 yield 或 return 结束
下面开始作死
在上面的例子中,如果不传递参数会这么样呢?
在第二次运行 next 方法的时候不带参数,导致了 y 的值等于 6 * undefined 也就是 NaN 所以返回的对象的 value 属性也是 NaN
我们再变一下
在原先的例子中,我们说第一个 next 是用来启动遍历器对象,那么如果传入参数会怎么样?
其实这样传递参数是无效的,因为我们说 next 方法的参数表示上一个 yield 表达式的返回值。
V8 引擎直接忽略第一次使用 next 方法时的参数
与 Iterator 接口的关系
在上一篇中我们知道,一个对象的 Symbol.iterator 方法,等于该对象的遍历器生成函数,调用该函数会返回一个遍历器对象
在这一篇我们知道,生成器函数就是遍历器生成函数,那么是不是有什么想法了呢?
我们可以把生成器赋值给对象的 Symbol.iterator 属性,实现 iterator 接口
let myIterable = {}; myIterable[Symbol.iterator] = function* () { yield 1; yield 2; yield 3; } [...myIterable] // [1, 2, 3]
提前终止生成器
生成器函数返回的遍历器对象,都有 next 方法,以及可选的 return 方法和 throw 方法
我们先来看 return 方法
return
return 方法会强制生成器进入关闭状态,提供给 return 方法的值,就是终止迭代器对象的值,也就是说此时返回的对象状态为true,值为传入的值。我们来验证一下
function* genFn() { for (const x of [1, 2, 3]) { yield x } } // 创建遍历器对象 g const g = genFn() // 手动结束 console.log(g.return('结束'))
在上面的代码中,输出了 {value: "结束", done: true} ,这和我们预料的一样,我们生成了遍历器对象后,直接调用 return 终止了生成器
如果生成器函数内部有 try...finally 代码块,且正在执行 try 代码块,那么 return() 方法会导致立刻进入 finally 代码块,执行完以后,整个函数才会结束。
function* genFn() { try { yield 111 } finally { console.log('我在finally中'); yield 999 } } // 创建遍历器对象 g const g = genFn() // 启动 g.next() console.log(g.return('结束'))
在上面的代码中,执行 next 函数,使得 try 代码块开始执行,再调用 return 方法,就会开始执行 finally 代码块,然后等待执行完毕,再返回 return 方法指定的返回值
throw
throw() 方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭
在很多的资料中都说的很复杂,其实就很简单:
有错误你就给我一个 catch 来处理掉,不然你就给我退出,就是这么霸道
function* gen(){ console.log("state1"); let state1 = yield "state1"; console.log("state2"); let state2 = yield "state2"; console.log("end"); } let g = gen(); g.next(); g.throw();
在上面的代码中,throw 方法提出的错误,没有被处理,因此会被直接退出,因此上面的代码只会输出 state1 ,然后报错 注意:可以给 throw 方法传递参数,用来解释错误
g.throw(new Error('出错了!'))
next()、throw()、return() 的共同点
到这里遍历器对象的3个方法,已经都涉及过了,虽然他们的功能各不相同,或者说完全没有关系,但是他们的本质确实在做同一件事,“采用语句替换 yield 表达式”
next 是将 yield 表达式替换成一个值
throw是将 yield 表达式替换成 throw 语句
gen.throw(new Error('出错了')); // Uncaught Error: 出错了 // 相当于将 let result = yield x + y // 替换成 let result = throw(new Error('出错了'));
return 是将 yield 表达式替换成 return 语句 yield* 表达式 带星号的 yield,可以增强yield的行为,使它能够迭代一个可迭代对象,从而一次产出一个值,这也叫委托迭代。通过这样的方式,能将多个生成器连接在一起。
function * anotherGenerator(i) { yield i + 1; yield i + 2; yield i + 3; } function * generator(i) { yield* anotherGenerator(i); } var gen = generator(1); gen.next().value; // 2 gen.next().value; // 3 gen.next().value; // 4
几个注意点:
任何数据结构只要有 Iterator 接口,就可以被yield*遍历。
如果被代理的 Generator 函数有return语句,那么就可以向代理它的 Generator 函数返回数据。
使用 yield* 实现递归算法
实现递归算法,这也是 yield* 最有用的地方,此时生成器可以产生自身
function* nTimes(n) { if (n > 0) { yield* nTimes(n - 1); yield n - 1; } } for (const x of nTimes(3)) { console.log(x); } // 0 1 2
上面的代码中,每个生成器首先会从新创建的生成器对象产出每个值,然后再产出一个整数。