JavaScript 陷阱集合






4.92/5 (163投票s)
JavaScript 中不寻常和意外特性的集合
引言
本文旨在介绍 JavaScript 中一些奇特的地方,它确实有奇特的一面!那些通常用其他语言编写代码的软件开发者,在开始使用世界上最广泛使用的语言编写代码时,会发现许多令人着迷的“特性”。希望即使是经验丰富的 JavaScript 开发者也能在本篇文章中找到一些新的需要注意的陷阱。祝您阅读愉快!
目录
函数和运算符
双等号
==
运算符会进行类型强制转换的比较。这意味着您可以比较两种不同类型的对象,它会尝试在进行比较之前将它们转换为同一类型。例如:
"1" == 1 //true
然而,这通常会产生误导,而且从不需要。在上面的例子中,您可以将 String
转换为 Number
并使用区分类型的三个等号
Number("1") === 1; //true
或者,更好的做法是,首先确保您的操作数类型正确。
由于其类型强制转换的行为,双等号经常会破坏传递性规则,这有点令人担忧。
"" == 0 //true - empty string is coerced to Number 0.
0 == "0" //true - Number 0 is coerced to String "0"
"" == "0" //false - operands are both String so no coercion is done.
当使用三个等号时,上述所有比较都将产生 false
。
parseInt 不假定为十进制
如果您在调用 parseInt
时省略第二个参数,那么基数将根据以下规则确定:
- 默认情况下,假定基数为 10。
- 如果数字以 0x 开头,则假定基数为 16。
- 如果数字以 0 开头,则假定基数为 8。
常见的错误是允许用户输入或类似的内容以 0 开头。此时将使用基数 8(八进制),我们看到了这种效果:
parseInt("8"); //8
parseInt("08"); //0
因此,请务必包含第二个参数。
parseInt("8", 10); //8
parseInt("08", 10); //8
ECMAScript5 旁注:ES5 不再包含基数 8 的假定。此外,省略第二个参数会引发 JSLint 警告,这很有帮助。
字符串替换
字符串 replace
函数只替换第一个匹配项,而不是您可能期望的所有匹配项。
"bob".replace("b", "x"); // "xob"
"bob".replace(/b/, "x"); // "xob" (regular expression version)
要替换所有匹配项,您必须使用正则表达式,并为其添加全局修饰符。
"bob".replace(/b/g, "x"); // "xox"
"bob".replace(new RegExp("b", "g"), "x"); // "xox" (alternate explicit RegExp)
全局修饰符确保替换不会在第一个匹配项后停止。
'+' 运算符既能相加也能拼接
PHP,另一种弱类型语言,使用 '.' 运算符进行 string
拼接。JavaScript 没有 - 因此“a + b
”当任一操作数为 string
时,结果总是拼接。当您尝试将数字添加到,例如,输入元素的内容(它将是 string
)时,这可能会让您措手不及,因此您需要先转换为 Number
。
1 + document.getElementById("inputElem").value; // Concatenates
1 + Number(document.getElementById("inputElem").value); // Adds
请注意,减法运算符会尝试将操作数转换为 Number
。
"3" - "1"; // 2
然而,如果您试图用一个 string
减去另一个 string
,那可能说明您的逻辑有问题。
有时人们试图通过与空 string
拼接来将 Number
转换为 string
。
3 + ""; // "3"
但这并不好,所以请使用 String(3)
代替。
typeof
返回基本类型实例的类型。Array
实际上不是基本类型,所以 typeof
数组对象的类型是 Object
。
typeof {} === "object" //true
typeof "" === "string" //true
typeof [] === "array"; //false
当您对自定义对象实例使用此运算符时,您将获得相同的结果(typeof = "object"
)。
顺便说一句,“typeof null
”返回“object
”,这有点奇怪。
instanceof
返回对象(或其原型链中的对象)是否是通过指定的构造函数构造的,这在尝试检查您自己定义的某种对象类型的类型时很有用。然而,当您使用字面量语法创建内置类型实例时,它会产生误导。
"hello" instanceof String; //false
new String("hello") instanceof String; //true
由于 Array
并不是真正的内置类型之一(它只是伪装成这样 - 因此 typeof
不如预期那样工作),instanceof
的工作方式符合预期。
["item1", "item2"] instanceof Array; //true
new Array("item1", "item2") instanceof Array; //true
好了!总而言之,如果您想测试 Boolean
、String
、Number
或 Function
的类型,您可以使用 typeof
。对于其他任何类型,您都可以使用 instanceof
。
哦,还有一件事。在函数内部,有一个预定义的变量“arguments
”,它给出了传递给函数的参数列表。然而,它实际上并不是一个数组,而是一个类数组对象,它有一个 length
属性,以及从 0
到 length
的属性。非常奇怪……但您可以使用这个常见的技巧将其转换为真正的数组。
var args = Array.prototype.slice.call(arguments, 0);
同样适用于 DOM 调用(如 getElementsByTagName
)返回的 NodeList
对象 - 您也可以使用上述代码将它们转换为标准的数组。
eval
eval
将 string
解释为代码,但其使用通常不被推荐。它很慢 - 当 JavaScript 加载到浏览器时,它会被编译成原生代码;然而,每当执行过程中遇到 eval
语句时,编译引擎就必须重新启动,这相当昂贵。它看起来也很丑陋,因为在大多数情况下它会被滥用。此外,eval
的代码在当前作用域中执行,因此它可以修改局部变量并向您的作用域添加内容,这可能是非预期的。
解析 JSON 是一个常见的用法;通常人们会使用“var obj = eval(jsonText);
”,但几乎所有浏览器现在都支持本地 JSON 对象,您可以使用它代替:“var obj = JSON.parse(jsonText);
”。还有一个用于相反用法的 JSON.stringify
函数。更好的是,您可以使用 jQuery.parseJSON
函数,它总是有效的。
setTimeout
和 setInterval
函数可以将 string
作为第一个参数,该参数将被解释,所以也不应该这样做。请使用实际的函数作为该参数。
最后,Function
构造函数与 eval
非常相似 - 唯一的区别是它将在全局上下文中执行。
with
with
语句提供了一种访问对象属性的简写方式,关于是否应该使用它存在相互冲突的观点。Douglas Crockford 不喜欢它。John Resig 在他的书中发现了一些巧妙的使用方法,但也承认它会影响性能,并且可能有点令人困惑。单独查看 with
块,无法确切了解发生了什么。例如:
with (obj) {
bob = "mmm";
eric = 123;
}
我只是修改了一个名为“bob
”的局部变量,还是设置了 obj.bob
?嗯,如果 obj.bob
已经定义,那么它将被重置为“mmm”。否则,如果作用域中还有另一个 bob
变量,则会更改它。否则,将设置全局变量 bob
。最终,写清楚您想做什么会更清晰。
obj.bob = "mmm";
obj.eric = 123;
ECMAScript5 旁注:ES5 严格模式将不支持 with
语句。
Math.min 和 Math.max
这两个是实用函数,它们给出参数的最大值或最小值。然而,如果您在没有任何参数的情况下使用它们,您会得到:
Math.max(); // -Infinity
Math.min(); // Infinity
这实际上是有道理的,因为 Math.max()
返回的是空数字列表中最大的那个。但如果您这样做,您可能想要的是 Number.MAX_VALUE
或 Number.MIN_VALUE
。
类型和构造函数
使用 'new' 关键字构造内置类型
JavaScript 具有 Object
、Array
、Boolean
、Number
、String
和 Function
类型。每种类型都有其自己的字面量语法,因此显式构造函数永远是必需的。
显式(错误) | 字面量(正确) |
---|---|
var a = new Object(); a.greet = "hello"; |
var a = { greet: "hello" }; |
var b = new Boolean(true); |
var b = true; |
var c = new Array("one", "two"); |
var c = ["one", "two"]; |
var d = new String("hello"); |
var d = "hello" |
var e = new Function("greeting", "alert(greeting);"); |
var e = function(greeting) { alert(greeting); }; |
然而,如果您使用 new
关键字来构造这些类型中的一种,您实际上得到的是一个 Object
类型的对象,它继承了您想要构造的类型的原型(Function
是例外)。因此,尽管您可以使用 new
关键字构造一个 Number
,但它的类型是 Object
。
typeof new Number(123); // "object"
typeof Number(123); // "number"
typeof 123; // "number"
第三种选择,即字面量语法,在构造这些类型中的任何一种时都应该始终使用,以避免任何混淆。
不使用 'new' 关键字构造其他任何东西
如果您编写了自己的构造函数,并忘记包含 new
关键字,那么就会发生糟糕的事情。
var Car = function(colour) {
this.colour = colour;
};
var aCar = new Car("blue");
console.log(aCar.colour); // "blue"
var bCar = Car("blue");
console.log(bCar.colour); // error
console.log(window.colour); //"blue"
使用 new
关键字调用函数会创建一个新对象,然后使用该新对象作为上下文来调用该函数。然后返回该对象。反之,在没有 'new
' 的情况下调用函数将导致上下文为全局对象(如果该函数不是在对象上调用的,而如果它被用作构造函数,它就不会!)。
意外忘记包含 'new
' 的危险意味着出现了一些替代的对象构造模式,这些模式完全消除了对该关键字的要求,尽管这超出了本文的范围,因此我建议 进一步阅读!
没有整数
数值计算相对较慢,因为没有整数类型,只有 Number
- 而 Number
是 IEEE 浮点双精度(64 位)类型。这意味着 Number
会出现浮点舍入误差。
0.1 + 0.2 === 0.3 //false
由于不像 C# 或 Java 那样区分整数和浮点数,这是真的。
0.0 === 0; //true
最后是一个 Number
谜题。您如何实现以下目标?
a === b; //true
1/a === 1/b; //false
答案是 Number
的规范允许正零和负零值。正零等于负零,但正无穷大不等于负无穷大。
var a = 0 * 1; // This simple sum gives 0
var b = 0 * -1; // This simple sum gives -0 (you could also just
// do "b = -0" but why would you do that?)
a === b; //true: 0 equals -0
1/a === 1/b; //false: Infinity does not equal -Infinity
作用域
没有块级作用域
正如您可能在上一条中注意到的,不存在块级作用域的概念,只有函数作用域。尝试以下代码:
for(var i=0; i<10; i++) {
console.log(i);
}
var i;
console.log(i); // 10
当 i
在 for
循环中声明时,它在循环退出后仍然在作用域内。因此,最后一次调用 console.log
将输出 10
。有一个 JSLint 警告旨在避免这种混淆:强制所有变量在函数开头声明,这样就可以清楚地了解发生了什么。
可以通过编写一个立即执行的函数来创建作用域。
(function (){
for(var i=0; i<10; i++) {
console.log(i);
}
}());
var i;
console.log(i); // undefined
当您在内部函数 *之前* 声明一个 var
,然后在该函数 *内部* 重新声明它时,会出现另一种扭曲。请看这个例子:
var x = 3;
(function (){
console.log(x + 2); // 5
x = 0; //No var declaration
}());
然而,如果您在内部函数中将 x
重新声明为 var
,则会出现奇怪的行为:
var x = 3;
(function (){
console.log(x + 2); //NaN - x is not defined
var x = 0; //var declaration
}());
这是因为“x
”在函数内部被重新定义了。这意味着解释器会将 var
语句移到函数顶部,我们最终会执行这个:
var x = 3;
(function (){
var x;
console.log(x + 2); //NaN - x is not defined
x = 0;
}());
这完全说得通!
全局变量
JavaScript 有一个全局作用域,您应该谨慎使用,通过命名空间来组织代码。全局变量会给您的应用程序带来性能损失,因为在访问它们时,运行时必须逐级向上遍历每个作用域直到找到它们。它们可以被您自己的代码和其他库有意或无意地访问和修改。这导致了另一个更严重的问题——跨站脚本攻击。如果一个坏人设法在您的页面上执行某些代码,那么他们也可以通过修改全局变量来轻松干扰您的应用程序本身。新手开发人员经常会无意中将变量添加到全局作用域,并且在本文中会有一些例子说明这种情况是如何发生的。
我见过以下代码,它试图声明两个具有相同值的局部变量:
var a = b = 3;
这正确地导致 a = 3
和 b = 3
,但是 a
在局部作用域,而 b
在全局作用域。“b = 3
”首先执行,然后该全局操作的结果 3 被赋值给局部变量 a
。
此代码具有所需的效果,即声明两个值为 3
的局部变量:
var a = 3,
b = a;
'this' 和内部函数
关键字 'this
' 始终引用当前函数被调用的对象。然而,如果函数不是在对象上调用的,就像内部函数那样,那么 'this
' 将被设置为全局对象(window)。
var obj = {
doSomething: function () {
var a = "bob";
console.log(this); // obj
(function () {
console.log(this); // window - "this" is reset
console.log(a); // "bob" - still in scope
}());
}
};
obj.doSomething();
然而,JavaScript 中有闭包,所以如果我们想在内部函数中引用 'this
',我们可以获取它的一个引用。
ECMAScript5 旁注:ES5 严格模式引入了此功能的一项更改,即 'this
' 将为 undefined,而不是被设置为全局对象。
杂项
数据的缺失:'null' 和 'undefined'
有两个对象状态表示缺少值:null
和 undefined
。这对于来自 C# 等其他语言的程序员来说相当令人困惑。您可能期望以下值为 true
:
var a;
a === null; //false
a === undefined; //true
实际上 'a
' 是 undefined(尽管如果您使用双等号与 null
进行比较,它将为 true
,但这只是另一个导致代码“看起来”正常工作的错误)。
如果您想检查一个变量是否真的有一个值,并且遵循从不使用双等号的规则,您需要这样做:
if(a !== null && a !== undefined) {
...
}
或者,您可以这样做,这完全相同(尽管 JSLint 会不幸地抱怨您):
if (a != null) {
...
}
“啊哈!”您可能会说。null
和 undefined
都是假值(即,被强制转换为 false
),所以您可以这样做:
if(a) {
...
}
但是,当然,零也是假值,空 string
也是。如果其中任何一个是 'a
' 的有效值,那么您将不得不使用前面更冗长的选项。较短的选项始终可用于对象、数组和布尔值。
重新定义 undefined
没错,您可以重新定义 undefined
,因为它不是保留字。
undefined = "surprise!";
然而,您可以通过赋值一个未定义变量或使用“void
”运算符(否则它相当无用)来恢复它。
undefined = void 0;
……这就是为什么 jQuery 库的第一行是:
(function ( window, undefined ) {
... // jQuery library!
}(window));
该函数以一个参数调用,确保第二个参数 undefined
确实是未定义的。
顺便说一句,您不能重新定义 null
- 但您可以重新定义 NaN
、Infinity
以及内置类型的构造函数。试试这个:
Array = function (){ alert("hello!"); }
var a = new Array();
当然,无论如何您都应该使用字面量语法来声明一个 Array
。
可选的分号
分号是可选的,以便初学者更容易编写,但省略它们非常糟糕,而且实际上并没有让任何人变得更容易。结果是,当解释器遇到错误时,它会回溯并尝试猜测分号应该放在哪里。
这是一个经典的例子,来自 Douglas Crockford:
return
{
a: "hello"
};
上面的代码不返回一个对象,它返回 undefined
- 并且没有抛出错误。分号会自动插入到 return
语句之后。其余的代码是完全有效的,即使它什么都不做,这应该足以证明在 JavaScript 中,开头的花括号应该放在当前行的末尾,而不是新的一行。这不仅仅是风格问题!这段代码返回一个带有一个属性“a
”的对象:
return {
a: "hello"
};
NaN
NaN
(不是数字)的类型是……Number
。
typeof NaN === "number" //true
此外,NaN
与任何东西比较都为 false。
NaN === NaN; // false
由于您无法进行 NaN
比较,因此测试一个数字是否等于 NaN
的唯一方法是使用辅助函数 isNaN
。
顺便说一句,我们还有辅助函数 isFinite
,当参数是 NaN
或 Infinity
时,它返回 false
。
'arguments' 对象
在函数内部,您可以引用 arguments
对象来检索参数列表。第一个陷阱是此对象不是 Array
,而是类数组对象(即具有 length
属性的对象,以及 [length-1]
处的值)。要转换为标准数组,您可以使用数组 splice
函数从 arguments
中的属性创建数组。
(function(){
console.log(arguments instanceof Array); // false
var argsArray = Array.prototype.slice.call(arguments);
console.log(argsArray instanceof Array); // true
}());
第二个陷阱是,当函数在其签名中有显式参数时,可以重新分配它们,并且 arguments
对象也会发生变化。这表明 arguments
对象指向变量本身而不是它们的值;您不能依赖 arguments
来获取它们的值。
(function(a){
alert(arguments[0]); //1
a = 2;
alert(arguments[0]); //2
}(1));
在 ES5 严格模式下,arguments
将肯定指向原始输入参数值,而不是它们当前的值。
结束!
以上就是我收集的陷阱列表。我敢肯定还有更多,我期待看到大量富有见地的评论!
附注:我确实 *喜欢* JavaScript。
参考文献
- http://www.scottlogic.co.uk/blog/luke-page/
- http://www.felixcrux.com/posts/douglas-crockford-talk-waterloo/
- http://dev.opera.com/articles/view/efficient-javascript/?page=2
- http://www.amazon.co.uk/JavaScript-Good-Parts-Douglas-Crockford/dp/0596517742
- https://mdn.org.cn/en/JavaScript/Reference/Operators/Special/typeof
- http://ejohn.org/blog/ecmascript-5-strict-mode-json-and-more/