JavaScript 循环与遍历的五个关键陷阱及最佳实践

作者:拾光

时间:2026-10-29

浏览:168

本文深入解析 JavaScript 中循环与遍历的常见陷阱。重点讨论 Object 属性顺序的不确定性、for..in 遍历数组时的类型错误,以及向 Object.prototype 添加属性对枚举的影响。同时推荐在 ES5 及以上环境中优先使用 forEach、map 和 filter 等遍历方法,以提升代码可读性与维护性,并指出 break/continue 场景下显式循环的必要性。

文章目录

优先使用数组表示有序集合

1、优先使用数组而不是Object类型来表示有顺序的集合

ECMAScript标准并未明确定义JavaScript中Object类型属性的存储顺序。

然而,在使用for..in循环遍历Object属性时,必然涉及某种顺序。由于标准未对此进行规范,各JavaScript引擎可根据自身特性实现,导致不同执行环境下的for..in行为缺乏一致性。

例如,以下代码在调用report方法时,其输出结果是不确定的:


function report(highScores) { 
  var result = ""; 
  var i = 1; 
  for (var name in highScores) { // unpredictable order 
    result += i + ". " + name + ": " + 
    highScores[name] + "\n"; 
    i++; 
  } 
  return result; 
} 
report([{ name: "Hank", points: 1110100 }, 
{ name: "Steve", points: 1064500 }, 
{ name: "Billy", points: 1050200 }]); 
// ? 

若需确保运行结果依赖于数据的特定顺序,应优先采用数组类型来存储数据,而非直接使用Object类型。同时,建议避免使用for..in循环,转而使用显式的for循环:


function report(highScores) { 
  var result = ""; 
  for (var i = 0, n = highScores.length; i < n; i++) { 
    var score = highScores[i]; 
    result += (i + 1) + ". " + 
    score.name + ": " + score.points + "\n"; 
  } 
  return result; 
} 
report([{ name: "Hank", points: 1110100 }, 
{ name: "Steve", points: 1064500 }, 
{ name: "Billy", points: 1050200 }]); 
// "1. Hank: 1110100 2. Steve: 1064500 3. Billy: 1050200\n" 

另一个高度依赖顺序的行为涉及浮点数计算:


var ratings = { 
  "Good Will Hunting": 0.8, 
  "Mystic River": 0.7, 
  "21": 0.6, 
  "Doubt": 0.9 
}; 

在Item 2中曾提及,浮点数加法甚至不满足交换律: (0.1 + 0.2) + 0.3 的结果与 0.1 + (0.2 + 0.3) 的结果分别为 0.600000000000001 和 0.6

因此,对于浮点数的算术运算,绝不能采用任意顺序:


var total = 0, count = 0; 
for (var key in ratings) { // unpredictable order 
  total += ratings[key]; 
  count++; 
} 
total /= count; 
total; // ? 

当for..in的遍历顺序不同时,最终得到的total结果也会不同。以下是两种计算顺序及其对应结果:


(0.8 + 0.7 + 0.6 +0.9) / 4 // 0.75
(0.6 + 0.8 + 0.7 +0.9) / 4 // 0.7499999999999999

当然,针对浮点数计算这类问题,有一种解决方案是使用整型数表示。例如,将上述浮点数先放大10倍转换为整型数据,计算完成后再缩小10倍:


(8+ 7 + 6 + 9) / 4 / 10  // 0.75
(6+ 8 + 7 + 9) / 4 / 10  // 0.75

避免向Object.prototype添加可列举属性

2、绝不要向Object.prototype中添加可列举的(Enumerable)属性

若你的代码依赖for..in循环来遍历Object属性,切勿向Object.prototype中添加任何可列举的属性。

但在增强JavaScript执行环境时,往往需要向Object.prototype对象添加新属性或方法。例如,可以添加一个用于获取对象所有属性名的方法:


Object.prototype.allKeys = function() { 
  var result = []; 
  for (var key in this) { 
    result.push(key); 
  } 
  return result; 
}; 

但结果如下所示:


({ a: 1, b: 2, c: 3}).allKeys(); // ["allKeys", "a", "b","c"]

一种可行的解决方案是使用函数而非在Object.prototype上定义新方法:


