第一章 让你自己习惯JavaScript
JS设计的让人感觉很熟悉。语法会让人联想起Java,而且它和许多其他脚本语言很相像(函数,数组,键值对和正则表达式),JS看起来像是一个可以被任何只有一点编程经验的人来学习的语言。对于新手程序员来说,有一点点相关的练习就可以开始写代码了,这多亏了这门语言只有少量的核心概念。
要想掌握JS,需要花更多的时间,需要对它的语义,特性,和最有效的用法有更深的理解。本书的每一章涵盖高效JS的一个主题。让我们从一些最基本的话题开始第一章吧!
第一节 了解你所使用的JS版本
像大多数成功的技术一样,JS一直在进化。最开始JS的市场定位是使用Java开发交互式Web页面时的补充,但它最终取代Java成为web上占绝对优势的语言。JS的流行使得它在1997年正式化,并且有了国际化标准,官方名称是ECMAScript。现在有很多符合各个版本ECMAScript的JS实现。
1999年定案的ES3,是最被广泛采纳的版本。下一代主要推崇的版本是2009年发布的ES5。ES5介绍了一系列被广泛支持的像标准一样的新特性,但它们之前都是未被明确定义的特性。由于ES5还没有被完全支持,我将会在这本书里指出那些在ES5中特有的特性。
除了各种标准版本,还有很多仅有一部分JS实现支持的非标准特性。比如,很多JS引擎支持const关键字来定义变量,但是ECMAScript并不提供关于const的语法或行为定义。此外,const的行为各种实现未必一致。有时候,使用const定义的变量是不允许重新赋值的:
const PI = 3.141592653589793;
PI = "modified!";
PI; //3.141592653589793
其他的实现仅仅把const当作var来处理:
const PI = 3.141592653589793;
PI = "modified!";
PI; //"modified!"
考虑到JS悠久的历史和多种多样的实现方式,想要追溯哪些特性在哪些平台可用是很困难的。使这个问题更加严重的现实是,JS的主要生态系统----浏览器,并没有给程序员哪个版本的JS能解析他们代码的支配权。由于最终的用户有可能会使用不同浏览器的不同版本,前端程序员不得不认真写好处理浏览器兼容问题的代码。
另一方面,JS不是客户端网页程序独有的,其他的作用包括服务端编程、浏览器扩展程序还有为手机和PC应用编写脚本。在这些情况下,你可能需要一个更加特殊的JS版本。这样的话,利用JS扩展特性来满足特定平台的JS实现就变得有意义了。
这本书主要关注JS的标准特性,但是探讨一下非标准但被广泛应用的特性也很有意义。了解你的应用是否能够在所有环境下支持新的标准和非标准特性是至关重要的。另外,你会发现你经常会处于这样一种情况下,你做的东西就像依赖于你电脑的环境,但部署在其他环境的时候别人运行起来就会有各种问题。举个例子,const在支持非标准特性的机器上运行的好好的,但是当发布在一个不识别这个关键字的浏览器时候就会报一个语法错误。
ES5提供了一个版本控制方式:严格模式。这个特性让你进入一个受限的JS版本,它不允许你使用一些晦涩和容易犯错的特性。这个语法是向前兼容的,以便于未实现严格模式的环境仍然可以解析严格模式的代码。严格模式的实现方法是在代码第一行加上:
"use strict";
同样,你也可以在一个函数体的第一行加上这行代码,让这个函数使用严格模式。
function f(x) {
"use strict";
}
这种用一个字符串作为指令的语法看起来有点奇怪,但是这种方式有利于向后兼容:解析一个字符串不会有副作用,因此一个ES3引擎把这个指令解析为一个声明----它解析这个字符串然后立刻忽略它。这样就使得在严格模式下写代码然后运行在旧的js引擎上成为可能,但是有一个很重要的限制:旧的引擎不会做和严格模式相关的校验。如果你不在ES5环境下测试,那么写的代码很容易在ES5环境下报错:
function f(x) {
"use strict";
var arguments = []; //error: redefinition of arguments
}
在严格模式下重新定义arguments变量是不可以的,但是如果在一个没有实现严格模式的环境下这行代码是可以执行的。在ES5环境下部署这部分代码是无法运行的。因此你应该时刻注意要在ES5环境下测试严格模式。
使用严格模式有一个陷阱,"use strict"这行代码必须写在脚本或者方法的第一行才会生效,这一点在脚本合并时候需要注意这一点,在大项目中开发时候是分成多个文件开发的,但是发布时候代码会合并成一个文件。有一个文件导出为严格模式:
//file1.js
"use strict";
function f() {
//...
}
//...
但是另一个文件导出为非严格模式:
//file2.js
//no strict-mode directive
function g() {
var arguments = [];
//...
}
//...
我们怎么才能正确的把这两个文件正确的合并在一起呢?如果我们从file1.js开始,打包好的代码就会在严格模式下执行:
//file1.js
"use strict";
function f() {
//...
}
//...
//file2.js
//no strict-mode directive
function g() {
var arguments = []; //error: redefinition of arguments
//...
}
//...
如果我们以file2.js开始,那么打包好的代码就不会在严格模式下执行了:
//file2.js
//no strict-mode directive
function g() {
var arguments = [];
//...
}
//...
//file1.js
"use strict";
function f() { //no longer strict
//...
}
//...
在你自己的项目里,你可以自己附加一个"strick-mode only"或者"non-strick-mode only"这样的策略,但是如果你想让你的代码更健壮,能适用的范围更广,你需要做出一些选择:
方案一:不要把严格模式的代码和非严格模式的代码合并。这可能是最简单的解决方案了,但是这也限制了你在项目或者库的文件结构上做的一些控制。你不得不发布两份代码,一份包含所有的严格模式代码,一份包含非严格模式代码。
方案二:把文件内容包在一个立即执行的函数表达式中。第13节会对立即执行函数表达式有一个深入的讲解,简单来说,通过把每个文件内容包在函数里,它们就能够被独立的解析成不同模式。上面例子合并后的版本大概像这样:
(function() {
//file1.js
"use strict";
function f() {
//...
}
})();
(function() {
//file2.js
//no strict-mode directive
function g() {
var arguments = [];
//...
}
})();
由于每个文件内容都被放在分开的区域,严格模式声明就只影响合并前它所在那个文件的内容。然而在实际工作中,代码自己是不可能假想它们是在全局环境中被解释的。比如说,var和function声明就不能再保持它全局变量的身份了(第8节有全局变量的更多说明)。这也碰巧是流行的模块化系统的案例,即自动把每个模块内容放在一个单独的函数里,通过这种方式来管理文件和依赖。由于文件都放在局部区域内,每个文件就可以自己决定它是不是要使用严格模式。
方案三:写你自己的代码,以便它们在两个模式中都能够表现一致。写一个可以在尽可能多的上下文中运行的库,你不能假想它是被代码合并工具放在一个函数内,同样你也不能假想客户端代码环境是严格模式还是非严格模式。最简单的把代码有最好兼容性的办法就是:直接把你的代码包在函数里,然后开启严格模式。这和之前的方法很相似,就是把每个文件内容包在IIFE(立即调用函数)中,但是这种方式其实是你手写IIFE来代替信任合并工具和模块系统,直接选择开启严格模式:
(function() {
"use strict";
function f() {
//...
}
//...
})();
要注意的是这样的代码会被当作严格模式来处理,无论它所在的打包后的代码环境是严格模式还是不是严格模式。对比一下,一个函数如果写的时候不是严格模式,但是合并后是在开启严格模式之后,那么它也会被当作严格模式来对待。所以更加通用的做法是写在严格模式里。
TIPS:
- 决定你的程序支持哪些版本的JS
- 确认你使用的所有JS特性在程序运行时候可以被所有环境所支持
- 在各个环境下测试严格模式的代码
- 要小心涉及严格模式的打包后的代码是否与期望一致
第二节 理解JavaScript的浮点数
多数编程语言都有多种数值类型,但是JS只有一种。通过typeof操作符可以发现这一点,它把整型和浮点型都归为数值类型:
typeof 17; //"number"
typeof 98.6; //"number"
typeof -2.1; //"number"
实际上,在JS中所有数字都是双精度浮点型,就是在IEEE 754标准定义的64位数值编码----我们通常说的"doubles."。如果这让你想知道在整型身上发生了什么,请记住浮点型可以用53位精度完美的体现整型。在-9,007,199,254,740,992(-253)和9,007,199,254,740,992(253)之间的所有整数都是合法的双精度浮点数。因此在JS中完全可以进行整型计算,尽管没有一个明确的整型定义。
大部分运算操作都是在整型,真实数值,或者二者结合起来进行的:
0.1 * 1.9; //0.19
-99 + 100; //1
21 - 12.3; //8.7
2.5 / 5; //0.5
21 % 8; //5
然而按位运算是特殊情况。与其说操作数被直接当做浮点数处理,倒不如说是被暗地里转成32位整型了。(准确的说,他们被当做32位高位补码整型)。举个例子,按位或操作符:
8 | 1; //9
这个看起来很简单的表达式实际上需要几个步骤来执行。一如既往,JS数值8和1被当做双精度数,但是他们同样可以呗当做32位整型,即32位0,1序列。用32位整型来表达,数字8看起来是这样的:
00000000000000000000000000001000
你可以自己通过使用数字的toString方法来看到这一点:
(8).toString(2); //"1000"
toString这个方法的参数是基数的意思,在这个例子中说明是用二进制来进行转换。这个结果舍弃了左边的0因为他们不会影响最终结果。
整数1用32位来表示:
00000000000000000000000000000001
按位或运算符在计算两个位序列的时候,会保留相同位置上的1,8和1按位或后的二进制结果:
00000000000000000000000000001001
这个序列代表了整型9。你可以使用标准库中的parseInt方法来验证这个结果,同样使用2为基数:
parseInt("1001", 2); //9
(同样剩下的0并非必须的,它们不会影响结果)
所有的位运算符工作机制都是一样的,把输入转换为整型,在整型位运算模式下执行,最后再把结果转换为JS的标准浮点数。通常情况下这些转换在JS引擎中需要额外的工作:由于数字是按浮点数存储的,它们不得不先转换为整型然后在转回浮点型。然后,优化的编译器有有时候可以推断什么时候算数表达式甚至是变量只通过整型来工作,通过在内部存储为整型来避免额外的转换。
最后一条关于浮点数的警告:会很可能让你有一点担忧。浮点数看起来似乎很熟悉,但是众所周知它不精确。甚至有些看起来很简单的运算都会是不精确的结果:
0.1 + 0.2; //0.3000000000000004
当然64位精度已经相当大了,相对于无限的真实数字来说,双精度仍然只能代表有限的数字集合。浮点数运算只能体现近似的结果,四舍五入到最接近的真实数字。当你做一系列计算时,这些凑整会累积,导致越来越不准确的结果。凑整还会使得我们平时常见的一些运算产生诡异的偏差。比如说,真实数字是可以结合的,意思就是对于真实数字x,y,z来说,(x + y) + z = x + (y + z)总是成立的,但是在浮点数中就不一定了:
(0.1 + 0.2) + 0.3; //0.6000000000000001
0.1 + (0.2 + 0.3); //0.6
浮点数在精确度和性能上做了个权衡。当精度主导时,一定要重视它们的限制。一个有用的工作经验是,能用整型的地方就用整型,因为它不会涉及凑整。涉及货币计算时,程序员经常会缩放数字到这种货币的最小面额,以保证他们可以完整计算数字。比如说,上边的计算是用美元衡量的,那么就可以使用美分的计算来代替:
(10 + 20) + 30; //60
10 + (20 + 30); //60
使用整型的时候,仍然需要注意所有运算都要限制在-253到253这个范围内,不过不需要担心有凑整的误差了。
TIPS:
- JS的数字是双精度浮点型数字
- 整型在JS中只是双精度浮点数的一个子集,而不是一个单独的数据类型
- 位运算符把操作数当做32位有符号整型来处理
- 注意浮点数运算的精度限制
第三节 注意隐式类型转换
JS让人吃惊的一点是它可以兼容类型错误。许多语言都把3 + true;这样的表达式当做一个错误,因为像true这样的布尔表达式与算数运算不兼容。在一个静态类型的语言里,带有这样表达式的程序是不能执行的。在一些动态类型语言里,程序执行时候会抛出一个异常。JS不仅允许程序执行,还能产生一个结果:4。
提供一个错误类型然后立刻报错,这种情况在JS中例子不多,比如调用一个不存在的函数或者尝试取null的一个属性:
"hello"(1); //error: not a function
null.x; //error: cannot read property 'x' of null
但在很多其他例子中,JS会把一个值按照以下变量转换规则强制转换为期望的类型。比如,算术运算符-,*,/,%都会在计算之前尝试把操作数转换为数值类型。+运算符更机智,因为它重载了数字相加和字符串连接,如何表现取决于操作数的类型:
2 + 3; //5
"hello" + "world"; //"hello world"
那现在把一个数字和字符串相加会发生什么呢?JS会把数字转成字符串:
"2" + 3; // "23"
2 + "3"; // "23"
像这样混合类型计算有时候让人很困惑,因为它对于操作数的顺序的很敏感的。拿这个表达式来说:
1 + 2 + "3"; // "33"
和下边的表达式是一样的:
(1 + 2) + "3"; // "123"
对比下面这个表达式:
1 + "2" + 3; // "123"
结果是"123",左结合性表明这个表达式和把左侧相加的数包在括号里是一样的。
(1 + "2") + 3; // "123"
位运算符不仅会转成数值类型,还会体现为32位整型,像第二节中讨论的那样。包括位运算符(~,&,^,|)和移位运算符(<<, >>, >>>)。这些强制转换规则的便利是十分诱人的----比如说,自动转换字符串来自于用户输入,一个文本文件或者一个网络流:
"17" * 3; //51
"8" | "1"; //9
但这同样会隐藏错误。一个结果是null的变量在算术运算中不会报错,而是静默转换为0;;一个undefined变量会被转换成一个特殊的浮点数:NaN(一个自相矛盾的命名“不是数字”的数----IEEE的浮点数标准说的)。这些强制转换经常会让计算继续产生一些让人迷惑的、不可预知的结果,而不是立刻抛出一个异常。令人沮丧的是,专门为NaN做测试是很困难的,有两个原因:第一,JS遵循IEEE浮点数标准中让人头疼的必要条件:NaN和它自己不相等。所以测试一个值是不是等于NaN是没用的:
var x = NaN;
x === NaN; //false
此外,标准库中的isNaN方法也不是很可靠,因为它有自己的隐式转换:在测试其参数之前把它转换成一个数值。(isNaN一个更精确的叫法可能是:强制转换为NaN)。如果你已经知道要测试的那个值是数值类型,那么可以使用isNaN来判断它是不是NaN:
isNaN(NaN); //true
其他一些不是直接定义为NaN,不过被强转为NaN的值,对于isNaN来说是无法分辨的:
isNaN("foo"); //true
isNaN(undefined); //true
isNaN({}); //true
isNaN({ valueOf: "foo"}); //true
幸好有一个习惯用法即可靠又简洁。由于NaN是JS中唯一一个和它自己不相等的值,你可以通过判断一个值是否和它自己相等来确定它是不是NaN:
var a = NaN;
a !== a; //true
var b = "foo";
b !== b; //false
var c = undefined;
c !== c; //false
var d = {};
d !== d; //false
var e = { valueOf : "foo"};
e !== e; //false
你也可以把这个模式抽象成一个命名准确的函数:
function isReallyNaN(x) {
return x !== x;
}
但是测试一个值是否和它自己相等太简单以至于通常没有函数来做这件事,所以意识到并理解这件事是很重要的。
隐式转换使得调试一个有问题的程序变得相当让人头疼,因为它会掩盖错误,让它更难被诊断出来。一个计算有错误时,最好的调试方法是检查这个运算的中间结果,跳回到发生错误之前的断点。在那检查每个操作符的参数,看哪个类型是错的。根据这个bug,有可能是逻辑错误,比如用错了运算符,也可能是类型错误,比如传入了一个undefined代替了数值。
对象也可以转换成原生类型,通常转换成字符串:
"the Math object: " + Math; //"[object Math]"
"the JSON object: " + JSON; //"[object JSON]"
对象用它的toString方法隐式转换为字符串,你可以自己用它试试:
Math.toString(); //"[object Math]"
JSON.toString(); //"[object JSON]"
相似的,对象可以通过它的valueOf方法转换为数值类型。你可以通过定义这些方法来控制对象的类型转换:
"J" + { toString: function() { return "S"; } }; //"JS"
2 * { valueOf: function() { return 3; } }; //6
重申一下,+被重载用于字符串连接和加法运算使得事情更复杂了。具体来说,一个对象同时包含toString和valueOf方法,当使用+时不是很明确应该调用哪个方法:我们希望它按照类型来决定使用字符串连接还是加法运算,但是有隐式转换存在,类型并不是实际给出的。JS盲目的选择valueOf优先于toString来解决这个模棱两可的问题,这就意味着如果有人想通过对象来表现字符串连接,那么结果可能不是他预想的那样:
var obj = {
toString: function() {
return "[object MyObject]";
},
valueOf: function() {
return 17;
}
};
"object: " + obj; // "object: 17"
这件事告诉我们valueOf仅仅是设计用于给对象表现数值类型的,比如Number对象。对于这些对象来说,toString和valueOf返回相同的结果----一个字符串或一个数值的表现形式最终都是数字----所以被重载的+运算符对于一个对象来说总是表现一致的,不管它实际用于字符串连接,还是加法运算。一般来说,强转为字符串远比强转为数值更通用。最好避免使用valueOf除非你的对象真的是一个数值抽象并且它的toString方法是valueOf方法的字符串体现。
最后一种强制转换有时候被称为真值。像if, ||, &&逻辑上要用布尔值运算,实际上它们可以接受任何类型的值。JS值根据一个简单的隐式转换解析为布尔值。大多数JS值都是真,就是说隐式转换为true。包括所有对象----和字符串和数字不同的是,真值处理不会调用任何类型转换的方法。有七个精确的假值:false, 0, -0, "", NaN, null, undefined。其他所有值都是真值。由于数字和字符串可以是假,通过真假判断一个函数的参数或者一个对象的属性是否定义并不可靠。考虑一下一个函数使用可选参数并且有默认值的情况:
function point(x, y) {
if (!x) {
x = 320;
}
if (!y) {
x = 240;
}
return { x: x, y: y};
}
这个函数忽略所有假值的参数包括0:
point(0, 0); // {x: 320, y: 240}
更准确的检验undefined的方法是使用typeof运算符:
function point(x, y) {
if (typeof x === "undefined") {
x = 320;
}
if (typeof y === "undefined") {
x = 240;
}
return { x: x, y: y};
}
这个版本的point方法可以正确区分0和undefined:
point(); // { x: 320, y: 240}
point(0, 0); //{ x: 0, y: 0}
另外一种方式是用参数和undefined进行比较:
if (x === undefined) { ... }
第54小节会讨论真值比较的实现,用于测试库和API的设计。
TIPS:
- 隐式类型转换会隐藏类型错误
- +运算符重载了两个用法,字符串连接和加法运算,如何使用取决于其操作数类型
- 对象通过valueOf转换为数值类型,通过toString转换为字符串类型
- 实现valueOf的对象需要同时实现toString来给valueOf提供的数值结果一个字符串表现
- 使用typeof或者和undefined进行比较是用来判断undefined的正确方法,而不是简单地判断真假
第四节 原生类型好过对象包装类
除了对象之外,JS还有五中原生类型:布尔,数值,字符串,null和undefined。(迷惑人的是,typeof对于null的计算结果是"object",但是ECMA-Script标准把它描述为一个明确的类型。)与此同时,标准库提供布尔,数值和字符串的包装类。你可以创建包含一个字符串的String对象:
var s = new String("hello");
在一些情况下,字符串包装类和字符串值表现一致。可以做字符串连接:
s + "world"; //"hello world"
可以按下标解析它的子串:
s[4]; // "o"
和原生字符串类型不一样的事,一个String对象真的是个对象:
typeof "hello"; // "string"
typeof s; // "object"
这是个重要的区别,它意味着你不能使用内置操作符来比较两个字符串对象的内容:
var s1 = new String("hello");
var s2 = new String("hello");
s1 === s2; // false
由于每个String对象是个单独的对象,它只和它自己相等。这同样适用于相等运算符:
s1 == s2; //false
由于这些包装类行为不是很准确,当你想做点什么时候它们提供不了什么帮助。它们存在最大的意义可能是他们的一些方法。JS用另外一种隐式转换让这些变的更方便:你可以把属性提取出来,然后用原生值调用函数,它和之前使用相应对象类型包装后表现一致。比如,String对象有一个toUpperCase方法,作用是把一个字符串转换成大写。你可以在原生类型上使用这个方法:
"hello".toUpperCase(); // "HELLO"
隐式包装会导致的一个奇怪的结果,你可以在原生值上设置属性但根本不会有效果。
"hello".someProperty = 17;
"hello".someProperty; //undefined
这是因为每次发生隐式包装的时候都会产生一个新的String对象,下边那一句对于第一个包装对象不会继续有效。给原生值设置属性真的没卵用,要避免这种行为。这里体现了另一个JS会隐藏类型错误的例子:如果你在一个你期望是对象的东西上设置属性,但是错误的使用了原生值,你的程序会静默的忽略这个赋值并继续运行,这会让这个错误很难被发现。
TIPS:
- 原生类型的包装类在判断相等时候和原生类型表现不一致
- 从原生类型设置和获取属性会隐式创建包装类
第五节 混合类型不要使用==
你希望这个表达式的值是什么?
"1.0e0" == { valueOf: function() { return true; }};
这两个看起来没什么关系的值实际上用==来判断是相等的,像第三节讨论的那样,它们在比较之前都转换为数值类型了。字符串"1.0e0"被转换为1,这个对象通过valueOf方法把结果转换为一个数值,也是1。
在工作中使用这些隐式转换是很诱人的,比如说从一个表单中读取一段内容和数字做比较:
var today = new Date();
if (form.month.value == (today.getMonth() + 1) && form.day.value == today.getDate()) {
//happy birthday!
//...
}
但实际上用Number的方法或者一元运算符+把值转换为数字是很容易:
var today = new Date();
if (+form.month.value == (today.getMonth() + 1) && +form.day.value == today.getDate()) {
//happy birthday!
//...
}
这样做更加明确,因为这准确的向阅读你代码的人传达了用了什么样的转换方式,不需要他们记住转换规则。一个更好的选择是使用全等运算符:
var today = new Date();
if (+form.month.value === (today.getMonth() + 1) && +form.day.value === today.getDate()) {
//happy birthday!
//...
}
当两个参数类型一致的时候,==和===的行为没有区别。所以如果你知道参数是一样的类型,那随便你用哪个。但是使用全等运算符是让读者明白在比较时没有类型转换。否则,你就需要代码读者回忆一下准确的类型转换规则来解读你代码的行为。
这证明了,这些转换规则一点都不明显。下边的表格说明了当参数类型不同时==运算符的转换规则。这个规则是对称的:比如说,第一条规则同事适用于null == undefined 和 undefined == null。大多数时候,转换都会尝试转换为数值类型。但是当处理对象的时候规则就很微妙了。操作符会尝试通过valueOf和toString方法把对象转换成一个原生类型,最终使用它得到的第一个原生值。更微妙的是,Date对象使用对立的规则尝试这两个方法。
参数1类型 | 参数2类型 | 转换规则 |
---|---|---|
null | undefined | 没有转换;总是true |
null 或者 undefined | 除了null和undefined的其他值 | 没有转换;总是false |
原生的字符串,数值或者布尔类型 | Date对象 | 原生类型=>数值类型,Date对象=>原生类型(先尝试toString然后再尝试valueOf) |
原生的字符串,数值或者布尔类型 | 不是Date的对象 | 原生类型=>数值类型,不是Date的对象=>原生类型(先尝试valueOf然后再尝试toString) |
原生的字符串,数值或者布尔类型 | 原生的字符串,数值或者布尔类型 | 原生类型=>数值类型 |
在不同的数据描述下==操作符会给人以假象。这种类型的错误修正有时候被称为"做我想做的"这个意思。但是计算机不能理解你真正的意图。在这个世界上你使用的JS有太多数据描述了。比如,你可能会想可以把一个包含日期的字符串和Date对象作比较:
var date = new Date("1999/12/31");
date == "1999/12/31"; //false
这个特例会失败,因为把一个Date对象转换为字符串使用的转换方式和例子中使用的方式不一样:
date.toString(); //"Fri Dec 31 1999 00:00:00 GMT-0800(PST)"
但是这个错误是更加常见的误解类型转换的一个具体表现。==运算符既不推断也不统一任何数据转换。它需要你和看你代码的人都要理解类型转换的规则。一个更好的方案是通过自定义代码逻辑让转换更加明确,并且使用全等运算符:
function toYMD(date) {
var y = date.getYear() + 1900, //year is 1900-indexed
m = date.getMonth() + 1, //month is 0-indexed
d = date.getDate();
return y + "/" + (m < 10 ? "0" + m : m) + "/" + (d < 10 ? "0" + d : d)
}
toYMD(date) === "1999/12/31"; //true
写出明确转换规则确保你没有把==的默认转换规则混淆进来,更好的一点是让你的代码读者不需要去看转换规则或者说记住这些规则。
TIPS:
- ==运算符对于不同类型的参数使用了一个令人迷惑的隐式转换规则
- 使用===,让你的代码读者明确你的比较中不包含任何隐式转换
- 在比较不同类型值时候使用自己定义的明确的转换规则,让你的代码行为更加清晰
第六节 了解分号插入的限制
JS的一个方便之处是在语句结尾可以没有分号。舍弃分号会产生一个令人愉悦的小小的美学效果:
function Point(x, y) {
this.x = x || 0
this.y = y || 0
}
Point.prototype.isOrigin = function() {
return this.x ===0 && this.y === 0
}
这段代码能生效多亏了自动分号插入,一个在确定语境中推断省略的分号的代码解析技术,效果就是自动在代码中插入分号。ECMAScript标准明确提出了分号插入机制,所以JS引擎中分号都是可选的。
但是和第三节第五节提到的隐式转换相似,分号插入有它的隐患,你不可避免要了解它的规则。除非你从来不省略分号,否则对于分号插入产生的后果,JS有一些额外的限制。好消息是当你了解了分号插入规则以后,你会发现舍弃那些没必要的分号真的是放飞自我了。
第一条规则是:分号只在}标记之前,一个或多个新行之后,或者是在程序的结尾输入。换句话说,你只能在一行,一个代码块或者一个程序的结尾省略分号。所以以下函数是合法的:
function square(x) {
var n = +x
return n * n
}
function area(r) { r = +r; return Math.PI * r * r }
function add1(x) { return x +1 }
但是这样不行:
function area(r) { r = +r rerurn Math.PI * r * r } //error
第二条规则是:分号只在下一次输入不能被解析时候被插入。换句话说,分号插入是一个错误检测机制。来看这段代码:
a = b
(f());
解析后和下边这个一样:
a = b(f());
就是说没有插入分号,对比下边这段:
a = b
f();
它被解析为两个独立的语句,因为:
a = b f();
解析会出错。
这个规则有一个不好的影响:你不得不注意下一行语句的开头来查出去掉分号后是否合法。如果下一行的开头可以和这一行连起来是可以被解析的,那就不能在该行省略分号。
有五个容易出问题的字符:(, [, +, -, /。每一个都可以作为表达式操作符或者语句前缀,取决于上下文是什么。因此要注意一表达式结尾的语句,像上边的语句那样。如果下一行以上边那五个可能有问题的字符开头,分号不会被插入。到现在为止,这种情况发生最常见的情形是语句以括号开头,像上边的例子一样。另一个常见情形是数组字面量:
a = b
["r", "g", "b"].forEach(function(key) {
background[key] = foreground[key] / 2;
});
这看起来是两个语句:一个赋值表达式后边是字符串"r" "g" "b"依次调用一个函数。但是,因为这个语句是以[开头的,它会被解析为:
a = b["r", "g", "b"].forEach(function(key) {
background[key] = foreground[key] / 2;
});
如果括弧类表达式看上去是单个的,要知道JS是有逗号运算符的,就是说从左到右依次执行,返回它最后一个子表达式:这个例子中就是"b"。
+,-,/在语句开头比较少见,但也不是没有。/的案例有点意思:在一个语句开头,它实际上不是一个完整标识,而是一个正则表达式开始的标记:
/Error/i.test(str) && fail();
这个语句用大小写敏感的正则表达式/Error/i测试一个字符串。如果找到了匹配,会调用fail方法。如果这行代码跟在没用分号结尾的语句后:
a = b
/Error/i.test(str) && fail();
那就会变成一个语句,相当于:
a = b / Error / i.test(str) && fail();
换句话说,开始的/会变成除法操作符!
有经验的JS程序员会在他们想省略分号时看看接下来的语句,确认它不会被错误的解析。重构时候也会注意这些。比如说,当分号在推断的位置时完全正确的一个程序:
a = b //semicolon inferred
var x //semicolon inferred
(f()) //semicolon inferred
如果只有两个分号推断就会变成一个不同的程序:
var x //semicolon inferred
a = b //no semicolon inferred
(f()) //semicolon inferred
即使把var语句挪到了第一行(请看第十二节对变量作用域的讲解),实际上b后边跟了括号,代码就错误的解析为:
var x;
a = b(f());
结果就是,你需要时刻注意省略分号并检查下一行开头有没有那些让分号插入机制失效的标记。或者,你可以遵循以下规则:当(, [, +, -, /为语句开头时,加一个额外的分号。比如说,之前的例子可以改成这样来保护括号内的函数调用:
a = b //semicolon inferred
var x //semicolon on next line
;(f()) //semicolon inferred
现在就可以安全的把var语句放到第一行,而不用担心程序会被改变了:
var x //semicolon inferred
a = b //semicolon on next line
;(f()) //semicolon inferred
另一个比较常见的省略分号会有问题的情况是,脚本连接(第一节有说过)。每个文件可能是由许多函数调用表达式组成的(第十三节会详细讲立即执行函数表达式)。
//file1.js
(function() {
//...
})()
//file2.js
(function() {
//...
})()
每个文件都加载为一个独立程序时,分号会自动加在结尾,把这个函数调用变成独立语句。可是在文件连在一起时候:
(function() {
//...
})()
(function() {
//...
})()
结果变成了一个语句,相当于:
(function() {
//...
})()(function() {
//...
})();
结果是:省略分号需要注意的不仅包括当前文件里的下一行,还有在脚本连接之后的语句。和之前的办法一样,你可以在每个文件开头加一个分号来保护整个脚本,至少要在以(, [, +, -, /开头的文件前加上:
//file1.js
;(function() {
//...
})()
//file2.js
;(function() {
//...
})()
这可以保证如果前边的脚本省略了最后的分号,合并后的代码仍然可以保证是相互独立的语句:
;(function() {
//...
})()
;(function() {
//...
})()
当然,最好脚本连接时候可以自动在文件中间加上分号。但不是所有连接工具都写的那么好,所以最安全的方式还是有点戒备,自己加一个分号。
这时候你可能会想了,“这也有太多事需要惦记着了。我不省略分号一样可以做的很好。”并不是这样,会有一些情况JS会强制插入分号,尽管并没有转换错误。这被称作是JS语法中的受限产物,不允许在两个标识之间有新行。最危险例子的是return语句,不允许在return关键字和它的可选参数之间有新行。所以这个语句:
return { };
返回一个新的对象,再看看这部分代码:
return
{ };
会被转换为三个独立的语句,相当于:
return;
{ }
;
换句话说,return关键字后边的新行会自动强制插入一个分号,上边例子里就会变成:一个没有参数的return语句,一个空代码块和一个空语句。其它受限的有:
- throw语句
- 有显式标签的break或者continue语句
- 后置++或--运算符
最后一条规则的目的是消除像下边这块代码的歧义:
a
++
b
由于++既可以前置也可以后置,但是后置的不能在一个新行里,它会被解析为:
a; ++b;
分号插入的第三条也是最后一条规则是:分号不会作为分隔符在for循环的头部被插入,也不会作为空语句被插入。意思就是在for循环的头部必须写分号。像下边这样:
for (var i = 0, total = 1 //parse error
i < n
i++) {
total *= i
}
结果就是解析错误。相似,一个没有循环体的循环需要显式分号。否则也会解析错误:
function infiniteLoop() { while (true) } //parse error
应该这样:
function infiniteLoop() { while (true); }
TIPS:
- 在}之前,一行的结尾,程序的结尾,分号才能被推断出来
- 分号只有在下一个符号不能被解析的时候才能被推断出来
- 在以(, [, +, -, /开头的语句前,千万不要省略分号
- 在脚本合并时候,在脚本之间明确的插入一个分号
- 不要在return, throw, break, continue, ++, --和它们的参数之间换行
- 分号不会作为分隔符在for循环的头部被插入,也不会作为空语句被插入
第七节 把字符串看做是一串16位代码单元
Unicode有一个有名的特点就是它很复杂----尽管它在字符串中无处不在,大多数程序员仍然避免学习Unicode,把事情尽可能往好的方面想。但是从概念的层面上来说,它其实没什么好怕的。Unicode的基础十分简单:世界上所有写入系统的每一个文本单元,都会被分配成0到114111之间的一个唯一整数,在Unicode术语里被称作是码字。和其它编码方式很不一样,比如ASCII。不同的点在于,ASCII把每个索引映射为一个唯一的二进制表现,而Unicode允许多个不同二进制编码成一个码字。使用不同的编码方式就需要权衡存储字符串所需空间,还有在操作字符串的效率,比如在字符串中索引。现在Unicode有很多标准,最常见的是UTF-8,UTF-16和UTF-32。
进一步思考这个情况,Unicode的设计者错误的估计的对码字的预算。起初都觉得Unicode不会有超过216个码字。这样就诞生了UCS-2,16位编码的原始标准,一个特别吸引人的选择。由于每个码字都对应一个16进制数字,这样就在码字和它们的编码之间有了一个简单的一对一关系,被称作是代码单元。就是说,UCS-2是由单独的16位代码单元组成的,每个对应一个单独的Unicode码字。这种编码方式最主要的好处是在字符串中进行索引是一个方便而且耗时固定的操作:想找第n个码字只需要取数组中的第16个元素就可以了。图标1.1是一个字符串只由原始16位码字组成的例子。你可以看到,在Unicode字符串中码字和编码元素是完美的一一对应,可以用下标来索引。
结果就是,不少平台那时候都使用16位编码字符串。Java就是其中一个,JavaScript也是:每个字符串元素都是一个16位数值。