【翻译】我竟渐渐迷上了生成器的设计巧思

原文链接: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 的常见原生类型StringArrayMap)都内置了这个协议。

自己实现可迭代协议,就能自定义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');

客观来说,这个写法简直太优雅了。想了解这个技巧的更多细节,可以看看我之前写的完整文章。

未来可期

我还不确定自己对生成器的这份喜爱能持续多久(可能现在还处于 "蜜月期")。

但即便这份热情明天就消退,我也很庆幸自己多了一项编程技能。掌握一个工具固然重要,但被迫重新思考自己的常规解题思路,收获会更大 ------ 这绝对是一笔划算的投入。

相关推荐
Wect2 小时前
LeetCode 104. 二叉树的最大深度:解题思路+代码解析
前端·算法·typescript
千寻girling2 小时前
面试官 : “ 请说一下 JS 的常见的数组 和 字符串方法有哪些 ? ”
前端·javascript·面试
Wect2 小时前
LeetCode 100. 相同的树:两种解法(递归+迭代)详解
前端·算法·typescript
我是伪码农2 小时前
Vue 大事件管理系统
前端·javascript·vue.js
henry1010102 小时前
DeepSeek生成的网页版小游戏 - 冰壶
前端·javascript·css·html5
木斯佳2 小时前
前端八股文面经大全:腾讯云前端实习一面(2025-12-26)·面经深度解析
前端·状态模式·腾讯云
哆啦A梦15882 小时前
Vue3魔法手册 作者 张天禹 012_路由_(二)
前端·vue.js·typescript
木斯佳2 小时前
前端八股文面经大全:2026-01-29 字节-AIDP前端实习一面面经深度解析
前端·状态模式
We་ct2 小时前
LeetCode 100. 相同的树:两种解法(递归+迭代)详解
前端·算法·leetcode·链表·typescript