function allKeys(obj) { 
  var result = []; 
  for (var key in obj) { 
    result.push(key); 
  } 
  return result; 
} 

但如果确实需要向Object.prototype添加新属性,且不希望该属性在for..in循环中被遍历到,可以利用ES5环境提供的Object.defineProperty方法:


Object.defineProperty(Object.prototype, "allKeys", { 
  value: function() { 
    var result = []; 
    for (var key in this) { 
      result.push(key); 
    } 
    return result; 
  }, 
  writable: true, 
  enumerable: false, 
  configurable: true 
}); 

上述代码的关键在于将enumerable属性设置为false。这样,在for..in循环中便无法遍历到该属性。

数组遍历优先使用标准for循环

3、对于数组遍历,优先使用for循环,而不是for..in循环

尽管上一项已提及此问题,但针对以下代码,你能看出最后的平均数是多少吗?


var scores = [98, 74, 85, 77, 93, 100, 89]; 
var total = 0; 
for (var score in scores) { 
  total += score; 
} 
var mean = total / scores.length; 
mean; // ? 

经过计算,最终结果应为88。

但别忘了,在for..in循环中,被遍历的永远是key而非value,数组亦然。因此,上述for..in循环中的score并非期望的98、74等值,而是0、1等索引。

因此,你可能会认为最终结果是: (0 + 1+ …+ 6) / 7 = 21

但该答案也是错误的。另一个关键点在于,for..in循环中key的类型始终为字符串,因此这里的+操作符执行的是字符串拼接:

最终得到的total实际上是字符串00123456。将该字符串转换为数值类型后值为123456,再除以元素个数7,得到最终结果:17636.571428571428

因此,对于数组遍历,使用标准for循环仍是最佳选择。

优先使用ES5遍历方法

4、优先使用遍历方法而非循环

