【译】在 JavaScript 中遍历数组

156 阅读10分钟

原文:Loop (for each) over an array in JavaScript

by:T.J. Crowder

摘要

  • 你最好的选择通常是

    • for-of 循环(仅适用于 ES2015+;spec | MDN)—— 简单且异步友好

      for (const element of theArray) {
          // ... 使用 `element`
      }
      
    • forEach(仅适用于 ES5+;spec | MDN)(或者 some 和其他类似的方法)—— 异步友好(具体看条件)

      theArray.forEach(element => {
          // ...使用 `element`...
      });
      
    • 简单、老式的 for 循环 —— 异步友好

      for (let index = 0; index < theArray.length; ++index) {
          const element = theArray[index];
          // ...使用 `element`...
      }
      
    • 带有保障措施(safeguards)的 for-in(很少用) —— 异步友好

      for (const propertyName in theArray) {
          if (/*...是数组的属性...*/) {
              const element = theArray[propertyName];
              // ...使用 `element`...
          }
      }
      
  • 一些“不要”:

    • 不要用 for-in。除非你在使用它时有保障措施(safeguards),或者至少知道为什么它可能会咬你一口(bite)。

    • 不要用 map。如果你不使用 map 的返回值

      (可悲的是,有的教程教人像用 forEach 一样用 map [spec / MDN] —— 但正如我在博客中写的那样,这不是它的正确用法。如果你并不打算用数组创建另一个数组,不要使用 map。)

    • 不要用 forEach。如果回调函数执行的是异步任务,并且你希望 forEach 等待任务完成(它不会)。

但还有很多东西需要探讨,请继续阅读……


JavaScript 具有强大的语义(semantics),可以遍历数组和类数组对象。我把答案分成了两部分:适用于真正数组的部分,以及适用于数组的部分,例如 arguments 对象,其他的可迭代对象(ES2015+),DOM 集合等等。

好吧,让我们看看这两部分:

对于真正的数组

你有五种选择(两种基本永远支持,另一种由 ECMAScript 5 ["ES5"] 引入,ECMAScript 2015 ("ES2015", 又称作 "ES6") 又引入了另外两种):

  1. 使用 for-of (隐式使用迭代器)(ES2015+)
  2. 使用 forEach 以及类似的方法(ES5+)
  3. 使用简单的 for 循环
  4. 正确地使用 for-in
  5. 显式使用迭代器(ES2015+)

(你可以从这里查看那些旧的标准:ES5, ES2015,但是二者都已经被新的标准取代了,当前版本的草案在这里。)

详情:

1. 使用 for-of(隐式使用迭代器)(ES2015+)

ES2015 将迭代器和可迭代对象iterators and iterables)引入了 JavaScript。数组是可迭代的(稍后你将看到,string,MapSet 以及 DOM 集合和列表也都是可迭代的)。可迭代对象为其值提供了迭代器。新的 for-of 语句就是遍历迭代器返回的值:

const a = ["a", "b", "c"];
for (const element of a) { // 你喜欢的话,也可以用 `let` 替代 `const`
    console.log(element);
}
// a
// b
// c

没有比这还要简单的方法了!在底层,它从数组获得一个迭代器,并循环遍历迭代器返回的值。数组提供的迭代器按照从开始到结束的顺序提供数组元素的值。

注意,element 的作用域是循环的每次迭代;因为它在循环外并不存在,所以在循环结束后尝试访问 element 将会失败。

理论上,一个 for-of 循环实际上会有多次函数调用(一是获得迭代器,另一个是从迭代器获取值)。即使这是这样,也没啥好担心的,函数调用在现代 JavaScript 引擎是中非常廉价的(forEach 的性能一直困扰着我(见下文),直到我仔细研究了一下;详细)。除此之外,JavaScript 引擎在处理原生迭代器(如数组)时,会优化这些调用(在性能关键代码中)。

for-of 是完全异步友好的。如果你需要循环体中的任务串行(而不是并行)完成,循环体中的 await 语句将等待 Promise 完成后再继续下一轮循环。这里有一个不太聪明的例子:

