JavaScript 生成器详解

生成器最常见于 sagas 中,但它们有更多的使用场景。你将在本文中看到其中的一些。

对于“什么是生成器?”这个问题的简短回答是:

生成器是 JavaScript 中的拉取流(pull stream)。

现在我们来剖析这个定义,然后跳入一些示例中。

首先,你需要理解两个术语:“拉取(pull)”和“流(stream)”。

什么是流?

流是随时间传递的数据。流有两种类型:推送流(push stream)和拉取流(pull stream)。

什么是推送流?

推送流是一种机制,你无法控制数据何时到达。

推送流的例子包括:

  • websocket,
  • 从磁盘读取文件,
  • 服务器发送事件。

你可以在下面看到一个使用 Node.js 从磁盘读取大文件的推送流 JavaScript 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const fs = require('fs');
const readStream = fs.createReadStream('./largeFile.txt');

readStream.on('data', chunk => {
console.log('data received', chunk.length);
});

readStream.on('end', () => {
console.log('finished reading file');
});

readStream.on('error', error => {
console.log('an error occurred while reading the file', error);
});

什么是拉取流?

拉取流是指你可以控制何时请求数据。

你很快会在生成器代码中看到 JavaScript 的拉取流示例,但首先你需要理解另一个概念。

惰性 vs. 饥渴

在编程中,数据处理可以通过两种基本方式进行:饥渴(eager)或惰性(lazy)。

饥渴

饥渴意味着数据会立即被评估,无论当下是否需要结果。推送流就是饥渴的。(其他例子:数组方法、Promise)

1
2
3
4
5
6
7
8
9
10
11
// 使用数组方法的饥渴求值:
const numbers = [1, 2, 3, 4, 5];

// Map 方法会立即处理数组中的所有元素。
const squares = numbers.map(num => {
console.log(`Squaring ${num}`);
return num * num;
});

console.log('squares:', squares); // [1, 4, 9, 16, 25]
console.log('squares:', squares); // [1, 4, 9, 16, 25]

你可能会想:“好吧,但为什么 Promise 是饥渴的呢?它们的结果是延迟到达的。”

JavaScript 中的 Promise 表现出饥渴求值有以下几个原因。

  • 立即执行:传递给新 Promise 的函数(称为执行器函数)在 Promise 构造时立即执行。
  • 不可逆操作:一旦执行器函数开始执行,无法通过消费代码停止或暂停。它执行的操作(无论是成功解析还是拒绝)会被排队到 JavaScript 事件循环中尽快处理。
  • 没有惰性选项:Promise 没有内置机制来推迟或取消执行器的执行,直到需要值时才开始。
  • 副作用:Promise 的饥渴特性意味着,任何包含在执行器中的副作用(如 API 调用、超时或 I/O 操作)都会立即发生。

下面的示例演示了 Promise 的立即执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用 Promise 和数组方法的饥渴求值

console.log("Before promise");

let promise = new Promise((resolve, reject) => {
console.log("Inside promise executor");
resolve("Resolved data");
});

console.log("After promise");

promise.then(result => {
console.log(result);
});

输出结果如下:

1
2
3
4
5
$ node eager-promise-example.js
Before promise
Inside promise executor
After promise
Resolved data

惰性

惰性意味着只有在需要值时才进行求值(而不是提前)。拉取流就是惰性的。

一个同步的例子是操作数选择运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用逻辑运算符进行惰性求值

function processData(data) {
console.log(`Processing ${data}`); // 这行代码永远不会被执行 🚫
return data * data;
}

console.log('惰性求值开始');
const data = 5;
const isDataProcessed = false;

// 使用逻辑与运算符进行惰性求值。
const result = isDataProcessed && processData(data);
console.log('结果:', result); // false

运行这段代码时,你会看到如下输出:

1
2
3
$ node lazy-evaluation-example.js
惰性求值开始
结果: false

由于 isDataProcessedfalse,因此 processData 函数从未执行,你也不会在控制台看到 “Processing 5”。这表明表达式只会计算得到结果所需的部分。

什么是生成器?

生成器是 JavaScript 中的拉取流。这意味着它是一种特殊的函数,你可以暂停执行并稍后恢复。

生成器函数返回的 Generator 对象符合可迭代协议和迭代器协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function* myGenerator() {
yield "Hire senior";
yield "React engineers";
yield "at ReactSquad.io";
}

const iterator = myGenerator();

// 将生成器用作迭代器
console.log(iterator.next()); // { done: false, value: "Hire senior" }
console.log(iterator.next()); // { done: false, value: "React engineers" }
console.log(iterator.next()); // { done: false, value: "at ReactSquad.io" }
console.log(iterator.next()); // { done: true, value: undefined }

// 将生成器用作可迭代对象
for (let string of myGenerator()) {
console.log(string); // "Hire senior", "React engineers", "at ReactSquad.io"
}

除了 .next() 方法,生成器还有 .return().throw() 方法。

  • .return() - .return() 方法终止生成器的执行并返回指定的值,还会触发任何 finally 代码块。
  • .throw() - .throw() 方法允许在生成器的最后一个 yield 处抛出一个错误,该错误可以被捕获和处理,或者允许生成器通过 finally 代码块进行清理。如果未捕获,它会停止生成器并将其标记为完成。
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
29
30
31
32
33
function* numberGenerator() {
try {
yield 1;
yield 2;
yield 3;
} finally {
console.log("清理完成");
}
}

