Effective JavaScript 编写高质量JavaScript代码的68个有效方法
Effective JavaScript 编写高质量JavaScript代码的68个有效方法
WaterBoatJavaScript 中的浮点数
大多数编程语言都有几种数值型数据类型,但是 JavaScript 却只有一种。你可以使用 typeof 运算符查看数字的类型。不管是整数还是浮点数,JavaScript 都将它们简单地归类为数字。
1 | typeof 10; // number |
事实上,JavaScript 中所有的数字都是双精度浮点数。这是由IEE 754标准制定的 64 位编码数字—即 “doubles” 。
那么 JavaScript 是如何表达整数的呢?
请先记住一个概念 : 双精度浮点数能完美地表示高达 53 位精度的整数,范围大小为 -2^53—-2^53 的所有整数都是有效的双精度浮点数
因此,尽管 JavaScript 中缺少明显的整数类型,但是完全可以进行整数运算。
大多数的算术运算符可以使用整数、实数或两者的组合进行计算。
1 | console.log(0.1 * 1.9); // 0.19; |
然而位算术运算符比较特殊。JavaScript 不会直接将操作数作为浮点数进行运算,而是会将其隐式地转换为 32 位整数后进行运算(确切地说,它们被转换为 32 位大端(big-endian) 的 2 补码表示的整数)
1 | console.log(8 | 1); //9 |
看似简单的表达式实际上需要几个步骤来完成运算
如前文所述,JavaScript 的数字都是双精度浮点数。同时也可以表示为 32 位整数,即 32 位 0、1 的序列。
整数 8 为 32 位二进制序列如下
00000000000000000000000000001000
我们也可以用 JavaScript 中数字类型的 toString 方法来查看
1 | // 转成二进制 |
整数 1 表示为 32 位二进制如下所示:
00000000000000000000000000000001
然后按照相同位只要一个为 1 即为 1 的运算法则,那么结果就是
00000000000000000000000000001001
其结果就是
1 | // 将 1001 看成二进制,返回十进制数 |
所有位运算符的工作方式都是相同的。其操作步骤为 :
- 将操作数转换为 32 位整数
- 使用整数位模式进行运算
- 将结果转换为标准的 JavaScript 浮点数
一般情况下,JavaScript 引擎需要做一些额外的工作来进行折现转换。
因为数字是以浮点数存储的,必须将其转为整数,然后再转回浮点数。
然后我们在进行浮点数的运算的时候会发现一些不正确的结果
1 | 0.1 + 0.2; // 0.30000000000000004 |
尽管 64 位的精度已经相当高了,但是双精度浮点数也只能表示一组有限的数字,而不能表示所有的实数集。浮点运算只能产生近似的结果,四舍五入到最接近的可表示的实数。
浮点数权衡了精度和性能。当我们关系精度的时候,要小心浮点数的局限性。一个有效的解决办法就是尽可能地采用整数值运算,因为整数在表示时不需要舍入。
总结
- JavaScript 的数字都是双精度的浮点数。
- JavaScript 中的整数仅仅是双精度浮点数的一个子集,而不是一个单独的数据类型。
- 位运算符将数字视为 32 位的有符号整数。
隐式转换
算术运算符 -、*、/ 和 % 在计算之前都会尝试将其参数转换为数字。而算术符 +,既重载了数字相加,又重载了字符串连接操作。
加法运算是左结合律
null 在算术运算中不会导致失败而是隐式地转换为 0
一个未定义的变量将被转换为特殊的浮点数值 NaN(not a number)
无奈的是,即便是测试 NaN 值也是异常困难。这有两个原因。
- JavaScript 遵循了 IEEE 浮点数标准令人头痛的要求—NaN 不等于其本身
1 | let x = NaN; |
另外,标准的库函数 isNaN 也不是很可靠,因为它带有自己的隐式强制转换,在测试其参数之前,会将参数转换为数字(isNaN 函数的一个更精确的名称可能是 coercesToNaN)。如果你已经知道一个值是数字,你可以使用 isNaN 函数测试它是否是 NaN。
1 | isNaN(NaN); //true |
但是对于其他绝对不是 NaN,但会被强制转换为 NaN 的值,使用 isNaN 方法是无法区分的。
1 | console.log(isNaN("刘德华")); // true |
最后一种强制转换有时称为真值运算(truthiness)
大多数的 JavaScript 值都为真值(truth),也就是能隐式地转换为 true,真值运算不会隐式调用任何强制转换方法
JavaScript 中有 7 个假值: false、0、-0、”“、NaN、null 和 undefined
其他所有的值都为真值。
由于数字和字符串可能为假值,因此,使用真值运算检查函数参数或者对象属性是否已定义不是绝对安全的。
对象也可以被强制转换为原始值。最常见的用法是转换为字符串
1 | console.log("the Math object" + Math); //the Math object[object Math] |
对象通过隐式地调用其自身的 toString 方法转换为字符串。
1 | console.log(Math.toString()); |
总结
- 类型错误可能被隐式的强制转换所隐藏。
- 重载的运算符+是进行加法运算还是字符串连接操作取决于其参数类型。
- 对象通过 valueof 方法强制转換为数字,通过 toString 方法强制转换为字符串。
- 具有 valueof 方法的对象应该实现 toString 方法,返回一个 valueof 方法产生的数字的字符串表示。
- 测试一个值是否为未定义的值,应该使用 typeof 或者与 undefined 进行比较而不是使用真值运算。
原始类型优于封装对象
除了对象之外,JavaScript 有 5 个原始值类型:布尔值、数字、字符串、null 和 undefined
(令人困惑的是,对 null 类型进行 typeof 操作得到的结果为“object”,然而,ECMAScript 标准描述其为一个独特的类型。)同时,标准库提供了构造函数来封装布尔值、数字和字符串作为对象。你可以创建一个 String 对象,该对象封装了一个字符串值。
1 | // 原始字符串 |
String 的原型对象有一个 toUpperCase 方法,可以将字符串转换为大写.你可以对原始字符串值调用这个方法。
1 | "hi".someProperty = 17; |
因为每次隐式封装都会产生一个新的 String 对象,更新第一个封装对象并不会造成持久的影响。对原始值设置属性的确是没有意义的,但是觉察到这种行为是值得的。事实证明,这是 JavaScript 隐藏类型错误的又一种情形。本来你想给一个对象设置属性,但没注意其实它是个原始值,程序只是忽略更新而继续运行。这容易导致一些难以发现的错误,并且难以诊断。
总结
- 当做相等比较时,原始类型的封装对象与其原始值行为不一样。
- 获取和设置原始类型值的属性会隐式地创建封装对象。
== 运算符的强制转换规则
参数类型 1 | 参数类型 2 | 强制转换 |
---|---|---|
null | undefined | 不转换,总是返回 true |
null 或 undefined | 其他任何非 null 或 undefined 的类型 | 不转换,总是返回 false |
原始类型 : string 、number 或 boolean | Date 对象 | 将原始类型转换为数字;将 Date 对象转换为原始类型(优先常识 toString 方法,再常识 valueOf 方法) |
原始类型 : string 、number 或 boolean | 非 Date 对象 | 将原始类型转换为数字;将非 Date 对象转换为原始类型(优先常识 valueOf 方法,再常识 toString 方法) |
原始类型 : string 、number 或 boolean | 原始类型 : string 、number 或 boolean | 将原始类型转换为数字 |
总结
- 当参数类型不同时,== 运算符应用了一套难以理解的隐式强制转换规则。
- 使用 === 运算符,使读者不需要涉及任何的隐式强制转換就能明白你的比较运算。
- 当比较不同类型的值时,使用你自己的显式强制转换使程序的行为更清晰。
视字符串为 16 位的代码单元序列
- JavaScript 字符串由 16 位的代码单元组成,而不是由 Unicode 代码点组成。
- JavaScript 使用两个代码单元表示 26 及其以上的 Unicode 代码点。这两个代码单元被称为代理对
- 代理对甩开了字符串元素计数,length、charAt、charCodeAt:方法以及正则表达式模式(例如“.”)受到了影响。
- 使用第三方的库编写可识别代码点的字符串操作。
- 每当你使用一个含有字符串操作的库时,你都需要查阅该库文档,看它如何处理代码点的整个范围。
熟练掌握闭包
我们可能经常时候闭包,但是你未发现这是闭包而已罢了
理解闭包只需要学会三个基本的事实。
第一个事实:JavaScript 允许你引用在当前函数以外定义的变量。
1 | function sayHi() { |
第二个事实:即使外部函数已经返回,当前函数仍然可以引用在外部函数所定义的变量。
如果这听起来让人难以置信,请记住,JavaScript 的函数是第一类(first-class 对象(请参阅第 19 条)。这意味着,你可以返回一个内部函数,并在稍后调用它。
1 | function sayHi() { |
和第一个例子不同的是,不是在外部调用函数 sayHi 中立即调用 name(“罗老师”),而是返回 name 函数本身。因此变量 a 的值是内部的 name 函数,调用变量 a 实际上是调用了 name 函数。但即使 sayHi 函数已经返回,name 函数仍然能记住 Hi 变量的值
学习闭包的第三个也是最后一个事实 : 闭包可以更新外部变量的值。
实际上,闭包存储的是外部变量的引用,而不是它们的值的副本。因此,对于任何具有访问这些外部变量的闭包,都可以进行更新。一个简单的惯用法 box 对象说明了这一切。它存储了一个可读写的内部值。
1 | function box() { |
这个例子产生了一个包含三个闭包的对象。这三个闭包是 set、get 和 type 属性。它们都共享访问 value 变量。se t 闭包更新 value 的值,随后调用 get 和 type 查看更新的结果。
总结
- Javascript 允许你引用当前函数以外定义的变量
- 即使外部函数已经返回,当前函数仍可以引用在外部函数所定义的变量
- 闭包可以更新外部变量的值。实际上,闭包存储的是外部变量的引用,而不是它们的值的副本
变量声明提升
JavaScript 支持词法作用域( lexical scoping),即除了极少的例外,对变量 foo 的引用会被绑定到声明 foo 变量最近的作用域中。但是, JavaScript 不支持块级作用域,即变量定义的作用域并不是离其最近的封闭语句或代码块,而是包含它们的函数。
1 | var a = 20; |
先来看一段案例
1 | function test(params) { |
变量声明提升也可能导致变量重声明的混淆。在同一函数中多次声明相同变量是合法。
JavaScript 中 var 声明的变量没有块级作用域的一个例外恰好是其异常处理。try…catch 语句将捕获的异常绑定到一个变量,该变量的作用域只是 catch 语句块。
1 | function test3() { |
总结
- 在代码块中的变量声明会被隐式地提升到封闭函数的顶部。
- 重声明变量被视为单个变量。
- 考虑手动提升局部变量的声明,从而避免混淆。
使用立即调用的函数表达式创建局部作用域
先来看一段代码输出的是什么?
1 | function test(arr) { |
我们希望输出的是 10 但是程序给我们的结果却是 undefined
我们似乎期望该函数存储的是嵌套函数创建时变量 i 的值。但事实上,它存储的是变量 i 的引用。由于每次函数创建后变量 i 的值都发生了变化,因此内部函数最终看到的是变量 i 最后的值。需要注意的是,闭包存储的是其外部变量的引用而不是值。
解决方案一 : 立即调用函数
1 | function test2(arr) { |
使用立即调用函数虽然可以解决问题但是要注意,代码块不能包含任何跳出块的 break 语句和 continue 语句。
解决方案二 : 使用 ES6 的 let 声明变量
1 | function test(arr) { |
总结
- 理解绑定与赋值的区别。
- 闭包存储的是外部变量的引用,而不是它们的值的副本
- 使用立即调用的函数表达式(IIFE)来创建局部作用域。
- 当心在立即调用的函数表达式中包裹代码块可能改变其行为的情形。
- 使用 let 吧,求求了
当心命名函数表达式笨拙的作用域
JavaScript 函数无论放在何处看起来似乎都是一样的,但是根据上下文其含义会发生变化
查看一下代码片段
1 | function test(x) { |
这段代码可以是一个函数声明,也可以是命名函数表达式,这取决于它出现的地方。它定义一个函数并且绑定到当前作用域的一个变量。在程序的最顶层,以上声明将创建一个名为 test 的全局函数。但是同一段代码也可以作为一个表达式,它可以有截然不同的含义。例如 :
1 | var result = function test(x) { |
根据 ECMAScript 规范,上面那一段代码将函数 test 绑定到了变量 result 而不是变量 test。当然,函数的表达式命名并不是必要的。我们可以省略
1 | var result = function (x) { |
匿名和命名函数表达式的官方区别在于命名函数会绑定到与其函数名相同的变量上,该变量将作为该函数内的一个局部变量。这也是递归函数的原理
1 | // 求个阶乘呗 |
需要注意的是变量 fn 的作用域只在其自身函数中。不像函数声明,命名函数表达式不能通过其内部的函数名在外部被引用。
使用外部作用域的函数名也可以达到同样的效果
1 | // 求个阶乘呗 |
:::tip
命名函数表达式真正的用处是进行调试。大多数现代的 JavaScript 环境都提供对 Error 对象的栈跟踪功能。在栈跟踪中,函数表达式的名称通常作为其入口使用。用于检查栈的设备调试器对命名函数表达式有类似的使用。
:::
总结
- 在 Eror 对象和调试器中使用命名函数表达式改进栈跟踪。
- 谨记在错误百出的 JavaScript 环境中会提升命名函数表达式声明,并导致命名函数表达式的重复存储。
- 考虑避免使用命名函数表达式或在发布前删除函数名。
当心局部块函数声明笨拙的作用域
先来看一段案例
1 | function f() { |
现在看起来丝毫没有问题,但是如果我们把 test 函数内的 f 函数移动到 if 语句块里面
1 | function f() { |
你或许你产生疑问为什么 13 行的代码会报错函数 f 不是在外部还定义了一个吗?为什么访问不到外部的全局 f 函数。
由于内部的函数 f 出现在 if 语句块中,因此你可能认为第一次调用 test 产生数组 [“local fff”,”globa fff”],第二次调用产生数组[“global”]。但是要记住 JavaScript 没有块级作用域,所以内部函数 f 的作用域应该是整个 test 函数。
第二个例子的合理猜测是 [“local fff”,”local fff”]和[“local fff”]。而事实上,一些 JavaScript 环境的确如此行事。但并不是所有的 JavaScript 环境都这样。其他一些环境在运行时根据包含函数 f 的块是否被执行来有条件地绑定函数 f。(不仅使代码更难理解,而且还致使性能降低。这与 with 语句没什么不同。)
关于这一点 ECMAScript 标准说了什么呢?令人惊讶的是,几乎没有。直到 ES5,JavaScript 标准才承认局部块函数声明的存在。官方指定函数声明只能出现在其他函数或者程序的最外层
。ES5 甚至建议将在非标准环境的函数声明转变成警告或错误。一些流行的 JavaScript 实现在严格模式下将这类函数报告为错误(具有局部块函数声明的处于严格模式下的程序将报告一个语法错误)。这有助于检测出不可移植的代码,并为未来的标准版本在给局部块函数声明指定更明智和可移植的语义开辟了一条路。
在此期间,编写可移植的函数的最好方式是始终避免将函数声明置于局部块或子语句中。如果你想编写嵌套函数声明,应该将它置于其父函数的最外层,正如最开始的示例所示。另外,如果你需要有条件地选择函数,最好的办法是使用 var 声明和函数表达式来实现。
1 | function f() { |
这消除了内部变量(重命名为 g)作用域的神秘性。它无条件地作为局部变量被绑定,而仅仅只有赋值语句是有条件的。结果很明确,该函数完全可移植。
总结
- 始终将函数声明置于程序或被包含的函数的最外层以避免不可移植的行为。
- 使用 var 声明和有条件的赋值语句替代有条件的函数声明。