JavaScript中的forEach,你踩过哪些坑?请避开这些常见误区

e39691cda8c531ff0726959732722c66.jpeg

在JavaScript的世界里,forEach是我们常用的数组遍历方法之一。大多数开发者都熟悉它的基础用法,但你知道吗?在处理异步操作时,forEach可能会让你掉进一些意想不到的“坑”。这篇文章将带你深入了解forEach的特性和局限,揭示一些你可能不知道的使用技巧和解决方案。无论你是前端新手,还是经验丰富的开发者,都能从中学到有用的知识,帮助你在实际项目中避开那些隐藏的陷阱。准备好了吗?让我们一探究竟!

先聊聊什么是forEach?

forEach是数组对象的一个原型方法,它会为数组中的每个元素执行一次给定的回调函数,并且总是返回undefined。不过需要注意的是,类似arguments这样的类数组对象是没有forEach方法的哦。

基本语法

arr.forEach(callback(currentValue [, index [, array]])[, thisArg])

别被这复杂的语法吓到,我们来逐个拆解。

参数详解

  1. callback:对每个元素执行的回调函数,它可以接受1到3个参数。

  • currentValue:当前处理的元素,必选。

  • index:当前处理元素的索引,可选。

  • array:正在操作的原数组对象,可选。

thisArg:执行回调函数时this的值,默认为全局对象,可选。

1、forEach() 方法不支持处理异步函数

在JavaScript中,forEach() 是一个同步方法,不支持处理异步函数。如果你在 forEach 中执行一个异步函数,forEach 不会等待异步函数完成,而是会立即处理下一个元素。这意味着如果你在 forEach 中使用异步函数,异步任务的执行顺序是无法保证的。

示例代码

async function test() {
    let arr = [3, 2, 1];
    arr.forEach(async item => {
        const res = await mockAsync(item);
        console.log(res);
    });
    console.log('end');
}

function mockAsync(x) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(x);
        }, 1000 * x);
    });
}
test();

预期结果:

3
2
1
end

实际结果:

end
1
2
3

这个例子中,虽然我们希望按顺序输出 3, 2, 1end,但实际结果是 end 先输出,然后才是 1, 2, 3。这是因为 forEach 不等待异步操作完成。

解决方法:使用 for...of 循环和 async/await

为了解决这个问题,我们可以使用 for...of 循环和 async/await 关键字来确保异步操作按顺序完成。

示例代码

async function test() {
    let arr = [3, 2, 1];
    for (let item of arr) {
        const res = await mockAsync(item);
        console.log(res);
    }
    console.log('end');
}

function mockAsync(x) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(x);
        }, 1000 * x);
    });
}
test();

输出结果:

3
2
1
end

在这个例子中,我们使用 for...of 循环代替 forEach 方法,通过在循环内部使用 await 关键字,确保每个异步操作完成后才处理下一个元素,从而实现了按顺序输出。

2、异步函数中的错误无法被捕获

除了不能处理异步函数外,forEach还有另一个重要的限制:它无法捕获异步函数中的错误。这意味着即使异步函数在执行过程中抛出错误,forEach 仍然会继续进行下一个元素的处理,而不会对错误进行处理。这种行为可能会导致程序出现意外的错误和不稳定性。

3、无法中断或跳过forEach循环

除了无法处理异步函数和捕获错误之外,forEach还有一个限制:它不支持使用break或continue语句来中断或跳过循环。如果你需要在循环中途退出或跳过某个元素,应该使用其他支持这些语句的方法,例如for循环。

示例代码

let arr = [1, 2, 3];
try {
    arr.forEach(item => {
        if (item === 2) {
            throw('error');
        }
        console.log(item);
    });
} catch(e) {
    console.log('e:', e);
}

// 输出结果:
// 1
// e: error

在这个例子中,我们尝试通过抛出异常来中断forEach循环。虽然这种方法在某些情况下有效,但并不是优雅或推荐的做法。

更好的解决方案:使用 for...of 循环

相比之下,for...of  循环更灵活,可以使用break和continue语句来控制循环的执行。

示例代码

let arr = [1, 2, 3];
for (let item of arr) {
    if (item === 2) {
        break; // 中断循环
    }
    console.log(item);
}

// 输出结果:
// 1

在这个例子中,当遇到元素2时,循环会被中断,从而避免输出2和3。

4、无法删除自身元素并重置索引

在forEach中,我们无法控制索引的值,它只是盲目地递增直到超过数组的长度并退出循环。因此,删除自身元素以重置索引也是不可能的。来看一个简单的例子:

示例代码

let arr = [1, 2, 3, 4];
arr.forEach((item, index) => {
    console.log(item); // 输出: 1 2 3 4
    index++;
});

