JavaScript iterators and generators:迭代器和生成器

随着ES6的引入,迭代器和生成器已经正式添加到JavaScript中。

迭代器能让你遍历任何符合规范的对象。在文章的第一部分,我们会探讨如何使用迭代器,以及如何让任意对象变得可迭代。
这篇博客的第二部分将完全聚焦于生成器(Generators):它们是什么、如何使用,以及在哪些场景下能派上用场。
我一向喜欢探究事物背后的运行原理。之前我写过一个系列,讲解了 JavaScript 在浏览器中是如何工作的。作为延续,本文就来深入剖析一下 JavaScript 中迭代器和生成器的底层机制。
一、什么是迭代器?
在深入理解生成器之前,我们必须先透彻掌握 JavaScript 中的迭代器,因为这两个概念是密不可分的。读完本节后,你就会明白:生成器本质上只是一种更安全、更便捷的编写迭代器的方式。
顾名思义,迭代器允许你遍历一个对象(数组本质上也是对象)。
你很可能已经用过 JavaScript 迭代器了。比如,每次你遍历数组时,其实就是在使用迭代器。除此之外,你还能遍历 Map 对象,甚至字符串。
for (let i of 'abc') {
  console.log(i);
}

// Output
// "a"
// "b"
// "c"
任何实现了可迭代协议(iterable protocol)的对象,都可以通过 for...of 循环来进行遍历。
如果我们往深了挖一挖,可以通过实现 @@iterator 函数(即 Symbol.iterator)来让任意对象变得可迭代,这个函数的作用是返回一个迭代器对象
二、使任何对象都可迭代
要透彻理解这一点,最好的方式就是来看一个具体的例子,演示如何让一个普通的对象变得可迭代。
我们先从一个包含用户姓名且按城市分组的对象开始:
 
const userNamesGroupedByLocation = {
  Tokio: [
    'Aiko',
    'Chizu',
    'Fushigi',
  ],
  'Buenos Aires': [
    'Santiago',
    'Valentina',
    'Lola',
  ],
  'Saint Petersburg': [
    'Sonja',
    'Dunja',
    'Iwan',
    'Tanja',
  ],
};
我之所以选这个例子,是因为如果数据是这种结构,想要遍历所有用户会非常麻烦;我们通常需要写多重循环才能把所有用户都取出来。
如果我们尝试直接遍历这个对象,就会得到下面这个错误信息:
 Uncaught ReferenceError: iterator is not defined

要让这个对象变得可迭代,我们首先得给它加上那个 @@iterator 函数。在代码里,我们是通过 Symbol.iterator 这个符号来访问它的。

userNamesGroupedByLocation[Symbol.iterator] = function() {
  // ...
}

正如我之前提到的,迭代器函数会返回一个迭代器对象。这个对象里包含一个 next 方法,该方法在被调用时也会返回一个对象,这个对象包含两个属性:donevalue 

userNamesGroupedByLocation[Symbol.iterator] = function() {
  return {
    next: () => {
      return {
        done: true,
        value: 'hi',
      };
    },
  };
}
这里的 value 包含了当前迭代的值,而 done 则是一个布尔值,用来告诉我们迭代是否已经结束了。
在实现这个函数时,我们需要格外地小心处理 done 的值,因为如果它一直返回 false,就会导致死循环。
上面的代码示例已经正确实现了可迭代协议。我们可以通过调用迭代器对象的 next 函数来测试一下:

 

// 调用迭代器函数返回迭代器对象
const iterator = userNamesGroupedByLocation[Symbol.iterator]();
console.log(iterator.next().value);
// "hi"

其实,用 for...of 循环来遍历对象,其底层原理就是调用 next 函数。 

 总结:


const userNamesGroupedByLocation = {
  Tokio: [
    'Aiko',
    'Chizu',
    'Fushigi',
  ],
  'Buenos Aires': [
    'Santiago',
    'Valentina',
    'Lola',
  ],
  'Saint Petersburg': [
    'Sonja',
    'Dunja',
    'Iwan',
    'Tanja',
  ],
};