使用循环时,极易违反DRY(Don't Repeat Yourself)原则。因为人们常通过复制粘贴来避免手写循环语句,但这会导致代码中出现大量重复,开发人员也在无意义地“重复造轮子”。更重要的是,复制粘贴时容易忽略循环细节,如起始索引值、终止判断条件等。

例如,以下for循环就存在此类问题,假设n是集合对象的长度:


for (var i = 0; i <= n; i++) { ... }
// 终止条件错误,应该是i < n
for (var i = 1; i < n; i++) { ... }
// 起始变量错误,应该是i = 0
for (var i = n; i >= 0; i--) { ... }
// 起始变量错误,应该是i = n - 1
for (var i = n - 1; i > 0; i--) { ... }
// 终止条件错误,应该是i >= 0

可见,在循环的细节处理上很容易出错。而利用JavaScript提供的闭包(参见Item 11),可将循环细节封装以供重用。实际上,ES5已提供了一些方法来解决这一问题。其中Array.prototype.forEach是最简单的一个。利用它,我们可以这样编写循环:


// 使用for循环
for (var i = 0, n = players.length; i < n; i++) {
  players[i].score++;
}

// 使用forEach
players.forEach(function(p) {
  p.score++;
});

除了遍历集合对象外,另一种常见模式是对原集合中的每个元素执行某种操作,然后得到新集合。我们也可以利用forEach方法实现如下:


// 使用for循环
var trimmed = [];
for (var i = 0, n = input.length; i < n; i++) {
  trimmed.push(input[i].trim());
}

// 使用forEach
var trimmed = [];
input.forEach(function(s) {
  trimmed.push(s.trim());
});

但由于这种将集合转换为另一集合的模式十分常见,ES5也提供了Array.prototype.map方法,使代码更加简洁优雅:


var trimmed = input.map(function(s) {
  return s.trim();
});

此外,还有一种常见模式是根据特定条件过滤集合,得到原集合的子集。ES5中提供了Array.prototype.filter来实现这一模式。该方法接受一个Predicate作为参数,它是一个返回true或false的函数:返回true表示该元素将保留在新集合中;返回false则表示该元素不会出现在新集合中。例如,我们使用以下代码来过滤商品价格,仅保留价格在[min, max]区间的商品:


listings.filter(function(listing) {
  return listing.price >= min && listing.price <= max;
});

当然,以上方法仅在支持ES5的环境中可用。在其他环境中,我们有两种选择:1. 使用第三方库,如underscore或lodash,它们提供了大量用于操作对象和集合的通用方法。2. 根据需求自行定义。

例如,定义如下方法以根据特定条件获取集合中的前若干元素:


function takeWhile(a, pred) {
  var result = [];
  for (var i = 0, n = a.length; i < n; i++) {
    if (!pred(a[i], i)) {
      break;
    }
    result[i] = a[i];
  }
  return result;
}

var prefix = takeWhile([1, 2, 4, 8, 16, 32], function(n) {
  return n < 10;
}); // [1, 2, 4, 8]

为了更好地重用该方法,我们可以将其定义在Array.prototype对象上,具体影响可参考Item 42。


Array.prototype.takeWhile = function(pred) {
  var result = [];
  for (var i = 0, n = this.length; i < n; i++) {
    if (!pred(this[i], i)) {
      break;
    }
    result[i] = this[i];
  }
  return result; 
};

var prefix = [1, 2, 4, 8, 16, 32].takeWhile(function(n) {
  return n < 10;
}); // [1, 2, 4, 8]

仅有一种场合使用循环比使用遍历函数更好:需要使用break和continue的时候。例如,当使用forEach来实现上述takeWhile方法时就会出现问题,在不满足predicate的情况下该如何实现呢?


function takeWhile(a, pred) {
  var result = [];
  a.forEach(function(x, i) {
    if (!pred(x)) {
      // ?
    }
    result[i] = x;
  });
  return result;
}

我们可以使用内部异常进行判断,但这同样显得笨拙且低效:


function takeWhile(a, pred) {
  var result = [];
  var earlyExit = {}; // unique value signaling loop break
  try {
    a.forEach(function(x, i) {
      if (!pred(x)) {
        throw earlyExit;
      }
      result[i] = x;
    });
  } catch (e) {
    if (e !== earlyExit) { // only catch earlyExit
      throw e;
    }
  }
  return result;
}

然而,使用forEach后,代码甚至比使用它之前更加冗长。这显然存在问题。针对此问题,ES5提供了some和every方法来处理存在提前终止的循环,其用法如下所示:


[1, 10, 100].some(function(x) { return x > 5; }); // true
[1, 10, 100].some(function(x) { return x < 0; }); // false

[1, 2, 3, 4, 5].every(function(x) { return x > 0; }); // true
[1, 2, 3, 4, 5].every(function(x) { return x < 3; }); // false

这两个方法均为短路方法(Short-circuiting):只要有任何一个元素在some方法的predicate中返回true,some就会返回;只要有任何一个元素在every方法的predicate中返回false,every方法也会返回false。

因此,takeWhile可实现如下:


function takeWhile(a, pred) {
  var result = [];
  a.every(function(x, i) {
    if (!pred(x)) {
      return false; // break
    }
    result[i] = x;
    return true; // continue
  });
  return result;
}

实际上,这就是函数式编程的思想。在函数式编程中,你很少能看见显式的for循环或while循环。循环的细节都被很好地封装起来了。

5、总结

  • 在使用for..in循环时,不要依赖于遍历的顺序。
  • 当使用Object类型来保存数据时,需保证其中的数据是无序的。
  • 当需要表示带有顺序的集合时,使用数组类型而不是Object类型。
  • 避免向Object.prototype中添加任何属性。
  • 如果确实有必要向Object.prototype中添加方法属性,可以考虑使用独立函数替代。
  • 使用Object.defineProperty来添加可以不被for..in循环遍历到的属性。
  • 当遍历数组时,使用标准的for循环,而不要使用for..in循环。
  • 在必要的场合考虑预先保存数组的长度,以提高性能。
  • 使用遍历方法Array.prototype.forEach和Array.prototype.map来代替循环,从而让代码更加清晰可读。
  • 对于重复出现的循环,可以考虑将它们进行抽象。通过第三方提供的方法或者自己实现。
  • 显式的循环在一些场合下还是有用武之地的,相应的也可以使用some或者every方法。

以上就是本文的全部内容,希望通过这篇文章大家更加了解javascript循环的原理,大家共同进步。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章