JavaScript 中各种遍历方法对比

原文:For vs forEach() vs for/in vs for/of in JavaScript

在 JavaScript 中有许多方法来遍历一个对象或者数组,他们之间的差别是一个令许多人人困惑的 问题
一些编码 规范 甚至禁止使用某些方法遍历。
在这篇文章中,我将解释以下 4 种方法遍历一个数组的不同:

  • for (let i = 0; i < arr.length; ++i)
  • arr.forEach((v, i) => { /* ... */ })
  • for (let i in arr)
  • for (const v of arr)

我将使用几种不同的边界情况概述这些循环结构之间的区别。我还将提供相关的 ESLint 规则链接,以便在你的项目中使用循环结构的最佳实践。

语法概述

forfor/in 结构让你可以得到数组索引,但不是真正的元素。例如,假设你想打印出下面这个数组中存储的内容:

const arr = ['a', 'b', 'c'];

使用 for 或者 for/in ,你需要打印 arr[i]

for (let i = 0; i < arr.length; ++i) {
    console.log(arr[i]);
}
for (let i in arr) {
    console.log(arr[i]);
}

使用另外两种方法, forEach()for/of ,你可以得到元素本身。 forEach() 也可以得到元素索引,但 for/of 不可以。

arr.forEach((v, i) => console.log(v));
for (const v of arr) {
    console.log(v);
}

非数值属性

JavaScript 中的数组也是对象。这意味着你不仅可以添加数字,也可以添加字符串属性到数组内。

const arr = ['a', 'b', 'c'];
typeof arr; // 'object'
// Assign to a non-numeric property
arr.test = 'bad';
arr.test; // 'bad'
arr[1] === arr['1']; // true, JavaScript arrays are just special objects

四种方法中的三种都忽视了非数值属性。只有 for/in 可以打印出’bad’:

const arr = ['a', 'b', 'c'];
arr.test = 'bad';
// Prints "a, b, c, bad"
for (let i in arr) {
    console.log(arr[i]);
}

这就是为什么 使用 for/in 来遍历一个数组一般不是一个好主意的原因。其他的遍历方法都正确的忽视了非数值属性:

const arr = ['a', 'b', 'c'];
arr.test = 'abc';

// Prints "a, b, c"
for (let i = 0; i < arr.length; ++i) {
    console.log(arr[i]);
}

// Prints "a, b, c"
arr.forEach((el, i) => console.log(i, el));

// Prints "a, b, c"
for (const el of arr) {
    console.log(el);
}

重点: 避免在数组中使用 for/in 循环,除非你清楚你想要遍历非数值键和继承的键。使用 ESLint 规则 guard-for-in 来禁止使用 for/in

空元素

JavaScript 数组允许 空元素。下面这个数组是语法正确的,而且长度为 3:

const arr = ['a', , 'c'];

arr.length; // 3

让人更困惑的是不同循环结构对待 ['a',, 'c']['a', undefined, 'c'] 不同。下面是四种循环结构如何对待像 ['a',, 'c'] 这样有一个空元素的方式。 for/infor/each 跳过了空元素, forfor/of 并没有。

// Prints "a, undefined, c"
for (let i = 0; i < arr.length; ++i) {
    console.log(arr[i]);
}

// Prints "a, c"
arr.forEach(v => console.log(v));

// Prints "a, c"
for (let i in arr) {
    console.log(arr[i]);
}

// Prints "a, undefined, c"
for (const v of arr) {
    console.log(v);
}

如果你想知道的话,对于 ['a', undefined, 'c'] 四种方法都会打印出 “a, undefined, c”。
这里有另外一种向数组中添加空元素的方法:

// Equivalent to `['a', 'b', 'c',, 'e']`
const arr = ['a', 'b', 'c'];
arr[5] = 'e';

forEach()for/in 跳过了数组中的空元素,但 forfor/of 没有。 forEach() 的行为可能会导致一些问题,然而,JavaScript 数组中的空元素一般是很罕见的,因为它们不被 JSON 支持:

