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/