function delay(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

async function showSlowly(messages) {
    for (const message of messages) {
        await delay(400);
        console.log(message);
    }
}

showSlowly([
    "So", "long", "and", "thanks", "for", "all", "the", "fish!"
]);
// 这里省略了 `.catch`,因为我们知道 Promise 并不会失败

注意每个单词出现前都有一个延迟。

虽然这是编码风格的问题,但是当要遍历任何可迭代的对象时,for-of 都是我的首要选择。

2. 使用 forEach 以及类似的方法(ES5+)

甚至是在非标准的现代(vaguely-modern)环境中(不包括 IE8),你也可以使用 ES5 为 Array 增添的特性。如果你只是处理同步代码(或不需要在循环中等待异步任务完成),你可以使用 forEach

const a = ["a", "b", "c"];
a.forEach((element) => {
    console.log(element);
});

forEach 接受一个回调函数,以及一个可选值作为参数,该值在回调函数中用作 this 的值(上面的例子没有使用)。它按照顺序为数组中每个元素都调用一次回调函数,同时跳过稀疏数组(sparse arrays)中不存在的元素。尽管上面的例子中我只使用了一个形参,回调函数事实上会使用三个实参:该次迭代的数组元素、该元素的索引以及正被迭代数组的引用。

for-of 类似,forEach 的优点是不必在作用域内声明索引和值变量;在本例中,它们作为参数提供给迭代函数,因此很好地限定了迭代变量的作用域。

for-of 不同,forEach 的缺点是它不识别异步函数和 await。如果将异步函数作为回调,forEach 不会等待 Promise 结束再进行下一轮迭代。下面是使用 forEach 代替 for-of 的异步示例 —— 注意观察,初始会有一个延迟,但随后所有文本会立即出现:

function delay(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

async function showSlowly(messages) {
    // **错误的用法**,它并不会等待
    // 也会不处理 Promise 的失败情况
    messages.forEach(async message => {
        await delay(400);
        console.log(message);
    });
}

showSlowly([
    "So", "long", "and", "thanks", "for", "all", "the", "fish!"
]);
// `.catch` omitted because we know it never rejects

forEach 是 “遍历所有数组元素” 的函数,但 ES5 还定义了其他几个有用的 “遍历数组并做一些操作” 的函数,包括:

  • every (spec | MDN) —— 在回调第一次返回 falsy 值时停止循环。
  • some (spec | MDN) —— 在回调第一次返回 truthy 值时停止循环。
  • filter (spec | MDN) —— 创建一个新的,使得回调返回 truthy 值的元素组成的数组,省略没有返回 truthy 值的元素。
  • map (spec | MDN) —— 使用回调的返回值创建一个新数组。
  • reduce (spec | MDN) —— 通过重复调用回调,传递前一次回调返回值(previous values)来构建一个值;详情见规范。
  • reduceRight (spec | MDN) —— 和 reduce 类似,但以相反的顺序迭代元素。

forEach 一样,如果你使用一个异步函数作为回调,上面这些方法中没有一个会等待 Promise 完成。这意味着:

  • 将异步函数用作 everysomefilter 的回调是不合适的。因为它们会将异步函数返回的 Promise 当作一个 truthy 值;它们不会等待 Pormise 完成,然后使用 Promise 的完成值(fulfillment value)。
  • 如果你的目的是将一个数组转换成一个 Promise 数组,也许是为了将其传给 Promise 组合函数(ombinator functions)(Promise.allPromise.racepromise.allSettled, 或 Promise.any),那么将异步函数用作 map 的回调是合适的。
  • 很少,也不适合将异步函数用作 reducereduceRight 的回调,同样是因为回调总是返回一个 Promise。但有这样一种使用场景,即可以使用 reduce 构建一个 Promise 链 (const promise = array.reduce((p, element) => p.then(/*...something using element...*/));),但通常情况下,使用 for-offor 会更清晰,也更容易调试。

3. 使用简单的 for 循环

有时候,老方法就是最好的方法:

const a = ["a", "b", "c"];
for (let index = 0; index < a.length; ++index) {
    const element = a[index];
    console.log(element);
}

如果数组的长度在循环过程中不会改变,而且是在对性能高度敏感(highly performance-sensitive)的代码中,那么下面稍微复杂一点的版本,在循环前保存数组长度可能会稍微快一点。

const a = ["a", "b", "c"];
for (let index = 0, len = a.length; index < len; ++index) {
    const element = a[index];
    console.log(element);
}

倒着循环也一样,可能会稍微快一点:

const a = ["a", "b", "c"];
for (let index = a.length - 1; index >= 0; --index) {
    const element = a[index];
    console.log(element);
}

但对于现代 JavaScript 引擎来说,你很少需要榨出那最后一点性能。

在 ES2015 之前,因为 var 只有函数级作用域,没有块级作用域,所以循环变量必须存在于包含它的作用域内。但正如你在上面的例子中所看到的,你可以在 for 中使用 let,使变量的作用域限于循环。当你这样做时,每次迭代都会重新创建索引变量,这意味着在循环体中创建的闭包会保持对该特定迭代的索引的引用,因而解决了“循环中的闭包”(closures in loops)这一老问题:

// (`querySelectorAll` 返回的 `NodeList` 是类数组对象(array-like))
const divs = document.querySelectorAll("div");
for (let index = 0; index < divs.length; ++index) {
    divs[index].addEventListener('click', e => {
        console.log("Index is: " + index);
    });
}
<div>zero</div>
<div>one</div>
<div>two</div>
<div>three</div>
<div>four</div>

在上面的例子中,如果你点击第一个元素,输出“Index is: 0”,如果点击最后一个元素,输出“Index is: 4”。如果你用 var 替换 let,那么无论点击哪一个元素都只会输出“Index is: 5”。

for-of 一样,for 循环是异步友好的。下面是先前的例子,使用 for 循环重写的版本:

function delay(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

async function showSlowly(messages) {
    for (let i = 0; i < messages.length; ++i) {
        const message = messages[i];
        await delay(400);
        console.log(message);
    }
}

showSlowly([
    "So", "long", "and", "thanks", "for", "all", "the", "fish!"
]);
// `.catch` omitted because we know it never rejects

4. 正确地使用 for-in

for-in 不是用来循环数组的,它是用来循环对象属性名称的。它确实经常被用来循环数组,但这只是数组也是对象这一事实的副产品,它循环的不只是数组的索引,而是对象的所有可枚举属性(包括继承来的属性)。(在以前,没有规定循环的顺序;现在,即使是制定了规范(详情见另一个答案),规则也很复杂,有例外情况,如果是希望依赖顺序地循环,这并不是一个最佳做法)。

真正地,也是唯一地可以使用 for-in 的用例是:

  • 有大量空元素的稀疏数组,或者
  • 你想循环数组对象中的非元素属性

来看第一个用例的例子:如果你使用适当的保障措施,你可以使用 for-in 来访问那些稀疏数组元素:

// `a` 是一个稀疏数组
const a = [];
a[0] = "a";
a[10] = "b";
a[10000] = "c";
for (const name in a) {
    if (Object.hasOwn(a, name) &&       // 这些检查
        /^0$|^[1-9]\d*$/.test(name) &&  // 在下面有
        name <= 4294967294              // 解释
       ) {
        const element = a[name];
        console.log(a[name]);
    }
}

以上三个检查分别是:

  1. 对象本身拥有这个名称的属性(而不是从原型继承的属性;这经常写成 a.hasOwnProperty(name),但是 ES2022 引入了 Object.hasOwn,这可能更可靠),并且
  2. 名称是十进制数字(例如,正常的字符串形式,而非科学符号),并且
  3. 当这个名称被强行转换成一个数字后,它的值 <= 2^32-2(也就是 4,294,967,294)。这个数字是怎么来的?它是规范中数组索引定义的一部分。其他数字(非整数、负数、大于 2^32 - 2 的数字)都不能用作数组索引。之所以是 2^32 - 2,是因为这使得最大的索引值比 2^32 - 1 小一位,而 2^32 - 1 是数组长度的最大值。

……尽管如此,大多数代码只做 hasOwnProperty 检查。

当然,你不会在内联代码中这样做。你可以写一个工具函数。可能像这样:

// 用于没有 `forEach' 的古老环境的工具函数
const hasOwn = Object.prototype.hasOwnProperty.call.bind(Object.prototype.hasOwnProperty);
const rexNum = /^0$|^[1-9]\d*$/;
function sparseEach(array, callback, thisArg) {
    for (const name in array) {
        const index = +name;
        if (hasOwn(a, name) &&
            rexNum.test(name) &&
            index <= 4294967294
           ) {
            callback.call(thisArg, array[name], index, array);
        }
    }
}

const a = [];
a[5] = "five";
a[10] = "ten";
a[100000] = "one hundred thousand";
a.b = "bee";

sparseEach(a, (value, index) => {
    console.log("Value at " + index + " is " + value);
});

for 一样,如果异步函数中的任务需要并行完成,那么 for-in 在异步函数中也能很好地完成。

function delay(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

async function showSlowly(messages) {
    for (const name in messages) {
        if (messages.hasOwnProperty(name)) { // 这几乎是所有人做的唯一检查
            const message = messages[name];
            await delay(400);
            console.log(message);
        }
    }
}

showSlowly([
    "So", "long", "and", "thanks", "for", "all", "the", "fish!"
]);
// `.catch` omitted because we know it never rejects

5. 显式使用迭代器(ES2015+)

for-of 隐式使用迭代器,为你完成所有繁琐的工作。有时,你可能需要显式地使用迭代器。它看起来像这样:

const a = ["a", "b", "c"];
const it = a.values(); // 或者 `const it = a[Symbol.iterator]();` if you like
let entry;
while (!(entry = it.next()).done) {
    const element = entry.value;
    console.log(element);
}

迭代器是一个与规范中迭代器定义匹配的对象。它的 next 方法每次调用都会返回一个新的 结果对象。结果对象有一个 done 属性,告诉我们迭代是否完成,还有一个属性 value 表示迭代的值。(如果 donefalse,它是可选的;如果 valueundefined,它是可选的。)

通过 value 得到的结果因迭代器而异。对于数组,默认的迭代器提供了数组中每个元素的值(如前面例子中的 "a"、"b" 和 "c")。数组还有另外三种返回迭代器的方法:

  • values():这是 [Symbol.iterator] 的别名,它返回默认迭代器。
  • keys():返回一个迭代器,提供数组中所有键(即索引)。在上面的例子中,它将提供 “0”,然后 “1” ,然后是 “2” (是的,返回的是字符串)。
  • entries():返回提供 [key, value] 数组的迭代器。

由于迭代器对象在调用 next 之前不会前进,因此它们在异步函数循环中运行得很好。下面是之前 for-of 示例的显式使用迭代器版本:

function delay(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

async function showSlowly(messages) {
    const it = messages.values()
    while (!(entry = it.next()).done) {
        await delay(400);
        const element = entry.value;
        console.log(element);
    }
}

showSlowly([
    "So", "long", "and", "thanks", "for", "all", "the", "fish!"
]);
// `.catch` omitted because we know it never rejects

对于类数组对象

除了真正的数组,还有一些类似数组的对象,它们也有 length 属性和全数字名称属性,如 NodeList 实例、 HTMLCollection 实例、 arguments 对象等。我们该如何遍历它们的内容?

使用上述大部分选项

以上的数组方法中,至少有一部分,或大部分,甚至全部,同样适用于类数组对象:

1. 使用 for-of (隐式使用迭代器)(ES2015+)

for-of 使用对象提供的迭代器(如果有的话)。这包括宿主提供(host-provided)的对象(如 DOM 集合和列表)。例如,getElementsByXYZ 方法返回的 HTMLCollection 实例和 querySelectorAll 方法返回的 NodeLists 实例都支持迭代。(这一点在 HTML 和 DOM 规范中得到了相当巧妙的定义。基本上,任何具有长度和可以使用索引访问的对象都是可迭代的。它不必标记为 iterableiterable仅用于除了可迭代之外,还支持 forEachvalueskeysentries 方法的集合。NodeList 被标记为 iterable;而 HTMLCollection 没有,但它们都是可迭代的。)

下面是一个遍历 div 元素的例子:

const divs = document.querySelectorAll("div");
for (const div of divs) {
    div.textContent = Math.random();
}
<div>zero</div>
<div>one</div>
<div>two</div>
<div>three</div>
<div>four</div>

2. 使用 forEach 和相关方法(ES5+)

数组原型上的各种函数都是“有意泛化的”(intentionally generic),可以通过Function#call (spec | MDN)或 Function#apply (spec | MDN)在类数组对象上使用。(如果你必须处理 IE8 或更早版本的浏览器,请参阅本答案最后的“宿主提供对象的注意事项”,但这对现代的浏览器来说不是问题。)

假设你想在一个节点的 childNodes 集合上使用 forEach 方法(这个集合是HTMLCollection,原生没有 forEach 方法)。你可以这样做:

Array.prototype.forEach.call(node.childNodes, (child) => {
    // Do something with `child`
});

(请注意,尽管可以这样做,你也可以仅使用 for-of)

如果你要经常这样做,你可能想要将函数引用保存到一个变量中,以方便重用。例如:

// (这大概都是在一个模块或一些范围内的函数中)
const forEach = Array.prototype.forEach.call.bind(Array.prototype.forEach);

// Then later...
forEach(node.childNodes, (child) => {
    // Do something with `child`
});

3. 使用简单的 for 循环

也许很明显,一个简单的 for 循环对类数组对象很有用。

4. 显示使用迭代器(ES2015+)

详细见第一部分。

也许可以用 for-in(有保障措施),但有了所有这些更合适的选择,就没有理由再去尝试。

创建真正的数组

其他时候,你可能想把一个类数组对象转换成一个真正的数组。要做到这点出乎意料地容易:

1. 使用 Array.from

Array.from (spec) | (MDN)(仅 ES2015+ 支持,但很容易通过 polyfill 获得支持)从一个类数组对象创建一个数组,可选择首先通过映射函数传递条目。所以:

const divs = Array.from(document.querySelectorAll("div"));

……从 querySelectorAll 获取 NodeList 后创建一个数组。

如果要以某种方式映射内容,映射函数是很方便的。例如,如果你想获取类名是指定值的元素的 tag 集合数组:

// Typical use (with an arrow function):
const divs = Array.from(document.querySelectorAll(".some-class"), element => element.tagName);

// Traditional function (since `Array.from` can be polyfilled):
var divs = Array.from(document.querySelectorAll(".some-class"), function(element) {
    return element.tagName;
});

2. 使用展开语法(...

也可以使用 ES2015 的扩展语法(spread syntax)。和 for-of 一样,它使用对象提供的迭代器(参见上一节的第一部分):

const trueArray = [...iterableObject];

因此,举例来说,如果我们想把 NodeList 转换为真正的数组,用拓展语法将变得非常简洁:

const divs = [...document.querySelectorAll("div")];

3. 使用数组的 slice 方法

我们可以使用数组的 slice 方法,和上面提到的其他方法一样,这个方法是“有意泛化的”,所以可以用于类似数组对象,比如这样:

const trueArray = Array.prototype.slice.call(arrayLikeObject);

因此,如果我们想把 NodeList 转换成一个真正的数组,我们可以这样做:

const divs = Array.prototype.slice.call(document.querySelectorAll("div"));

(如果你仍然要处理 IE8,这将会失败;IE8 不允许像这样将宿主提供对象用作 this。)

关于宿主提供对象的注意事项

如果你用数组原型函数处理宿主提供的类数组对象(例如 DOM 集合等由浏览器而不是 JavaScript 引擎提供的对象),像 IE8 那样的过时浏览器不一定能处理,所以如果你必须使用它们,一定要在目标环境下测试。但对于比较现代的浏览器来说,这不是问题。(对于非浏览器环境,这取决于具体环境。)