在这个例子中,forEach遍历数组 arr,输出每个元素的值。虽然我们尝试在循环内部递增 index,但这并不会影响forEach的内部机制。forEach中的索引是自动管理的,并且在每次迭代时都会自动递增。

为什么无法删除元素并重置索引?

在forEach中,索引的值是由forEach方法内部控制的。即使我们手动修改索引变量,也不会影响forEach的遍历行为。更具体地说,当我们试图在forEach内部删除元素时,forEach不会重新计算索引,这会导致一些元素被跳过,或者某些情况下出现未定义的行为。

例如,如果我们尝试删除当前元素:

错误示范

let arr = [1, 2, 3, 4];
arr.forEach((item, index) => {
    if (item === 2) {
        arr.splice(index, 1); // 尝试删除元素2
    }
    console.log(item); // 输出: 1 2 4
});
console.log(arr); // 输出: [1, 3, 4]

在这个例子中,当我们删除元素2时,forEach并不会重置或调整索引,因此它继续处理原数组中的下一个元素。这导致元素3被跳过,因为原来的元素3现在变成了元素2的位置。

当元素 2 被删除后,原数组变为 [1, 3, 4],forEach会继续按照原索引顺序进行,因此输出 1, 2, 4,而元素 3 被跳过了。这是因为元素 3 在 2 被删除后移动到了索引 1 的位置,而forEach的索引已经移动到 2,所以直接输出了删除后的索引 2 位置的新元素 4。

更好的解决方案:使用for循环

let arr = [1, 2, 3, 4];
for (let i = 0; i < arr.length; i++) {
    if (arr[i] === 2) {
        arr.splice(i, 1); // 删除元素2
        i--; // 调整索引
    } else {
        console.log(arr[i]); // 输出: 1 3 4
    }
}
console.log(arr); // 输出: [1, 3, 4]

5、this 关键字的作用域问题

在forEach方法中,this关键字指的是调用该方法的对象。然而,当我们使用常规函数或箭头函数作为参数时,this关键字的作用域可能会出现问题。在箭头函数中,this关键字指的是定义该函数的对象;而在常规函数中,this关键字指的是调用该函数的对象。为了确保this关键字的正确作用域,我们可以使用bind方法来绑定函数的作用域。以下是一个说明this关键字作用域问题的例子:

示例代码

const obj = {
  name: "Alice",
  friends: ["Bob", "Charlie", "Dave"],
  printFriends: function () {
    this.friends.forEach(function (friend) {
      console.log(this.name + " is friends with " + friend);
    });
  },
};
obj.printFriends();

在这个例子中,我们定义了一个名为obj的对象,里面有一个printFriends方法。我们使用forEach方法遍历friends数组,并使用常规函数来打印每个朋友的名字和obj对象的name属性。然而,运行这段代码时,输出如下:

undefined is friends with Bob
undefined is friends with Charlie
undefined is friends with Dave

这是因为在forEach方法中使用常规函数时,该函数的作用域不是调用printFriends方法的对象,而是全局作用域。因此,无法访问obj对象的属性。

使用bind方法解决

为了解决这个问题,我们可以使用bind方法来绑定函数的作用域,将其绑定到obj对象。下面是一个使用bind方法解决问题的例子:

示例代码

const obj = {
  name: "Alice",
  friends: ["Bob", "Charlie", "Dave"],
  printFriends: function () {
    this.friends.forEach(
      function (friend) {
        console.log(this.name + " is friends with " + friend);
      }.bind(this) // 使用bind方法绑定函数的作用域
    );
  },
};
obj.printFriends();

运行这段代码,输出如下:

Alice is friends with Bob
Alice is friends with Charlie
Alice is friends with Dave

通过使用bind方法绑定函数的作用域,我们可以正确地访问obj对象的属性。

使用箭头函数解决

另一个解决方案是使用箭头函数。由于箭头函数没有自己的this,它会继承其当前作用域的this。因此,在箭头函数中,this关键字指的是定义该函数的对象。

示例代码

const obj = {
  name: "Alice",
  friends: ["Bob", "Charlie", "Dave"],
  printFriends: function () {
    this.friends.forEach((friend) => {
      console.log(this.name + " is friends with " + friend);
    });
  },
};
obj.printFriends();

运行这段代码,输出如下:

Alice is friends with Bob
Alice is friends with Charlie
Alice is friends with Dave

使用箭头函数,我们可以确保this关键字指向正确的对象,从而正确访问对象的属性。

6、forEach 的性能低于 for 循环

