迭代协议

迭代协议不是新的内置函数或语法,而是协议。任何对象都可以通过遵循一些约定来实现这些协议。

有两个协议:可迭代协议迭代器协议

可迭代协议

可迭代协议允许 JavaScript 对象定义或自定义其迭代行为,例如在 for...of 结构中循环遍历哪些值。某些内置类型是具有默认迭代行为的内置可迭代对象,例如 ArrayMap,而其他类型(例如 Object)则不是。

为了成为可迭代的,对象必须实现[Symbol.iterator]() 方法,这意味着该对象(或其原型链上的某个对象)必须具有一个键为 [Symbol.iterator] 的属性,可以通过常量 Symbol.iterator 访问。

[Symbol.iterator]()

一个零参数函数,它返回一个符合迭代器协议的对象。

每当需要迭代对象时(例如在 for...of 循环开始时),都会在其上调用其 [Symbol.iterator]() 方法,并且不带任何参数,并且返回的迭代器用于获取要迭代的值。

请注意,当调用此零参数函数时,它会作为可迭代对象上的方法调用。因此,在函数内部,this 关键字可用于访问可迭代对象的属性,以决定在迭代期间提供什么内容。

此函数可以是普通函数,也可以是生成器函数,以便在调用时返回一个迭代器对象。在此生成器函数内部,可以使用 yield 提供每个条目。

迭代器协议

迭代器协议定义了一种标准方法来生成一系列值(有限或无限),并在生成所有值后可能返回一个返回值。

当对象实现具有以下语义的next() 方法时,该对象就是一个迭代器

next()

一个接受零个或一个参数的函数,并返回一个符合 IteratorResult 接口的对象(见下文)。如果在内置语言特性(如 for...of)使用迭代器时返回了一个非对象值(例如 falseundefined),则会抛出一个 TypeError"iterator.next() returned a non-object value")。

所有迭代器协议方法(next()return()throw())都应返回一个实现 IteratorResult 接口的对象。它必须具有以下属性

done 可选

一个布尔值,如果迭代器能够生成序列中的下一个值,则为 false。(这等效于完全不指定 done 属性。)

如果迭代器已完成其序列,则值为 true。在这种情况下,value 可选地指定迭代器的返回值。

value 可选

迭代器返回的任何 JavaScript 值。当 donetrue 时可以省略。

在实践中,这两个属性都不是严格必需的;如果返回的对象既没有这两个属性,则它实际上等效于 { done: false, value: undefined }

如果迭代器返回的结果为 done: true,则对 next() 的任何后续调用都应返回 done: true,尽管这在语言级别上没有强制执行。

next 方法可以接收一个值,该值将可用于方法体。没有内置语言特性会传递任何值。传递给 生成器next 方法的值将成为相应 yield 表达式的值。

可选地,迭代器还可以实现return(value)throw(exception) 方法,这些方法在被调用时告诉迭代器调用者已完成对其的迭代,并且可以执行任何必要的清理操作(例如关闭数据库连接)。

return(value) 可选

一个接受零个或一个参数的函数,并返回一个符合 IteratorResult 接口的对象,通常 value 等于传入的 valuedone 等于 true。调用此方法告诉迭代器调用者不打算再进行任何 next() 调用,并且可以执行任何清理操作。当内置语言特性调用 return() 进行清理时,value 始终为 undefined

throw(exception) 可选

一个接受零个或一个参数的函数,并返回一个符合 IteratorResult 接口的对象,通常 done 等于 true。调用此方法告诉迭代器调用者检测到错误条件,并且 exception 通常是 Error 实例。没有内置语言特性会为清理目的调用 throw()——它是生成器的一个特殊特性,用于 return/throw 的对称性。

注意:无法反射地(即,如果不实际调用 next() 并验证返回的结果)知道特定对象是否实现了迭代器协议。

使迭代器也成为可迭代的非常容易:只需实现一个返回 this[Symbol.iterator]() 方法即可。

js
// Satisfies both the Iterator Protocol and Iterable
const myIterator = {
  next() {
    // ...
  },
  [Symbol.iterator]() {
    return this;
  },
};

这种对象被称为可迭代迭代器。这样做允许迭代器被各种期望可迭代对象的语法使用——因此,如果不实现可迭代,很少有用处实现迭代器协议。(事实上,几乎所有语法和 API 都期望可迭代对象,而不是迭代器。)生成器对象就是一个例子。