$ node
> JSON.parse('{"arr":["a","b","c"]}')
{ arr: [ 'a', 'b', 'c' ] }
> JSON.parse('{"arr":["a",null,"c"]}')
{ arr: [ 'a', null, 'c' ] }
> JSON.parse('{"arr":["a",,"c"]}')
SyntaxError: Unexpected token , in JSON at position 12

所以你不必担心用户数据中的空元素,除非你给了用户完全访问 JavaScript 运行时的权限。

重点: for/inforEach() 跳过了数组中的空元素,也被称作 “holes”。很少有情况将空元素视为特殊情况而不是 undefined 。如果你关心空元素的特殊情况,下面是一个 .eslintrc.yml 的例子来禁止调用 forEach()

parserOptions:
  ecmaVersion: 2018
rules:
  no-restricted-syntax:

    - error
    - selector: CallExpression[callee.property.name="forEach"]

      message: Do not use `forEach()` , use `for/of` instead

函数上下文 (Function Context)

Function context 是一个很好的用来表示 this 所指对象的方法。 forfor/infor/of 中的 this 保持了和外面一样的指向,但 forEach() 回调将会有一个不同的 this,除非你使用 箭头函数

'use strict';

const arr = ['a'];

// Prints "undefined"
arr.forEach(function() {
    console.log(this);
});

重点: 使用 forEach() 时使用箭头函数。使用 no-arrow-callback ESLint rule 来对不使用 this 的回调函数要求使用箭头函数。

Async/Await 和 生成器 (Generators)

另一种使用 forEach() 的边界情况是它 不能与 async/await 或者 generators 使用。如果你的 forEach() 回调是同步的,那没问题,但你不能在 forEach() 回调中使用 await

async function run() {
    const arr = ['a', 'b', 'c'];
    arr.forEach(el => {
        // SyntaxError
        await new Promise(resolve => setTimeout(resolve, 1000));
        console.log(el);
    });
}

你也不能使用 yield

function* run() {
    const arr = ['a', 'b', 'c'];
    arr.forEach(el => {
        // SyntaxError
        yield new Promise(resolve => setTimeout(resolve, 1000));
        console.log(el);
    });
}

上面的例子在 for/of 下是正常的:

async function asyncFn() {
    const arr = ['a', 'b', 'c'];
    for (const el of arr) {
        await new Promise(resolve => setTimeout(resolve, 1000));
        console.log(el);
    }
}

function* generatorFn() {
    const arr = ['a', 'b', 'c'];
    for (const el of arr) {
        yield new Promise(resolve => setTimeout(resolve, 1000));
        console.log(el);
    }
}

即使你在 forEach() 回调上标记了 async ,在尝试让异步 forEach() 顺序执行时,会感到非常头疼。例如,下面的程序将会将 0-9 用相反的顺序打印出来。

async function print(n) {
    // Wait 1 second before printing 0, 0.9 seconds before printing 1, etc.
    await new Promise(resolve => setTimeout(() => resolve(), 1000 - n * 100));
    // Will usually print 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 but order is not strictly
    // guaranteed.
    console.log(n);
}

async function test() {
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(print);
}

test();

重点: 如果你使用 async/await 或者 generators,记住 forEach() 是语法糖。它应该小心的使用并且不要将它用于所有情况。

总结

一般来说, for/of 是用来遍历数组的方法中最健壮的。它比传统的 for 循环更加简洁,也没有像 for/inforEach() 那样多的边界情况。 for/of 的主要缺点是需要使用额外的手段来得到索引[1] ,也不能像 forEach() 一样链式使用。使用 forEach() 有许多需要注意的地方,它应该被谨慎的使用,但在很多情况下它能让代码变得更简洁。


[1] 要在 for/of 循环中得到当前数组的索引,可以使用 Array.entries() 函数.

for (const [i, v] of arr.entries()) {
    console.log(i, v); // Prints "0 a", "1 b", "2 c"
}

转载规则

《JavaScript 中各种遍历方法对比》Konata 采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。
  目录