const generator = numberGenerator();

// 正常使用生成器
console.log(generator.next()); // { done: false, value: 1 }
console.log(generator.next()); // { done: false, value: 2 }

// 使用 return() 提前结束生成器
console.log(generator.return(10)); // { done: true, value: 10 }
// 在 return() 之后,不再生成新的值
console.log(generator.next()); // { done: true, value: undefined }

// 为 throw 示例重置生成器
const newGenerator = numberGenerator();
console.log(newGenerator.next()); // { done: false, value: 1 }

// 使用 throw() 抛出错误信号
try {
newGenerator.throw(new Error("出错了"));
} catch (e) {
console.log(e.message); // "出错了"
}
// 在 throw() 之后,生成器关闭
console.log(newGenerator.next()); // { done: true, value: undefined }

你也可以在调用 .next() 时传入数字或其他值。

试着预测以下示例中的日志输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* moreNumbers(x) {
console.log('x', x);
const y = yield x + 2;
console.log('y', y);
const z = yield x + y;
console.log('z', z);
}

const it2 = moreNumbers(40);

console.log(it2.next());
console.log(it2.next(2012));
console.log(it2.next());

此示例演示了生成器函数 moreNumbers 如何根据每次调用 .next() 时收到的输入操作和生成值。

看看输出,并检查你的预测。

1
2
3
4
5
6
7
8
9
10
const it2 = moreNumbers(40);

// x: 40
console.log(it2.next()); // { value: 42, done: false }

// y: 2012
console.log(it2.next(2012)); // { value: 2052, done: false }

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

我们逐步分析 moreNumbers 生成器函数的每一步,以便你充分理解它。

步骤 代码行 控制台输出 解释
1 const it2 = moreNumbers(40) 初始化生成器,x 设置为 40。
2 console.log(it2.next()); { value: 42, done: false } 生成器启动并将 x 记录为 40,然后生成 42 (x + 2)。
3 console.log(it2.next(2012)); { value: 2052, done: false } 继续执行,y 为 2012,记录 y,生成 2052 (x + y)。
4 console.log(it2.next()); { value: undefined, done: true } 继续执行,zundefined(没有新的输入),完成执行。

生成器的使用场景

生成器有三大主要使用场景:

  1. 惰性求值 - 按需生成数据或处理大型或无限的数据集。
  2. 异步编程 - 处理异步操作。
  3. 迭代器 - 允许在复杂流程的各个步骤之间暂停执行。

之前你看到了将从磁盘读取文件作为推送流的示例。下面是如何使用生成器将其转换为拉取流的代码:

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
29
30
31
32
33
34
35
36
37
const fs = require('fs');

function getChunkFromStream(stream) {
return new Promise((resolve, reject) => {
stream.once('data', (chunk) => {
stream.pause();
resolve(chunk);
});

stream.once('end', () => {
resolve(null);
});

stream.once('error', (err) => {
reject(err);
});

stream.resume();
});
}

async function* readFileChunkByChunk(filePath) {
const stream = fs.createReadStream(filePath);
let chunk;

while (chunk = await getChunkFromStream(stream)) {
yield chunk;
}
}

const generator = readFileChunkByChunk('./largeFile.txt');

(async () => {
for await (const chunk of generator) {
console.log("数据接收", chunk.length);
}
})();

实际案例

Sagas 是处理异步 I/O 操作的一个典型例子。但你将在 Redux 系列文章中学习如何使用 Sagas

通常,当你想控制何时获取一个值时,会使用生成器。

来看下面这个测试示例:

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
29
30
31
32
33
34
35
36
37
test('当用户为已加入的组织所有者时:显示邀请链接创建 UI 以及组织成员,并允许用户更改其角色', async ({ page }) => {
// 组织中角色的生成器。
function* roleGenerator() {
const allRoles = Object.values(ORGANIZATION_MEMBERSHIP_ROLES);
for (const role of allRoles) {
yield role;
}
}

const roleIterator = roleGenerator();
const data = await setup({
page,
role: ORGANIZATION_MEMBERSHIP_ROLES.OWNER,
numberOfOtherTeamMembers: allRoles.length,
});

const { organization, sortedUsers, user } = data;

// 导航到团队成员设置页面。
await page.goto(`/organizations/${organization.slug}/settings/team-members`);

// 通过生成器为每个团队成员分配角色。
for (let index = 0; index < sortedUsers.length; index++) {
const memberListItem = page.getByRole('list', { name: /team members/i }).getByRole('listitem').nth(index);
const otherUser = sortedUsers[index];

// 为每个团队成员更改角色,除了当前用户。
if (otherUser.id !== user.id) {
await memberListItem.getByRole('button', { name: /member/i }).click();
const role = roleIterator.next().value!;
await page.getByRole('option', { name: role }).getByRole('button').click();
await page.keyboard.press('Escape');
}
}

await teardown(data);
});

在此测试中,定义了一个 roleGenerator 来顺序提供组织用户的角色列表。该方法允许测试在 UI 中的角色管理功能中为每个用户动态分配预定义列表中的唯一角色。

在这个例子中使用生成器而不是数组的原因是,测试中主用户在 sortedUsers 数组中的位置是未知的,而生成器是一个拉取流,因此你可以按需获取角色值。