userNamesGroupedByLocation[Symbol.iterator] = function() {
  return {
    next: () => {
      return {
        done: true,
        value: 'hi',
      };
    },
  };
}

// Calling the iterator function returns the iterator object
const iterator = userNamesGroupedByLocation[Symbol.iterator]();
console.log(iterator.next().value);
// "hi"

运行结果如下:

image 

如果这样写的话,用 for...of 循环是遍历不出任何东西的,因为我们直接把 done 设成了 false(导致循环立即结束)。而且,用这种方式实现的话,我们也根本拿不到任何用户名。这恰恰就是我们一开始想要让这个对象变得可迭代的原因——为了能真正拿到数据。

 三、实现迭代器
 
 
首先,我们需要获取对象的键,也就是那些代表城市的名字。我们可以通过对 this 关键字调用 Object.keys 来实现,这里的 this 指向该函数的父级对象,在本例中就是 userNamesGroupedByLocation 对象。
只有当我们使用 function 关键字来定义这个可迭代函数时,才能通过 this 访问到这些键。如果我们用了箭头函数,这招就行不通了,因为箭头函数会继承其父级的作用域,导致 this 指向不对。
const cityKeys = Object.keys(this);
我们还需要两个变量来追踪当前的迭代状态。
let cityIndex = 0;
let userIndex = 0;
我们把这些变量定义在 iterator 函数里,但处于 next 函数之外。这样做能让我们在多次迭代之间保留这些数据。 
next 函数里,我们首先需要利用之前定义的索引,取出当前城市对应的用户数组以及当前具体的用户。
我们可以利用这些数据来调整现在的返回值了。
 
 
return {
  next: () => {
    const users = this[cityKeys[cityIndex]];
    const user = users[userIndex];

    return {
      done: false,
      value: user,        
    };
  },
};
接下来,我们需要在每次迭代时递增索引。
通常情况下,我们会递增用户索引;除非我们已经到达了当前城市的最后一位用户。如果是这种情况,我们就将用户索引(userIndex)重置为 0,转而递增城市索引。
 
return {
  next: () => {
    const users = this[cityKeys[cityIndex]];
    const user = users[userIndex];

    const isLastUser = userIndex >= users.length - 1;
    if (isLastUser) {
      // Reset user index
      userIndex = 0;
      // Jump to next city
      cityIndex++
    } else {
      userIndex++;
    }

    return {
      done: false,
      value: user,        
    };
  },
};
小心不要使用 “for…of” 来遍历这个对象。鉴于 done 始终为 false,这将导致无限循环。
我们需要添加的最后一项内容是一个退出条件,用于将 done 设置为 true。当我们遍历完所有城市后,就退出循环。
 
if (cityIndex > cityKeys.length - 1) {
  return {
    value: undefined,
    done: true,
  };
}
将所有部分组合在一起后,我们的函数看起来是如下所示这样的: 
 
userNamesGroupedByLocation[Symbol.iterator] = function() {
  const cityKeys = Object.keys(this);
  let cityIndex = 0;
  let userIndex = 0;

  return {
    next: () => {
      // We already iterated over all cities
      if (cityIndex > cityKeys.length - 1) {
        return {
          value: undefined,
          done: true,
        };
      }

      const users = this[cityKeys[cityIndex]];
      const user = users[userIndex];

      const isLastUser = userIndex >= users.length - 1;

      userIndex++;
      if (isLastUser) {
        // Reset user index
        userIndex = 0;
        // Jump to next city
        cityIndex++
      }

      return {
        done: false,
        value: user,        
      };
    },
  };
};
这使我们能够使用“for…of”循环快速提取出对象中的所有名称。如下所示:
for (let name of userNamesGroupedByLocation) {
  console.log('name', name);
}
完整代码,如下:

