我喜欢 JavaScript 在过去十年里出现的“语法糖”,像箭头函数、模板字面量、解构赋值等,因为它们解决了很多实际痛点。但生成器函数有点特别,它虽然存在很久了,实用性却没那么流行。不过我至少有一次发现它很有用,还写过相关文章。我决定认真试试生成器,了解之后发现它在特定场景下的人体工程学和思维模型还挺不错的。下面我来详细说说。
迭代器和可迭代协议
要理解生成器,得先了解迭代器和可迭代这两个协议,它们都和产生不确定值序列有关,可迭代协议建立在迭代器协议之上。
迭代器协议
这个协议规定了返回序列的对象的样子和行为。只要一个对象有 next() 方法,这个方法返回一个包含 value(任意值)和 done(布尔值)两个属性的对象,那它就是迭代器。
下面是个简单例子:
const gospelIterator = {
index: -1,
next() {
const gospels = ['Matthew', 'Mark', 'Luke', 'John'];
this.index++;
return {
value: gospels.at(this.index),
done: this.index + 1 > gospels.length,
};
},
};
gospelIterator.next(); // {value: 'Matthew', done: false}
gospelIterator.next(); // {value: 'Mark', done: false}
gospelIterator.next(); // {value: 'Luke', done: false}
gospelIterator.next(); // {value: 'John', done: false}
gospelIterator.next(); // {value: undefined, done: true}
迭代器的序列不一定要结束,无限迭代器也是可以的,比如:
const infiniteIterator = {
count: 0,
next() {
this.count++;
return { value: this.count, done: false };
},
};
infiniteGenerator.next(); // 永远计数...
可迭代协议
如果一个对象有 [Symbol.iterator]() 方法,并且这个方法返回一个迭代器对象,那它就是可迭代的。我们用 for...of 循环或者解构数组的时候,就用到了这个协议。像 String、Array 和 Map 这些常见的类型都内置了这个协议。
我们自己也能实现可迭代协议,定义 for...of 循环的行为。看下面这个例子:
const gospelIteratable = {
[Symbol.iterator]() {
return {
index: -1,
next() {
const gospels = ['Matthew', 'Mark', 'Luke', 'John'];
this.index++;
return {
value: gospels.at(this.index),
done: this.index + 1 > gospels.length,
};
},
};
},
};
现在 for...of 循环和解构都能正常工作了:
for (const author of gospelIteratable) {
console.log(author); // Matthew, Mark, Luke, John
}
console.log([...gospelIteratable]); // ['Matthew', 'Mark', 'Luke', 'John']
再看一个不能用简单数组轻松模拟的例子,遍历 1900 年后的每个闰年:
function isLeapYear(year) {
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);
}
这里不需要提前构建整个年份序列,所有状态都存储在可迭代对象里,下一个项目是按需计算的。
惰性求值
惰性求值是迭代器的一大优势。我们不需要一开始就获取序列里的每个项目,这样在某些情况下能避免性能问题。
还是看上面的 leapYears 可迭代对象,如果不用可迭代对象,用 for 循环和预构建的数组来处理,代码可能是这样:
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 循环,还得预先处理整个值列表。对于复杂计算或者大数据集,这样会更耗费资源。
比如:
for (const thing of getExpensiveThings(1000)) {
// Do something important with thing.
}
如果没有自定义的可迭代对象,就得在循环前构建 1000 个项目的完整列表,会延长执行时间。
再比如,我们想根据出生年份找到一个人经历的第一个闰年,用预构建的数组可能会有一部分数据用不上:
function getFirstLeapYear(birthYear) {
for (const leapYear of leapYears) {
if (leapYear >= birthYear) return leapYear;
}
return null;
}
// 仅计算到 1992 年的闰年:
getFirstLeapYear(1989) // 1992
通过生成器平滑处理协议
我们可以把上面的可迭代对象写成生成器函数,它会返回一个生成器对象。
function* generateGospels() {
yield 'Matthew';
yield 'Mark';
yield 'Luke';
yield 'John';
}
这里有两个关键的东西:function* 和 yield 关键字。function* 把它标记为生成器函数,yield 可以看作是生成器被要求提供下一个值时的“暂停点”。
底层还是会调用 next() 方法,每次调用就会移动到下一个 yield 语句。
const generator = generateGospels();
console.log(generator.next()); // {value: 'Matthew', done: false}
for...of 循环也能正常工作:
for (const gospel of generateGospels()) {
console.log(gospel);
}
// Matthew
// Mark
// Luke
// John
生成器可以是无限的,比如:
function* multipleGenerator(base) {
let current = base;
while (true) {
yield current;
current += base;
}
}
虽然看起来是无限循环,但只要每次迭代之间有 yield,就不会锁住浏览器。
const multiplier = multipleGenerator(22);
multiplier.next(); // {value: 22, done: false}
multiplier.next(); // {value: 44, done: false}
multiplier.next(); // {value: 66, done: false}
//... no infinite loop!
不过要注意,生成器是同步执行的,可能会阻塞主线程,有方法可以避免这种情况,还有 AsyncGenerator 对象可以帮忙。
我开始欣赏它们的地方
生成器虽然没有什么突破性的能力,很多问题用其他常见方法也能解决,但我用得越多,就越欣赏它,主要有下面几个原因。
它们可以帮助减少紧密耦合
生成器和其他迭代器在封装自身和管理状态方面做得很好,能缓解组件之间的紧密耦合。
比如,点击按钮依次显示某个价格在过去五年中的移动平均值,一开始的代码可能是这样:
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 = `Average: $${avg}`;
windowStart++;
});
这段代码需要在高作用域中保留 windowStart 变量,事件监听器还要负责更新状态,而且在其他地方复用计算移动平均值的逻辑会很困难。
用生成器可以解决这些问题:
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 () {
const { value } = generator.next();
average.innerHTML = `Average: $${value}`;
});
这样做有很多好处:
windowStart变量只在需要的地方出现。- 状态和逻辑是自包含的,可以并行使用多个不同的生成器。
- 每个部分的目的更明确,生成器负责数学和状态,点击处理程序只更新 DOM。
我们还可以进一步改进,让生成器只提供值,点击处理程序只处理值,双方不需要知道对方的细节:
for (const value of calculateMovingAverage(prices, 5)) {
await new Promise((r) => {
loadButton.addEventListener(
'click',
function () {
average.innerHTML = `Average: $${value}`;
r();
},
{ once: true }
);
});
}
它们可以帮助避免一些“烦人”的小事
以前我们做一些事情的时候,经常会用到递归、回调等方法,虽然这些方法本身没错,但用起来有点麻烦。
比如,一个仪表板每秒显示最新的应用程序状态,有几种实现方法。
setInterval()
// ...一遍又一遍!
function monitorVitals(cb) {
setInterval(async () => {
const vitals = await requestVitals();
cb(vitals);
}, 1000);
}
monitorVitals((vitals) => {
console.log('Update the UI...', vitals);
});
这种方法需要传递回调,可能会引发“回调地狱”,而且间隔时间不考虑请求数据的时间,可能会导致乱序问题。
setTimeout()
async function monitorVitals(cb) {
const vitals = await requestVitals();
cb(vitals);
await new Promise((r) => {
setTimeout(() => monitorVitals(cb), 1000);
});
}
monitorVitals((vitals) => {
console.log('Update the UI...', vitals);
});
这种方法还是需要传递回调。
while(){}
async function monitorVitals(cb) {
while (true) {
await new Promise((r) => setTimeout(r, 1000));
const vitals = await requestVitals();
cb(vitals);
}
}
monitorVitals((vitals) => {
console.log('Update the UI...', vitals);
});
这种方法同样需要传递回调。
异步生成器的引入
async function* generateVitals() {
while (true) {
const result = await requestVitals();
await new Promise((r) => setTimeout(r, 1000));
yield result;
}
}
for await (const vitals of generateVitals()) {
console.log('更新 UI...', vitals);
}
用异步生成器就没有那些问题了,没有时间风险,没有递归,也没有回调,不同的关注点能很好地分开。
再比如获取分页资源的所有项目,原来的代码可能是这样:
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;
}
这种方法有很多辅助变量,还得拼接列表,而且要等 API 全部获取完才能做其他操作。
用异步生成器可以改进:
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()) {
// 做一些事情。
}
这样辅助变量更少,处理开始前的时间更短,关注点也能很好地分开。
它们使得实时生成项目集合变得非常方便
因为生成器是可迭代的,所以可以像数组一样被解构。如果需要生成各种批次的东西,用生成器很简单。
function* getElements(tagName = 'div') {
while (true) yield document.createElement(tagName);
}
现在可以直接解构:
const [el1, el2, el3] = getElements('div');
我们拭目以待
我现在对生成器很欣赏,但不知道这种热情能持续多久。不过就算热情消退了,我也很高兴有了更多的实践经验。知道怎么用这个工具很有价值,更重要的是,它让我重新思考处理问题的方式,这个回报率还是挺不错的。