forEach 方法虽然使用方便,但在性能方面却逊色于传统的 for 循环。原因在于 forEach 的函数签名包含参数和上下文,使得其性能低于 for 循环。

为什么 for 循环更快?

  1. 简单实现:for 循环的实现最为简单,没有额外的函数调用和上下文处理。

  2. 减少函数调用栈:forEach 方法每次迭代都会调用一次回调函数,增加了函数调用栈的开销。

  3. 上下文处理:forEach 方法需要处理函数的上下文和参数,这些操作都会消耗额外的时间和资源。

7、跳过已删除或未初始化的项

forEach方法在遍历数组时会跳过未初始化的值和已删除的值。这可能会导致一些意想不到的行为。

跳过未初始化的值

在数组中,如果某些值未初始化,forEach会直接跳过这些值。来看下面这个例子:

const array = [1, 2, /* 空 */, 4];
let num = 0;

array.forEach((ele) => {
  console.log(ele);
  num++;
});

console.log("num:", num);

// 输出结果:
// 1
// 2
// 4
// num: 3

在这个例子中,数组中的第三个元素未初始化,forEach直接跳过了它。因此,虽然数组的长度是4,但实际被遍历的元素只有3个。

跳过已删除的值

当在forEach循环中删除数组元素时,forEach会跳过这些已删除的值。来看下面这个例子:

const words = ['one', 'two', 'three', 'four'];
words.forEach((word) => {
  console.log(word);
  if (word === 'two') {
    words.shift(); // 删除数组中的第一个元素 'one'
  }
});

// 输出结果:
// one
// two
// four

console.log(words); // ['two', 'three', 'four']

在这个例子中,当遍历到元素 'two' 时,执行了 words.shift(),删除了数组中的第一个元素 'one'。由于数组元素向前移动,元素 'three' 被跳过,forEach 直接处理新的第三个元素 'four'。

8、不会改变原数组

当调用forEach方法时,它不会改变原数组,即它被调用的数组。然而,传递的回调函数可能会改变数组中的对象。

示例代码1

const array = [1, 2, 3, 4]; 
array.forEach(ele => { ele = ele * 3 }) 
console.log(array); // [1, 2, 3, 4]

在这个例子中,forEach方法并没有改变原数组。虽然在回调函数中对每个元素进行了乘3的操作,但这些操作并没有反映在原数组中。

如果希望通过forEach改变原数组,需要直接修改数组元素的值,而不是简单地对元素进行赋值。

示例代码

const numArr = [33, 4, 55];
numArr.forEach((ele, index, arr) => {
    if (ele === 33) {
        arr[index] = 999;
    }
});
console.log(numArr);  // [999, 4, 55]

在这个例子中,我们通过forEach方法直接修改了数组中的元素,从而改变了原数组。

示例代码2

const changeItemArr = [{
    name: 'wxw',
    age: 22
}, {
    name: 'wxw2',
    age: 33
}];
changeItemArr.forEach(ele => {
    if (ele.name === 'wxw2') {
        ele = {
            name: 'change',
            age: 77
        };
    }
});
console.log(changeItemArr); // [{name: "wxw", age: 22}, {name: "wxw2", age: 33}]

在这个例子中,尝试对数组中的对象进行替换操作,但这种方式并不会改变原数组中的对象。

解决方案:通过索引改变数组中的对象

为了正确替换数组中的对象,可以通过索引来直接修改数组中的对象。

示例代码

const allChangeArr = [{
    name: 'wxw',
    age: 22
}, {
    name: 'wxw2',
    age: 33
}];
allChangeArr.forEach((ele, index, arr) => {
    if (ele.name === 'wxw2') {
        arr[index] = {
            name: 'change',
            age: 77
        };
    }
});
console.log(allChangeArr); // [{name: "wxw", age: 22}, {name: "change", age: 77}]

在这个例子中,通过索引直接修改数组中的对象,从而实现了对原数组的修改。

结束

总结一下,forEach虽然方便,但在一些特定场景下,使用传统的for循环或其他遍历方法可能更适合你的需求。比如,当你需要精确控制循环流程、处理异步操作或是修改原数组时,for循环往往能提供更高的灵活性和性能。

你是否在项目中遇到过使用forEach时的“坑”?你会在什么情况下选择forEach,又会在什么情况下选择其他循环方法呢?欢迎在评论区分享你的经验和观点,与我们一起交流进步!

如果你觉得这篇文章对你有帮助,别忘了点赞、收藏并转发给你的朋友们。想要了解更多JavaScript技巧和编程知识,记得关注我的公众号,第一时间获取最新的技术干货。你的支持是我们创作的最大动力!

我们下期再见,Happy Coding!

  • 19
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值