const userNamesGroupedByLocation = {
  Tokio: [
    'Aiko',
    'Chizu',
    'Fushigi',
  ],
  'Buenos Aires': [
    'Santiago',
    'Valentina',
    'Lola',
  ],
  'Saint Petersburg': [
    'Sonja',
    'Dunja',
    'Iwan',
    'Tanja',
  ],
};

userNamesGroupedByLocation[Symbol.iterator] = function() {
  const cityKeys = Object.keys(this);
  let cityIndex = 0;
  let userIndex = 0;

  return {
    next: () => {
      // We already iterated over all cities
      if (cityIndex > cityKeys.length - 1) {
        return {
          value: undefined,
          done: true,
        };
      }

      const users = this[cityKeys[cityIndex]];
      const user = users[userIndex];

      const isLastUser = userIndex >= users.length - 1;

      userIndex++;
      if (isLastUser) {
        // Reset user index
        userIndex = 0;
        // Jump to next city
        cityIndex++
      }

      return {
        done: false,
        value: user,
      };
    },
  };
};

for (let name of userNamesGroupedByLocation) {
  console.log('name', name);
}
运行结果如下:
image
正如你所见,让一个对象变得可迭代(iterable)并没有什么魔法。不过,这件事需要非常小心地处理,因为在 next 函数中的一点小错误,就很容易导致无限循环。 
 
总结一下我们创建可迭代对象(iterable)的过程,以下是我们遵循的步骤:
  1. 使用 @@iterator 键(可以通过 Symbol.iterator 访问)给对象添加一个迭代器函数。
  2. 该函数返回一个包含 next 函数的对象。
  3. next 函数返回一个带有 donevalue 属性的对象。

What are generators?什么是生成器?

我们已经学会了如何让任何对象变得可迭代,但这与生成器有什么关系呢?
虽然迭代器是一个强大的工具,但在实际开发中,我们很少像上面的例子那样去手动创建它们。编写迭代器时需要格外小心,因为一旦出错后果可能很严重,而且管理其内部逻辑也颇具挑战性。
生成器是一个非常有用的工具,它允许我们通过定义一个函数来创建迭代器。
这种方法不仅更不容易出错,还能让我们更高效地创建迭代器。
生成器和迭代器的一个核心特性是,它们允许你根据需要暂停和继续执行。在本节中,我们将看到几个利用这一特性的例子
 
定义一个生成器函数
创建生成器函数与常规函数非常相似。我们只需要在函数名前面加一个星号 (*) 即可。
function *generator() {
  // ...
}
如果我们想创建一个匿名的生成器函数,这个星号就要移到 function 关键字的后面。
 
function* () {
  // ...
}
使用 yield 关键字
声明一个生成器函数只是完成了一半的工作,光有它本身并没有太大用处。
正如前面提到的,生成器是创建可迭代对象的一种更简单的方法。但是,迭代器怎么知道它应该在函数的哪一部分进行迭代呢?难道要它逐行遍历吗?
这正是 yield 关键字发挥作用的地方。你可以把它想象成你在 JavaScript Promise 中可能见过的 await 关键字,只不过它是用于生成器的。
我们可以在希望迭代暂停的每一行代码处添加这个关键字。随后,next() 函数会将该行语句的结果作为迭代器对象的一部分返回(即 { done: false, value: 'something' })。
function* stringGenerator() {
  yield 'hi';
  yield 'hi';
  yield 'hi';
}

const strings = stringGenerator();

console.log(strings.next());
console.log(strings.next());
console.log(strings.next());
console.log(strings.next());

代码的输出结果如下:

{value: "hi", done: false}
{value: "hi", done: false}
{value: "hi", done: false}
{value: undefined, done: true}
调用 stringGenerator 本身并不会执行任何操作,因为它会在遇到第一个 yield 语句时自动暂停执行。
一旦函数执行到了末尾,value 的值就会变成 undefined,而 done 属性会自动被设置为 true
 
