JavaScript 中三类循环的对比与性能剖析

231 阅读5分钟

在 JavaScript 的编程世界里,循环是一种基础且重要的控制结构,不同类型的循环有着各自的特点和适用场景。本文将深入探讨 for 循环、for...in 循环以及 for...of 循环,并对它们进行性能分析。同时,我们还会研究 forEach 方法,因为它与 for 循环有着紧密的联系。

for 循环与 forEach 底层原理

for 循环

for 循环由程序员自己完全掌控循环的过程,它包含三个部分:初始化、条件判断和迭代语句。在性能方面,存在以下几种情况:

  • 基于 var 声明:在不确定循环次数的情况下,for 循环和 while 循环性能相近。这是因为 var 声明的变量存在函数作用域和变量提升的特性,在循环中使用 var 声明的变量可能会导致一些意外行为,但在性能上二者没有明显差异。
  • 基于 let 声明for 循环使用 let 声明变量时性能更好。原因在于 let 声明的变量具有块级作用域,每次迭代都会创建一个新的变量实例,不会像 var 那样在全局作用域中留下不释放的变量,从而减少了内存开销。

forEach 方法

forEach 是数组原型上的方法,用于对数组的每个元素执行一次给定的函数。它的底层实现其实就是一个 for 循环。我们可以通过以下代码模拟其实现:

Array.prototype.forEach = function forEach(callback, context) {
    let self = this,
        i = 0,
        len = self.length;
    context = context == null? window : context;
    for (; i < len; i++) {
        typeof callback === "function"? callback.call(context, self[i], i) : null;
    }
};

这里 forEach 接收一个回调函数 callback 和一个可选的 context(用于指定回调函数执行时的 this 指向)。在内部,通过 for 循环遍历数组,对每个元素执行回调函数。

for...in 循环的 BUG 及解决方案

for...in 循环用于迭代对象的所有可枚举属性(包括私有属性和公有属性)。然而,它存在一些问题:

  • 性能问题:它会按照原型链一级级查找属性,这在性能上是比较消耗的,尤其是当对象的原型链较长时。

  • 遍历问题

    • 不能迭代 Symbol 属性Symbol 类型的属性是独一无二的,for...in 无法遍历到这类属性。
    • 迭代顺序问题:它的迭代顺序会以数字属性优先,这可能与我们预期的顺序不一致。
    • 公有可枚举属性问题:公有可枚举的属性(一般是自定义属性)也会被迭代,这可能会导致一些意外结果。

解决方案可以通过以下方式:

Object.prototype.fn = function fn() {};
let obj = {
    name: 'rx',
    age: 1,
    [Symbol('AA')]: 100,
    0: 200,
    1: 300
};
let keys = Object.keys(obj);
if (typeof Symbol!== "undefined") keys = keys.concat(Object.getOwnPropertySymbols(obj));
keys.forEach(key => {
    console.log('属性名:', key);
    console.log('属性值:', obj[key]);
}); 

这里先使用 Object.keys() 获取对象的可枚举字符串属性,然后检查 Symbol 是否存在(因为在旧版本 JavaScript 中可能不存在 Symbol),如果存在则使用 Object.getOwnPropertySymbols() 获取对象的 Symbol 属性,最后通过 forEach 遍历所有属性。

for...of 循环的底层机制

for...of 循环基于迭代器(iterator)规范进行遍历。迭代器是一个对象,它必须具备 next 方法,每次执行 next 方法会返回一个对象,该对象包含 value(当前迭代的值)和 done(表示迭代是否结束)两个属性。

部分数据结构的迭代器

JavaScript 中的数组、部分类数组、SetMap 等数据结构默认实现了迭代器规范,即它们都有一个 Symbol.iterator 方法。例如数组:

arr = [10, 20, 30];
arr[Symbol.iterator] = function () {
    let self = this,
        index = 0;
    return {
        next() {
            if (index > self.length - 1) {
                return {
                    done: true,
                    value: undefined
                };
            }
            return {
                done: false,
                value: self[index++]
            };
        }
    };
};

这里手动为数组定义了一个符合迭代器规范的 Symbol.iterator 方法,实际上数组默认就有这样的实现。

让对象具备可迭代性

对于普通对象,默认是不具备迭代器规范的。但我们可以通过给对象添加 Symbol.iterator 方法使其可迭代。例如类数组对象:

let obj = {
    0: 200,
    1: 300,
    2: 400,
    length: 3
};
obj[Symbol.iterator] = Array.prototype[Symbol.iterator];
for (let val of obj) {
    console.log(val);
} 

这里通过将数组的 Symbol.iterator 方法赋值给类数组对象,使得该对象可以使用 for...of 循环进行遍历。

性能测试

通过实际的性能测试可以更直观地了解不同循环的性能差异。我们使用 console.time()console.timeEnd() 来测量不同循环的执行时间。

let arr = new Array(9999999).fill(0);

// FOR 循环测试
console.time('FOR~~');
for (let i = 0; i < arr.length; i++) {}
console.timeEnd('FOR~~');

// WHILE 循环测试
console.time('WHILE~~');
let i = 0;
while (i < arr.length) {
    i++;
}
console.timeEnd('WHILE~~');

// FOREACH 测试
console.time('FOREACH~~');
arr.forEach(function (item) {});
console.timeEnd('FOREACH~~');

// FOR IN 测试
console.time('FOR IN~~');
for (let key in arr) {}
console.timeEnd('FOR IN~~');

// FOR OF 测试
console.time('FOR OF~~');
for (const val of arr) {
    console.log(val);
}
console.timeEnd('FOR OF~~');

一般来说,在简单遍历数组时,for 循环(尤其是基于 let 声明变量的情况)和 while 循环性能较好,因为它们的控制逻辑简单直接。forEach 方法由于其内部是 for 循环实现,性能也不错,但它的回调函数会带来一定的额外开销。for...in 循环由于其遍历机制涉及原型链查找,性能相对较差,特别是在遍历大型数组时。for...of 循环在遍历可迭代对象时性能也较为可观,且代码简洁,更适用于需要获取迭代值的场景。

综上所述,在实际编程中,我们应根据具体的需求和场景选择合适的循环类型,以提高代码的性能和可读性。希望本文对您理解 JavaScript 中的循环机制有所帮助。