JavaScript 循环与遍历的五个关键陷阱及最佳实践
本文深入解析 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循环的原理,大家共同进步。
JS处理含Emoji或中文的字符串转数组乱码问题
2026-11-05 16:12:28
JS怎么使用展开运算符合并对象
2026-11-05 14:42:18
ES6+环境下判断JS对象为空的简洁写法
2026-11-05 12:12:23
怎么通过replacer函数自定义JS中JSON的格式化输出
2026-11-05 11:19:44
JS正则表达式基础:字面量与RegExp构造函数的区别及常用元字符
2026-11-05 11:16:58