使用yield*(委托生成器(Delegating Generator)
 
如果我们在 yield 关键字后面加上一个星号,我们就是将执行权委托给了另一个迭代器对象。
例如,我们可以利用这一点,将执行委托给另一个函数或者数组:
function* nameGenerator() {
  yield 'Iwan';
  yield 'Aiko';
}

function* stringGenerator() {
  yield* nameGenerator();
  yield* ['one', 'two'];
  yield 'hi';
  yield 'hi';
  yield 'hi';
}

const strings = stringGenerator();

for (let value of strings) {
  console.log(value);
}
 以上代码的输出结果如下:
Iwan
Aiko
one
two
hi
hi
hi
 
传递参数给生成器 
生成器的迭代器返回的 next 函数还有一个额外功能:它允许你覆盖返回值。
拿之前的例子来说,我们可以强制覆盖掉 yield 原本要返回的值。
 
function* overrideValue() {
  const result = yield 'hi';
  console.log(result);
}

const overrideIterator = overrideValue();
overrideIterator.next();
overrideIterator.next('bye');
“在传值之前,我们需要先调用一次 next 来启动生成器。” 
 
生成器函数
“除了任何迭代器都必须具备的 next 方法之外,生成器还提供了 returnthrow 这两个函数。”

return函数

在迭代器上调用 return 而不是 next,会导致循环在下一次迭代时退出。
在调用 return 之后的每一次迭代,都会将 done 设为 true,并将 value 设为 undefined
如果我们给这个函数传递一个值,它会替换掉迭代器对象上的 value 属性。
Web MDN 文档里的这个例子非常完美地展示了这一点:
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

const g = gen();

g.next(); // { value: 1, done: false }
g.return('foo'); // { value: "foo", done: true }
g.next(); // { value: undefined, done: true }

throw函数

生成器还实现了一个 throw 方法,它不会继续循环,而是会抛出一个错误并终止执行:

function* errorGenerator() {
  try {
    yield 'one';
    yield 'two';
  } catch(e) {
    console.error(e);
  }
}

const errorIterator = errorGenerator();

console.log(errorIterator.next()); 
console.log(errorIterator.throw('Bam!'));
以上代码的运行结果如下:
{value: 'one', done: false}
Bam!
{value: undefined, done: true}
如果我们在抛出错误后尝试继续迭代,返回的值将是 undefined,并且 done 会被设为 true。
 

Why use generators?为什么要使用生成器?

正如我们在本文中所见,我们可以利用生成器来创建可迭代对象。这个话题听起来可能非常抽象,而且我不得不承认,我自己平时很少用到生成器。
不过,有些特定的使用场景却能从这一特性中获益良多。这些场景通常都利用了生成器可以“暂停”和“恢复”执行这一特点。
 

Unique ID generator唯一的ID生成器

这是我最喜欢的一个用例,因为它简直是为生成器量身定做的。
要生成唯一且递增的 ID,你就得时刻追踪那些已经生成过的 ID。
有了生成器,你可以创建一个无限循环,每次迭代时生成一个新的 ID。
每当你需要一个新 ID 时,只需调用 next 方法,剩下的工作生成器都会帮你搞定:
function* idGenerator() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

const ids = idGenerator();

console.log(ids.next().value); // 0
console.log(ids.next().value); // 1
console.log(ids.next().value); // 2
console.log(ids.next().value); // 3
console.log(ids.next().value); // 4
 

总结:

生成器和迭代器可能不是我们每天都要用的东西,但当遇到需要它们独特能力的场景时,懂得如何使用它们将是一个巨大的优势。
在本文中,我们了解了迭代器以及如何使任何对象变得可迭代。在第二部分,我们学习了生成器是什么、如何使用它们,以及在哪些情况下可以使用它们。
如果你想更深入地了解 JavaScript 的底层工作原理,可以去看看我关于“JavaScript 在浏览器中如何工作”的系列博客,里面详细解释了事件循环和 JavaScript 的内存管理。

 

引用地址:https://blog.logrocket.com/javascript-iterators-and-generators-a-complete-guide/

 
 
 
 
posted @ 2026-03-11 19:42  chenlight  阅读(10)  评论(0)    收藏  举报