js
const aGeneratorObject = (function* () {
  yield 1;
  yield 2;
  yield 3;
})();

console.log(typeof aGeneratorObject.next);
// "function" — it has a next method (which returns the right result), so it's an iterator

console.log(typeof aGeneratorObject[Symbol.iterator]);
// "function" — it has an [Symbol.iterator] method (which returns the right iterator), so it's an iterable

console.log(aGeneratorObject[Symbol.iterator]() === aGeneratorObject);
// true — its [Symbol.iterator] method returns itself (an iterator), so it's an iterable iterator

所有内置迭代器都继承自Iterator.prototype,它实现了[Symbol.iterator]()方法,该方法返回this,因此内置迭代器也是可迭代的。

但是,在可能的情况下,最好让iterable[Symbol.iterator]()返回不同的迭代器,这些迭代器始终从开头开始,就像Set.prototype[Symbol.iterator]()一样。

异步迭代器和异步可迭代协议

还有一对用于异步迭代的协议,名为异步迭代器异步可迭代协议。与可迭代和迭代器协议相比,它们的接口非常相似,只是迭代器方法调用的每个返回值都包装在一个 Promise 中。

当一个对象实现以下方法时,它就实现了异步可迭代协议

[Symbol.asyncIterator]()

一个零参数函数,返回一个符合异步迭代器协议的对象。

当一个对象实现以下方法时,它就实现了异步迭代器协议

next()

一个接受零个或一个参数并返回 Promise 的函数。该 Promise 解析为一个符合IteratorResult接口的对象,并且属性与同步迭代器的属性具有相同的语义。

return(value) 可选

一个接受零个或一个参数并返回 Promise 的函数。该 Promise 解析为一个符合IteratorResult接口的对象,并且属性与同步迭代器的属性具有相同的语义。

throw(exception) 可选

一个接受零个或一个参数并返回 Promise 的函数。该 Promise 解析为一个符合IteratorResult接口的对象,并且属性与同步迭代器的属性具有相同的语义。

语言和迭代协议之间的交互

语言指定了产生或使用可迭代对象和迭代器的 API。

内置可迭代对象

StringArrayTypedArrayMapSetSegments(由Intl.Segmenter.prototype.segment()返回)都是内置可迭代对象,因为它们的每个prototype对象都实现了[Symbol.iterator]()方法。此外,arguments对象和一些 DOM 集合类型,例如NodeList,也是可迭代的。核心 JavaScript 语言中没有异步可迭代的对象。一些 Web API,例如ReadableStream,默认设置了Symbol.asyncIterator方法。

生成器函数返回生成器对象,它们是可迭代迭代器。异步生成器函数返回异步生成器对象,它们是异步可迭代迭代器。

从内置可迭代对象返回的迭代器实际上都继承自一个公共类Iterator,它实现了前面提到的[Symbol.iterator]() { return this; }方法,使它们都成为可迭代迭代器。除了迭代器协议所需的next()方法之外,Iterator类还提供了其他辅助方法。您可以通过在图形控制台中记录它来检查迭代器的原型链。

console.log([][Symbol.iterator]());

Array Iterator {}
  [[Prototype]]: Array Iterator     ==> This is the prototype shared by all array iterators
    next: ƒ next()
    Symbol(Symbol.toStringTag): "Array Iterator"
    [[Prototype]]: Object           ==> This is the prototype shared by all built-in iterators
      Symbol(Symbol.iterator): ƒ [Symbol.iterator]()
      [[Prototype]]: Object         ==> This is Object.prototype

接受可迭代对象的内置 API

有许多 API 接受可迭代对象。一些示例包括

js
const myObj = {};

new WeakSet(
  (function* () {
    yield {};
    yield myObj;
    yield {};
  })(),
).has(myObj); // true

期望可迭代对象的语法

一些语句和表达式期望可迭代对象,例如for...of循环、数组和参数展开yield*数组解构

js
for (const value of ["a", "b", "c"]) {
  console.log(value);
}
// "a"
// "b"
// "c"

console.log([..."abc"]); // ["a", "b", "c"]

function* gen() {
  yield* ["a", "b", "c"];
}

