深扒深入理解 JavaScript 中的生成器

Posted 小丞同学

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深扒深入理解 JavaScript 中的生成器相关的知识,希望对你有一定的参考价值。

📢 大家好,我是小丞同学,本文将会带你理解 ES6 中的生成器

写在前面

在上篇文章中,我们深入了理解了迭代器的原理和作用,这一篇我们来深扒与迭代器息息相关的生成器

关于生成器有这样的描述

红宝书:生成器是 ES6 新增的一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力

阮一峰老师Generator 函数是 ES6 提供的一种异步编程解决方案

从上面的两段话中,我们可以知道生成器有着至少两个作用

  1. 打破完整运行,拥有暂停和启动的能力
  2. 解决异步操作

下面我们来看看生成器是如何实现这些功能的

一个例子了解生成器

我们先来看一个例子

下面是一个 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 传递参数

生成器的另一强大之处在于内建消息输入输出能力,而这一能力仰仗于 yieldnext 方法

yield 表达式本身没有返回值,或者说总是返回 undefinednext 方法可以带一个参数,该参数就会被当作上一个 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) 的值,运行到下一个 yieldreturn 结束

下面开始作死

在上面的例子中,如果不传递参数会这么样呢?
在第二次运行 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

几个注意点:

  1. 任何数据结构只要有 Iterator 接口,就可以被yield*遍历。
  2. 如果被代理的 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

上面的代码中,每个生成器首先会从新创建的生成器对象产出每个值,然后再产出一个整数。

参考资料

[译] 什么是 JavaScript 生成器?如何使用生成器?

阮一峰老师 Generator 函数的语法

《JavaScript高级程序设计第四版》


上篇文章:【深扒】 JavaScript 中的迭代器

本文内容就到这里结束了,关于生成器的核心应用异步编码模式以及回调问题,将在下篇总结。

非常感谢您的阅读,欢迎提出你的意见,有什么问题欢迎指出,谢谢!🎈

我的博客即将同步至腾讯云+社区,邀请大家一同入驻

以上是关于深扒深入理解 JavaScript 中的生成器的主要内容,如果未能解决你的问题,请参考以下文章

深扒深入理解 JavaScript 中的异步编程

深扒深入理解 JavaScript 中的异步编程

深扒 JavaScript 中的迭代器

深扒 JavaScript 中的迭代器

深入理解javascript编程中的同步和异步

深入理解javascript执行上下文