原文链接:macarthur.me/posts/gener...
我花了些功夫深入学习迭代器、可迭代对象和生成器,如今总算开始体会到其中的精妙之处。
我钟爱过去十年里 JavaScript 出现的那些 "语法糖"(箭头函数、模板字符串、解构赋值等等)。究其原因,是这些特性大多解决了我实际开发中的痛点 ------ 有些痛点我甚至自己都未曾察觉。它们的优势显而易见,也让我有大把机会在开发中大展拳脚。
但这些新特性里,也有一些 "异类",比如生成器函数。它和 ES2015 的其他核心特性诞生于同一时期,实用性却始终没怎么被大众认可,你甚至可能一眼都认不出它的写法:
javascript
function* generateAlphabet() {
yield "a";
yield "b";
// ... 依次生成
yield "z";
}
平心而论,我至少发现过一次它的实用之处。我曾写过一篇文章,讲如何用生成器按需解构任意数量的元素,直到现在我依然觉得这个用法超棒。但我总忍不住想,自己是不是还错过了它更多的妙用。
于是,我决定认认真真、沉下心来研究它一番。或许深入了解后,就能发现它的更多适用场景。没想到,我竟真的开始欣赏起它的设计巧思,以及它所传递的编程思维模式 ------ 至少在某些场景下是这样。接下来,我就和大家聊聊我的心得体会。首先,我们先退一步,把相关的基础概念梳理清楚。
迭代器协议与可迭代协议
生成器的底层依赖两个截然不同的协议,不了解它们,就无法真正理解生成器:迭代器协议和可迭代协议。这两个协议都用于生成一组长度不确定的序列值,后者是在前者的基础上构建而来的。
迭代器协议
该协议标准化了生成序列的对象 的结构和行为。一个对象只要满足以下条件,就是一个迭代器:它暴露一个next()方法,该方法返回一个包含两个属性的对象:
value: any:序列中的当前值done: boolean:标记序列是否遍历完毕
仅此而已。来看一个简单易懂的示例:
javascript
const gospelIterator = {
index: -1,
next() {
const gospels = ["马太福音", "马可福音", "路加福音", "约翰福音"];
this.index++;
return {
value: gospels.at(this.index),
done: this.index + 1 > gospels.length,
};
},
};
gospelIterator.next(); // {value: '马太福音', done: false}
gospelIterator.next(); // {value: '马可福音', done: false}
gospelIterator.next(); // {value: '路加福音', done: false}
gospelIterator.next(); // {value: '约翰福音', done: false}
gospelIterator.next(); // {value: undefined, done: true}
顺带一提,迭代器生成的序列并非必须有终点,无限迭代器是完全合法的:
javascript
const infiniteIterator = {
count: 0,
next() {
this.count++;
return {
value: this.count,
done: false,
};
},
};
infiniteIterator.next(); // 会一直生成递增的数字...
单看这个协议,除了能让迭代行为保持一致,似乎没什么实际用处。而可迭代协议,会让它的价值显现出来。
可迭代协议
一个对象如果拥有[Symbol.iterator]()方法,且该方法返回一个迭代器对象,那么这个对象就是可迭代对象 。我们平时使用的for...of循环、数组解构,底层都是基于这个协议实现的。JavaScript 的常见原生类型(String、Array、Map)都内置了这个协议。
自己实现可迭代协议,就能自定义for...of循环的行为。基于上面的示例,我们来实现一个可迭代对象:
javascript
const gospelIterable = {
[Symbol.iterator]() {
return {
index: -1,
next() {
const gospels = ["马太福音", "马可福音", "路加福音", "约翰福音"];
this.index++;
return {
value: gospels.at(this.index),
done: this.index + 1 > gospels.length,
};
},
};
},
};
现在,这个对象就能直接用for...of循环遍历,也能进行解构操作了:
javascript
for (const author of gospelIterable) {
console.log(author); // 马太福音、马可福音、路加福音、约翰福音
}
console.log([...gospelIterable]);
// ['马太福音', '马可福音', '路加福音', '约翰福音']
接下来我们看一个更进阶的示例,这个示例的效果很难用简单的数组实现:生成 1900 年之后的所有闰年并遍历:
javascript
function isLeapYear(year) {
// 闰年判断规则:整百年能被400整除,非整百年能被4整除
return year % 100 === 0 ? year % 400 === 0 : year % 4 === 0;
}
const leapYears = {
[Symbol.iterator]() {
return {
startYear: 1900,
currentYear: new Date().getFullYear(),
next() {
this.startYear++;
// 找到下一个闰年
while (!isLeapYear(this.startYear)) {
this.startYear++;
}
return {
value: this.startYear,
done: this.startYear > this.currentYear,
};
},
};
},
};
for (const leapYear of leapYears) {
console.log(leapYear);
}
注意看,我们无需提前生成所有的闰年序列,所有状态都存储在可迭代对象内部,下一个值会在需要时才计算生成。这一点非常重要,值得我们重点关注。
惰性求值
惰性求值是可迭代对象最受推崇的优势之一:我们无需从一开始就生成序列中的所有值。在某些情况下,这能有效避免性能问题。
再看上面的闰年可迭代对象示例。如果不用可迭代对象,而是用普通的for循环实现相同的功能,你大概率会提前生成一个包含所有闰年的数组:
javascript
const leapYears = [];
const startYear = 1900;
const currentYear = new Date().getFullYear();
// 先遍历生成所有闰年,存入数组
for (let year = startYear + 1; year <= currentYear; year++) {
if (isLeapYear(year)) {
leapYears.push(year);
}
}
// 再遍历数组使用闰年
for (const leapYear of leapYears) {
console.log(leapYear);
}
这段代码的可读性确实很高(很多人会觉得比可迭代对象的版本更易读),但也存在明显的取舍:需要执行两层for循环,更重要的是,所有值都会被提前计算并存储。在这个示例中,性能影响微乎其微,但如果是计算成本极高的操作,或是处理超大规模的数据集,这种方式的性能损耗就会非常明显。比如这样的场景:
javascript
for (const thing of getExpensiveThings(1000)) {
// 对每个元素执行重要操作
}
如果getExpensiveThings()底层没有自定义可迭代对象支撑,那么循环执行前,必须先生成包含 1000 个元素的完整数组。从执行脚本到真正开始处理业务逻辑,会产生不必要的时间损耗。
同理,当我们不需要序列中的所有值时,惰性求值的优势会更突出。比如,我们想根据一个人的出生年份,找到其经历的第一个闰年。一旦找到目标值,就无需继续计算后续的闰年了。如果用提前生成数组的方式,数组中后续的元素就相当于白生成了。
javascript
function getFirstLeapYear(birthYear) {
for (const leapYear of leapYears) {
if (leapYear >= birthYear) return leapYear;
}
return null;
}
// 只会计算到1992年的闰年,后续会直接终止
getFirstLeapYear(1989) // 1992
显然,在计算资源高度密集的场景中,惰性求值带来的效率提升会更显著,相信你已经明白其中的道理了:不需要的元素,就不会浪费计算资源去生成。
顺带说一句,如果你觉得手动实现可迭代对象的过程过于繁琐,其实很多人都有同感。所以,我们终于可以聊生成器了 ------ 这个特性的诞生,就是为了让这一切实现起来更简洁、更优雅。
用生成器简化协议实现
下面我们用生成器函数重写上面的可迭代对象,生成器函数会返回一个生成器对象:
javascript
function* generateGospels() {
yield "马太福音";
yield "马可福音";
yield "路加福音";
yield "约翰福音";
}
这里有两个关键的语法:function*和yield关键字。前者标记这是一个生成器函数;而yield关键字你可以理解为 "暂停键"------ 每当生成器被请求获取下一个值时,执行就会在yield处暂停。
生成器的底层,依然会调用next()方法。每次调用该方法,执行都会推进到下一个yield语句(如果还有的话)。
javascript
const generator = generateGospels();
console.log(generator.next()); // {value: '马太福音', done: false}
当然,生成器对象也能直接用for...of循环遍历,效果和预期一致:
javascript
for (const gospel of generateGospels()) {
console.log(gospel);
}
// 马太福音
// 马可福音
// 路加福音
// 约翰福音
记住:可迭代对象(包括生成器)可以是无限的,所以你可能会在实际开发中看到这样的代码:
javascript
function* multipleGenerator(base) {
let current = base;
while (true) {
yield current;
current += base;
}
}
这样的无限循环看起来很吓人,但并不会导致浏览器卡死。因为每次迭代之间都有yield语句,每当请求下一个值时,执行就会暂停,主线程就能继续处理其他任务。
javascript
const multiplier = multipleGenerator(22);
multiplier.next(); // {value: 22, done: false}
multiplier.next(); // {value: 44, done: false}
multiplier.next(); // {value: 66, done: false}
// ... 并不会出现无限循环!
不过有一点需要注意:生成器的执行是同步的 ,所以依然有可能阻塞主线程。好在我们有办法避免这个问题,比如AsyncGenerator(异步生成器)对象,就能帮我们解决这类问题。
我开始偏爱生成器的几个原因
生成器并非什么具有突破性的特性,我很难找到一个只能用它解决、而普通方法无法实现的问题。但随着使用次数的增多,我对它的好感也与日俱增。总结下来,主要有这几个原因:
减少代码的紧耦合
生成器(以及所有迭代器)的一大优势是高度的封装性,包括自身的状态管理。我越来越发现,这一特性能有效降低组件之间的耦合度 ------ 而我以前总是下意识地让组件之间产生不必要的依赖。
举个场景:点击按钮时,需要按时间顺序展示某一价格过去五年里的移动平均值,从最早的时间段开始。我们每次只需要一个时间段的平均值,甚至可能用不到所有的结果(用户可能不会一直点击按钮)。用普通方法实现的代码大概是这样的:
javascript
// 全局作用域的状态变量,用于标记当前计算的起始位置
let windowStart = 0;
function calculateMovingAverage(values, windowSize) {
// 截取当前窗口的数值
const section = values.slice(windowStart, windowStart + windowSize);
if (section.length < windowSize) return null;
// 计算移动平均值
return section.reduce((sum, val) => sum + val, 0) / windowSize;
}
loadButton.addEventListener("click", function () {
const avg = calculateMovingAverage(prices, 5);
average.innerHTML = `平均值:$${avg}`;
// 事件监听器需要负责更新状态
windowStart++;
});
每次点击按钮,页面就会渲染下一个平均值。但这种实现有个明显的问题:我们需要在高层作用域定义一个持久化的windowStart变量,而且让事件监听器负责更新状态,这让我很不舒服 ------ 我希望监听器只专注于更新 UI。
除此之外,如果页面的其他地方也需要计算这个移动平均值,这种实现方式会让代码变得一团糟:各个逻辑相互交织,边界模糊,更谈不上可移植性。
而生成器能完美解决这些问题:
javascript
function* calculateMovingAverage(values, windowSize) {
// 状态变量封装在生成器内部,仅在需要时暴露
let windowStart = 0;
while (windowStart <= values.length - 1) {
const section = values.slice(windowStart, windowStart + windowSize);
yield section.reduce((sum, val) => sum + val, 0) / windowSize;
windowStart++;
}
}
// 初始化生成器,传入参数
const generator = calculateMovingAverage(prices, 5);
loadButton.addEventListener("click", function () {
// 监听器只需要请求值并更新UI,无需关心内部逻辑
const { value } = generator.next();
average.innerHTML = `平均值:$${value}`;
});
这样的实现有很多优点:
windowStart变量只在需要它的地方暴露,不会污染外部作用域;- 状态和逻辑自包含,我们可以同时创建多个独立的生成器实例,彼此互不影响;
- 职责更单一:生成器负责计算和状态管理,点击监听器只负责更新 DOM,代码边界清晰。
我很喜欢这种编程模式,而且我们还能把它做得更极致。到目前为止,都是由点击监听器主动请求下一个值,直接依赖生成器的返回结果。但我们可以反过来,让生成器只负责生成就绪的值,监听器只负责消费这些值 ------ 两者都无需知道对方的内部实现细节。
javascript
// 生成器控制迭代节奏,监听器只负责响应事件
for (const value of calculateMovingAverage(prices, 5)) {
await new Promise((r) => {
loadButton.addEventListener(
"click",
function () {
average.innerHTML = `平均值:$${value}`;
r();
},
{ once: true } // 事件只触发一次,自动移除监听器
);
});
}
我猜你看到这段代码可能会感到诧异,甚至有点费解。这确实不是一种很自然的编程模式,但我很认可它的实现思路 ------控制反转。这段代码中,两个模块之间几乎没有任何依赖,彼此都不需要知道对方的实现细节。事件处理完成后,监听器会被自动清理,执行权交还给生成器。我觉得,鲍勃大叔(《代码整洁之道》作者)至少会认可这个设计思路(如果不认可,那就让他穿着浴袍吐槽我吧🤞)。
避开那些令人 "心烦" 的写法
我惊讶地发现,过去开发中很多不得不使用的繁琐写法,都能用生成器替代。比如递归、回调函数等等 ------ 这些写法本身没有问题,但用起来总让人觉得不爽。
其中一个典型场景就是循环执行的任务。比如,仪表盘需要每秒刷新一次应用的最新运行指标。这个需求可以拆分成两个职责:请求数据、渲染 UI。实现的方式有很多种:
方式 1:使用 setInterval
你可以选择经典的setInterval------ 它的设计初衷就是重复执行某个操作,看起来是最适合的选择:
javascript
// 一直重复执行!
function monitorVitals(cb) {
setInterval(async () => {
const vitals = await requestVitals();
// 借助回调函数传递数据,更新UI
cb(vitals);
}, 1000);
}
// 传入回调函数处理UI更新
monitorVitals((vitals) => {
console.log("更新UI...", vitals);
});
但这种写法也有两个让人不爽的点:为了分离 "请求数据" 和 "渲染 UI" 的职责,必须传递回调函数,这可能会让人想起 Promise 出现前 JavaScript 的 "回调地狱";此外,setInterval不会关心数据请求的耗时,如果请求耗时超过 1 秒,就会出现数据返回顺序错乱的问题。
方式 2:使用 setTimeout + 递归
作为替代方案,你可能会用 Promise 封装setTimeout,再结合递归实现:
javascript
async function monitorVitals(cb) {
const vitals = await requestVitals();
cb(vitals);
await new Promise((r) => {
// 递归调用,实现循环执行
setTimeout(() => monitorVitals(cb), 1000);
});
}
monitorVitals((vitals) => {
console.log("更新UI...", vitals);
});
这种写法能解决时序问题,但递归可能会让你产生心理阴影(毕竟很多人都踩过递归的坑),而且依然需要传递回调函数。
方式 3:使用无限 while 循环
还可以用无限while循环,结合异步代码实现:
javascript
async function monitorVitals(cb) {
while (true) {
await new Promise((r) => setTimeout(r, 1000));
const vitals = await requestVitals();
cb(vitals);
}
}
monitorVitals((vitals) => {
console.log("更新UI...", vitals);
});
这种写法没有了递归,但回调函数依然存在。
再次强调,上面这些写法本身都没有本质问题,只是用起来总觉得有那么点别扭。好在,我们还有另一种选择。
方式 4:使用异步生成器
我之前简单提过异步生成器,在生成器函数前加上async关键字,普通生成器就变成了异步生成器 。这个写法看似理所当然,却有一个特殊的能力:异步生成器可以配合for await...of循环,遍历所有解析后的异步值。
javascript
async function* generateVitals() {
while (true) {
const result = await requestVitals();
await new Promise((r) => setTimeout(r, 1000));
// 生成异步结果
yield result
}
}
// 用for await...of循环遍历消费
for await (const vitals of generateVitals()) {
console.log("更新UI...", vitals);
}
实现的效果和前面的方式一致,但却避开了那些让人不舒服的写法:没有时序问题、没有递归、没有回调函数,各个职责之间完美解耦,你只需要专注于处理序列本身即可。
让全量分页查询更高效
如果你做过分页接口的全量数据查询,大概率写过这样的代码:
javascript
async function fetchAllItems() {
let currentPage = 1;
let hasMore = true;
let items = [];
while (hasMore) {
const data = await requestFromApi(currentPage);
hasMore = data.hasMore;
currentPage++;
// 拼接每一页的结果,存入数组
items = items.concat(data.items);
}
// 所有数据查询完成后,才返回结果
return items;
}
看着这些辅助变量和数组拼接的逻辑,很少有人能觉得这种写法优雅。更重要的是,你必须等所有分页数据都查询完成,才能开始处理数据:
javascript
const allItems = await fetchAllItems();
// 必须等所有数据查询完成,这段代码才会执行
for (const item of items) {
// 处理数据
}
从时间和内存效率来看,这都不是最优解。我们可以重构代码,让每查询一页数据就立即处理,但这样又会遇到前面提到的那些问题。
不妨试试用异步生成器实现:
javascript
async function* fetchAllItems() {
let currentPage = 1;
while (true) {
const data = await requestFromApi(currentPage);
// 没有更多数据时,直接终止
if (!data.hasMore) return;
currentPage++;
// 每查询一页,就生成一页的数据,立即供外部处理
yield data.items;
}
}
// 边查询边处理,无需等待全量数据
for await (const items of fetchAllItems()) {
// 处理当前页的数据
}
这种写法的辅助变量更少,能更快开始处理数据,而且各个职责依然保持解耦,效果非常不错。
便捷地按需生成批量元素
我在文章开头提过这个用法,它实在太好用了,我必须再夸一遍。因为生成器是可迭代对象,所以它能像数组一样被解构。如果你需要一个工具函数来批量生成任意元素,生成器会让这个过程变得无比简单:
javascript
function* getElements(tagName = 'div') {
// 无限生成指定标签的DOM元素
while (true) yield document.createElement(tagName);
}
现在,你可以随心所欲地解构生成器,获取任意数量的元素:
javascript
// 解构生成3个div元素
const [el1, el2, el3] = getElements('div');
客观来说,这个写法简直太优雅了。想了解这个技巧的更多细节,可以看看我之前写的完整文章。
未来可期
我还不确定自己对生成器的这份喜爱能持续多久(可能现在还处于 "蜜月期")。
但即便这份热情明天就消退,我也很庆幸自己多了一项编程技能。掌握一个工具固然重要,但被迫重新思考自己的常规解题思路,收获会更大 ------ 这绝对是一笔划算的投入。