console.log(gen().next()); // { value: "a", done: false }

[a, b, c] = new Set(["a", "b", "c"]);
console.log(a); // "a"

当内置语法迭代迭代器时,如果最后一个结果的donefalse(即迭代器能够产生更多值),但不再需要更多值,则如果存在,将调用return方法。例如,如果在for...of循环中遇到breakreturn,或者如果数组解构中所有标识符都已绑定,则可能会发生这种情况。

js
const obj = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        i++;
        console.log("Returning", i);
        if (i === 3) return { done: true, value: i };
        return { done: false, value: i };
      },
      return() {
        console.log("Closing");
        return { done: true };
      },
    };
  },
};

const [a] = obj;
// Returning 1
// Closing

const [b, c, d] = obj;
// Returning 1
// Returning 2
// Returning 3
// Already reached the end (the last call returned `done: true`),
// so `return` is not called

for (const b of obj) {
  break;
}
// Returning 1
// Closing

for await...of循环和异步生成器函数中的yield*(但不是同步生成器函数)是与异步可迭代对象交互的唯一方法。在不是同步可迭代对象的异步可迭代对象上使用for...of、数组展开等将抛出 TypeError:x 不是可迭代的。

错误处理

因为迭代涉及在迭代器和使用者之间来回传递控制权,所以错误处理也以两种方式发生:使用者如何处理迭代器抛出的错误,以及迭代器如何处理使用者抛出的错误。当您使用内置的迭代方式之一时,语言也可能抛出错误,因为可迭代对象违反了某些不变性。我们将描述内置语法如何生成和处理错误,如果手动步进迭代器,这可以作为您自己代码的指南。

格式不正确的可迭代对象

从可迭代对象获取迭代器时可能会发生错误。此处强制执行的语言不变性是可迭代对象必须产生一个有效的迭代器。

  • 它具有一个可调用的[Symbol.iterator]()方法。
  • [Symbol.iterator]()方法返回一个对象。
  • [Symbol.iterator]()返回的对象具有一个可调用的next()方法。

当使用内置语法在格式不正确的可迭代对象上启动迭代时,会抛出 TypeError。

js
const nonWellFormedIterable = { [Symbol.iterator]: 1 };
[...nonWellFormedIterable]; // TypeError: nonWellFormedIterable is not iterable
nonWellFormedIterable[Symbol.iterator] = () => 1;
[...nonWellFormedIterable]; // TypeError: [Symbol.iterator]() returned a non-object value
nonWellFormedIterable[Symbol.iterator] = () => ({});
[...nonWellFormedIterable]; // TypeError: nonWellFormedIterable[Symbol.iterator]().next is not a function

对于异步可迭代对象,如果其[Symbol.asyncIterator]()属性的值为undefinednull,则 JavaScript 会回退到使用[Symbol.iterator]属性(并通过转发方法将结果迭代器包装到异步迭代器中)。否则,[Symbol.asyncIterator]属性也必须符合上述不变性。

可以通过在尝试迭代之前先验证可迭代对象来防止此类错误。但是,这相当罕见,因为通常您知道要迭代的对象的类型。如果您从其他一些代码接收此可迭代对象,则应该让错误传播到调用者,以便他们知道提供了无效的输入。

迭代期间的错误

大多数错误发生在步进迭代器(调用next())时。此处强制执行的语言不变性是next()方法必须返回一个对象(对于异步迭代器,等待后返回一个对象)。否则,会抛出 TypeError。

如果不变性被破坏或next()方法抛出错误(对于异步迭代器,它也可能返回一个被拒绝的 Promise),则错误会传播到调用者。对于内置语法,正在进行的迭代将中止,不会重试或清理(假设如果next()方法抛出错误,则它已经清理过了)。如果您手动调用next(),您可以捕获错误并重试调用next(),但一般来说,您应该假设迭代器已经关闭。

如果调用者出于任何原因决定退出迭代(除了上一段中的错误,例如当它进入自身代码中的错误状态时(例如,在处理迭代器产生的无效值时)),它应该在迭代器上调用return()方法(如果存在)。这允许迭代器执行任何清理。只有在提前退出时才会调用return()方法——如果next()返回done: true,则不会调用return()方法,假设迭代器已经清理过了。

