你不知道的 JavaScript Generator
探索迄今为止我遇到的少数几个实用的迭代器和生成器用例之一------按需解构任意数量的实体。
JavaScript 有一个定义对象迭代行为的标准,每当你解构数组或在 for..of 循环中使用它时,它都会起作用。
javascript
const arr = ['first', 'second', 'third'];
// 解构
const [first, second, third] = arr;
// for...of 遍历
for(const item of arr) {
doStuff(item);
}
但这不仅仅适用于内置对象。你可以通过给任何对象赋予 Symbol.iterator
函数属性来使其可迭代,该属性返回遵循迭代器规则的对象。在最基本的形式中,返回的对象具有 next()
方法,返回另一个具有 value
和 done
属性的对象。
例如,下面是一个提供数字范围(1 到 3)的迭代:
javascript
let count = 1;
const iterableObj = {
[Symbol.iterator]() {
return {
next() {
return {
done: count === 4,
value: count++,
};
},
};
},
};
for (const item of iterableObj) {
console.log(item);
}
// 1
// 2
// 3
其中,正确设置 done
属性至关重要,因为这样 for
循环才能知道何时停止执行,或者,如果你正在解构,则何时开始获取未定义的值:
javascript
const [a, b, c, d] = iterableObj;
// 1, 2, 3, undefined
虽然有意思,但众所周知,自定义可迭代对象缺乏"真实"的用例。至少我没碰到过。
直到我看到了国外某个大佬推特发的推文,在他的示例中,迭代器和解构用于动态生成任意数量的对象(在他的示例中为 DOM 元素)。这是该实现(稍作修改,但思想相同):
javascript
function getElements(tagName = 'div') {
return {
[Symbol.iterator]() {
return {
next() {
return {
done: false,
value: document.createElement(tagName),
};
},
};
},
};
}
const [el1, el2, el3] = getElements('div');
console.log(el1, el2, el3);
// HTMLDivElement, HTMLDivElement, HTMLDivElement
起初,它看起来不必要地复杂,特别是考虑到我个人会用数组做:
javascript
function getElements(tagName = 'div', number) {
return new Array(number).fill(null).map(i => document.createElement(tagName));
}
// or
function getElements(tagName, length) {
return Array.from({ length }, () => document.createElement(tagName));
}
const [el1, el2, el3] = getElements('div', 3);
console.log(el1, el2, el3);
// HTMLDivElement, HTMLDivElement, HTMLDivElement
尽管需要预先指定要生成多少个项目,但这种方法总是让我满意。但后来我想起来,在 JavaScript 中可迭代的方法不止一种。
如果使用 Symbol.iterator
太冗长,你可以使用 generator 的语法糖,generator 生成器是一个返回 Generator
对象的特殊函数,其功能与手写迭代器的功能相同。这是作为生成器函数编写的方法:
javascript
function* getElements(tagName = 'div') {
while (true) {
yield document.createElement(tagName);
}
}
const [el1, el2, el3] = getElements('div');
console.log(el1, el2, el3);
无限的 while
循环可能会让人觉得不安,但它是安全的------yield
关键字不允许它继续运行,直到每次调用生成器为止,无论是通过 for..of
(你甚至可以异步使用)、解构, 或者是其他东西。
使用生成器解构的一些好处
- 我不需要预先指定要生成多少项目。因此,函数的参数稍微简单一些(不再需要数字参数)。
- 有一个(小的)性能优势。对象是按需生产的,而不是预先生产的。我永远不会意外地生成五个对象,最终只需要三个,而留下两个孤儿。
使用案例
为此,我特地查阅了相关资料,找到了一些关于生成器函数的相关实践场景,并给出相关的 demo。
自定义序列生成
假如我们有个分组序列,同时我们又需要在需要的时候放入一个序列中:
javascript
function* groupSequence(groups) {
for (let group of groups) {
yield* group;
}
}
function* mainSequence() {
const seq = [1, 2, 3];
const groups = [[4, 5, 6], [7, 8, 9]];
yield* seq;
yield* groupSequence(groups);
yield 10;
}
// 使用生成器函数获取整个序列
const result = [...mainSequence()];
console.log(result);
在上面的示例中,我们定义了两个生成器函数。groupSequence
生成器函数接受一个包含多个分组的数组 groups
,然后使用 yield*
关键字逐个生成分组中的元素。
mainSequence
生成器函数首先使用 yield*
关键字逐个生成序列 seq
中的元素。然后,使用 yield*
关键字调用 groupSequence
生成器函数,将分组序列放入主序列中。最后,使用 yield
关键字生成值为 10 的元素。
最后,我们使用扩展运算符 ...
将生成的序列转换为数组,并将结果打印出来,结果如下:
csharp
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
通过定义自己的生成器函数,你可以根据需要自由组合不同的序列,并在需要的时候将其放入一个序列中。你可以根据具体需求定义不同的生成器函数来生成不同类型的序列,并使用 yield
或 yield*
关键字来控制序列的生成和组合。
由此可见,生成器函数可以用于转换和处理数据。我们可以使用生成器函数对输入数据进行过滤、映射、排序等操作,并以生成器的形式输出处理后的结果,从而实现数据管道的效果。
惰性计算
一个实际可用的案例是从一个大型数据集中筛选出满足特定条件的元素。使用生成器函数进行惰性计算可以避免计算整个数据集,只在需要时生成满足条件的元素。以下是一个示例:
javascript
function* filterGenerator(array, condition) {
for (let i = 0; i < array.length; i++) {
if (condition(array[i])) {
yield array[i];
}
}
}
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const filteredData = filterGenerator(data, num => num % 2 === 0);
console.log(filteredData.next().value); // 输出: 2
console.log(filteredData.next().value); // 输出: 4
console.log(filteredData.next().value); // 输出: 6
在上面的例子中,我们定义了一个生成器函数 filterGenerator
,它接受一个数组 array
和一个条件函数 condition
作为参数。
在每次循环迭代中,我们检查数组中的元素是否满足条件。如果满足条件,则使用 yield
关键字生成该元素。
接下来,我们创建了一个名为 filteredData
的生成器对象,该对象使用 filterGenerator
生成器函数进行初始化,并传入数据集 data
和条件函数 num => num % 2 === 0
,该条件函数筛选出偶数。
最后,我们通过多次调用 filteredData.next().value
来获取生成器对象的下一个满足条件的元素。每次调用 next()
方法时,都会检查下一个元素是否满足条件,并使用 .value
来获取生成的元素值。
这种惰性计算的方式避免了计算整个数据集,只在需要时生成满足条件的元素。这在处理大型数据集或需要复杂条件筛选时尤其有用,可以节省大量的计算资源。
简化分页请求
使用生成器函数可以简化分页请求。假设我们需要从服务器获取一系列数据,每次请求一页数据,直到获取到所有数据为止。以下是一个示例:
javascript
async function fetchData(page) {
// 发送分页请求并返回数据
const response = await fetch(`https://api.example.com/data?page=${page}`);
const data = await response.json();
return data;
}
async function* paginatedDataGenerator() {
let page = 1;
let data = await fetchData(page);
while (data.length > 0) {
yield data;
page++;
data = await fetchData(page);
}
}
const dataGenerator = paginatedDataGenerator();
(async () => {
for await (const data of dataGenerator) {
console.log(data); // 输出每一页的数据
}
})();
在上面的例子中,我们定义了一个生成器函数 paginatedDataGenerator
,它使用 fetchData
异步函数来请求分页数据。
在生成器函数内部,我们初始化 page
和 data
变量,并使用 await
关键字来等待第一页数据的请求结果。然后,我们使用一个 while
循环来检查是否还有数据需要获取。在每次循环迭代中,我们使用 yield
关键字返回当前页的数据。然后,我们递增 page
变量,发送下一页的请求,并将结果赋值给 data
变量。 在外部,我们创建了一个名为 dataGenerator
的生成器对象,该对象使用 paginatedDataGenerator
生成器函数进行初始化。然后,我们使用 for await...of
循环来迭代生成器对象,并使用 const data
来接收每一页的数据。
在循环体内部,我们可以对每一页的数据执行自定义的操作,例如打印到控制台或将其存储在数组中。
这种使用生成器简化分页请求的方式可以帮助我们更方便地处理分页数据,避免了嵌套的回调或手动管理分页状态的麻烦。而且,使用生成器还可以提高代码的可读性和可维护性。
数据流处理
生成器函数可以用于处理数据流,特别是在处理大型数据集时非常有用。通过逐个生成数据项,我们可以在内存中有效地处理大量数据,而不必一次性加载全部数据。以下是一些实际使用生成器函数处理数据流的案例:
- 大型文件处理:当需要处理大型文件时,一次性将整个文件加载到内存可能会导致内存溢出。通过使用生成器函数,我们可以逐行读取文件并逐个生成数据项,从而避免一次性加载整个文件。这样可以有效地处理大型文件,而不会消耗太多的内存。
- 数据库查询:当需要处理大量数据库记录时,一次性将所有记录加载到内存可能会造成性能问题。通过使用生成器函数,我们可以逐个获取数据库记录并生成数据项,从而在内存中有效地处理大量数据,而不必一次性加载全部数据。
- 数据流转换:在数据流处理中,我们可能需要对输入数据进行转换和过滤。生成器函数可以用于逐个生成转换后的数据项,并将处理后的数据流传递给下一个处理阶段。这样可以实现数据流的转换和过滤,而不必一次性加载和处理全部数据。
- 网络请求:在处理网络请求时,生成器函数可以用于逐个生成请求结果。通过使用生成器函数,我们可以在异步请求完成后逐个生成数据项,并在需要时暂停和恢复请求的执行。这样可以避免同时发起大量请求,从而提高性能和资源利用率。
- 日志处理:当需要处理大量日志数据时,一次性加载和处理所有日志可能会导致性能问题。通过使用生成器函数,我们可以逐个生成日志项并进行处理,从而在内存中有效地处理大量日志数据,而不必一次性加载全部数据。
开源库:js-coroutines
js-coroutines 是一个用于创建协作式程序(coroutines)的 JavaScript 库。它使用生成器(generator)来实现协作式调度,使得多个任务可以在同一线程上按顺序执行,同时保持高优先级的动画流畅度。通过 js-coroutines,你可以编写基于生成器的函数,以便在需要时暂停和恢复执行。这使得你可以创建复杂的控制流程,包括高优先级的顺序动画和低优先级的增量计算。
此外,js-coroutines 还提供了一些原语(primitives),如数据排序和 LZ 压缩 JSON 数据等,以便在协作式程序中处理其他类型的任务。
在需要进行大量数字运算的情况下,你可以使用 js-coroutines 来管理任务调度,确保动画保持 60FPS 的流畅度。通过合理地安排任务,你可以优化程序的执行效率,同时保持用户界面的流畅响应。