原文: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`... } }
-
-
一些“不要”:
但还有很多东西需要探讨,请继续阅读……
JavaScript 具有强大的语义(semantics),可以遍历数组和类数组对象。我把答案分成了两部分:适用于真正数组的部分,以及适用于类数组的部分,例如 arguments 对象,其他的可迭代对象(ES2015+),DOM 集合等等。
好吧,让我们看看这两部分:
对于真正的数组
你有五种选择(两种基本永远支持,另一种由 ECMAScript 5 ["ES5"] 引入,ECMAScript 2015 ("ES2015", 又称作 "ES6") 又引入了另外两种):
- 使用
for-of(隐式使用迭代器)(ES2015+) - 使用
forEach以及类似的方法(ES5+) - 使用简单的
for循环 - 正确地使用
for-in - 显式使用迭代器(ES2015+)
(你可以从这里查看那些旧的标准:ES5, ES2015,但是二者都已经被新的标准取代了,当前版本的草案在这里。)
详情:
1. 使用 for-of(隐式使用迭代器)(ES2015+)
ES2015 将迭代器和可迭代对象(iterators and iterables)引入了 JavaScript。数组是可迭代的(稍后你将看到,string,Map,Set 以及 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 完成。这意味着:
- 将异步函数用作
every、some和filter的回调是不合适的。因为它们会将异步函数返回的 Promise 当作一个 truthy 值;它们不会等待 Pormise 完成,然后使用 Promise 的完成值(fulfillment value)。 - 如果你的目的是将一个数组转换成一个 Promise 数组,也许是为了将其传给 Promise 组合函数(ombinator functions)(
Promise.all,Promise.race,promise.allSettled, 或Promise.any),那么将异步函数用作map的回调是合适的。 - 很少,也不适合将异步函数用作
reduce或reduceRight的回调,同样是因为回调总是返回一个 Promise。但有这样一种使用场景,即可以使用reduce构建一个 Promise 链 (const promise = array.reduce((p, element) => p.then(/*...something using element...*/));),但通常情况下,使用for-of或for会更清晰,也更容易调试。
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]);
}
}
以上三个检查分别是:
- 对象本身拥有这个名称的属性(而不是从原型继承的属性;这经常写成
a.hasOwnProperty(name),但是 ES2022 引入了Object.hasOwn,这可能更可靠),并且 - 名称是十进制数字(例如,正常的字符串形式,而非科学符号),并且
- 当这个名称被强行转换成一个数字后,它的值 <= 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 表示迭代的值。(如果 done 是 false,它是可选的;如果 value 是 undefined,它是可选的。)
通过 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 规范中得到了相当巧妙的定义。基本上,任何具有长度和可以使用索引访问的对象都是可迭代的。它不必标记为 iterable;iterable仅用于除了可迭代之外,还支持 forEach、values、keys 和 entries 方法的集合。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 那样的过时浏览器不一定能处理,所以如果你必须使用它们,一定要在目标环境下测试。但对于比较现代的浏览器来说,这不是问题。(对于非浏览器环境,这取决于具体环境。)