return()方法也可能无效!语言还强制执行return()方法必须返回一个对象,否则会抛出 TypeError。如果return()方法抛出错误,则错误会传播到调用者。但是,如果调用return()方法是因为调用者在其自身代码中遇到错误,则此错误会覆盖return()方法抛出的错误。

通常,调用者会像这样实现错误处理

js
try {
  for (const value of iterable) {
    // ...
  }
} catch (e) {
  // Handle the error
}

catch将能够捕获当iterable不是有效的可迭代对象时、当next()抛出错误时、当return()抛出错误(如果for循环提前退出)以及当for循环体抛出错误时抛出的错误。

大多数迭代器都是用生成器函数实现的,因此我们将演示生成器函数通常如何处理错误。

js
function* gen() {
  try {
    yield doSomething();
    yield doSomethingElse();
  } finally {
    cleanup();
  }
}

这里缺少catch会导致doSomething()doSomethingElse()抛出的错误传播到gen的调用者。如果在生成器函数内捕获这些错误(这同样建议这样做),则生成器函数可以决定继续生成值或提前退出。但是,对于保持打开资源的生成器,finally块是必要的。finally块保证会运行,无论是在调用最后一个next()时还是在调用return()时。

转发错误

一些内置语法将迭代器包装到另一个迭代器中。它们包括由Iterator.from()迭代器辅助函数map()filter()take()drop()flatMap())、yield*以及当您在同步迭代器上使用异步迭代(for await...ofArray.fromAsync)时隐藏的包装器产生的迭代器。然后,包装的迭代器负责在内部迭代器和调用者之间转发错误。

  • 所有包装器迭代器都直接转发内部迭代器的next()方法,包括其返回值和抛出的错误。
  • 包装器迭代器通常直接转发内部迭代器的return()方法。如果内部迭代器上不存在return()方法,则它改为返回{ done: true, value: undefined }。在迭代器辅助函数的情况下:如果尚未调用迭代器辅助函数的next()方法,则在尝试对内部迭代器调用return()之后,当前迭代器始终返回{ done: true, value: undefined }。这与生成器函数在尚未进入yield*表达式的情况一致。
  • yield*是唯一转发内部迭代器的throw()方法的内置语法。有关yield*如何转发return()throw()方法的信息,请参阅其自身参考。

示例

用户定义的可迭代对象

您可以像这样创建自己的可迭代对象

js
const myIterable = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
    yield 3;
  },
};

console.log([...myIterable]); // [1, 2, 3]

简单的迭代器

迭代器本质上是有状态的。如果您没有将其定义为生成器函数(如上面的示例所示),则您可能希望将状态封装在闭包中。

js
function makeIterator(array) {
  let nextIndex = 0;
  return {
    next() {
      return nextIndex < array.length
        ? {
            value: array[nextIndex++],
            done: false,
          }
        : {
            done: true,
          };
    },
  };
}

const it = makeIterator(["yo", "ya"]);

console.log(it.next().value); // 'yo'
console.log(it.next().value); // 'ya'
console.log(it.next().done); // true

无限迭代器

js
function idMaker() {
  let index = 0;
  return {
    next() {
      return {
        value: index++,
        done: false,
      };
    },
  };
}

const it = idMaker();

console.log(it.next().value); // 0
console.log(it.next().value); // 1
console.log(it.next().value); // 2
// ...

使用生成器定义可迭代对象

js
function* makeSimpleGenerator(array) {
  let nextIndex = 0;
  while (nextIndex < array.length) {
    yield array[nextIndex++];
  }
}

const gen = makeSimpleGenerator(["yo", "ya"]);

console.log(gen.next().value); // 'yo'
console.log(gen.next().value); // 'ya'
console.log(gen.next().done); // true

function* idMaker() {
  let index = 0;
  while (true) {
    yield index++;
  }
}

const it = idMaker();

console.log(it.next().value); // 0
console.log(it.next().value); // 1
console.log(it.next().value); // 2
// ...

使用类定义可迭代对象

状态封装也可以使用私有属性来完成。

js
class SimpleClass {
  #data;

  constructor(data) {
    this.#data = data;
  }

  [Symbol.iterator]() {
    // Use a new index for each iterator. This makes multiple
    // iterations over the iterable safe for non-trivial cases,
    // such as use of break or nested looping over the same iterable.
    let index = 0;

    return {
      // Note: using an arrow function allows `this` to point to the
      // one of `[Symbol.iterator]()` instead of `next()`
      next: () => {
        if (index < this.#data.length) {
          return { value: this.#data[index++], done: false };
        } else {
          return { done: true };
        }
      },
    };
  }
}

const simple = new SimpleClass([1, 2, 3, 4, 5]);

for (const val of simple) {
  console.log(val); // 1 2 3 4 5
}

覆盖内置可迭代对象

例如,String是一个内置的可迭代对象。

js
const someString = "hi";
console.log(typeof someString[Symbol.iterator]); // "function"

String默认迭代器逐个返回字符串的代码点。

js
const iterator = someString[Symbol.iterator]();
console.log(`${iterator}`); // "[object String Iterator]"

console.log(iterator.next()); // { value: "h", done: false }
console.log(iterator.next()); // { value: "i", done: false }
console.log(iterator.next()); // { value: undefined, done: true }

您可以通过提供我们自己的[Symbol.iterator]()来重新定义迭代行为。

js
// need to construct a String object explicitly to avoid auto-boxing
const someString = new String("hi");

someString[Symbol.iterator] = function () {
  return {
    // this is the iterator object, returning a single element (the string "bye")
    next() {
      return this._first
        ? { value: "bye", done: (this._first = false) }
        : { done: true };
    },
    _first: true,
  };
};

请注意,重新定义[Symbol.iterator]()如何影响使用迭代器协议的内置构造的行为。

js
console.log([...someString]); // ["bye"]
console.log(`${someString}`); // "hi"

迭代时的并发修改

几乎所有可迭代对象都具有相同的底层语义:它们在迭代开始时不会复制数据。相反,它们保持一个指针并移动它。因此,如果您在遍历集合时添加、删除或修改集合中的元素,您可能会无意中更改集合中其他未更改元素是否被访问。这与迭代数组方法的工作方式非常相似。

考虑以下使用URLSearchParams的案例

js
const searchParams = new URLSearchParams(
  "deleteme1=value1&key2=value2&key3=value3",
);

// Delete unwanted keys
for (const [key, value] of searchParams) {
  console.log(key);
  if (key.startsWith("deleteme")) {
    searchParams.delete(key);
  }
}

// Output:
// deleteme1
// key3

请注意它从未记录key2。这是因为URLSearchParams底层是一个键值对列表。当访问并删除deleteme1时,所有其他条目都向左移动一位,因此key2占据了deleteme1曾经所在的位置,并且当指针移动到下一个键时,它会落在key3上。

某些可迭代对象的实现通过设置“墓碑”值来避免此问题,从而避免移动剩余的值。考虑使用Map的类似代码

js
const myMap = new Map([
  ["deleteme1", "value1"],
  ["key2", "value2"],
  ["key3", "value3"],
]);

for (const [key, value] of myMap) {
  console.log(key);
  if (key.startsWith("deleteme")) {
    myMap.delete(key);
  }
}

// Output:
// deleteme1
// key2
// key3

请注意它记录了所有键。这是因为Map在删除一个键时不会移动剩余的键。如果您想实现类似的功能,以下是可能的方式

js
const tombstone = Symbol("tombstone");

class MyIterable {
  #data;
  constructor(data) {
    this.#data = data;
  }
  delete(deletedKey) {
    for (let i = 0; i < this.#data.length; i++) {
      if (this.#data[i][1] === deletedKey) {
        this.#data[i] = tombstone;
        return true;
      }
    }
    return false;
  }
  *[Symbol.iterator]() {
    for (let i = 0; i < this.#data.length; i++) {
      if (this.#data[i] !== tombstone) {
        yield this.#data[i];
      }
    }
  }
}

const myIterable = new MyIterable([
  ["deleteme1", "value1"],
  ["key2", "value2"],
  ["key3", "value3"],
]);
for (const [key, value] of myIterable) {
  console.log(key);
  if (key.startsWith("deleteme")) {
    myIterable.delete(key);
  }
}

警告:通常,并发修改很容易出错且令人困惑。除非您准确了解可迭代对象是如何实现的,否则最好避免在迭代时修改集合。

规范

规范
ECMAScript 语言规范
# sec-iteration

另请参阅