# 第一部分 类型和语法

# 📚 类型

# 📘 内置类型

JavaScript 有 7 种内置类型:

  • 空值(null)

  • 未定义(undefined)

  • 布尔值(boolean)

  • 数字(number)

  • 字符串(string)

  • 对象(object)

  • 符号(symbol,ES6 新增)

除对象之外,其他 6 种统称为“基本类型”。

可以用 typeof 运算符来查看值的类型,它返回的是类型的字符串值。

不过,值得注意的是,这 7 种类型和它们的字符串值并不一一对应。

typeof undefined === "undefined"; // true
typeof true === "boolean"; // true
typeof 42 === "number"; // true
typeof "42" === "string"; // true
typeof { life: 42 } === "object"; // true
typeof Symbol() === "symbol"; // true
typeof null === "object"; // true
1
2
3
4
5
6
7

可以看到,除了 null 类型与它的类型字符串值不一致,其他 6 种类型与它们的类型字符串值都是一一对应的。这是 JavaScript 中存在很久的 bug 了。

我们需要使用复合条件来检测 null 值的类型:

var a = null;
!a && typeof a === "object"; // true
1
2

null 是基本类型中唯一的一个“假值”(falsy 或者 false-like)类型,typeof 对它的返回值为 "object"。

注意

ES10 新增了一种基本数据类型 BigInt (opens new window),因此现在 JavaScript 其实是有 8 种内置类型。

# 📌 函数

还有一种情况:

typeof function a() {} === "function"; // true
1

这样看来,function(函数)也是 JavaScript 的一个内置类型。然而查阅规范就会知道,它实际上是 object 的一个“子类型”。具体来说,函数是“可调用对象”,它有一个内部属性 [[Call]],该属性使其可以被调用

函数不仅是对象,还可以拥有属性。例如:

function a(b, c) {}
1

🔔 函数对象的 length 属性是其声明的参数的个数。

a.length; // 2
1

# 📌 数组

typeof [1, 2, 3] === "object"; // true
1

数组也是对象。确切地说,它也是 object 的一个“子类型”,数组的元素按数字顺序来进行索引(而非普通像对象那样通过字符串键值),其 length 属性是元素的个数。

# 📘 值和类型

🔔 JavaScript 中的变量是没有类型的,只有值才有。变量可以随时持有任何类型的值。

换个角度来理解就是,JavaScript 不做“类型强制”;也就是说,语言引擎不要求变量总是持有与其初始值同类型的值。一个变量可以现在被赋值为字符串类型值,随后又被赋值为数字类型值。

🔔 在对变量执行 typeof 操作时,得到的结果并不是该变量的类型,而是该变量持有的值的类型,因为 JavaScript 中的变量没有类型。

var a = 42;
typeof a; // "number"

a = true;
typeof a; // "boolean"
1
2
3
4
5

typeof 运算符总是会返回一个字符串。

typeof typeof 42; // "string"
1

# 📌 undefined 和 undeclared

变量在未持有值的时候为 undefined。此时 typeof 返回 "undefined":

var a;
typeof a; // undefined

var b = 42;
var c;

b = c;

typeof b; // undefined
typeof c; // undefined
1
2
3
4
5
6
7
8
9
10

大多数开发者倾向于将 undefined 等同于 undeclared(未声明),但在 JavaScript 中它们完全是两回事。

🔔 已在作用域中声明但还没有赋值的变量,是 undefined 的。而还没有在作用域中声明过的变量,是 undeclared 的。

var a;

a; // undefined
b; // Uncaught ReferenceError: b is not defined
1
2
3
4

“undefined” 和 “is not defined” 是两码事。此时如果浏览器报错成 “b is not found” 或者 “b is not declared” 会更准确。

typeof 处理 undeclared 变量的方式也会很容易让人误解。例如:

var a;

typeof a; // undefined
typeof b; // undefined
1
2
3
4

对于 undeclared(或者 not defined)变量,typeof 照样返回 "undefined"。请注意虽然 b 是一个 undeclared 变量,但 typeof b 并没有报错。这是因为 typeof 有一个特殊的安全防范机制

# 📌 typeof Undeclared

该安全防范机制对在浏览器中运行的 JavaScript 代码来说还是很有帮助的,因为多个脚本文件会在共享的全局命名空间中加载变量。

举个简单的例子,在程序中使用全局变量 DEBUG 作为“调试模式”的开关。在输出调试信息到控制台之前,我们会检查 DEBUG 变量是否已被声明。顶层的全局变量声明 var DEBUG = true 只在 debug.js 文件中才有,而该文件只在开发和测试时才被加载到浏览器,在生产环境中不予加载。

问题是如何在程序中检查全局变量 DEBUG 才不会出现 ReferenceError 错误。这时 typeof 的安全防范机制就成了我们的好帮手:

// 这样会抛出错误
if (DEBUG) {
  console.log("Debugging is starting");
}

// 这样是安全的
if (typeof DEBUG !== "undefined") {
  console.log("Debugging is starting");
}
1
2
3
4
5
6
7
8
9

这不仅对用户定义的变量(比如 DEBUG)有用,对内建的 API 也有帮助:

// atob() 方法用于解码使用 base-64 编码的字符串
// 还有一个 btoa() 方法用于对 ASCII 编码的字符串进行 base64 编码
if (typeof atob === "undefined") {
  atob = function() {
    /*..*/
  };
}
1
2
3
4
5
6
7

提示

如果要为某个缺失的功能写 polyfill(即衬垫代码或者补充代码,用来补充当前运行环境中缺失的功能),一般不会用 var atob 来声明变量 atob。如果在 if 语句中使用 var atob,声明会被提升(hoisted)到作用域(即当前脚本或函数的作用域)的最顶层,即使 if 条件不成立也是如此(因为 atob 全局变量已经存在)。在有些浏览器中,对于一些特殊的内建全局变量(通常称为“宿主对象”,host object),这样的重复声明会报错。去掉 var 则可以防止声明被提升。

还有一种不用通过 typeof 的安全防范机制的方法,就是检查所有全局变量是否是全局对象的属性,浏览器中的全局对象是 window。所以前面的例子也可以这样来实现:

if (window.DEBUG) {
  // ...
}
if (!window.atob) {
  // ...
}
1
2
3
4
5
6

与 undeclared 变量不同,访问不存在的对象属性(甚至是在全局对象 window 上)不会产生 ReferenceError 错误。

一些开发人员不喜欢通过 window 来访问全局对象,尤其当代码需要运行在多种 JavaScript 环境中时(不仅仅是浏览器,还有服务器端,如 node.js 等),因为此时全局对象并非总是 window。

从技术角度来说,typeof 的安全防范机制对于非全局变量也很管用,虽然这种情况并不多见,也有一些开发人员不大愿意这样做。如果想让别人在他们的程序或模块中复制粘贴你的代码,就需要检查你用到的变量是否已经在宿主程序中定义过:

function doSomethingCool() {
  var helper =
    typeof FeatureXYZ !== "undefined"
      ? FeatureXYZ
      : function() {
          /*.. default feature ..*/
        };
  var val = helper();
  // ..
}
1
2
3
4
5
6
7
8
9
10

其他模块和程序引入 doSomethingCool() 时,doSomethingCool() 会检查 FeatureXYZ 变量是否已经在宿主程序中定义过;如果是,就用现成的,否则就自己定义:

(function() {
  function FeatureXYZ() {
    /*.. my XYZ feature ..*/
  }
  // 包含doSomethingCool(..)
  function doSomethingCool() {
    var helper =
      typeof FeatureXYZ !== "undefined"
        ? FeatureXYZ
        : function() {
            /*.. default feature ..*/
          };
    var val = helper();
    // ..
  }
  doSomethingCool();
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这里,FeatureXYZ 并不是一个全局变量,但我们还是可以使用 typeof 的安全防范机制来做检查,因为这里没有全局对象可用。

还有一些人喜欢使用“依赖注入”(dependency injection)设计模式,就是将依赖通过参数显式地传递到函数中,如:

function doSomethingCool(FeatureXYZ) {
  var helper =
    FeatureXYZ ||
    function() {
      /*.. default feature ..*/
    };
  var val = helper();
  // ..
}
1
2
3
4
5
6
7
8
9

# 📘 小结

  1. JavaScript 有七种内置类型:null、undefined、boolean、number、string、objectsymbol,可以使用 typeof 运算符来查看。

  2. 变量没有类型,但它们持有的值有类型。类型定义了值的行为特征。

  3. 很多开发人员将 undefined 和 undeclared 混为一谈,但在 JavaScript 中它们是两码事。undefined 是值的一种。undeclared 则表示变量还没有被声明过。

  4. 遗憾的是,JavaScript 却将它们混为一谈,在我们试图访问 "undeclared" 变量时这样报错:ReferenceError: a is not defined,并且 typeof 对 undefined 和 undeclared 变量都返回 "undefined"。

  5. 然而,通过 typeof 的安全防范机制(阻止报错)来检查 undeclared 变量,有时是个不错的办法。

# 📚 值

本章主要介绍 JavaScript 中的几个内置值类型。

# 📘 数组

和其他强类型语言不同,在 JavaScript 中,数组可以容纳任何类型的值,可以是字符串、数字、对象(object),甚至是其他数组(多维数组就是通过这种方式来实现的):

var a = [1, "2", [3]];
a.length; // 3
a[0] === 1; // true
a[2][0] === 3; // true
1
2
3
4

对数组声明后即可向其中加入值,不需要预先设定大小:

var a = [];
a.length; // 0
a[0] = 1;
a[1] = "2";
a[2] = [3];
a.length; // 3
1
2
3
4
5
6

注意

使用 delete 运算符可以将单元从数组中删除,但是请注意,单元删除后,数组的 length 属性并不会发生变化。

在创建“稀疏”数组(sparse array,即含有空白或空缺单元的数组)时要特别注意:

var a = [];
a[0] = 1;
// 此处没有设置a[1]单元
a[2] = [3];
a[1]; // undefined
a.length; // 3
1
2
3
4
5
6

a[1] 的值为 undefined,但这与将其显式赋值为 undefined(a[1] = undefined)还是有所区别。

数组通过数字进行索引,但有趣的是它们也是对象,所以也可以包含字符串键值和属性(但这些并不计算在数组长度内):

var a = [];
a[0] = 1;
a["foobar"] = 2;
a.length; // 1
a["foobar"]; // 2
a.foobar; // 2
1
2
3
4
5
6

注意

如果字符串键值能够被强制类型转换为十进制数字的话,它就会被当作数字索引来处理。

var a = [];
a["13"] = 42;
a.length; // 14
1
2
3

在数组中加入字符串键值/属性并不是一个好主意。建议使用对象来存放键值/属性值,用数组来存放数字索引值。

# 📌 类数组

有时需要将类数组(一组通过数字索引的值)转换为真正的数组,这一般通过数组工具函数(如 indexOf(..)、concat(..)、forEach(..) 等)来实现。

例如,一些 DOM 查询操作会返回 DOM 元素列表,它们并非真正意义上的数组,但十分类似。另一个例子是通过 arguments 对象(类数组)将函数的参数当作列表来访问(从 ES6 开始已废止)。

工具函数 slice(..) 经常被用于这类转换:

function foo() {
  var arr = Array.prototype.slice.call(arguments);
  arr.push("bam");
  console.log(arr);
}
foo("bar", "baz"); // ['bar', 'baz', 'bam']
1
2
3
4
5
6

在这个例子中,slice() 返回参数列表(上例中是一个类数组)的一个数组复本

用 ES6 中的内置工具函数 Array.from(..) 也能实现同样的功能:

var arr = Array.from(arguments);
1

# 📘 字符串

字符串经常被当成字符数组。字符串的内部实现究竟有没有使用数组并不好说,但 JavaScript 中的字符串和字符数组并不是一回事,最多只是看上去相似而已。

var a = "foo";
var b = ["f", "o", "o"];
1
2

字符串和数组的确很相似,它们都是类数组,都有 length 属性以及 indexOf(..)(从 ES5 开始数组支持此方法)和 concat(..) 方法:

a.length; // 3
b.length; // 3

a.indexOf("o"); // 1
b.indexOf("o"); // 1

var c = a.concat("bar"); // "foobar"
var d = b.concat(["b", "a", "r"]); // ["f","o","o","b","a","r"]

a === c; // false
b === d; // false

a; // "foo"
b; // ["f","o","o"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

但这并不意味着它们都是“字符数组”,比如:

a[1] = "O";
b[1] = "O";
a; // "foo"
b; // ["f","O","o"]
1
2
3
4

🔔 JavaScript 中字符串是不可变的,而数组是可变的。

并且 a[1] 在 JavaScript 中并非总是合法语法,在老版本的 IE 中就不被允许(现在可以了)。正确的方法应该是 a.charAt(1)

字符串不可变是指字符串的成员函数不会改变其原始值,而是创建并返回一个新的字符串。而数组的成员函数都是在其原始值上进行操作。

c = a.toUpperCase();
a === c; // false
a; // "foo"
c; // "FOO"
b.push("!");
b; // ["f","O","o","!"]
1
2
3
4
5
6

许多数组函数用来处理字符串很方便。虽然字符串没有这些函数,但可以通过“借用”数组的非变更方法来处理字符串:

a.join; // undefined
a.map; // undefined
var c = Array.prototype.join.call(a, "-");
var d = Array.prototype.map
  .call(a, function(v) {
    return v.toUpperCase() + ".";
  })
  .join("");
c; // "f-o-o"
d; // "F.O.O."
1
2
3
4
5
6
7
8
9
10

另一个不同点在于字符串反转(JavaScript 面试常见问题)。数组有一个字符串没有的可变更成员函数 reverse():

a.reverse; // undefined
b.reverse(); // ["!","o","O","f"]
b; // ["f","O","o","!"]
1
2
3

可惜我们无法“借用”数组的可变更成员函数,因为字符串是不可变的:

// 返回值仍然是字符串"foo"的一个封装对象
Array.prototype.reverse.call(a); // Uncaught TypeError: Cannot assign to read only property '0' of object '[object String]'

// 一个解决的方法就是先将字符串转为数组,反转完之后再转回字符串
var c = a
  // 将a的值转换为字符数组
  .split("")
  // 将数组中的字符进行倒转
  .reverse()
  // 将数组中的字符拼接回字符串
  .join("");
c; // "oof"
1
2
3
4
5
6
7
8
9
10
11
12

# 📘 数字

JavaScript 只有一种数值类型:number(数字),包括“整数”和带小数的十进制数。

此处“整数”之所以加引号是因为和其他语言不同,JavaScript 没有真正意义上的整数,这也是它一直以来为人诟病的地方。这种情况在将来或许会有所改观,但目前只有数字类型。

JavaScript 中的“整数”就是没有小数的十进制数。所以 42.0 即等同于“整数”42。

与大部分现代编程语言(包括几乎所有的脚本语言)一样,JavaScript 中的数字类型是基于 IEEE 754 标准来实现的,该标准通常也被称为“浮点数”。JavaScript 使用的是“双精度”格式(即 64 位二进制)

# 📌 数字的语法

JavaScript 中的数字常量一般用十进制表示。例如:

var a = 42;
var b = 42.3;
1
2

数字前面的 0 可以省略:

var a = 0.42;
var b = 0.42;
1
2

小数点后小数部分最后面的 0 也可以省略:

var a = 42.0;
var b = 42;
1
2

特别大和特别小的数字默认用指数格式显示,与 toExponential() (opens new window) 函数的输出结果相同。例如:

var a = 5e10;
a; // 50000000000
a.toExponential(); // "5e+10"
var b = a * a;
b; // 2.5e+21
var c = 1 / a;
c; // 2e-11
1
2
3
4
5
6
7

由于数字值可以使用 Number 对象进行封装,因此数字值可以调用 Number.prototype 中的方法。例如,toFixed(..) 方法可指定小数部分的显示位数:

var a = 42.59;
a.toFixed(0); // "43"
a.toFixed(1); // "42.6"
a.toFixed(2); // "42.59"
a.toFixed(3); // "42.590"
a.toFixed(4); // "42.5900"
1
2
3
4
5
6

请注意,上例中的输出结果实际上是给定数字的字符串形式,如果指定的小数部分的显示位数多于实际位数就用 0 补齐。

toPrecision(..) (opens new window) 方法用来指定有效数位的显示位数:

var a = 42.59;
a.toPrecision(1); // "4e+1"
a.toPrecision(2); // "43"
a.toPrecision(3); // "42.6"
a.toPrecision(4); // "42.59"
a.toPrecision(5); // "42.590"
a.toPrecision(6); // "42.5900"
1
2
3
4
5
6
7

上面的方法不仅适用于数字变量,也适用于数字常量。

🔔 不过对于 . 运算符需要给予特别注意,因为它是一个有效的数字字符,会被优先识别为数字常量的一部分,然后才是对象属性访问运算符。

// 无效语法:
42.toFixed(3); // SyntaxError

// 下面的语法都有效:
(42).toFixed(3); // "42.000"
0.42.toFixed(3); // "0.420"
42..toFixed(3); // "42.000"
1
2
3
4
5
6
7

下面的语法也是有效的(请注意其中的空格):

(42).toFixed(3); // "42.000"
1

我们还可以用指数形式来表示较大的数字,如:

var onethousand = 1e3; // 即 1 * 10^3
var onemilliononehundredthousand = 1.1e6; // 即 1.1 * 10^6
1
2

当前的 JavaScript 版本都支持这些格式:

0xf3; // 243的十六进制
0xf3; // 同上

0363; // 243的八进制
1
2
3
4

从 ES6 开始,严格模式(strict mode)不再支持 0363 八进制格式(新格式如下)。0363 格式在非严格模式(non-strict mode)中仍然受支持,但是考虑到将来的兼容性,最好不要再使用(我们现在使用的应该是严格模式)。

ES6 支持以下新格式:

0o363; // 243的八进制
0o363; // 同上

0b11110011; // 243的二进制
0b11110011; // 同上
1
2
3
4
5

考虑到代码的易读性,不推荐使用 0O363 格式,因为 0 和大写字母 O 在一起容易混淆。建议尽量使用小写的 0x、0b 和 0o。

# 📌 较小的数值

二进制浮点数最大的问题(不仅 JavaScript,所有遵循 IEEE 754 规范的语言都是如此),是会出现如下情况:

0.1 + 0.2 === 0.3; // false
1

简单来说,二进制浮点数中的 0.1 和 0.2 并不是十分精确,它们相加的结果并非刚好等于 0.3,而是一个比较接近的数字 0.30000000000000004,所以条件判断结果为 false。

那么应该怎样来判断 0.1 + 0.2 和 0.3 是否相等呢?

最常见的方法是设置一个误差范围值,通常称为“机器精度”(machine epsilon),对 JavaScript 的数字来说,这个值通常是 2^-52 (2.220446049250313e-16)。

从 ES6 开始,该值定义在 Number.EPSILON (opens new window) 中,我们可以直接拿来用,也可以为 ES6 之前的版本写 polyfill:

if (!Number.EPSILON) {
  Number.EPSILON = Math.pow(2, -52);
}
1
2
3

可以使用 Number.EPSILON 来比较两个数字是否相等(在指定的误差范围内):

function numbersCloseEnoughToEqual(n1, n2) {
  return Math.abs(n1 - n2) < Number.EPSILON;
}

var a = 0.1 + 0.2;
var b = 0.3;

numbersCloseEnoughToEqual(a, b); // true
numbersCloseEnoughToEqual(0.0000001, 0.0000002); // false
1
2
3
4
5
6
7
8
9

能够呈现的最大浮点数大约是 1.798e+308(这是一个相当大的数字),它定义在 Number.MAX_VALUE (opens new window) 中。最小浮点数定义在 Number.MIN_VALUE (opens new window) 中,大约是 5e-324,它不是负数,但无限接近于 0 !

# 📌 整数的安全范围

数字的呈现方式决定了“整数”的安全值范围远远小于 Number.MAX_VALUE。

能够被“安全”呈现的最大整数是 2^53 - 1,即 9007199254740991,在 ES6 中被定义为 Number.MAX_SAFE_INTEGER (opens new window)。最小整数是 -9007199254740991,在 ES6 中被定义为 Number.MIN_SAFE_INTEGER (opens new window)

# 📌 整数检测

要检测一个值是否是整数,可以使用 ES6 中的 Number.isInteger() (opens new window) 方法:

Number.isInteger(42); // true
Number.isInteger(42.0); // true
Number.isInteger(42.3); // false
Number.isInteger(NaN); // false
Number.isInteger(Infinity); // false
1
2
3
4
5

也可以为 ES6 之前的版本 polyfill Number.isInteger(..) 方法:

if (!Number.isInteger) {
  Number.isInteger = function(num) {
    return typeof num == "number" && num % 1 == 0;
  };
}
1
2
3
4
5

要检测一个值是否是安全的整数,可以使用 ES6 中的 Number.isSafeInteger() (opens new window) 方法:

Number.isSafeInteger(Number.MAX_SAFE_INTEGER); // true
Number.isSafeInteger(Math.pow(2, 53)); // false
Number.isSafeInteger(Math.pow(2, 53) - 1); // true
1
2
3

可以为 ES6 之前的版本 polyfill Number.isSafeInteger(..) 方法:

if (!Number.isSafeInteger) {
  Number.isSafeInteger = function(num) {
    return Number.isInteger(num) && Math.abs(num) <= Number.MAX_SAFE_INTEGER;
  };
}
1
2
3
4
5

# 📌 32 位有符号整数

虽然整数最大能够达到 53 位,但是有些数字操作(如数位操作)只适用于 32 位数字,所以这些操作中数字的安全范围就要小很多,变成从 Math.pow(-2,31)(-2147483648, 约-21 亿)到 Math.pow(2,31) - 1(2147483647,约 21 亿)。

a | 0 可以将变量 a 中的数值转换为 32 位有符号整数,因为数位运算符 | 只适用于 32 位整数(它只关心 32 位以内的值,其他的数位将被忽略)。因此与 0 进行操作即可截取 a 中 的 32 位数位。

# 📘 特殊数值

JavaScript 数据类型中有几个特殊的值需要开发人员特别注意和小心使用。

# 📌 不是值的值

undefined 类型只有一个值,即 undefined。null 类型也只有一个值,即 null。它们的名称既是类型也是值。

undefined 和 null 常被用来表示“空的”值或“不是值”的值。二者之间有一些细微的差别。例如:

  • null 指空值(empty value)

  • undefined 指没有值(missing value)

或者:

  • undefined 指从未赋值

  • null 指曾赋过值,但是目前没有值

🔔 null 是一个特殊关键字,不是标识符,我们不能将其当作变量来使用和赋值。然而 undefined 却是一个标识符,可以被当作变量来使用和赋值。

# 📌 undefined

在非严格模式下,我们可以为全局标识符 undefined 赋值(这样的设计实在是欠考虑!):

function foo() {
  undefined = 2; // 非常糟糕的做法!
}
foo();

function foo() {
  "use strict";
  undefined = 2; // TypeError!
}
foo();
1
2
3
4
5
6
7
8
9
10

在非严格和严格两种模式下,我们可以声明一个名为 undefined 的局部变量。再次强调最好不要这样做!

function foo() {
  "use strict";
  var undefined = 2;
  console.log(undefined); // 2
}
foo();
1
2
3
4
5
6

🔔 永远不要重新定义 undefined。

# 📌 void 运算符

undefined 是一个内置标识符,它的值为 undefined,通过 void 运算符即可得到该值。

表达式 void ___ 没有返回值,因此返回结果是 undefined。void 并不改变表达式的结果,只是让表达式不返回值:

var a = 42;
console.log(void a, a); // undefined 42
1
2

按惯例我们用 void 0 来获得 undefined(这主要源自 C 语言,当然使用 void true 或其他 void 表达式也是可以的)。void 0、void 1 和 undefined 之间并没有实质上的区别。

void 运算符在其他地方也能派上用场,比如不让表达式返回任何结果(即使其有副作用)。比如:

function doSomething() {
  // 注: APP.ready 由程序自己定义
  if (!APP.ready) {
    // 稍后再试
    return void setTimeout(doSomething, 100);
  }
  var result;
  // 其他
  return result;
}
// 现在可以了吗?
if (doSomething()) {
  // 立即执行下一个任务
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这里 setTimeout(..) 函数返回一个数值(计时器间隔的唯一标识符,用来取消计时),但是为了确保 if 语句不产生误报(false positive),我们要 void 掉它。

很多开发人员喜欢分开操作,效果都一样,只是没有使用 void 运算符:

if (!APP.ready) {
  // 稍后再试
  setTimeout(doSomething, 100);
  return;
}
1
2
3
4
5

总之,如果要将代码中的值(如表达式的返回值)设为 undefined,就可以使用 void。这种做法并不多见,但在某些情况下却很有用。

# 📌 特殊的数字

# 1. 不是数字的数字

如果数学运算的操作数不是数字类型(或者无法解析为常规的十进制或十六进制数字),就无法返回一个有效的数字,这种情况下返回值为 NaN。

NaN 意指 “不是一个数字”(not a number),这个名字容易引起误会,后面将会提到。将它理解为 “无效数值” “失败数值” 或者 “坏数值” 可能更准确些。

比如:

var a = 2 / "foo"; // NaN
typeof a === "number"; // true
1
2

换句话说,“不是数字的数字” 仍然是数字类型。

NaN 是一个 “警戒值”(sentinel value,有特殊用途的常规值),用于指出数字类型中的错误情况,即 “执行数学运算没有成功,这是失败后返回的结果”。

有人也许认为如果要检查变量的值是否为 NaN,可以直接和 NaN 进行比较,就像比较 null 和 undefined 那样。实则不然。

var a = 2 / "foo";
a == NaN; // false
a === NaN; // false
1
2
3

🔔 NaN 是一个特殊值,它和自身不相等,是唯一一个非自反(自反,reflexive,即 x === x 不成立)的值。而 NaN != NaN 为 true。

既然我们无法对 NaN 进行比较(结果永远为 false),那应该怎样来判断它呢?

var a = 2 / "foo";
isNaN(a); // true
1
2

很简单,可以使用内建的全局工具函数 isNaN() (opens new window) 来判断一个值是否是 NaN。

然而操作起来并非这么容易。isNaN(..) 有一个严重的缺陷,它的检查方式过于死板,就是 “检查参数是否不是 NaN,也不是数字”。但是这样做的结果并不太准确:

var a = 2 / "foo";
var b = "foo";

a; // NaN
b; // "foo"

window.isNaN(a); // true
window.isNaN(b); // true
1
2
3
4
5
6
7
8

很明显 "foo" 不是一个数字,但是它也不是 NaN。这个 bug 自 JavaScript 问世以来就一直存在。

从 ES6 开始我们可以使用工具函数 Number.isNaN() (opens new window),它会判断传递的值是否为 NaN,并且检查其类型是否为 Number,是比 isNaN() 更稳妥的版本。ES6 之前的浏览器的 polyfill 如下:

if (!Number.isNaN) {
  Number.isNaN = function(n) {
    return (
      typeof n === "number" && window.isNaN(n);
    );
  };
}

var a = 2 / "foo";
var b = "foo";

Number.isNaN( a ); // true
Number.isNaN( b ); // false
1
2
3
4
5
6
7
8
9
10
11
12
13

实际上还有一个更简单的方法,即利用 NaN 不等于自身这个特点。NaN 是 JavaScript 中唯一一个不等于自身的值。

于是我们可以这样:

if (!Number.isNaN) {
  Number.isNaN = function(n) {
    return n !== n;
  };
}
1
2
3
4
5

很多 JavaScript 程序都可能存在 NaN 方面的问题,所以我们应该尽量使用 Number.isNaN(..) 这样可靠的方法,无论是系统内置还是 polyfill。

# 2. 无穷数

熟悉传统编译型语言(如 C)的开发人员可能都遇到过编译错误(compiler error)或者运行时错误(runtime exception),例如 “除以 0”。

var a = 1 / 0;
1

然而在 JavaScript 中上例的结果为 Infinity(即 Number.POSITIVE_INfiNITY (opens new window))。同样:

var a = 1 / 0; // Infinity
var b = -1 / 0; // -Infinity
1
2

如果除法运算中的一个操作数为负数,则结果为 -Infinity(即 Number.NEGATIVE_INfiNITY (opens new window))。

JavaScript 使用有限数字表示法(finite numeric representation,即之前介绍过的 IEEE 754 浮点数),所以和纯粹的数学运算不同,JavaScript 的运算结果有可能溢出,此时结果为 Infinity 或者 -Infinity。例如:

var a = Number.MAX_VALUE; // 1.7976931348623157e+308
a + a; // Infinity
a + Math.pow(2, 970); // Infinity
a + Math.pow(2, 969); // 1.7976931348623157e+308
1
2
3
4

规范规定,如果数学运算(如加法)的结果超出处理范围,则由 IEEE 754 规范中的 “就近取整”(round-to-nearest)模式来决定最后的结果。例如,相对于 Infinity,Number.MAX_VALUE + Math.pow(2, 969) 与 Number.MAX_VALUE 更为接近,因此它被 “向下取整”(round down);而 Number.MAX_VALUE + Math.pow(2, 970) 与 Infinity 更为接近,所以它被 “向上取整”(round up)。

计算结果一旦溢出为无穷数(infinity)就无法再得到有穷数。换句话说,就是你可以从有穷走向无穷,但无法从无穷回到有穷。

Infinity / Infinity; // NaN
-Infinity / -Infinity; // NaN

12 / Infinity; // 0
-12 / Infinity; // -0

12 / -Infinity; // -0
-12 / -Infinity; // 0
1
2
3
4
5
6
7
8
# 3. 零值

JavaScript 有一个常规的 0(也叫作 +0)和一个 -0。

-0 除了可以用作常量以外,也可以是某些数学运算的返回值。例如:

var a = 0 / -3; // -0
var b = 0 * -3; // -0
1
2

加法和减法运算不会得到负零(negative zero)。

负零在开发调试控制台中通常显示为 -0,但在一些老版本的浏览器中仍然会显示为 0。

根据规范,对负零进行字符串化会返回 "0":

var a = 0 / -3;

a; // -0

a.toString(); // "0"
a + ""; // "0"
String(a); // "0"

JSON.stringify(a); // "0"
1
2
3
4
5
6
7
8
9

有意思的是,如果反过来将其从字符串转换为数字,得到的结果是准确的:

+"-0"; // -0
Number("-0"); // -0
JSON.parse("-0"); // -0
1
2
3

负零转换为字符串的结果令人费解,它的比较操作也是如此:

var a = 0;
var b = 0 / -3;

a == b; // true
-0 == 0; // true

a === b; // true
-0 === 0; // true

0 > -0; // false
a > b; // false
1
2
3
4
5
6
7
8
9
10
11

要区分 -0 和 0,不能仅仅依赖开发调试窗口的显示结果,还需要做一些特殊处理:

// 是否是 -0
function isNegZero(n) {
  n = Number(n);
  return n === 0 && 1 / n === -Infinity;
}

isNegZero(-0); // true
isNegZero(0 / -3); // true
isNegZero(0); // false
1
2
3
4
5
6
7
8
9

🔔 为什么需要负零呢?

有些应用程序中的数据需要以级数形式来表示(比如动画帧的移动速度),数字的符号位(sign)用来代表其他信息(比如移动的方向)。此时如果一个值为 0 的变量失去了它的符号位,它的方向信息就会丢失。所以保留 0 值的符号位可以防止这类情况发生。

# 📌 特殊等式

NaN 和 -0 在相等比较时的表现有些特别。由于 NaN 和自身不相等,所以必须使用 ES6 中的 Number.isNaN(..)(或者 polyfill)。而 -0 等于 0,因此我们必须使用 isNegZero(..) 这样的工具函数。

ES6 中新加入了一个工具方法 Object.is() (opens new window) 来判断两个值是否绝对相等,可以用来处理上述所有的特殊情况:

var a = 2 / "foo";
var b = -3 * 0;

Object.is(a, NaN); // true
Object.is(b, -0); // true
Object.is(b, 0); // false
1
2
3
4
5
6

对于 ES6 之前的版本,Object.is(..) 有一个简单的 polyfill:

if (!Object.is) {
  Object.is = function(v1, v2) {
    // 判断是否是-0
    if (v1 === 0 && v2 === 0) {
      return 1 / v1 === 1 / v2;
    }

    // 判断是否是NaN
    if (v1 !== v1) {
      return v2 !== v2;
    }
    // 其他情况
    return v1 === v2;
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

能使用 == 和 === 时就尽量不要使用 Object.is(..),因为前者效率更高、更为通用。Object.is(..) 主要用来处理那些特殊的相等比较。

# 📘 值和引用

在许多编程语言中,赋值和参数传递可以通过值复制(value-copy)或者引用复制(reference-copy)来完成,这取决于我们使用什么语法。

JavaScript 中没有指针,引用的工作机制也不尽相同。在 JavaScript 中变量不可能成为指向另一个变量的引用。

JavaScript 引用指向的是值。如果一个值有 10 个引用,这些引用指向的都是同一个值,它们相互之间没有引用 / 指向关系。

JavaScript 对值和引用的赋值 / 传递在语法上没有区别,完全根据值的类型来决定。

比如:

var a = 2;
var b = a; // b 是 a 的值的一个副本
b++;
a; // 2
b; // 3

var c = [1, 2, 3];
var d = c; // d 是 [1,2,3] 的一个引用
d.push(4);
c; // [1,2,3,4]
d; // [1,2,3,4]
1
2
3
4
5
6
7
8
9
10
11

简单值和复合值

简单值(即标量基本类型值,scalar primitive)总是通过值复制的方式来赋值 / 传递,包括 null、undefined、字符串、数字、布尔和 ES6 中的 symbol。

复合值(compound value)——对象(包括数组和封装对象)和函数,则总是通过引用复制的方式来赋值 / 传递。

由于引用指向的是值本身而非变量,所以一个引用无法更改另一个引用的指向。

var a = [1, 2, 3];
var b = a;
a; // [1,2,3]
b; // [1,2,3]

// 然后
b = [4, 5, 6];
a; // [1,2,3]
b; // [4,5,6]
1
2
3
4
5
6
7
8
9

b = [4,5,6] 并不影响 a 指向值 [1,2,3],除非 b 不是指向数组的引用,而是指向 a 的指针,但在 JavaScript 中不存在这种情况!

函数参数就经常让人产生这样的困惑:

function foo(x) {
  x.push(4);
  x; // [1,2,3,4]

  // 然后
  x = [4, 5, 6];
  x.push(7);
  x; // [4,5,6,7]
}
var a = [1, 2, 3];
foo(a);
a; // 是[1,2,3,4],不是[4,5,6,7]
1
2
3
4
5
6
7
8
9
10
11
12

我们向函数传递 a 的时候,实际是将引用 a 的一个复本赋值给 x,而 a 仍然指向 [1,2,3]。在函数中我们可以通过引用 x 来更改数组的值(push(4) 之后变为 [1,2,3,4])。但 x = [4,5,6] 并不影响 a 的指向,所以 a 仍然指向 [1,2,3,4]。

我们不能通过引用 x 来更改引用 a 的指向,只能更改 a 和 x 共同指向的值。

如果要将 a 的值变为 [4,5,6,7],必须更改 x 指向的数组,而不是为 x 赋值一个新的数组。

function foo(x) {
  x.push(4);
  x; // [1,2,3,4]

  // 然后
  x.length = 0; // 清空数组
  x.push(4, 5, 6, 7);
  x; // [4,5,6,7]
}
var a = [1, 2, 3];
foo(a);
a; // 是[4,5,6,7],不是[1,2,3,4]
1
2
3
4
5
6
7
8
9
10
11
12

请记住:我们无法自行决定使用值复制还是引用复制,一切由值的类型来决定。

如果通过值复制的方式来传递复合值(如数组),就需要为其创建一个复本,这样传递的就不再是原始值。例如:

foo(a.slice());
1

slice(..) 不带参数会返回当前数组的一个浅复本(shallow copy)。由于传递给函数的是指向该复本的引用,所以 foo(..) 中的操作不会影响 a 指向的数组。

相反,如果要将标量基本类型值传递到函数内并进行更改,就需要将该值封装到一个复合值(对象、数组等)中,然后通过引用复制的方式传递。

function foo(wrapper) {
  wrapper.a = 42;
}

var obj = {
  a: 2,
};

foo(obj);
obj.a; // 42
1
2
3
4
5
6
7
8
9
10

这里 obj 是一个封装了标量基本类型值 a 的封装对象。obj 引用的一个复本作为参数 wrapper 被传递到 foo(..) 中。这样我们就可以通过 wrapper 来访问该对象并更改它的属性。函数执行结束后 obj.a 将变成 42。

与预期不同的是,虽然传递的是指向数字对象的引用复本,但我们并不能通过它来更改其中的基本类型值:

function foo(x) {
  x = x + 1;
  x; // 3
}

var a = 2;
var b = new Number(a); // Object(a)也一样

foo(b);
console.log(b); // 是2,不是3
1
2
3
4
5
6
7
8
9
10

原因是标量基本类型值是不可更改的(字符串和布尔也是如此)。如果一个数字对象的标量基本类型值是 2,那么该值就不能更改,除非创建一个包含新值的数字对象。

x = x + 1 中,x 中的标量基本类型值 2 从数字对象中拆封(或者提取)出来后,x 就神不知鬼不觉地从引用变成了数字对象,它的值为 2 + 1 等于 3。然而函数外的 b 仍然指向原来那个值为 2 的数字对象。

# 📘 小结

  1. JavaScript 中的数组是通过数字索引的一组任意类型的值。字符串和数组类似,但是它们的行为特征不同,在将字符作为数组来处理时需要特别小心。JavaScript 中的数字包括 “整数” 和 “浮点型”。

  2. 基本类型中定义了几个特殊的值。null 类型只有一个值 null,undefined 类型也只有一个值 undefined。所有变量在赋值之前默认值都是 undefined。void 运算符返回 undefined。

  3. 数字类型有几个特殊值,包括 NaN(意指 “not a number”,更确切地说是 “invalid number”)、+Infinity、-Infinity 和 -0。

  4. 简单标量基本类型值(字符串和数字等)通过值复制来赋值 / 传递,而复合值(对象等)通过引用复制来赋值 / 传递。JavaScript 中的引用和其他语言中的引用 / 指针不同,它们不能指向别的变量 / 引用,只能指向值。

# 📚 原生函数

JavaScript 的内建函数(built-in function),也叫原生函数(native function),常用的原生函数有 10 种:

  • String()

  • Number()

  • Boolean()

  • Array()

  • Object()

  • Function()

  • RegExp()

  • Date()

  • Error()

  • Symbol()

原生函数可以被当作构造函数来使用,但其构造出来的对象可能会和我们设想的有所出入:

var a = new String("abc");

typeof a; // 是"object",不是"String"

a instanceof String; // true

Object.prototype.toString.call(a); // "[object String]"
1
2
3
4
5
6
7

通过构造函数(如 new String("abc"))创建出来的是封装了基本类型值(如 "abc")的封装对象。

请注意:typeof 在这里返回的是对象类型的子类型

可以这样来查看封装对象:

console.log(a); // String {"abc"}
1

# 📘 内部属性[[Class]]

所有 typeof 返回值为 "object" 的对象(如数组)都包含一个内部属性 [[Class]](我们可以把它看作一个内部的分类,而非传统的面向对象意义上的类)。

这个属性无法直接访问,一般通过 Object.prototype.toString.call(..) 来查看。例如:

Object.prototype.toString.call([1, 2, 3]); // "[object Array]"

Object.prototype.toString.call(/regex-literal/i); // "[object RegExp]"
1
2
3

上例中,数组的内部 [[Class]] 属性值是 "Array",正则表达式的值是 "RegExp"。

多数情况下,对象的内部 [[Class]] 属性和创建该对象的内建原生构造函数相对应,但并非总是如此。

那么基本类型值呢?下面先来看看 null 和 undefined:

Object.prototype.toString.call(null); // "[object Null]"

Object.prototype.toString.call(undefined); // "[object Undefined]"
1
2
3

虽然 Null() 和 Undefined() 这样的原生构造函数并不存在,但是内部 [[Class]] 属性值仍然是 "Null" 和 "Undefined"。

其他基本类型值(如字符串、数字和布尔)的情况有所不同,通常称为“包装”(boxing):

Object.prototype.toString.call("abc"); // "[object String]"

Object.prototype.toString.call(42); // "[object Number]"

Object.prototype.toString.call(true); // "[object Boolean]"
1
2
3
4
5

上例中基本类型值被各自的封装对象自动包装,所以它们的内部 [[Class]] 属性值分别为 "String"、"Number" 和 "Boolean"。

# 📘 封装对象包装

封装对象(object wrapper)扮演着十分重要的角色。由于基本类型值没有 .length 和 .toString() 这样的属性和方法,需要通过封装对象才能访问,此时 JavaScript 会自动为基本类型值包装(box 或者 wrap)一个封装对象:

var a = "abc";

a.length; // 3
a.toUpperCase(); // "ABC"
1
2
3
4

如果需要经常用到这些字符串属性和方法,比如在 for 循环中使用 i < a.length,那么从一开始就创建一个封装对象也许更为方便,这样 JavaScript 引擎就不用每次都自动创建了。

但实际证明这并不是一个好办法,因为浏览器已经为 .length 这样的常见情况做了性能优化,直接使用封装对象来“提前优化”代码反而会降低执行效率

注意

一般情况下,我们不需要直接使用封装对象。最好的办法是让 JavaScript 引擎自己决定什么时候应该使用封装对象。换句话说,就是应该优先考虑使用 "abc" 和 42 这样的基本类型值,而非 new String("abc") 和 new Number(42)。

使用封装对象时有些地方需要特别注意。

比如 Boolean:

var a = new Boolean(false);
if (!a) {
  console.log("Oops"); // 执行不到这里
}
1
2
3
4

我们为 false 创建了一个封装对象,然而该对象是真值,所以这里使用封装对象得到的结果和使用 false 截然相反。

如果想要自行封装基本类型值,可以使用 Object(..) 函数(不带 new 关键字):

var a = "abc";
var b = new String(a);
var c = Object(a);

typeof a; // "string"
typeof b; // "object"
typeof c; // "object"

b instanceof String; // true
c instanceof String; // true

Object.prototype.toString.call(b); // "[object String]"
Object.prototype.toString.call(c); // "[object String]"
1
2
3
4
5
6
7
8
9
10
11
12
13

再次强调,一般不推荐直接使用封装对象(如上例中的 b 和 c),但它们偶尔也会派上用场。

# 📘 拆封

如果想要得到封装对象中的基本类型值,可以使用 valueOf() 函数:

var a = new String("abc");
var b = new Number(42);
var c = new Boolean(true);

a.valueOf(); // "abc"
b.valueOf(); // 42
c.valueOf(); // true
1
2
3
4
5
6
7

在需要用到封装对象中的基本类型值的地方会发生隐式拆封。

var a = new String("abc");
var b = a + ""; // b的值为"abc"

typeof a; // "object"
typeof b; // "string"
1
2
3
4
5

# 📘 原生函数作为构造函数

关于数组(array)、对象(object)、函数(function)和正则表达式,我们通常喜欢以常量的形式来创建它们。实际上,使用常量和使用构造函数的效果是一样的(创建的值都是通过封装对象来包装)。

如前所述,应该尽量避免使用构造函数,除非十分必要,因为它们经常会产生意想不到的结果。

# 📌 Array()

var a = new Array(1, 2, 3);
a; // [1, 2, 3]

var b = [1, 2, 3];
b; // [1, 2, 3]
1
2
3
4
5

构造函数 Array(..) 不要求必须带 new 关键字。不带时,它会被自动补上。因此 Array(1,2,3) 和 new Array(1,2,3) 的效果是一样的。

Array 构造函数只带一个数字参数的时候,该参数会被作为数组的预设长度(length),而非只充当数组中的一个元素。

Array(3); // Chrome:[empty × 3],Firefox:Array(3) [ <3 empty slots> ]
1

数组并没有预设长度这个概念。这样创建出来的只是一个空数组,只不过它的 length 属性被设置成了指定的值。

如若一个数组没有任何单元,但它的 length 属性中却显示有单元数量,这样奇特的数据结构会导致一些怪异的行为。而这一切都归咎于已被废止的旧特性(类似 arguments 这样的类数组)。

var a = new Array(3);
var b = [undefined, undefined, undefined];

a.join("-"); // "--"
b.join("-"); // "--"

a.map(function(v, i) {
  return i;
}); // [empty × 3]
b.map(function(v, i) {
  return i;
}); // [0, 1, 2]
1
2
3
4
5
6
7
8
9
10
11
12

我们将包含至少一个 “空单元” 的数组称为 “稀疏数组”。

我们可以通过下述方式来创建包含 undefined 单元(而非 “空单元”)的数组:

var a = Array.apply(null, { length: 3 });
a; // [undefined, undefined, undefined]
1
2

我们执行的实际上是 Array(undefined, undefined, undefined),所以结果是单元值为 undefined 的数组,而非空单元数组。

注意

永远不要创建和使用空单元数组。

# 📌 Object()、Function() 和 RegExp()

同样,除非万不得已,否则尽量不要使用 Object(..)/Function(..)/RegExp(..):

var c = new Object();
c.foo = "bar";
c; // { foo: "bar" }
var d = { foo: "bar" };
d; // { foo: "bar" }

var e = new Function("a", "return a * 2;");
var f = function(a) {
  return a * 2;
};
function g(a) {
  return a * 2;
}

var h = new RegExp("^a*b+", "g");
var i = /^a*b+/g;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

在实际情况中没有必要使用 new Object() 来创建对象,因为这样就无法像常量形式那样一次设定多个属性,而必须逐一设定。

构造函数 Function 只在极少数情况下很有用,比如动态定义函数参数和函数体的时候。不要把 Function(..) 当作 eval(..) 的替代品,你基本上不会通过这种方式来定义函数。

强烈建议使用常量形式(如 /^a*b+/g)来定义正则表达式,这样不仅语法简单,执行效率也更高,因为 JavaScript 引擎在代码执行前会对它们进行预编译和缓存。

与前面的构造函数不同,RegExp(..) 有时还是很有用的,比如动态定义正则表达式时:

var name = "Kyle";
var namePattern = new RegExp("\\b(?:" + name + ")+\\b", "ig");
var matches = someText.match(namePattern);
1
2
3

# 📌 Date() 和 Error()

相较于其他原生构造函数,Date(..) 和 Error(..) 的用处要大很多,因为没有对应的常量形式来作为它们的替代。

创建日期对象必须使用 new Date()。Date(..) 可以带参数,用来指定日期和时间,而不带参数的话则使用当前的日期和时间。

Date(..) 主要用来获得当前的 Unix 时间戳(从 1970 年 1 月 1 日开始计算,以秒为单位)。该值可以通过日期对象中的 getTime() 来获得。

从 ES5 开始引入了一个更简单的方法,即静态函数 Date.now()。对 ES5 之前的版本我们可以使用下面的 polyfill:

if (!Date.now) {
  Date.now = function() {
    return new Date().getTime();
  };
}
1
2
3
4
5

如果调用 Date() 时不带 new 关键字,则会得到当前日期的字符串值。

Date(); // "Wed Sep 16 2020 15:38:35 GMT+0800 (中国标准时间)"

new Date(); // Wed Sep 16 2020 15:38:44 GMT+0800 (中国标准时间)
1
2
3

构造函数 Error(..)(与前面的 Array() 类似)带不带 new 关键字都可。

创建错误对象(error object)主要是为了获得当前运行栈的上下文(大部分 JavaScript 引擎通过只读属性 .stack 来访问)。栈上下文信息包括函数调用栈信息和产生错误的代码行号,以便于调试(debug)。

错误对象通常与 throw 一起使用:

function foo(x) {
  if (!x) {
    throw new Error("x wasn’t provided");
  }
  // ..
}
1
2
3
4
5
6

通常错误对象至少包含一个 message 属性,有时也不乏其他属性(必须作为只读属性访问),如 type。除了访问 stack 属性以外,最好的办法是调用(显式调用或者通过强制类型转换隐式调用)toString() 来获得经过格式化的便于阅读的错误信息。

除 Error(..) 之外,还有一些针对特定错误类型的原生构造函数,如 EvalError(..)RangeError(..)ReferenceError(..)SyntaxError(..)TypeError(..)URIError(..)。这些构造函数很少被直接使用,它们在程序发生异常(比如试图使用未声明的变量产生 ReferenceError 错误)时会被自动调用。

# 📌 Symbol()

ES6 中新加入了一个基本数据类型 —— 符号(Symbol (opens new window))。符号是具有唯一性的特殊值(并非绝对),用它来命名对象属性不容易导致重名。该类型的引入主要源于 ES6 的一些特殊构造,此外符号也可以自行定义。

符号可以用作属性名,但无论是在代码还是开发控制台中都无法查看和访问它的值,只会显示为诸如 Symbol(Symbol.create) 这样的值。

ES6 中有一些预定义符号,以 Symbol 的静态属性形式出现,如 Symbol.create、Symbol.iterator 等,可以这样来使用:

obj[Symbol.iterator] = function() {
  /*..*/
};
1
2
3

我们可以使用 Symbol(..) 原生构造函数来自定义符号。但它比较特殊,不能带 new 关键字,否则会出错:

var mysym = Symbol("my own symbol");
mysym; // Symbol(my own symbol)
mysym.toString(); // "Symbol(my own symbol)"
typeof mysym; // "symbol"

var a = {};
a[mysym] = "foobar";
Object.getOwnPropertySymbols(a); // [Symbol(my own symbol)]
1
2
3
4
5
6
7
8

虽然符号实际上并非私有属性(通过 Object.getOwnPropertySymbols(..) (opens new window) 便可以公开获得对象中的所有符号),但它却主要用于私有或特殊属性。很多开发人员喜欢用它来替代有下划线(_)前缀的属性,而下划线前缀通常用于命名私有或特殊属性。

# 📌 原生原型

原生构造函数有自己的 .prototype 对象,如 Array.prototype、String.prototype 等。

这些对象包含其对应子类型所特有的行为特征。

例如,将字符串值封装为字符串对象之后,就能访问 String.prototype 中定义的方法。

然而,有些原生原型(native prototype)并非普通对象那么简单:

typeof Function.prototype; // "function"
Function.prototype(); // 空函数!

RegExp.prototype.toString(); // "/(?:)/"——空正则表达式
1
2
3
4

更糟糕的是,我们甚至可以修改它们(而不仅仅是添加属性):

Array.isArray(Array.prototype); // true
Array.prototype.push(1, 2, 3); // 3
Array.prototype; // [1,2,3]

// 需要将 Array.prototype 设置回空,否则会导致问题!
Array.prototype.length = 0;
1
2
3
4
5
6

这里,Function.prototype 是一个函数,RegExp.prototype 是一个正则表达式,而 Array.prototype 是一个数组。

# 将原型作为默认值

Function.prototype 是一个空函数,RegExp.prototype 是一个“空”的正则表达式(无任何匹配),而 Array.prototype 是一个空数组。对未赋值的变量来说,它们是很好的默认值。

function isThisCool(vals, fn, rx) {
  vals = vals || Array.prototype;
  fn = fn || Function.prototype;
  rx = rx || RegExp.prototype;
  return rx.test(vals.map(fn).join(""));
}
isThisCool(); // true

isThisCool(
  ["a", "b", "c"],
  function(v) {
    return v.toUpperCase();
  },
  /D/
); // false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

从 ES6 开始,我们不再需要使用 vals = vals || .. 这样的方式来设置默认值,因为默认值可以通过函数声明中的内置语法来设置。

注意

  1. 这种方法的一个好处是 .prototypes 已被创建并且仅创建一次。相反,如果将 []function(){}/(?:)/ 作为默认值,则每次调用 isThisCool(..) 时它们都会被创建一次(具体创建与否取决于 JavaScript 引擎,稍后它们可能会被垃圾回收),这样无疑会造成内存和 CPU 资源的浪费。

  2. 如果默认值随后会被更改,那就不要使用 Array.prototype。上例中的 vals 是作为只读变量来使用,更改 vals 实际上就是更改 Array.prototype,而这样会导致前面提到过的一系列问题!

# 📘 小结

  1. JavaScript 为基本数据类型值提供了封装对象,称为原生函数(如 String、Number、Boolean 等)。它们为基本数据类型值提供了该子类型所特有的方法和属性(如:String.prototype.trim() 和 Array.prototype.concat())。

  2. 对于简单标量基本类型值,比如 "abc",如果要访问它的 length 属性或 String.prototype 方法,JavaScript 引擎会自动对该值进行封装(即用相应类型的封装对象来包装它)来实现对这些属性和方法的访问。

# 📚 强制类型转换

关于强制类型转换是一个设计上的缺陷还是有用的特性,这一争论从 JavaScript 诞生之日起就开始了。在很多的 JavaScript 书籍中强制类型转换被说成是危险、晦涩和糟糕的设计。

本章旨在全面介绍强制类型转换的优缺点,让你能够在开发中合理地运用它。

# 📘 值类型转换

将值从一种类型转换为另一种类型通常称为类型转换(type casting),这是显式的情况;隐式的情况称为强制类型转换(coercion)。

注意

JavaScript 中的强制类型转换总是返回标量基本类型值,如字符串、数字和布尔值,不会返回对象和函数。我们介绍过 “封装”,就是为标量基本类型值封装一个相应类型的对象,但这并非严格意义上的强制类型转换。

也可以这样来区分:

  • 类型转换发生在静态类型语言的编译阶段

  • 强制类型转换则发生在动态类型语言的运行时(runtime)

然而在 JavaScript 中通常将它们统称为强制类型转换,我个人则倾向于用 “隐式强制类型转换”(implicit coercion)和 “显式强制类型转换”(explicit coercion)来区分。

二者的区别显而易见:我们能够从代码中看出哪些地方是显式强制类型转换,而隐式强制类型转换则不那么明显,通常是某些操作产生的副作用。

var a = 42;
var b = a + ""; // 隐式强制类型转换
var c = String(a); // 显式强制类型转换
1
2
3

# 📘 抽象值操作

介绍显式和隐式强制类型转换之前,我们需要掌握字符串、数字和布尔值之间类型转换的基本规则。

ES5 规范第 9 节中定义了一些“抽象操作”(即“仅供内部使用的操作”)和转换规则。这里着重介绍 ToStringToNumberToBoolean,附带讲一讲 ToPrimitive

# 📌 ToString

抽象操作 ToString 负责处理非字符串到字符串的强制类型转换。

基本类型值的字符串化规则为:null 转换为 "null",undefined 转换为 "undefined",true 转换为 "true"

数字的字符串化则遵循通用规则,不过那些极小和极大的数字使用指数形式

// 1.07 连续乘以七个 1000
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;

// 七个1000一共21位数字
a.toString(); // "1.07e+21"
1
2
3
4
5

对普通对象来说,除非自行定义,否则 toString()(Object.prototype.toString())返回内部属性 [[Class]] 的值,如 "[object Object]"。但是,如果对象有自己的 toString() 方法,字符串化时就会调用该方法并使用其返回值

注意

将对象强制类型转换为 string 是通过 ToPrimitive 抽象操作来完成的。

数组的默认 toString() 方法经过了重新定义,将所有单元字符串化以后再用 "," 连接起来

var a = [1, 2, 3];
a.toString(); // "1,2,3"
1
2

toString() 可以被显式调用,或者在需要字符串化时自动调用。

# JSON 字符串化

工具函数 JSON.stringify(..) (opens new window) 在将 JSON 对象序列化为字符串时也用到了 ToString。

注意

JSON 字符串化并非严格意义上的强制类型转换,因为其中也涉及 ToString 的相关规则,所以这里顺带介绍一下。

对大多数简单值来说,JSON 字符串化和 toString() 的效果基本相同,只不过序列化的结果总是字符串:

JSON.stringify(42); // "42"
JSON.stringify("42"); // ""42"" (含有双引号的字符串)
JSON.stringify(null); // "null"
JSON.stringify(true); // "true"
1
2
3
4

所有安全的 JSON 值(JSON-safe)都可以使用 JSON.stringify(..) 字符串化。安全的 JSON 值是指能够呈现为有效 JSON 格式的值

undefined、function、symbol(ES6+)和包含循环引用(对象之间相互引用,形成一个无限循环)的对象都不符合 JSON 结构标准,支持 JSON 的语言无法处理它们。

🔔 JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在数组中则会返回 null(以保证单元位置不变)。

JSON.stringify(undefined); // undefined

JSON.stringify(function() {}); // undefined

JSON.stringify([1, undefined, function() {}, 4]); // "[1,null,null,4]"

JSON.stringify({ a: 2, b: function() {} }); // "{"a":2}"
1
2
3
4
5
6
7

对包含循环引用的对象执行 JSON.stringify(..) 会出错。

如果对象中定义了 toJSON() 方法,JSON 字符串化时会首先调用该方法,然后用它的返回值来进行序列化。

🔔 如果要对含有非法 JSON 值的对象做字符串化,或者对象中的某些值无法被序列化时,就需要定义 toJSON() 方法来返回一个安全的 JSON 值。

var o = {};
var a = {
  b: 42,
  c: o,
  d: function() {},
};

// 在a中创建一个循环引用
o.e = a;

// 循环引用在这里会产生错误
// JSON.stringify(a);

// 自定义的JSON序列化
a.toJSON = function() {
  // 序列化仅包含b
  return { b: this.b };
};

JSON.stringify(a); // "{"b":42}"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

很多人误以为 toJSON() 返回的是 JSON 字符串化后的值,其实不然,除非我们确实想要对字符串进行字符串化(通常不会!)。

toJSON() 返回的应该是一个适当的值,可以是任何类型,然后再由 JSON.stringify(..) 对其进行字符串化。

也就是说,toJSON() 应该 “返回一个能够被字符串化的安全的 JSON 值”,而不是 “返回一个 JSON 字符串”。比如:

var a = {
  val: [1, 2, 3],
  // 可能是我们想要的结果!
  toJSON: function() {
    return this.val.slice(1);
  },
};

var b = {
  val: [1, 2, 3],
  // 可能不是我们想要的结果!
  toJSON: function() {
    return "[" + this.val.slice(1).join() + "]";
  },
};

JSON.stringify(a); // "[2,3]"
JSON.stringify(b); // ""[2,3]""
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这里第二个函数是对 toJSON 返回的字符串做字符串化,而非数组本身。

JSON.stringify(..) 几个不太为人知但却非常有用的功能。

1. replacer

我们可以向 JSON.stringify(..) 传递一个可选参数 replacer,它可以是数组或者函数,用来指定对象序列化过程中哪些属性应该被处理,哪些应该被排除,和 toJSON() 很像

如果 replacer 是一个数组,那么它必须是一个字符串数组,其中包含序列化要处理的对象的属性名称,除此之外其他的属性则被忽略。

如果 replacer 是一个函数,它会对对象本身调用一次,然后对对象中的每个属性各调用一次,每次传递两个参数,键和值。如果要忽略某个键就返回 undefined,否则返回指定的值。

var a = {
  b: 42,
  c: "42",
  d: [1, 2, 3],
};

JSON.stringify(a, ["b", "c"]); // "{"b":42,"c":"42"}"

JSON.stringify(a, function(k, v) {
  if (k !== "c") return v;
}); // "{"b":42,"d":[1,2,3]}"
1
2
3
4
5
6
7
8
9
10
11

注意

如果 replacer 是函数,它的参数 k 在第一次调用时为 undefined(就是对对象本身调用的那次)。

if 语句将属性 "c" 排除掉。由于字符串化是递归的,因此数组 [1,2,3] 中的每个元素都会通过参数 v 传递给 replacer,即 1、2 和 3,参数 k 是它们的索引值,即 0、1 和 2。

2. space

JSON.stringify(..) 还有一个可选参数 space,用来指定输出的缩进格式

space 为正整数时是指定每一级缩进的字符数,它还可以是字符串,此时最前面的十个字符被用于每一级的缩进:

var a = {
  b: 42,
  c: "42",
  d: [1, 2, 3],
};

JSON.stringify(a, null, 3);
/*
"{
   "b": 42,
   "c": "42",
   "d": [
      1,
      2,
      3
   ]
}"
*/

JSON.stringify(a, null, "-----");
/*
"{
-----"b": 42,
-----"c": "42",
-----"d": [
----------1,
----------2,
----------3
-----]
}"
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

请记住,JSON.stringify(..) 并不是强制类型转换。在这里介绍是因为它涉及 ToString 强制类型转换,具体表现在以下两点。

  • 字符串、数字、布尔值和 null 的 JSON.stringify(..) 规则与 ToString 基本相同。

  • 如果传递给 JSON.stringify(..) 的对象中定义了 toJSON() 方法,那么该方法会在字符串化前调用,以便将对象转换为安全的 JSON 值。

# 📌 ToNumber

有时我们需要将非数字值当作数字来使用,比如数学运算。为此 ES5 规范在 9.3 节定义了抽象操作 ToNumber。

🔔 其中 true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。

ToNumber 对字符串的处理基本遵循数字常量的相关规则 / 语法。处理失败时返回 NaN(处理数字常量失败时会产生语法错误)。

不同之处是 ToNumber 对以 0 开头的十六进制数并不按十六进制处理(而是按十进制)。

对象(包括数组)会首先被转换为相应的基本类型值,如果返回的是非数字的基本类型值,则再遵循以上规则将其强制转换为数字。

🔔 为了将值转换为相应的基本类型值,抽象操作 ToPrimitive(参见 ES5 规范 9.1 节)会首先(通过内部操作 DefaultValue,参见 ES5 规范 8.12.8 节)检查该值是否有 valueOf() 方法。如果有并且返回基本类型值,就使用该值进行强制类型转换。如果没有就使用 toString() 的返回值(如果存在)来进行强制类型转换。

如果 valueOf() 和 toString() 均不返回基本类型值,会产生 TypeError 错误。

从 ES5 开始,使用 Object.create(null) 创建的对象 [[Prototype]] 属性为 null,并且没有 valueOf() 和 toString() 方法,因此无法进行强制类型转换

var a = {
  valueOf: function() {
    return "42";
  },
};
var b = {
  toString: function() {
    return "42";
  },
};
var c = [4, 2];
c.toString = function() {
  return this.join(""); // "42"
};
Number(a); // 42
Number(b); // 42
Number(c); // 42
Number(""); // 0
Number([]); // 0
Number(["abc"]); // NaN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 📌 ToBoolean

首先也是最重要的一点是,JavaScript 中有两个关键词 true 和 false,分别代表布尔类型中的真和假。我们常误以为数值 1 和 0 分别等同于 true 和 false。在有些语言中可能是这样,但在 JavaScript 中布尔值和数字是不一样的。虽然我们可以将 1 强制类型转换为 true, 将 0 强制类型转换为 false,反之亦然,但它们并不是一回事。

1. 假值(falsy value)

JavaScript 中的值可以分为以下两类:

  • 可以被强制类型转换为 false 的值。

  • 其他(被强制类型转换为 true 的值)。

JavaScript 规范具体定义了一小撮可以被强制类型转换为 false 的值。

ES5 规范 9.2 节中定义了抽象操作 ToBoolean,列举了布尔强制类型转换所有可能出现的结果。

以下这些是假值:

  • undefined

  • null

  • false

  • +0、-0 和 NaN

  • ""

假值的布尔强制类型转换结果为 false。

从逻辑上说,假值列表以外的都应该是真值(truthy)。但 JavaScript 规范对此并没有明确定义,只是给出了一些示例,例如规定所有的对象都是真值,我们可以理解为假值列表以外的值都是真值

2. 假值对象(falsy object)

有人可能会以为假值对象就是包装了假值的封装对象(如 ""、0 和 false),实际不然。比如:

var a = new Boolean(false);
var b = new Number(0);
var c = new String("");
1
2
3

它们都是封装了假值的对象。那它们究竟是 true 还是 false 呢?答案很简单:

var d = Boolean(a && b && c);
d; // true
1
2

d 为 true,说明 a、b、c 都为 true。

如果假值对象并非封装了假值的对象,那它究竟是什么?

值得注意的是,虽然 JavaScript 代码中会出现假值对象,但它实际上并不属于 JavaScript 语言的范畴

🔔 浏览器在某些特定情况下,在常规 JavaScript 语法基础上自己创建了一些外来(exotic)值,这些就是 “假值对象”。

假值对象看起来和普通对象并无二致(都有属性,等等),但将它们强制类型转换为布尔值时结果为 false。

最常见的例子是 document.all,它是一个类数组对象,包含了页面上的所有元素,由 DOM(而不是 JavaScript 引擎)提供给 JavaScript 程序使用。

document.all 并不是一个标准用法,早就被废止了。

3. 真值(truthy value)

真值就是假值列表之外的值。

比如:

var a = "false";
var b = "0";
var c = "''";
var d = Boolean(a && b && c);
d; // true
1
2
3
4
5
var a = [];
var b = {};
var c = function() {};
var d = Boolean(a && b && c);
d; // true
1
2
3
4
5

也就是说真值列表可以无限长,无法一一列举,所以我们只能用假值列表作为参考。

# 📘 显式强制类型转换

显式强制类型转换是那些显而易见的类型转换,很多类型转换都属于此列。

我们在编码时应尽可能地将类型转换表达清楚,以免给别人留坑。类型转换越清晰,代码可读性越高,更容易理解。

# 📌 字符串和数字之间的显式转换

字符串和数字之间的转换是通过 String(..)Number(..) 这两个内建函数(原生构造函数)来实现的,请注意它们前面没有 new 关键字,并不创建封装对象

比如:

var a = 42;
var b = String(a);

var c = "3.14";
var d = Number(c);

b; // "42"
d; // 3.14
1
2
3
4
5
6
7
8

String(..) 遵循前面讲过的 ToString 规则,将值转换为字符串基本类型。Number(..) 遵循前面讲过的 ToNumber 规则,将值转换为数字基本类型。

除了 String(..) 和 Number(..) 以外,还有其他方法可以实现字符串和数字之间的显式转换:

var a = 42;
var b = a.toString();

var c = "3.14";
var d = +c;

b; // "42"
d; // 3.14
1
2
3
4
5
6
7
8

a.toString() 是显式的(“toString”意为“to a string”),不过其中涉及隐式转换。因为 toString() 对 42 这样的基本类型值不适用,所以 JavaScript 引擎会自动为 42 创建一个封装对象,然后对该对象调用 toString()。这里显式转换中含有隐式转换。

+c 是 + 运算符的一元(unary)形式(即只有一个操作数)。+ 运算符显式地将 c 转换为数字,而非数字加法运算(也不是字符串拼接)。

不过这样有时候也容易产生误会。例如:

var c = "3.14";
var d = 5 + +c;

d; // 8.14
1
2
3
4

一元运算符 - 和 + 一样,并且它还会反转数字的符号位。由于 -- 会被当作递减运算符来处理,所以我们不能使用 -- 来撤销反转,而应该像 - -"3.14" 这样,在中间加一个空格,才能得到正确结果 3.14。

运算符的一元和二元形式的组合你也许能够想到很多种情况,下面是一个疯狂的例子:

1 + -+(+(+-+1)); // 2
1

尽量不要把一元运算符 +(还有 -)和其他运算符放在一起使用。上面的代码可以运行,但非常糟糕。此外 d = +c(还有 d =+ c)也容易和 d += c 搞混,两者天壤之别。

d = +c; // 3.14
d = +c; // 3.14
d += c; // "3.143.14"
1
2
3

我们的目的是让代码更清晰、更易懂,而非适得其反。

1. 日期显式转换为数字

一元运算符 + 的另一个常见用途是将日期(Date)对象强制类型转换为数字,返回结果为 Unix 时间戳,以微秒为单位(从 1970 年 1 月 1 日 00:00:00 UTC 到当前时间):

var d = new Date("Mon, 18 Aug 2014 08:53:06 CDT");
+d; // 1408369986000
1
2

我们常用下面的方法来获得当前的时间戳,例如:

var timestamp = +new Date();
1

提示

JavaScript 有一处奇特的语法,即构造函数没有参数时可以不用带 ()。于是我们可能会碰到 var timestamp = +new Date; 这样的写法。

将日期对象转换为时间戳并非只有强制类型转换这一种方法,或许使用更显式的方法会更好一些:

var timestamp = new Date().getTime();
var timestamp = new Date().getTime();
var timestamp = new Date().getTime();
1
2
3

不过最好还是使用 ES5 中新加入的静态方法 Date.now():

var timestamp = Date.now();
1

为老版本浏览器提供 Date.now() 的 polyfill 也很简单:

if (!Date.now) {
  Date.now = function() {
    return +new Date();
  };
}
1
2
3
4
5

注意

不建议对日期类型使用强制类型转换,应该使用 Date.now() 来获得当前的时间戳,使用 new Date(..).getTime() 来获得指定时间的时间戳。

2. 奇特的 ~ 运算符

~ 运算符是字位操作 “非”。字位运算符只适用于 32 位整数,运算符会强制操作数使用 32 位格式。这是通过抽象操作 ToInt32 来实现的(ES5 规范 9.5 节)。

ToInt32 首先执行 ToNumber 强制类型转换,比如 "123" 会先被转换为 123,然后再执行 ToInt32。

虽然严格说来并非强制类型转换(因为返回值类型并没有发生变化),但字位运算符(如 | 和 ~)和某些特殊数字一起使用时会产生类似强制类型转换的效果,返回另外一个数字。

例如 | 运算符(字位操作“或”)的空操作(no-op)0 | x,它仅执行 ToInt32 转换:

0 | -0; // 0
0 | NaN; // 0
0 | Infinity; // 0
0 | -Infinity; // 0
1
2
3
4

以上这些特殊数字无法以 32 位格式呈现(因为它们来自 64 位 IEEE 754 标准),因此 ToInt32 返回 0。

~ 首先将值强制类型转换为 32 位数字,然后执行字位操作“非”(对每一个字位进行反转)

~ 还可以有另外一种诠释,源自早期的计算机科学和离散数学:~ 返回 2 的补码

~x 大致等同于 -(x+1)。很奇怪,但相对更容易说明问题:

~42; // -(42+1) ==> -43
1

在 -(x+1) 中唯一能够得到 0(或者严格说是 -0)的 x 值是 -1。也就是说如果 x 为 -1 时,~ 和一些数字值在一起会返回假值 0,其他情况则返回真值。

~-1; // 0
1

-1 是一个“哨位值”,哨位值是那些在各个类型中(这里是数字)被赋予了特殊含义的值。在 C 语言中我们用 -1 来代表函数执行失败,用大于等于 0 的值来代表函数执行成功。

JavaScript 中字符串的 indexOf(..) 方法也遵循这一惯例,该方法在字符串中搜索指定的子字符串,如果找到就返回子字符串所在的位置(从 0 开始),否则返回 -1。

indexOf(..) 不仅能够得到子字符串的位置,还可以用来检查字符串中是否包含指定的子字符串,相当于一个条件判断。比如:

var a = "Hello World";
if (a.indexOf("lo") >= 0) {
  // true
  // 找到匹配!
}
if (a.indexOf("lo") !== -1) {
  // true
  // 找到匹配!
}
if (a.indexOf("ol") < 0) {
  // true
  // 没有找到匹配!
}
if (a.indexOf("ol") === -1) {
  // true
  // 没有找到匹配!
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

>= 0=== -1 这样的写法不是很好,称为“抽象渗漏”,意思是在代码中暴露了底层的实现细节,这里是指用 -1 作为失败时的返回值,这些细节应该被屏蔽掉。

到这里,终于明白 ~ 有什么用处了!~ 和 indexOf() 一起可以将结果强制类型转换(实际上仅仅是转换)为真/假值

var a = "Hello World";

~a.indexOf("lo"); // -4 <-- 真值!

if (~a.indexOf("lo")) {
  // true
  // 找到匹配!
}

~a.indexOf("ol"); // 0 <-- 假值!

!~a.indexOf("ol"); // true

if (!~a.indexOf("ol")) {
  // true
  // 没有找到匹配!
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

如果 indexOf(..) 返回 -1,~ 将其转换为假值 0,其他情况一律转换为真值。

注意

由 -(x+1) 推断 ~-1 的结果应该是 -0,然而实际上结果是 0,因为它是字位操作而非数学运算。

3. 字位截除

一些开发人员使用 ~~ 来截除数字值的小数部分,以为这和 Math.floor(..) 的效果一样,实际上并非如此。

~~ 中的第一个 ~ 执行 ToInt32 并反转字位,然后第二个 ~ 再进行一次字位反转,即将所有字位反转回原值,最后得到的仍然是 ToInt32 的结果。

对 ~~ 我们要多加注意。首先它只适用于 32 位数字,更重要的是它对负数的处理与 Math.floor(..) 不同。

Math.floor(-49.6); // -50
~~-49.6; // -49
1
2

~~x 能将值截除为一个 32 位整数,x | 0 也可以,而且看起来还更简洁。

出于对运算符优先级的考虑,我们可能更倾向于使用 ~~x:

~~1e20 / 10; // 166199296

1e20 | (0 / 10); // 1661992960
(1e20 | 0) / 10; // 166199296
1
2
3
4

我们在使用 ~ 和 ~~ 进行此类转换时需要确保其他人也能够看得懂。

# 📌 显式解析数字字符串

解析字符串中的数字和将字符串强制类型转换为数字的返回结果都是数字。但解析和转换两者之间还是有明显的差别。比如:

var a = "42";
var b = "42px";

Number(a); // 42
parseInt(a); // 42

Number(b); // NaN
parseInt(b); // 42
1
2
3
4
5
6
7
8

解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败并返回 NaN。

解析和转换之间不是相互替代的关系。它们虽然类似,但各有各的用途。如果字符串右边的非数字字符不影响结果,就可以使用解析。而转换要求字符串中所有的字符都是数字,像 "42px" 这样的字符串就不行。

解析字符串中的浮点数可以使用 parseFloat(..) 函数。

parseInt(..) 针对的是字符串值。向 parseInt(..) 传递数字和其他类型的参数是没有用的,比如 true、function(){...} 和 [1,2,3]。

非字符串参数会首先被强制类型转换为字符串,依赖这样的隐式强制类型转换并非上策,应该避免向 parseInt(..) 传递非字符串参数

ES5 之前的 parseInt(..) 有一个坑导致了很多 bug。即如果没有第二个参数来指定转换的基数(又称为 radix),parseInt(..) 会根据字符串的第一个字符来自行决定基数。

如果第一个字符是 x 或 X,则转换为十六进制数字。如果是 0,则转换为八进制数字。

x 和 X 开头的十六进制相对来说还不太容易搞错,而八进制则不然。例如:

var hour = parseInt(selectedHour.value);
var minute = parseInt(selectedMinute.value);
console.log("The time you selected was: " + hour + ":" + minute);
1
2
3

上面的代码看似没有问题,但是当小时为 08、分钟为 09 时,结果是 0:0,因为 8 和 9 都不是有效的八进制数。

将第二个参数设置为 10,即可避免这个问题:

var hour = parseInt(selectedHour.value, 10);
var minute = parseInt(selectedMiniute.value, 10);
1
2

从 ES5 开始 parseInt(..) 默认转换为十进制数,除非另外指定。如果你的代码需要在 ES5 之前的环境运行,请记得将第二个参数设置为 10。

解析非字符串

来看下面这个坑:

parseInt(1 / 0, 19); // 18
1

很多人想当然地以为(实际上大错特错)“如果第一个参数值为 Infinity,解析结果也应该是 Infinity”,然而并不是。

其中第一个错误是向 parseInt(..) 传递非字符串,这完全是在自找麻烦。此时 JavaScript 会将参数强制类型转换为它能够处理的字符串。

有人可能会觉得这不合理,parseInt(..) 应该拒绝接受非字符串参数。但如果这样的话,它是否应该抛出一个错误?这是 Java 的做法。一想到 JavaScript 代码中到处是抛出的错误,要在每个地方加上 try..catch,我整个人都不好了。

那是不是应该返回 NaN ?也许吧,但是下面的情况是否应该运行失败?

parseInt(new String("42"));
1

因为它的参数也是一个非字符串。如果你认为此时应该将 String 封装对象拆封(unbox)为 "42",那么将 42 先转换为 "42" 再解析回 42 不也合情合理吗?

这种半显式、半隐式的强制类型转换很多时候非常有用。例如:

var a = {
  num: 21,
  toString: function() {
    return String(this.num * 2);
  },
};
parseInt(a); // 42
1
2
3
4
5
6
7

parseInt(..) 先将参数强制类型转换为字符串再进行解析,这样做没有任何问题。因为传递错误的参数而得到错误的结果,并不能归咎于函数本身。

怎么来处理 Infinity(1/0 的结果)最合理呢?有两个选择:"Infinity" 和 "∞",JavaScript 选择的是 "Infinity"。

JavaScript 中所有的值都有一个默认的字符串形式,这很不错,能够方便我们调试。

再回到基数 19,这显然是个玩笑话,在实际的 JavaScript 代码中不会用到基数 19。它的有效数字字符范围是 0-9 和 a-i(区分大小写)

parseInt(1/0, 19) 实际上是 parseInt("Infinity", 19)。第一个字符是 "I",以 19 为基数时值为 18。第二个字符 "n" 不是一个有效的数字字符,解析到此为止,和 "42px" 中的 "p" 一样。

最后的结果是 18,而非 Infinity 或者报错。所以理解其中的工作原理对于我们学习 JavaScript 是非常重要的。

此外还有一些看起来奇怪但实际上解释得通的例子:

parseInt(0.000008); // 0 ("0" 来自于 "0.000008")
parseInt(0.0000008); // 8 ("8" 来自于 "8e-7")
parseInt(false, 16); // 250 ("fa" 来自于 "false")
parseInt(parseInt, 16); // 15 ("f" 来自于 "function..")
parseInt("0x10"); // 16
parseInt("103", 2); // 2
1
2
3
4
5
6

其实 parseInt(..) 函数是十分靠谱的,只要使用得当就不会有问题。因为使用不当而导致一些莫名其妙的结果,并不能归咎于 JavaScript 本身。

# 📌 显示转换为布尔值

与前面的 String(..) 和 Number(..) 一样,Boolean(..)(不带 new)是显式的 ToBoolean 强制类型转换:

var a = "0";
var b = [];
var c = {};
var d = "";
var e = 0;
var f = null;
var g;
Boolean(a); // true
Boolean(b); // true
Boolean(c); // true
Boolean(d); // false
Boolean(e); // false
Boolean(f); // false
Boolean(g); // false
1
2
3
4
5
6
7
8
9
10
11
12
13
14

虽然 Boolean(..) 是显式的,但并不常用。

和前面讲过的 + 类似,一元运算符 ! 显式地将值强制类型转换为布尔值。但是它同时还将真值反转为假值(或者将假值反转为真值)。所以显式强制类型转换为布尔值最常用的方法是 !!,因为第二个 ! 会将结果反转回原值:

var a = "0";
var b = [];
var c = {};
var d = "";
var e = 0;
var f = null;
var g;
!!a; // true
!!b; // true
!!c; // true
!!d; // false
!!e; // false
!!f; // false
!!g; // false
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在 if(..).. 这样的布尔值上下文中,如果没有使用 Boolean(..) 和 !!,就会自动隐式地进行 ToBoolean 转换。建议使用 Boolean(..) 和 !! 来进行显式转换以便让代码更清晰易读。

显式 ToBoolean 的另外一个用处,是在 JSON 序列化过程中将值强制类型转换为 true 或 false:

var a = [
  1,
  function() {
    /*..*/
  },
  2,
  function() {
    /*..*/
  },
];
JSON.stringify(a); // "[1,null,2,null]"
JSON.stringify(a, function(key, val) {
  if (typeof val == "function") {
    // 函数的ToBoolean强制类型转换
    return !!val;
  } else {
    return val;
  }
});
// "[1,true,2,true]"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

下面的语法对于熟悉 Java 的人并不陌生:

var a = 42;
var b = a ? true : false;
1
2

三元运算符 ? : 判断 a 是否为真,如果是则将变量 b 赋值为 true,否则赋值为 false。

表面上这是一个显式的 ToBoolean 强制类型转换,因为返回结果是 true 或者 false。

然而这里涉及隐式强制类型转换,因为 a 要首先被强制类型转换为布尔值才能进行条件判断。这种情况称为“显式的隐式”,有百害而无一益,我们应彻底杜绝。

建议使用 Boolean(a) 和 !!a 来进行显式强制类型转换。

# 📘 隐式强制类型转换

隐式强制类型转换指的是那些隐蔽的强制类型转换,副作用也不是很明显。换句话说,你自己觉得不够明显的强制类型转换都可以算作隐式强制类型转换

显式强制类型转换旨在让代码更加清晰易读,而隐式强制类型转换看起来就像是它的对立面,会让代码变得晦涩难懂。

对强制类型转换的诟病大多是针对隐式强制类型转换。

问题是,隐式强制类型转换真是如此不堪吗?它是不是 JavaScript 语言的设计缺陷?我们是否应该对其退避三舍?估计大多数人会回答“是的”。其实不然,请容我细细道来。

让我们从另一个角度来看待隐式强制类型转换,看看它究竟为何物、该如何使用,不要简单地把它当作“显式强制类型转换的对立面”,因为这样理解过于狭隘,忽略了它们之间一个细微却十分重要的区别。

隐式强制类型转换的作用是减少冗余,让代码更简洁。

# 📌 字符串和数字之间的隐式强制类型转换

通过重载,+ 运算符既能用于数字加法,也能用于字符串拼接。JavaScript 怎样来判断我们要执行的是哪个操作?例如:

var a = "42";
var b = "0";

var c = 42;
var d = 0;

a + b; // "420"
c + d; // 42
1
2
3
4
5
6
7
8

这里为什么会得到 "420" 和 42 两个不同的结果呢?通常的理解是,因为某一个或者两个操作数都是字符串,所以 + 执行的是字符串拼接操作。这样解释只对了一半,实际情况要复杂得多。

例如:

var a = [1, 2];
var b = [3, 4];

a + b; // "1,23,4"
1
2
3
4

a 和 b 都不是字符串,但是它们都被强制转换为字符串然后进行拼接。原因何在?

根据 ES5 规范 11.6.1 节,如果某个操作数是字符串或者能够通过以下步骤转换为字符串的话,+ 将进行拼接操作

如果其中一个操作数是对象(包括数组),则首先对其调用 ToPrimitive 抽象操作(规范 9.1 节),该抽象操作再调用 [[DefaultValue]](规范 8.12.8 节),以数字作为上下文。

你或许注意到这与 ToNumber 抽象操作处理对象的方式一样。因为数组的 valueOf() 操作无法得到简单基本类型值,于是它转而调用 toString()。因此上例中的两个数组变成了 "1,2" 和 "3,4"。+ 将它们拼接后返回 "1,23,4"。

注意

有一个坑常常会被提到,即 [] + {} 和 {} + [] 的结果不同。

[] + {}; // "[object Object]"

{
}
+[]; // 0
1
2
3
4
5

我们可以将数字和空字符串 "" 相 + 来将其转换为字符串:

var a = 42;
var b = a + "";

b; // "42"
1
2
3
4
  • 作为数字加法操作是可互换的,即 2 + 3 等同于 3 + 2。作为字符串拼接操作则不行,但对空字符串 "" 来说,a + "" 和 "" + a 结果一样。

a + ""(隐式)和前面的 String(a)(显式)之间有一个细微的差别需要注意。

根据 ToPrimitive 抽象操作规则,a + "" 会对 a 调用 valueOf() 方法,然后通过 ToString 抽象操作将返回值转换为字符串。而 String(a) 则是直接调用 ToString()

它们最后返回的都是字符串,但如果 a 是对象而非数字结果可能会不一样!

例如:

var a = {
  valueOf: function() {
    return 42;
  },
  toString: function() {
    return 4;
  },
};
a + ""; // "42"
String(a); // "4"
1
2
3
4
5
6
7
8
9
10

你一般不太可能会遇到这个问题,除非你的代码中真的有这些匪夷所思的数据结构和操作。在定制 valueOf() 和 toString() 方法时需要特别小心,因为这会影响强制类型转换的结果。

再来看看从字符串强制类型转换为数字的情况。

var a = "3.14";
var b = a - 0;

b; // 3.14
1
2
3
4

- 是数字减法运算符,因此 a - 0 会将 a 强制类型转换为数字。也可以使用 a * 1a / 1,因为这两个运算符也只适用于数字,只不过这样的用法不太常见。

对象的 - 操作与 + 类似:

var a = [3];
var b = [1];

a - b; // 2
1
2
3
4

为了执行减法运算,a 和 b 都需要被转换为数字,它们首先被转换为字符串(通过 toString()),然后再转换为数字。

# 📌 布尔值到数字的隐式强制类型转换

在将某些复杂的布尔逻辑转换为数字加法的时候,隐式强制类型转换能派上大用场。当然这种情况并不多见,属于特殊情况特殊处理。

例如:

function onlyOne(a, b, c) {
  return !!((a && !b && !c) || (!a && b && !c) || (!a && !b && c));
}

var a = true;
var b = false;

onlyOne(a, b, b); // true
onlyOne(b, a, b); // true
onlyOne(a, b, a); // false
1
2
3
4
5
6
7
8
9
10

如果其中有且仅有一个参数为 true,则 onlyOne(..) 返回 true。其在条件判断中使用了隐式强制类型转换,其他地方则是显式的,包括最后的返回值。

但如果有多个参数时(4 个、5 个,甚至 20 个),用上面的代码就很难处理了。

这时就可以使用从布尔值到数字(0 或 1)的强制类型转换:

function onlyOne() {
  var sum = 0;
  for (var i = 0; i < arguments.length; i++) {
    // 跳过假值,和处理0一样,但是避免了NaN
    if (arguments[i]) {
      sum += arguments[i];
    }
  }
  return sum == 1;
}

var a = true;
var b = false;

onlyOne(b, a); // true
onlyOne(b, a, b, b, b); // true
onlyOne(b, b); // false
onlyOne(b, a, b, b, b, a); // false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

通过 sum += arguments[i] 中的隐式强制类型转换,将真值(true/truthy)转换为 1 并进行累加。如果有且仅有一个参数为 true,则结果为 1;否则不等于 1,sum == 1 条件不成立。

同样的功能也可以通过显式强制类型转换来实现:

function onlyOne() {
  var sum = 0;
  for (var i = 0; i < arguments.length; i++) {
    sum += Number(!!arguments[i]);
  }
  return sum === 1;
}
1
2
3
4
5
6
7

!!arguments[i] 首先将参数转换为 true 或 false。因此非布尔值参数在这里也是可以的,比如:onlyOne("42", 0)(否则的话,字符串会执行拼接操作,这样结果就不对了)。

转换为布尔值以后,再通过 Number(..) 显式强制类型转换为 0 或 1。

这里使用显式强制类型转换会不会更好一些?注释里说这样的确能够避免 NaN 带来的问题,不过最终是看我们自己的需要。我个人觉得前者,即隐式强制类型转换,更为简洁(前提是不会传递 undefined 和 NaN 这样的值),而显式强制类型转换则会带来一些代码冗余。

无论使用隐式还是显式,我们都能通过修改 onlyTwo(..) 或者 onlyFive(..) 来处理更复杂的情况,只需要将最后的条件判断从 1 改为 2 或 5。这比加入一大堆 && 和 || 表达式简洁得多。所以强制类型转换在这里还是很有用的。

# 📌 隐式强制类型转换为布尔值

现在我们来看看到布尔值的隐式强制类型转换,它最为常见也最容易搞错。

相对布尔值,数字和字符串操作中的隐式强制类型转换还算比较明显。下面的情况会发生布尔值隐式强制类型转换。

  • if (..) 语句中的条件判断表达式。

  • for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。

  • while (..) 和 do..while(..) 循环中的条件判断表达式。

  • ? : 中的条件判断表达式。

  • 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。

以上情况中,非布尔值会被隐式强制类型转换为布尔值,遵循前面介绍过的 ToBoolean 抽象操作规则。

例如:

var a = 42;
var b = "abc";
var c;
var d = null;

if (a) {
  console.log("yep"); // yep
}
while (c) {
  console.log("nope, never runs");
}

c = d ? a : b;
c; // "abc"

if ((a && d) || c) {
  console.log("yep"); // yep
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 📌 || 和 &&

逻辑运算符 ||(或)和 &&(与)和其他语言不同,在 JavaScript 中它们返回的并不是布尔值。

它们的返回值是两个操作数中的一个(且仅一个)。即选择两个操作数中的一个,然后返回它的值。

因此,或许称它们为“选择器运算符”(selector operators)或者“操作数选择器运算符”(operand selector operators)更恰当些。

ES5 规范 11.11 节

&& 和 || 运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

例如:

var a = 42;
var b = "abc";
var c = null;

a || b; // 42
a && b; // "abc"

c || b; // "abc"
c && b; // null
1
2
3
4
5
6
7
8
9

在 C 和 PHP 中,上例的结果是 true 或 false,在 JavaScript(以及 Python 和 Ruby)中却是某个操作数的值。

|| 和 && 首先会对第一个操作数(a 和 c)执行条件判断,如果其不是布尔值(如上例)就先进行 ToBoolean 强制类型转换,然后再执行条件判断。

对于 || 来说,如果条件判断结果为 true 就返回第一个操作数(a 和 c)的值,如果为 false 就返回第二个操作数(b)的值。

&& 则相反,如果条件判断结果为 true 就返回第二个操作数(b)的值,如果为 false 就返回第一个操作数(a 和 c)的值。

换一个角度来理解:

a || b;
// 大致相当于(roughly equivalent to):
a ? a : b;

a && b;
// 大致相当于(roughly equivalent to):
a ? b : a;
1
2
3
4
5
6
7

之所以说大致相当,是因为它们返回结果虽然相同但是却有一个细微的差别。

在 a ? a : b 中,如果 a 是一个复杂一些的表达式(比如有副作用的函数调用等),它有可能被执行两次(如果第一次结果为真)。而在 a || b 中 a 只执行一次,其结果用于条件判断和返回结果(如果适用的话)。

a && b 和 a ? b : a 也是如此。

下面是一个十分常见的 || 的用法,也许你已经用过但并未完全理解:

function foo(a, b) {
  a = a || "hello";
  b = b || "world";
  console.log(a + " " + b);
}

foo(); // "hello world"
foo("yeah", "yeah!"); // "yeah yeah!"
1
2
3
4
5
6
7
8

a = a || "hello"(又称为 C# 的 “空值合并运算符” 的 JavaScript 版本)检查变量 a,如果还未赋值(或者为假值),就赋予它一个默认值("hello")。

这里需要注意!

foo("That’s it!", ""); // "That’s it! world"
1

第二个参数 "" 是一个假值,因此 b = b || "world" 条件不成立,返回默认值 "world"。

这种用法很常见,但是其中不能有假值,除非加上更明确的条件判断,或者转而使用 ? : 三元表达式。

通过这种方式来设置默认值很方便。

再来看看 &&。

有一种用法对开发人员不常见,然而 JavaScript 代码压缩工具常用。就是如果第一个操作数为真值,则 && 运算符“选择”第二个操作数作为返回值,这也叫作“守护运算符”(guard operator),即前面的表达式为后面的表达式“把关”:

function foo() {
  console.log(a);
}
var a = 42;
a && foo(); // 42
1
2
3
4
5

foo() 只有在条件判断 a 通过时才会被调用。如果条件判断未通过,a && foo() 就会悄然终止(也叫作“短路”,short circuiting),foo() 不会被调用。

你大概会有疑问:既然返回的不是 true 和 false,为什么 a && (b || c) 这样的表达式在 if 和 for 中没出过问题?

这或许并不是代码的问题,问题在于你可能不知道这些条件判断表达式最后还会执行布尔值的隐式强制类型转换。

例如:

var a = 42;
var b = null;
var c = "foo";
if (a && (b || c)) {
  console.log("yep");
}
1
2
3
4
5
6

这里 a && (b || c) 的结果实际上是 "foo" 而非 true,然后再由 if 将 foo 强制类型转换为布尔值,所以最后结果为 true。

如果要避免隐式强制类型转换就得这样:

if (!!a && (!!b || !!c)) {
  console.log("yep");
}
1
2
3

# 📌 符号的强制类型转换

目前我们介绍的显式和隐式强制类型转换结果是一样的,它们之间的差异仅仅体现在代码可读性方面。

但 ES6 中引入了符号类型,它的强制类型转换有一个坑,在这里有必要提一下。

ES6 允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误。

例如:

var s1 = Symbol("cool");
String(s1); // "Symbol(cool)"

var s2 = Symbol("not cool");
s2 + ""; // TypeError
1
2
3
4
5

符号不能够被强制类型转换为数字(显式和隐式都会产生错误),但可以被强制类型转换为布尔值(显式和隐式结果都是 true)。

由于规则缺乏一致性,我们要对 ES6 中符号的强制类型转换多加小心。

# 📘 宽松相等和严格相等

宽松相等(loose equals)== 和严格相等(strict equals)=== 都用来判断两个值是否“相等”,但是它们之间有一个很重要的区别,特别是在判断条件上。

常见的误区是“== 检查值是否相等,=== 检查值和类型是否相等”。听起来蛮有道理,然而还不够准确。很多 JavaScript 的书籍和博客也是这样来解释的,但是很遗憾他们都错了。

正确的解释是:

== 允许在相等比较中进行强制类型转换,而 === 不允许。

# 📌 相等比较操作的性能

我们来看一看两种解释的区别。

根据第一种解释(不准确的版本),=== 似乎比 == 做的事情更多,因为它还要检查值的类型。第二种解释中 == 的工作量更大一些,因为如果值的类型不同还需要进行强制类型转换。

有人觉得 == 会比 === 慢,实际上虽然强制类型转换确实要多花点时间,但仅仅是微秒级(百万分之一秒)的差别而已。

如果进行比较的两个值类型相同,则 == 和 === 使用相同的算法,所以除了 JavaScript 引擎实现上的细微差别之外,它们之间并没有什么不同。

如果两个值的类型不同,我们就需要考虑有没有强制类型转换的必要,有就用 ==,没有就用 ===,不用在乎性能。

注意

== 和 === 都会检查操作数的类型。区别在于操作数类型不同时它们的处理方式不同。

# 📌 抽象相等

ES5 规范 11.9.3 节的“抽象相等比较算法”定义了 == 运算符的行为。该算法简单而又全面,涵盖了所有可能出现的类型组合,以及它们进行强制类型转换的方式。

其中第一段(11.9.3.1)规定如果两个值的类型相同,就仅比较它们是否相等。例如,42 等于 42,"abc" 等于 "abc"。

注意

  • NaN 不等于 NaN

  • +0 等于 -0

11.9.3.1 的最后定义了对象(包括函数和数组)的宽松相等 ==。两个对象指向同一个值时即视为相等,不发生强制类型转换

=== 的定义和 11.9.3.1 一样,包括对象的情况。实际上在比较两个对象的时候,== 和 === 的工作原理是一样的。

11.9.3 节中还规定,== 在比较两个不同类型的值时会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较

宽松不相等(loose not-equality)!= 就是 == 的相反值,!== 同理。

👉 1. 字符串和数字之间的相等比较

var a = 42;
var b = "42";

a === b; // false
a == b; // true
1
2
3
4
5

因为没有强制类型转换,所以 a === b 为 false,42 和 "42" 不相等。

而 a == b 是宽松相等,即如果两个值的类型不同,则对其中之一或两者都进行强制类型转换。

具体怎么转换?是 a 从 42 转换为字符串,还是 b 从 "42" 转换为数字?

ES5 规范 11.9.3.4-5 这样定义:

  • 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。

  • 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果。

根据规范,"42" 应该被强制类型转换为数字以便进行相等比较。本例中两个值相等,均为 42。

👉 2. 其他类型和布尔类型之间的相等比较

== 最容易出错的一个地方是 true 和 false 与其他类型之间的相等比较。

例如:

var a = "42";
var b = true;

a == b; // false
1
2
3
4

"42" 是一个真值,为什么 == 的结果不是 true 呢?

原因既简单又复杂,让人很容易掉坑里,很多 JavaScript 开发人员对这个地方并未引起足够的重视。

规范 11.9.3.6-7 是这样说的:

  • 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果;

  • 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。

仔细分析例子,首先:

var x = true;
var y = "42";

x == y; // false
1
2
3
4

Type(x) 是布尔值,所以 ToNumber(x) 将 true 强制类型转换为 1,变成 1 == "42",二者的类型仍然不同,"42" 根据规则被强制类型转换为 42,最后变成 1 == 42,结果为 false。

反过来也一样:

var x = "42";
var y = false;

x == y; // false
1
2
3
4

Type(y) 是布尔值,所以 ToNumber(y) 将 false 强制类型转换为 0,然后 "42" == 0 再变成 42 == 0,结果为 false。

也就是说,字符串 "42" 既不等于 true,也不等于 false。一个值怎么可以既非真值也非假值,这也太奇怪了吧?

这个问题本身就是错误的,我们被自己的大脑欺骗了。

"42" 是一个真值没错,但 "42" == true 中并没有发生布尔值的比较和强制类型转换。这里不是 "42" 转换为布尔值(true),而是 true 转换为 1,"42" 转换为 42。

这里并不涉及 ToBoolean,所以 "42" 是真值还是假值与 == 本身没有关系!

重点是我们要搞清楚 == 对不同的类型组合怎样处理。== 两边的布尔值会被强制类型转换为数字。

很奇怪吧?我个人建议无论什么情况下都不要使用 == true 和 == false。

请注意,这里说的只是 ==,=== true 和 === false 不允许强制类型转换,所以并不涉及 ToNumber。

例如:

var a = "42";

// 不要这样用,条件判断不成立:
if (a == true) {
  // ..
}

// 也不要这样用,条件判断不成立:
if (a === true) {
  // ..
}

// 这样的显式用法没问题:
if (a) {
  // ..
}

// 这样的显式用法更好:
if (!!a) {
  // ..
}

// 这样的显式用法也很好:
if (Boolean(a)) {
  // ..
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

避免了 == true 和 == false(也叫作布尔值的宽松相等)之后我们就不用担心这些坑了。

👉 3. null 和 undefined 之间的相等比较

null 和 undefined 之间的 == 也涉及隐式强制类型转换。

ES5 规范 11.9.3.2-3 规定:

  • 如果 x 为 null,y 为 undefined,则结果为 true。

  • 如果 x 为 undefined,y 为 null,则结果为 true。

在 == 中 null 和 undefined 相等(它们也与其自身相等),除此之外其他值都不存在这种情况。

这也就是说在 == 中 null 和 undefined 是一回事,可以相互进行隐式强制类型转换:

var a = null;
var b;

a == b; // true
a == null; // true
b == null; // true
a == false; // false
b == false; // false
a == ""; // false
b == ""; // false
a == 0; // false
b == 0; // false
1
2
3
4
5
6
7
8
9
10
11
12

null 和 undefined 之间的强制类型转换是安全可靠的。个人认为通过这种方式将 null 和 undefined 作为等价值来处理比较好。

例如:

var a = doSomething();
if (a == null) {
  // ..
}
1
2
3
4

条件判断 a == null 仅在 doSomething() 返回非 null 和 undefined 时才成立,除此之外其他值都不成立,包括 0、false 和 "" 这样的假值。

下面是显式的做法,其中不涉及强制类型转换,个人感觉更繁琐一些(大概执行效率也会更低):

var a = doSomething();
if (a === undefined || a === null) {
  // ..
}
1
2
3
4

我认为 a == null 这样的隐式强制类型转换在保证安全性的同时还能提高代码可读性。

👉 4. 对象和非对象之间的相等比较

关于对象(对象 / 函数 / 数组)和标量基本类型(字符串 / 数字 / 布尔值)之间的相等比较,ES5 规范 11.9.3.8-9 做如下规定:

  • 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果;

  • 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPrimitive(x) == y 的结果。

这里只提到了字符串和数字,没有布尔值。原因是我们之前介绍过 11.9.3.6-7 中规定了布尔值会先被强制类型转换为数字。

例如:

var a = 42;
var b = [42];
a == b; // true
1
2
3

[42] 首先调用 ToPrimitive 抽象操作,返回 "42",变成 "42" == 42,然后又变成 42 == 42,最后二者相等。

之前介绍过的 ToPrimitive 抽象操作的所有特性(如 toString()、valueOf())在这里都适用。如果我们需要自定义 valueOf() 以便从复杂的数据结构返回一个简单值进行相等比较,这些特性会很有帮助。

我们介绍过“拆封”,即“打开”封装对象(如 new String("abc")),返回其中的基本数据类型值("abc")。

== 中的 ToPromitive 强制类型转换也会发生这样的情况:

var a = "abc";
var b = Object(a); // 和 new String(a) 一样

a === b; // false
a == b; // true
1
2
3
4
5

a == b 结果为 true,因为 b 通过 ToPrimitive 进行强制类型转换(也称为“拆封”,英文为 unboxed 或者 unwrapped),并返回标量基本类型值 "abc",与 a 相等。

但有一些值不这样,原因是 == 算法中其他优先级更高的规则。例如:

var a = null;
var b = Object(a); // 和 Object() 一样
a == b; // false

var c = undefined;
var d = Object(c); // 和 Object() 一样
c == d; // false

var e = NaN;
var f = Object(e); // 和 new Number(e) 一样
e == f; // false
1
2
3
4
5
6
7
8
9
10
11

因为没有对应的封装对象,所以 null 和 undefined 不能够被封装(boxed),Object(null) 和 Object() 均返回一个常规对象。

NaN 能够被封装为数字封装对象,但拆封之后 NaN == NaN 返回 false,因为 NaN 不等于 NaN。

# 📌 比较少见的情况

首先来看看更改内置原生原型会导致哪些奇怪的结果。

👉 1. 返回其他数字

Number.prototype.valueOf = function() {
  return 3;
};
new Number(2) == 3; // true
1
2
3
4

2 == 3 不会有这种问题,因为 2 和 3 都是数字基本类型值,不会调用 Number.prototype.valueOf() 方法。而 Number(2) 涉及 ToPrimitive 强制类型转换,因此会调用 valueOf()。

还有更奇怪的情况:

if (a == 2 && a == 3) {
  // ..
}
1
2
3

你也许觉得这不可能,因为 a 不会同时等于 2 和 3。但“同时”一词并不准确,因为 a == 2 在 a == 3 之前执行。

如果让 a.valueOf() 每次调用都产生副作用,比如第一次返回 2,第二次返回 3,就会出现这样的情况。这实现起来很简单:

var i = 2;
Number.prototype.valueOf = function() {
  return i++;
};
var a = new Number(42);
if (a == 2 && a == 3) {
  console.log("Yep, this happened.");
}
1
2
3
4
5
6
7
8

👉 2. 假值的相等比较

== 中的隐式强制类型转换最为人诟病的地方是假值的相等比较。

下面分别列出了常规和非常规的情况:

"0" == null; // false
"0" == undefined; // false
"0" == false; // true -- 晕!
"0" == NaN; // false
"0" == 0; // true
"0" == ""; // false

false == null; // false
false == undefined; // false
false == NaN; // false
false == 0; // true -- 晕!
false == ""; // true -- 晕!
false == []; // true -- 晕!
false == {}; // false

"" == null; // false
"" == undefined; // false
"" == NaN; // false
"" == 0; // true -- 晕!
"" == []; // true -- 晕!
"" == {}; // false

0 == null; // false
0 == undefined; // false
0 == NaN; // false
0 == []; // true -- 晕!
0 == {}; // false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

以上 24 种情况中有 17 种比较好理解。比如我们都知道 "" 和 NaN 不相等,"0" 和 0 相等。

👉 3. 极端情况

这还不算完,还有更极端的例子:

[] == ![]; // true
1

让我们看看 ! 运算符都做了些什么?根据 ToBoolean 规则,它会进行布尔值的显式强制类型转换(同时反转奇偶校验位)。所以 [] == ![] 变成了 [] == false。前面我们讲过 false == [],最后的结果就顺理成章了。

再来看看其他情况:

2 == [2]; // true
"" == [null]; // true
1
2

介绍 ToNumber 时我们讲过,== 右边的值 [2] 和 [null] 会进行 ToPrimitive 强制类型转换,以便能够和左边的基本类型值(2 和 "")进行比较。因为数组的 valueOf() 返回数组本身,所以强制类型转换过程中数组会进行字符串化。

第一行中的 [2] 会转换为 "2",然后通过 ToNumber 转换为 2。第二行中的 [null] 会直接转换为 ""。

还有一个坑常常被提到:

0 == "\n"; // true
1

前面介绍过,""、"\n"(或者 " " 等其他空格组合)等空字符串被 ToNumber 强制类型转换为 0。

与前面 24 种情况列表相对应的是下面这个列表:

42 == "43"; // false
"foo" == 42; // false
"true" == true; // false

42 == "42"; // true
"foo" == ["foo"]; // true
1
2
3
4
5
6

👉 4. 完整性检查

再来看看上面提到过的相等比较中的强制类型转换的 7 个坑:

"0" == false; // true -- 晕!
false == 0; // true -- 晕!
false == ""; // true -- 晕!
false == []; // true -- 晕!
"" == 0; // true -- 晕!
"" == []; // true -- 晕!
0 == []; // true -- 晕!
1
2
3
4
5
6
7

其中有 4 种情况涉及 == false,之前我们说过应该避免,应该不难掌握。

现在剩下 3 种:

"" == 0; // true -- 晕!
"" == []; // true -- 晕!
0 == []; // true -- 晕!
1
2
3

正常情况下我们应该不会这样来写代码。我们应该不太可能会用 == [] 来做条件判断,而是用 == "" 或者 == 0,如:

function doSomething(a) {
  if (a == "") {
    // ..
  }
}
1
2
3
4
5

如果不小心碰到 doSomething(0) 和 doSomething([]) 这样的情况,结果会让你大吃一惊。

又如:

function doSomething(a, b) {
  if (a == b) {
    // ..
  }
}
1
2
3
4
5

doSomething("",0) 和 doSomething([],"") 也会如此。

这些特殊情况会导致各种问题,我们要多加小心,好在它们并不十分常见。

👉 5. 安全运用隐式强制类型转换

我们要对 == 两边的值认真推敲,以下两个原则可以让我们有效地避免出错。

  • 如果两边的值中有 true 或者 false,千万不要使用 ==。

  • 如果两边的值中有 []、"" 或者 0,尽量不要使用 ==。

这时最好用 === 来避免不经意的强制类型转换。这两个原则可以让我们避开几乎所有强制类型转换的坑。

这种情况下强制类型转换越显式越好,能省去很多麻烦。

所以 == 和 === 选择哪一个取决于是否允许在相等比较中发生强制类型转换。

有一种情况下强制类型转换是绝对安全的,那就是 typeof 操作。typeof 总是返回七个字符串之一(参见第 1 章),其中没有空字符串。所以在类型检查过程中不会发生隐式强制类型转换。typeof x == "function" 是 100% 安全的,和 typeof x === "function" 一样。事实上两者在规范中是一回事。所以既不要盲目听命于代码工具每一处都用 ===,更不要对这个问题置若罔闻。我们要对自己的代码负责。

Alex Dorey(GitHub 用户名 @dorey)在 GitHub 上制作了一张图表,列出了各种相等比较的情况,如下图所示。

jsunknow

# 📘 抽象关系比较

a < b 中涉及的隐式强制类型转换不太引人注意,不过还是很有必要深入了解一下。

ES5 规范 11.8.5 节定义了“抽象关系比较”(abstract relational comparison),分为两个部分:比较双方都是字符串(后半部分)和其他情况(前半部分)。

该算法仅针对 a < b,a=""> b 会被处理为 b <>。

比较双方首先调用 ToPrimitive,如果结果出现非字符串,就根据 ToNumber 规则将双方强制类型转换为数字来进行比较。

例如:

var a = [42];
var b = ["43"];

a < b; // true
b < a; // false
1
2
3
4
5

前面介绍过的 -0 和 NaN 的相关规则在这里也适用。

如果比较双方都是字符串,则按字母顺序来进行比较:

var a = ["42"];
var b = ["043"];

a < b; // false
1
2
3
4

a 和 b 并没有被转换为数字,因为 ToPrimitive 返回的是字符串,所以这里比较的是 "42" 和 "043" 两个字符串,它们分别以 "4" 和 "0" 开头。因为 "0" 在字母顺序上小于 "4",所以最后结果为 false。

同理:

var a = [4, 2];
var b = [0, 4, 3];

a < b; // false
1
2
3
4

a 转换为 "4, 2",b 转换为 "0, 4, 3",同样是按字母顺序进行比较。

再比如:

var a = { b: 42 };
var b = { b: 43 };

a < b; // ??
1
2
3
4

结果还是 false,因为 a 是 [object Object],b 也是 [object Object],所以按照字母顺序 a < b 并不成立。

下面的例子就有些奇怪了:

var a = { b: 42 };
var b = { b: 43 };

a < b; // false
a == b; // false
a > b; // false

a <= b; // true
a >= b; // true
1
2
3
4
5
6
7
8
9

为什么 a == b 的结果不是 true ?它们的字符串值相同(同为 "[object Object]"),按道理应该相等才对?实际上不是这样,你可以回忆一下前面讲过的对象的相等比较。

但是如果 a < b 和 a == b 结果为 false,为什么 a <= b 和 a >= b 的结果会是 true 呢?

因为根据规范 a <= b 被处理为 b < a,然后将结果反转。因为 b < a 的结果是 false,所以 a <= b 的结果是 true。

这可能与我们设想的大相径庭,即 <= 应该是“小于或者等于”。实际上 JavaScript 中 <= 是“不大于”的意思(即 !(a > b),处理为 !(b < a))。同理 a >= b 处理为 b <= a。

相等比较有严格相等,关系比较却没有“严格关系比较”(strict relational comparison)。也就是说如果要避免 a < b 中发生隐式强制类型转换,我们只能确保 a 和 b 为相同的类型,除此之外别无他法

与 == 和 === 的完整性检查一样,我们应该在必要和安全的情况下使用强制类型转换,如:42 < "43"。换句话说就是为了保证安全,应该对关系比较中的值进行显式强制类型转换:

var a = [42];
var b = "043";

a < b; // false -- 字符串比较!
Number(a) < Number(b); // true -- 数字比较!
1
2
3
4
5

# 📘 小结

本章介绍了 JavaScript 的数据类型之间的转换,即强制类型转换:包括显式和隐式。

强制类型转换常常为人诟病,但实际上很多时候它们是非常有用的。作为有使命感的 JavaScript 开发人员,我们有必要深入了解强制类型转换,这样就能取其精华,去其糟粕。

显式强制类型转换明确告诉我们哪里发生了类型转换,有助于提高代码可读性和可维护性。

隐式强制类型转换则没有那么明显,是其他操作的副作用。感觉上好像是显式强制类型转换的反面,实际上隐式强制类型转换也有助于提高代码的可读性。

在处理强制类型转换的时候要十分小心,尤其是隐式强制类型转换。在编码的时候,要知其然,还要知其所以然,并努力让代码清晰易读。

# 📚 语法

JavaScript 语法定义了词法规则(syntax rule,如运算符和关键词等)是如何构成可运行的程序代码的。换句话说,只看词法不看语法会遗漏掉很多重要的细节。

# 📘 语句和表达式

JavaScript 中表达式可以返回一个结果值。例如:

var a = 3 * 6;
var b = a;
b;
1
2
3

这里,3 * 6 是一个表达式(结果为 18)。第二行的 a 也是一个表达式,第三行的 b 也是。表达式 a 和 b 的结果值都是 18。

这三行代码都是包含表达式的语句。var a = 3 _ 6 和 var b = a 称为 “声明语句”(declaration statement),因为它们声明了变量(还可以为其赋值)。a = 3 _ 6 和 b = a(不带 var)叫作 “赋值表达式”

第三行代码中只有一个表达式 b,同时它也是一个语句(虽然没有太大意义)。这样的情况通常叫作 “表达式语句”(expression statement)。

# 📌 语句的结果值

很多人不知道,语句都有一个结果值(statement completion value,undefined 也算)

获得结果值最直接的方法是在浏览器开发控制台中输入语句,默认情况下控制台会显示所执行的最后一条语句的结果值。

以赋值表达式 b = a 为例,其结果值是赋给 b 的值(18),但规范定义 var 的结果值是 undefined。如果在控制台中输入 var a = 42 会得到结果值 undefined,而非 42。

如果你用开发控制台(或者 JavaScript REPL——read/evaluate/print/loop 工具)调试过代码,应该会看到很多语句的返回值显示为 undefined,只是你可能从未探究过其中的原因。其实控制台中显示的就是语句的结果值。

但我们在代码中是没有办法获得这个结果值的,具体解决方法比较复杂,首先得弄清楚为什么要获得语句的结果值。

先来看看其他语句的结果值。比如代码块 { .. } 的结果值是其最后一个语句 / 表达式的结果。

例如:

var b;
if (true) {
  b = 4 + 38;
}
1
2
3
4

在控制台 /REPL 中输入以上代码应该会显示 42,即最后一个语句 / 表达式 b = 4 + 38 的结果值。

换句话说,代码块的结果值就如同一个隐式的返回,即返回最后一个语句的结果值

但下面这样的代码无法运行:

var a, b;
a = if (true) {
  b = 4 + 38;
};
1
2
3
4

因为语法不允许我们获得语句的结果值并将其赋值给另一个变量(至少目前不行)

那应该怎样获得语句的结果值呢?

注意

以下代码仅为演示,切勿在实际开发中这样操作!

可以使用万恶的 eval(..)(又读作“evil”)来获得结果值:

var a, b;
a = eval("if (true) { b = 4 + 38; }");
a; // 42
1
2
3

这并不是个好办法,但确实管用。

ES7 规范有一项“do 表达式”(do expression)提案,类似下面这样:

var a, b;
a = do {
  if (true) {
    b = 4 + 38;
  }
};
a; // 42
1
2
3
4
5
6
7

上例中,do { .. } 表达式执行一个代码块(包含一个或多个语句),并且返回其中最后一个语句的结果值,然后赋值给变量 a。

其目的是将语句当作表达式来处理(语句中可以包含其他语句),从而不需要将语句封装为函数再调用 return 来返回值。

# 📌 表达式的副作用

大部分表达式没有副作用。例如:

var a = 2;
var b = a + 3;
1
2

表达式 a + 3 本身没有副作用(比如改变 a 的值)。它的结果值为 5,通过 b = a + 3 赋值给变量 b。

最常见的有副作用(也可能没有)的表达式是函数调用:

function foo() {
  a = a + 1;
}
var a = 1;
foo(); // 结果值:undefined。副作用:a的值被改变
1
2
3
4
5

其他一些表达式也有副作用,比如:

var a = 42;
var b = a++;
1
2

a++ 首先返回变量 a 的当前值 42(再将该值赋给 b),然后将 a 的值加 1:

var a = 42;
var b = a++;

a; // 43
b; // 42
1
2
3
4
5
var a = 42;

a++; // 42
a; // 43

++a; // 44
a; // 44
1
2
3
4
5
6
7

++ 在前面时,如 ++a,它的副作用(将 a 递增)产生在表达式返回结果值之前,而 a++ 的副作用则产生在之后。

++a++ 会产生 ReferenceError 错误,因为运算符需要将产生的副作用赋值给一个变量。以 ++a++ 为例,它首先执行 a++(根据运算符优先级,如下),返回 42,然后执行 ++42,这时会产生 ReferenceError 错误,因为 ++ 无法直接在 42 这样的值上产生副作用。

常有人误以为可以用括号 ( ) 将 a++ 的副作用封装起来,例如:

var a = 42;
var b = a++;

a; // 43
b; // 42
1
2
3
4
5

事实并非如此。( ) 本身并不是一个封装表达式,不会在表达式 a++ 产生副作用之后执行。即便可以,a++ 会首先返回 42,除非有表达式在 ++ 之后再次对 a 进行运算,否则还是不会得到 43,也就不能将 43 赋值给 b。

但也不是没有办法,可以使用 , 语句系列逗号运算符(statement-series comma operator)将多个独立的表达式语句串联成一个语句:

var a = 42,
  b;
b = (a++, a);

a; // 43
b; // 43
1
2
3
4
5
6

a++, a 中第二个表达式 a 在 a++ 之后执行,结果为 43,并被赋值给 b。

再如 delete 运算符。delete 用来删除对象中的属性和数组中的单元。它通常以单独一个语句的形式出现:

var obj = {
  a: 42,
};

obj.a; // 42
delete obj.a; // true
obj.a; // undefined
1
2
3
4
5
6
7

如果操作成功,delete 返回 true,否则返回 false。其副作用是属性被从对象中删除(或者单元从 array 中删除)。

操作成功是指对于那些不存在或者存在且可配置(configurable,参见《你不知道的 JavaScript(上卷)》的“this 和对象原型”部分的第 3 章)的属性,delete 返回 true,否则返回 false 或者报错。

另一个有趣的例子是 = 赋值运算符。

例如:

var a;

a = 42; // 42
a; // 42
1
2
3
4

a = 42 中的 = 运算符看起来没有副作用,实际上它的结果值是 42,它的副作用是将 42 赋值给 a。

组合赋值运算符,如 += 和 -= 等也是如此。例如,a = b += 2 首先执行 b += 2(即 b = b + 2),然后结果再被赋值给 a。

多个赋值语句串联时(链式赋值,chained assignment),赋值表达式(和语句)的结果值就能派上用场,比如:

var a, b, c;
a = b = c = 42;
1
2

这里 c = 42 的结果值为 42(副作用是将 c 赋值 42),然后 b = 42 的结果值为 42(副作用是将 b 赋值 42),最后是 a = 42(副作用是将 a 赋值 42)。

注意

链式赋值常常被误用,例如 var a = b = 42,看似和前面的例子差不多,实则不然。如果变量 b 没有在作用域中象 var b 这样声明过,则 var a = b = 42 不会对变量 b 进行声明。在严格模式中这样会产生错误,或者会无意中创建一个全局变量(参见《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分)。

另一个需要注意的问题是:

function vowels(str) {
  var matches;
  if (str) {
    // 提取所有元音字母
    matches = str.match(/[aeiou]/g);
    if (matches) {
      return matches;
    }
  }
}
vowels("Hello World"); // ["e","o","o"]
1
2
3
4
5
6
7
8
9
10
11

上面的代码没问题,很多开发人员也喜欢这样做。其实我们可以利用赋值语句的副作用将两个 if 语句合二为一:

function vowels(str) {
  var matches;
  // 提取所有元音字母
  if (str && (matches = str.match(/[aeiou]/g))) {
    return matches;
  }
}
vowels("Hello World"); // ["e","o","o"]
1
2
3
4
5
6
7
8

# 📌 上下文规则

在 JavaScript 语法规则中,有时候同样的语法在不同的情况下会有不同的解释。这些语法规则孤立起来会很难理解。

下面介绍一些常见情况。

👉 1. 大括号

下面两种情况会用到大括号 { .. }(随着 JavaScript 的演进会出现更多类似的情况)。

(1) 对象常量

用大括号定义对象常量(object literal):

// 假定函数 bar() 已经定义
var a = {
  foo: bar(),
};
1
2
3
4

{ .. } 被赋值给 a,因而它是一个对象常量。

a 是赋值的对象,称为“左值”(l-value)。{ .. } 是所赋的值(即本例中赋给变量 a 的值),称为“右值”(r-value)。

(2) 标签

如果将上例中的 var a = 去掉会发生什么情况呢?

// 假定函数 bar() 已经定义
{
  foo: bar();
}
1
2
3
4

很多开发人员以为这里的 { .. } 只是一个孤立的对象常量,没有赋值。事实上不是这样。

{ .. } 在这里只是一个普通的代码块。JavaScript 中这种情况并不多见(在其他语言中则常见得多),但语法上是完全合法的,特别是和 let(块作用域声明)在一起时非常有用(参见《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分)。

{ .. } 和 for/while 循环以及 if 条件语句中代码块的作用基本相同。

foo: bar() 这样奇怪的语法为什么也合法呢?

这里涉及 JavaScript 中一个不太为人知(也不建议使用)的特性,叫作 “标签语句”(labeled statement)。foo 是语句 bar() 的标签(后面没有 ;)。

标签语句具体是做什么用的呢?

如果 JavaScript 有 goto 语句,理论上我们可以使用 goto foo 跳转到 foo 处执行。goto 被公认为是一种极为糟糕的编码方式,它会让代码变得晦涩难懂(也叫作 spaghetti code),好在 JavaScript 不支持 goto。

然而 JavaScript 通过标签跳转能够实现 goto 的部分功能。continue 和 break 语句都可以带一个标签,因此能够像 goto 那样进行跳转。例如:

// 标签为foo的循环
foo: for (var i = 0; i < 4; i++) {
  for (var j = 0; j < 4; j++) {
    // 如果j和i相等,继续外层循环
    if (j == i) {
      // 跳转到foo的下一个循环
      continue foo;
    }
    // 跳过奇数结果
    if ((j * i) % 2 == 1) {
      // 继续内层循环(没有标签的)
      continue;
    }
    console.log(i, j);
  }
}
// 1 0
// 2 0
// 2 1
// 3 0
// 3 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

contine foo 并不是指“跳转到标签 foo 所在位置继续执行”,而是“执行 foo 循环的下一轮循环”。所以这里的 foo 并非 goto。

上例中 continue 跳过了循环 3 1,continue foo(带标签的循环跳转,labeled-loop jump)跳过了循环 1 1 和 2 2。

带标签的循环跳转一个更大的用处在于,和 break 一起使用可以实现从内层循环跳转到外层循环。没有它们的话实现起来有时会非常麻烦:

// 标签为foo的循环
foo: for (var i = 0; i < 4; i++) {
  for (var j = 0; j < 4; j++) {
    if (i * j >= 3) {
      console.log("stopping!", i, j);
      break foo;
    }
    console.log(i, j);
  }
}
// 0 0
// 0 1
// 0 2
// 0 3
// 1 0
// 1 1
// 1 2
// stopping! 1 3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

break foo 不是指“跳转到标签 foo 所在位置继续执行”,而是“跳出标签 foo 所在的循环 / 代码块,继续执行后面的代码”。因此它并非传统意义上的 goto。

上例中如果使用不带标签的 break,就可能需要用到一两个函数调用和共享作用域的变量等,这样代码会更难懂,使用带标签的 break 可能更好一些。

标签也能用于非循环代码块,但只有 break 才可以。我们可以对带标签的代码块使用 break,但是不能对带标签的非循环代码块使用 continue,也不能对不带标签的代码块使用 break:

// 标签为bar的代码块
function foo() {
  bar: {
    console.log("Hello");
    break bar;
    console.log("never runs");
  }
  console.log("World");
}
foo();
// Hello
// World
1
2
3
4
5
6
7
8
9
10
11
12

注意

带标签的循环 / 代码块十分少见,也不建议使用。例如,循环跳转也可以通过函数调用来实现。不过在某些情况下它们也能派上用场,这时请务必将注释写清楚!

👉 2. 代码块

还有一个坑常被提到(涉及强制类型转换,参见第 4 章):

[] + {}; // "[object Object]"
{} + []; // 0
1
2

表面上看 + 运算符根据第一个操作数([] 或 {})的不同会产生不同的结果,实则不然。

第一行代码中,{} 出现在 + 运算符表达式中,因此它被当作一个值(空对象)来处理。第 4 章讲过 [] 会被强制类型转换为 "",而 {} 会被强制类型转换为 "[object Object]"。

但在第二行代码中,{} 被当作一个独立的空代码块(不执行任何操作)。代码块结尾不需要分号,所以这里不存在语法上的问题。最后 + [] 将 [] 显式强制类型转换(参见第 4 章)为 0。

👉 3. 对象解构

从 ES6 开始,{ .. } 也可用于“解构赋值”(destructuring assignment),特别是对象的解构。例如:

function getData() {
  // ..
  return {
    a: 42,
    b: "foo",
  };
}
var { a, b } = getData();
console.log(a, b); // 42 "foo"
1
2
3
4
5
6
7
8
9

{ a , b } = .. 就是 ES6 中的解构赋值,相当于下面的代码:

var res = getData();
var a = res.a;
var b = res.b;
1
2
3

{ .. } 还可以用作函数命名参数(named function argument)的对象解构(object destructuring),方便隐式地用对象属性赋值:

function foo({ a, b, c }) {
  // 不再需要这样:
  // var a = obj.a, b = obj.b, c = obj.c
  console.log(a, b, c);
}
foo({
  c: [1,2,3],
  a: 42,
  b: "foo"
}); // 42 "foo" [1, 2, 3]
1
2
3
4
5
6
7
8
9
10

在不同的上下文中 { .. } 的作用不尽相同,这也是词法和语法的区别所在。掌握这些细节有助于我们了解 JavaScript 引擎解析代码的方式。

👉 4. else if 和可选代码块

很多人误以为 JavaScript 中有 else if,因为我们可以这样来写代码:

if (a) {
  // ..
}
else if (b) {
  // ..
}
else {
  // ..
}
1
2
3
4
5
6
7
8
9

事实上 JavaScript 没有 else if,但 if 和 else 只包含单条语句的时候可以省略代码块的 { }。下面的代码你一定不会陌生:

if (a) doSomething(a);
1

很多 JavaScript 代码检查工具建议对单条语句也应该加上 { },如:

if (a) { doSomething(a); }
1

else 也是如此,所以我们经常用到的 else if 实际上是这样的:

if (a) {
  // ..
}
else {
  if (b) {
    // ..
  }
  else {
    // ..
  }
}
1
2
3
4
5
6
7
8
9
10
11

if (b) { .. } else { .. } 实际上是跟在 else 后面的一个单独的语句,所以带不带 { } 都可以。

换句话说,else if 不符合前面介绍的编码规范,else 中是一个单独的 if 语句。

注意

else if 极为常见,能省掉一层代码缩进,所以很受青睐。但这只是我们自己发明的用法,切勿想当然地认为这些都属于 JavaScript 语法的范畴。

# 📘 运算符优先级

第 4 章中介绍过,JavaScript 中的 && 和 || 运算符返回它们其中一个操作数的值,而非 true 或 false。在一个运算符两个操作数的情况下这比较好理解:

var a = 42;
var b = "foo";

a && b; // "foo"
a || b; // 42
1
2
3
4
5

那么两个运算符三个操作数呢?

var a = 42;
var b = "foo";
var c = [1,2,3];

a && b || c; // ???
a || b && c; // ???
1
2
3
4
5
6

想知道结果就需要了解超过一个运算符时表达式的执行顺序。

这些规则被称为 “运算符优先级”(operator precedence)。

回顾前面的例子:

var a = 42, b;
b = (a++, a);

a; // 43
b; // 43
1
2
3
4
5

如果去掉 ( ) 会出现什么情况?

var a = 42, b;
b = a++, a;

a; // 43
b; // 42
1
2
3
4
5

为什么上面两个例子中 b 的值会不一样?

原因是 , 运算符的优先级比 = 低。所以 b = a++, a 其实可以理解为 (b = a++), a。前面说过 a++ 有后续副作用(after side effect),所以 b 的值是 ++ 对 a 做递增之前的值 42。

这只是一个简单的例子。请务必记住,用 , 来连接一系列语句的时候,它的优先级最低,其他操作数的优先级都比它高。

回顾前面的一个例子:

if (str && (matches = str.match( /[aeiou]/g ))) {
  // ..
}
1
2
3

这里对赋值语句使用 ( ) 是必要的,因为 && 运算符的优先级高于 =,如果没有 ( ) 对其中的表达式进行绑定(bind)的话,就会执行作 (str && matches) = str.match..。这样会出错,由于 (str && matches) 的结果并不是一个变量,而是一个 undefined 值,因此它不能出现在 = 运算符的左边!

下面再来看一个更复杂的例子:

var a = 42;
var b = "foo";
var c = false;

var d = a && b || c ? c || b ? a : c && b : a;

d; // ??
1
2
3
4
5
6
7

上例的结果是 42,当然只要运行一下代码就能够知道答案,但是弄明白其中的来龙去脉更有意思。

首先我们要搞清楚 (a && b || c) 执行的是 (a && b) || c 还是 a && (b || c) ?它们之间有什么区别?

(false && true) || true; // true
false && (true || true); // false
1
2

事实证明它们是有区别的,false && true || true 的执行顺序如下:

false && true || true; // true
(false && true) || true; // true
1
2

&& 先执行,然后是 ||。

那执行顺序是否就一定是从左到右呢?不妨将运算符颠倒一下看看:

true || false && false; // true

(true || false) && false; // false
true || (false && false); // true
1
2
3
4

这说明 && 运算符先于 || 执行,而且执行顺序并非我们所设想的从左到右。原因就在于运算符优先级。

每门语言都有自己的运算符优先级。遗憾的是,对 JavaScript 运算符优先级有深入了解的开发人员并不多。

遗憾的是,JavaScript 规范对运算符优先级并没有一个集中的介绍,因此我们需要从语法规则中间逐一了解。下面列出一些常见并且有用的优先级规则,以方便查阅。

完整列表请参见:运算符优先级 (opens new window)

# 📌 短路

对 && 和 || 来说,如果从左边的操作数能够得出结果,就可以忽略右边的操作数。我们将这种现象称为 “短路”(即执行最短路径)

以 a && b 为例,如果 a 是一个假值,足以决定 && 的结果,就没有必要再判断 b 的值。同样对于 a || b,如果 a 是一个真值,也足以决定 || 的结果,也就没有必要再判断 b 的值。

“短路”很方便,也很常用,如:

function doSomething(opts) {
  if (opts && opts.cool) {
    // ..
  }
}
1
2
3
4
5

opts && opts.cool 中的 opts 条件判断如同一道安全保护,因为如果 opts 未赋值(或者不是一个对象),表达式 opts.cool 会出错。通过使用短路特性,opts 条件判断未通过时 opts.cool 就不会执行,也就不会产生错误!

|| 运算符也一样:

function doSomething(opts) {
  if (opts.cache || primeCache()) {
    // ..
  }
}
1
2
3
4
5

这里首先判断 opts.cache 是否存在,如果是则无需调用 primeCache() 函数,这样可以避免执行不必要的代码。

# 📌 更强的绑定

回顾一下前面多个运算符串联在一起的例子:

a && b || c ? c || b ? a : c && b : a
1

其中 ? : 运算符的优先级比 && 和 || 高还是低呢?执行顺序是这样?

a && b || (c ? c || (b ? a : c) && b : a)
1

还是这样?

(a && b || c) ? (c || b) ? a : (c && b) : a
1

答案是后者。因为 && 运算符的优先级高于 ||,而 || 的优先级又高于 ? :。

因此表达式 (a && b || c) 先于包含它的 ? : 运算符执行。另一种说法是 && 和 || 比 ? : 的绑定更强。反过来,如果 c ? c... 的绑定更强,执行顺序就会变成 a && b || (c ? c..)。

# 📌 关联

&& 和 || 运算符先于 ? : 执行,那么如果多个相同优先级的运算符同时出现,又该如何处理呢?它们的执行顺序是从左到右还是从右到左?

一般说来,运算符的关联(associativity)不是从左到右就是从右到左,这取决于组合(grouping)是从左开始还是从右开始。

请注意:关联和执行顺序不是一回事。

但它为什么又和执行顺序相关呢?原因是表达式可能会产生副作用,比如函数调用:

var a = foo() && bar();
1

这里 foo() 首先执行,它的返回结果决定了 bar() 是否执行。所以如果 bar() 在 foo() 之前执行,整个结果会完全不同。

这里遵循从左到右的顺序(JavaScript 的默认执行顺序),与 && 的关联无关。因为上例中只有一个 && 运算符,所以不涉及组合和关联。

而 a && b && c 这样的表达式就涉及组合(隐式),这意味着 a && b 或 b && c 会先执行。

从技术角度来说,因为 && 运算符是左关联(|| 也是),所以 a && b && c 会被处理为 (a && b) && c。不过右关联 a && (b && c) 的结果也一样。

注意

如果 && 是右关联的话会被处理为 a && (b && c)。但这并不意味着 c 会在 b 之前执行。右关联不是指从右往左执行,而是指从右往左组合。任何时候,不论是组合还是关联,严格的执行顺序都应该是从左到右,a,b,然后 c。

所以,&& 和 || 运算符是不是左关联这个问题本身并不重要,只要对此有一个准确的定义即可。

但情况并非总是这样。一些运算符在左关联和右关联时的表现截然不同。

比如 ? :(即三元运算符或者条件运算符):

a ? b : c ? d : e;
1

? : 是右关联,它的组合顺序是以下哪一种呢?

a ? b : (c ? d : e)

(a ? b : c) ? d : e
1
2
3

答案是 a ? b : (c ? d : e)。和 && 以及 || 运算符不同,右关联在这里会影响返回结果,因为 (a ? b : c) ? d : e 对有些值(并非所有值)的处理方式会有所不同。

例如:

true ? false : true ? true : true; // false

true ? false : (true ? true : true); // false
(true ? false : true) ? true : true; // true
1
2
3
4

在某些情况下,返回的结果没有区别,但其中却有十分微妙的差别。例如:

true ? false : true ? true : false; // false

true ? false : (true ? true : false); // false
(true ? false : true) ? true : false; // false
1
2
3
4

这里返回的结果一样,运算符组合看似没起什么作用。然而实际情况是:

var a = true, b = false, c = true, d = true, e = false;

a ? b : (c ? d : e); // false, 执行 a 和 b
(a ? b : c) ? d : e; // false, 执行 a, b 和 e
1
2
3
4

这里我们可以看出,? : 是右关联,并且它的组合方式会影响返回结果。

另一个右关联(组合)的例子是 = 运算符。本章前面介绍过一个串联赋值的例子:

var a, b, c;
a = b = c = 42;
1
2

它首先执行 c = 42,然后是 b = ..,最后是 a = ..。因为是右关联,所以它实际上是这样来处理的:a = (b = (c = 42))。

再看看本章前面那个更为复杂的赋值表达式的例子:

var a = 42;
var b = "foo";
var c = false;

var d = a && b || c ? c || b ? a : c && b : a;

d; // 42
1
2
3
4
5
6
7

掌握了优先级和关联等相关知识之后,就能够根据组合规则将上面的代码分解如下:

((a && b) || c) ? ((c || b) ? a : (c && b)) : a
1

也可以通过缩进显式让代码更容易理解:

(
  (a && b)
    ||
  c
)
  ?
(
  (c || b)
    ?
  a
    :
  (c && b)
)
  :
a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

现在来逐一执行。

(1) (a && b) 结果为 "foo"。

(2) "foo" || c 结果为 "foo"。

(3) 第一个 ? 中,"foo" 为真值。

(4) (c || b) 结果为 "foo"。

(5) 第二个 ? 中,"foo" 为真值。

(6) a 的值为 42。

因此,最后结果为 42。

# 📘 自动分号

有时 JavaScript 会自动为代码行补上缺失的分号,即自动分号插入(Automatic Semicolon Insertion,ASI)。

因为如果缺失了必要的 ;,代码将无法运行,语言的容错性也会降低。ASI 能让我们忽略那些不必要的 ;。

请注意,ASI 只在换行符处起作用,而不会在代码行的中间插入分号。

如果 JavaScript 解析器发现代码行可能因为缺失分号而导致错误,那么它就会自动补上分号。并且,只有在代码行末尾与换行符之间除了空格和注释之外没有别的内容时,它才会这样做

例如:

var a = 42, b
c;
1
2

如果 b 和 c 之间出现 a , 的话(即使另起一行),c 会被作为 var 语句的一部分来处理。在上例中,JavaScript 判断 b 之后应该有 ;,所以 c; 被处理为一个独立的表达式语句。

又比如:

var a = 42, b = "foo";

a
b // "foo"
1
2
3
4

上述代码同样合法,不会产生错误,因为 ASI 也适用于表达式语句。

ASI 在某些情况下很有用,比如:

var a = 42;

do {
  // ..
} while (a) // <-- 这里应该有;
a;
1
2
3
4
5
6

语法规定 do..while 循环后面必须带 ;,而 while 和 for 循环后则不需要。大多数开发人员都不记得这一点,此时 ASI 就会自动补上分号。

本章前面讲过,语句代码块结尾不用带 ;,所以不需要用到 ASI:

var a = 42;
while (a) {
  // ..
} // <-- 这里可以没有;
a;
1
2
3
4
5

其他涉及 ASI 的情况是 break、continue、return 和 yield(ES6)等关键字:

function foo(a) {
  if (!a) return
  a *= 2;
  // ..
}
1
2
3
4
5

由于 ASI 会在 return 后面自动加上 ;,所以这里 return 语句并不包括第二行的 a *= 2。

return 语句的跨度可以是多行,但是其后必须有换行符以外的代码:

function foo(a) {
  return (
    a * 2 + 3 / 12
  );
}
1
2
3
4
5

上述规则对 break、continue 和 yield 也同样适用。

# 📘 错误

JavaScript 不仅有各种类型的运行时错误(TypeError、ReferenceError、SyntaxError 等),它的语法中也定义了一些编译时错误。

在编译阶段发现的代码错误叫作 “早期错误”(early error)。语法错误是早期错误的一种(如 a = ,)。另外,语法正确但不符合语法规则的情况也存在。

这些错误在代码执行之前是无法用 try..catch 来捕获的,相反,它们还会导致解析 / 编译失败。

注意

规范没有明确规定浏览器(和开发工具)应该如何处理报错,因此下面的报错处理(包括错误类型和错误信息)在不同的浏览器中可能会有所不同。

举个简单的例子:正则表达式常量中的语法。这里 JavaScript 语法没有问题,但非法的正则表达式也会产生早期错误:

var a = /+foo/; // Uncaught SyntaxError: Invalid regular expression: /+foo/: Nothing to repeat
1

语法规定赋值对象必须是一个标识符(identifier,或者 ES6 中的解构表达式),因此下面的 42 会报错:

var a;
42 = a; // Uncaught SyntaxError: Invalid left-hand side in assignment
1
2

ES5 规范的严格模式定义了很多早期错误。比如在严格模式中,函数的参数不能重名:

function foo(a,b,a) { } // 没问题

function bar(a,b,a) { "use strict"; } // Uncaught SyntaxError: Duplicate parameter name not allowed in this context
1
2
3

再如,对象常量不能包含多个同名属性:

(function(){
  "use strict";
  var a = {
    b: 42,
    b: 43
  }; // 错误!
})();
1
2
3
4
5
6
7

从语义角度来说,这些错误并非词法错误,而是语法错误,因为它们在词法上是正确的。只不过由于没有 GrammarError 类型,一些浏览器选择用 SyntaxError 来代替。

# 📌 提前使用变量

ES6 规范定义了一个新概念,叫作 TDZ(Temporal Dead Zone,暂时性死区)。

TDZ 指的是由于代码中的变量还没有初始化而不能被引用的情况。

对此,最直观的例子是 ES6 规范中的 let 块作用域:

{
  a = 2; // ReferenceError!
  let a;
}
1
2
3
4

a = 2 试图在 let a 初始化 a 之前使用该变量(其作用域在 { .. } 内),这里就是 a 的 TDZ,会产生错误。

有意思的是,对未声明变量使用 typeof 不会产生错误(参见第 1 章),但在 TDZ 中却会报错:

{
  typeof a; // undefined
  typeof b; // ReferenceError! (TDZ)
  let b;
}
1
2
3
4
5

# 📘 函数参数

另一个 TDZ 违规的例子是 ES6 中的参数默认值(参见本系列的《你不知道的 JavaScript(下卷)》的“ES6 & Beyond”部分):

var b = 3;

function foo(a = 42, b = a + b + 5) {
  // ..
}
1
2
3
4
5

b = a + b + 5 在参数 b(= 右边的 b,而不是函数外的那个)的 TDZ 中访问 b,所以会出错。而访问 a 却没有问题,因为此时刚好跨出了参数 a 的 TDZ。

在 ES6 中,如果参数被省略或者值为 undefined,则取该参数的默认值:

function foo(a = 42, b = a + 1) {
  console.log(a, b);
}

foo(); // 42 43
foo(undefined); // 42 43
foo(5); // 5 6
foo(void 0, 7); // 42 7
foo(null); // null 1
1
2
3
4
5
6
7
8
9

表达式 a + 1 中 null 被强制类型转换为 0。

对 ES6 中的参数默认值而言,参数被省略或被赋值为 undefined 效果都一样,都是取该参数的默认值。然而某些情况下,它们之间还是有区别的:

function foo(a = 42, b = a + 1) {
  console.log(
    arguments.length, a, b,
    arguments[0], arguments[1]
  );
}
foo(); // 0 42 43 undefined undefined
foo(10); // 1 10 11 10 undefined
foo(10, undefined); // 2 10 11 10 undefined
foo(10, null); // 2 10 null 10 null
1
2
3
4
5
6
7
8
9
10

虽然参数 a 和 b 都有默认值,但是函数不带参数时,arguments 数组为空。

相反,如果向函数传递 undefined 值,则 arguments 数组中会出现一个值为 undefined 的单元,而不是默认值。

ES6 参数默认值会导致 arguments 数组和相对应的命名参数之间出现偏差,ES5 也会出现这种情况:

function foo(a) {
  a = 42;
  console.log(arguments[0]);
}

foo(2); // 42 (linked)
foo(); // undefined (not linked)
1
2
3
4
5
6
7

向函数传递参数时,arguments 数组中的对应单元会和命名参数建立关联(linkage)以得到相同的值。相反,不传递参数就不会建立关联。

但是,在严格模式中并没有建立关联这一说:

function foo(a) {
  "use strict";
  a = 42;
  console.log(arguments[0]);
}

foo(2); // 2 (not linked)
foo(); // undefined (not linked)
1
2
3
4
5
6
7
8

因此,在开发中不要依赖这种关联机制。实际上,它是 JavaScript 语言引擎底层实现的一个抽象泄漏(leaky abstraction),并不是语言本身的特性。

arguments 数组已经被废止(特别是在 ES6 引入剩余参数 ... 之后,参见本系列的《你不知道的 JavaScript(下卷)》的“ES6 & Beyond”部分),不过它并非一无是处。

ES6 之前,获得函数所有参数的唯一途径就是 arguments 数组。此外,即使将命名参数和 arguments 数组混用也不会出错,只需遵守一个原则,即不要同时访问命名参数和其对应的 arguments 数组单元。

function foo(a) {
 console.log(a + arguments[1]); // 安全!
}
foo(10, 32); // 42
1
2
3
4

# 📘 try..finally

finally 中的代码总是会在 try 之后执行,如果有 catch 的话则在 catch 之后执行。也可以将 finally 中的代码看作一个回调函数,即无论出现什么情况最后一定会被调用。

如果 try 中有 return 语句会出现什么情况呢? return 会返回一个值,那么调用该函数并得到返回值的代码是在 finally 之前还是之后执行呢?

function foo() {
  try {
    return 42;
  }
  finally {
    console.log("Hello");
  }
  console.log("never runs");
}
console.log(foo());
// Hello
// 42
1
2
3
4
5
6
7
8
9
10
11
12

这里 return 42 先执行,并将 foo() 函数的返回值设置为 42。然后 try 执行完毕,接着执行 finally。最后 foo() 函数执行完毕,console.log(..) 显示返回值。

try 中的 throw 也是如此:

function foo() {
  try {
    throw 42;
  }
  finally {
    console.log("Hello");
  }
  console.log("never runs");
}
console.log(foo());
// Hello
// Uncaught Exception: 42
1
2
3
4
5
6
7
8
9
10
11
12

如果 finally 中抛出异常(无论是有意还是无意),函数就会在此处终止。如果此前 try 中已经有 return 设置了返回值,则该值会被丢弃

function foo() {
  try {
    return 42;
  }
  finally {
    throw "Oops!";
  }
  console.log(4"never runs");
}
console.log(foo());
// Uncaught Exception: Oops!
1
2
3
4
5
6
7
8
9
10
11

continue 和 break 等控制语句也是如此:

for (var i = 0; i < 10; i++) {
  try {
    continue;
  }
  finally {
    console.log(i);
  }
}
// 0 1 2 3 4 5 6 7 8 9
1
2
3
4
5
6
7
8
9

continue 在每次循环之后,会在 i++ 执行之前执行 console.log(i),所以结果是 0..9 而非 1..10。

ES6 中新加入了 yield(参见本书的“异步和性能”部分),可以将其视为 return 的中间版本。然而与 return 不同的是,yield 在 generator(ES6 的另一个新特性)重新开始时才结束,这意味着 try { .. yield .. } 并未结束,因此 finally 不会在 yield 之后立即执行。

finally 中的 return 会覆盖 try 和 catch 中 return 的返回值

function foo() {
  try {
    return 42;
  }
  finally {
    // 没有返回语句,所以没有覆盖
  }
}
function bar() {
 try {
  return 42;
 }
 finally {
  // 覆盖前面的 return 42
  return;
 }
}
function baz() {
 try {
  return 42;
 }
 finally {
  // 覆盖前面的 return 42
  return "Hello";
 }
}
foo(); // 42
bar(); // undefined
baz(); // Hello
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

通常来说,在函数中省略 return 的结果和 return; 及 return undefined; 是一样的,但是在 finally 中省略 return 则会返回前面的 return 设定的返回值。

事实上,还可以将 finally 和带标签的 break 混合使用(参见 5.1.3 节)。例如:

function foo() {
  bar: {
    try {
      return 42;
    }
    finally {
      // 跳出标签为bar的代码块
      break bar;
    }
  }
  console.log("Crazy");
  return "Hello";
}
console.log(foo());
// Crazy
// Hello
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

但切勿这样操作。利用 finally 加带标签的 break 来跳过 return 只会让代码变得晦涩难懂,即使加上注释也是如此。

# 📘 switch

现在来简单介绍一下 switch,可以把它看作 if..else if..else.. 的简化版本:

switch (a) {
  case 2:
    // 执行一些代码
    break;
  case 42:
    // 执行另外一些代码
    break;
  default:
    // 执行缺省代码
}
1
2
3
4
5
6
7
8
9
10

这里 a 与 case 表达式逐一进行比较。如果匹配就执行该 case 中的代码,直到 break 或者 switch 代码块结束。

这看似并无特别之处,但其中存在一些不太为人所知的陷阱。

首先,a 和 case 表达式的匹配算法与 ===(参见第 4 章)相同。通常 case 语句中的 switch 都是简单值,所以这并没有问题。

然而,有时可能会需要通过强制类型转换来进行相等比较(即 ==,参见第 4 章),这时就需要做一些特殊处理:

var a = "42";
switch (true) {
  case a == 10:
    console.log("10 or '10'");
    break;
  case a == 42;
    console.log("42 or '42'");
    break;
  default:
    // 永远执行不到这里
}
// 42 or '42'
1
2
3
4
5
6
7
8
9
10
11
12

除简单值以外,case 中还可以出现各种表达式,它会将表达式的结果值和 true 进行比较。因为 a == 42 的结果为 true,所以条件成立。

尽管可以使用 ==,但 switch 中 true 和 true 之间仍然是严格相等比较。即如果 case 表达式的结果为真值,但不是严格意义上的 true(参见第 4 章),则条件不成立。所以,在这里使用 || 和 && 等逻辑运算符就很容易掉进坑里

var a = "hello world";
var b = 10;
switch (true) {
  case (a || b == 10):
    // 永远执行不到这里
    break;
  default:
    console.log("Oops");
}
// Oops
1
2
3
4
5
6
7
8
9
10

因为 (a || b == 10) 的结果是 "hello world" 而非 true,所以严格相等比较不成立。此时可以通过强制表达式返回 true 或 false,如 case !!(a || b == 10):(参见第 4 章)。

最后,default 是可选的,并非必不可少(虽然惯例如此)。break 相关规则对 default 仍然适用:

var a = 10;
switch (a) {
  case 1:
  case 2:
    // 永远执行不到这里
  default:
    console.log("default");
  case 3:
    console.log("3");
    break;
  case 4:
    console.log("4");
}
// default
// 3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

上例中的代码是这样执行的,首先遍历并找到所有匹配的 case,如果没有匹配则执行 default 中的代码。因为其中没有 break,所以继续执行已经遍历过的 case 3 代码块,直到 break 为止。

理论上来说,这种情况在 JavaScript 中是可能出现的,但在实际情况中,开发人员一般不会这样来编码。如果确实需要这样做,就应该仔细斟酌并做好注释。

# 📘 小结

JavaScript 语法规则中的许多细节需要我们多花点时间和精力来了解。从长远来看,这有助于更深入地掌握这门语言。

JavaScript 语法规则之上是语义规则(也称作上下文)。例如,{ } 在不同情况下的意思不尽相同,可以是语句块、对象常量、解构赋值(ES6)或者命名函数参数(ES6)。

JavaScript 详细定义了运算符的优先级(运算符执行的先后顺序)和关联(多个运算符的组合方式)。只要熟练掌握了这些规则,就能对如何合理地运用它们作出自己的判断。

ASI(自动分号插入)是 JavaScript 引擎的代码解析纠错机制,它会在需要的地方自动插入分号来纠正解析错误。问题在于这是否意味着大多数的分号都不是必要的(可以省略),或者由于分号缺失导致的错误是否都可以交给 JavaScript 引擎来处理。

JavaScript 中有很多错误类型,分为两大类:早期错误(编译时错误,无法被捕获)运行时错误(可以通过 try..catch 来捕获)所有语法错误都是早期错误,程序有语法错误则无法运行

函数参数和命名参数之间的关系非常微妙。尤其是 arguments 数组,它的抽象泄漏给我们挖了不少坑。因此,尽量不要使用 arguments,如果非用不可,也切勿同时使用 arguments 和其对应的命名参数

finally 中代码的处理顺序需要特别注意。它们有时能派上很大用场,但也容易引起困惑,特别是在和带标签的代码块混用时。总之,使用 finally 旨在让代码更加简洁易读,切忌弄巧成拙。

switch 相对于 if..else if.. 来说更为简洁。需要注意的一点是,如果对其理解得不够透彻,稍不注意就很容易出错。

# 第二部分 异步和性能

# 📚 异步:现在与将来

使用像 JavaScript 这样的语言编程时,很重要但常常被误解的一点是,如何表达和控制持续一段时间的程序行为。

这不仅仅是指从 for 循环开始到结束的过程,当然这也需要持续一段时间(几微秒或几毫秒)才能完成。它是指程序的一部分现在运行,而另一部分则在将来运行——现在和将来之间有段间隙,在这段间隙中,程序没有活跃执行。

事实上,程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

# 📘 分块的程序

可以把 JavaScript 程序写在单个 .js 文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。

大多数 JavaScript 新手程序员都会遇到的问题是:程序中将来执行的部分并不一定在现在运行的部分执行完之后就立即执行。换句话说,现在无法完成的任务将会异步完成,因此并不会出现人们本能地认为会出现的或希望出现的阻塞行为。

例如:

// ajax(..) 是某个库中提供的某个 Ajax 函数
var data = ajax("http://some.url.1");

console.log(data);
// data 通常不会包含 Ajax 结果
1
2
3
4
5

标准 Ajax 请求不是同步完成的,这意味着 ajax(..) 函数还没有返回任何值可以赋给变量 data。如果 ajax(..) 能够阻塞到响应返回,那么 data = .. 赋值就会正确工作。

但我们并不是这么使用 Ajax 的。现在我们发出一个异步 Ajax 请求,然后在将来才能得到返回的结果。

从现在到将来的“等待”,最简单的方法(但绝对不是唯一的,甚至也不是最好的!)是使用一个通常称为回调函数的函数:

// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax("http://some.url.1", function myCallbackFunction(data) {
  console.log(data); // 这里得到了一些数据!
});
1
2
3
4

注意

可能你已经听说过,可以发送同步 Ajax 请求。尽管技术上说是这样,但是,在任何情况下都不应该使用这种方式,因为它会锁定浏览器 UI(按钮、菜单、滚动条等),并阻塞所有的用户交互。这是一个可怕的想法,一定要避免。

考虑一下下面这段代码:

function now() {
  return 21;
}
function later() {
  answer = answer * 2;
  console.log("Meaning of life:", answer);
}
var answer = now();
setTimeout(later, 1000); // Meaning of life: 42
1
2
3
4
5
6
7
8
9

这个程序有两个块:现在执行的部分,以及将来执行的部分。

现在:

function now() {
  return 21;
}
function later() { .. }
var answer = now();
setTimeout(later, 1000);
1
2
3
4
5
6

将来:

answer = answer * 2;
console.log("Meaning of life:", answer);
1
2

现在这一块在程序运行之后就会立即执行。但是,setTimeout(..) 还设置了一个事件(定时)在将来执行,所以函数 later() 的内容会在之后的某个时间(从现在起 1000 毫秒之后)执行。

任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax 响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。

# 📌 异步控制台

并没有什么规范或一组需求指定 console.* 方法族如何工作——它们并不是 JavaScript 正式的一部分,而是由宿主环境(请参考本书的“类型和语法”部分)添加到 JavaScript 中的

因此,不同的浏览器和 JavaScript 环境可以按照自己的意愿来实现,有时候这会引起混淆。

尤其要提出的是,在某些条件下,某些浏览器的 console.log(..) 并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是 JavaScript)中,I/O 是非常低速的阻塞部分。所以,(从页面 /UI 的角度来说)浏览器在后台异步处理控制台 I/O 能够提高性能,这时用户甚至可能根本意识不到其发生。

下面这种情景不是很常见,但也可能发生,从中(不是从代码本身而是从外部)可以观察到这种情况:

var a = {
  index: 1
};

// 然后
console.log(a); // ??

// 再然后
a.index++;
1
2
3
4
5
6
7
8
9

我们通常认为恰好在执行到 console.log(..) 语句的时候会看到 a 对象的快照,打印出类似于 { index: 1 } 这样的内容,然后在下一条语句 a.index++ 执行时将其修改,这句的执行会严格在 a 的输出之后。

多数情况下,前述代码在开发者工具的控制台中输出的对象表示与期望是一致的。但是,这段代码运行的时候,浏览器可能会认为需要把控制台 I/O 延迟到后台,在这种情况下,等到浏览器控制台输出对象内容时,a.index++ 可能已经执行,因此会显示 { index: 2 }。

到底什么时候控制台 I/O 会延迟,甚至是否能够被观察到,这都是游移不定的。如果在调试的过程中遇到对象在 console.log(..) 语句之后被修改,可你却看到了意料之外的结果,要意识到这可能是这种 I/O 的异步化造成的。

建议

如果遇到这种少见的情况,最好的选择是在 JavaScript 调试器中使用断点,而不要依赖控制台输出。次优的方案是把对象序列化到一个字符串中,以强制执行一次“快照”,比如通过 JSON.stringify(..)。

# 📘 事件循环

尽管能够编写异步 JavaScript 代码,但直到最近(ES6),JavaScript 才真正内建有直接的异步概念。

补充

这里的“直到最近”是指 ES6 从本质上改变了在哪里管理事件循环。本来它几乎已经是一种正式的技术模型了,但现在 ES6 精确指定了事件循环的工作细节,这意味着在技术上将其纳入了 JavaScript 引擎的势力范围,而不是只由宿主环境来管理。这个改变的一个主要原因是 ES6 中 Promise 的引入,因为这项技术要求对事件循环队列的调度运行能够直接进行精细控制。

JavaScript 引擎本身所做的只不过是在需要的时候,在给定的任意时刻执行程序中的单个代码块。

“需要”,谁的需要?这正是关键所在!

JavaScript 引擎并不是独立运行的,它运行在宿主环境中,对多数开发者来说通常就是 Web 浏览器

经过最近几年(不仅于此)的发展,JavaScript 已经超出了浏览器的范围,进入了其他环境,比如通过像 Node.js 这样的工具进入服务器领域。实际上,JavaScript 现如今已经嵌入到了从机器人到电灯泡等各种各样的设备中。

但是,所有这些环境都有一个共同“点”,即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JavaScript 引擎,这种机制被称为事件循环

换句话说,JavaScript 引擎本身并没有时间的概念,只是一个按需执行 JavaScript 任意代码片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行

所以,举例来说,如果你的 JavaScript 程序发出一个 Ajax 请求,从服务器获取一些数据,那你就在一个函数(通常称为回调函数)中设置好响应代码,然后 JavaScript 引擎会通知宿主环境:“嘿,现在我要暂停执行,你一旦完成网络请求,拿到了数据,就请调用这个函数。”

然后浏览器就会设置侦听来自网络的响应,拿到要给你的数据之后,就会把回调函数插入到事件循环,以此实现对这个回调的调度执行。

那么,什么是事件循环?

先通过一段伪代码了解一下这个概念 :

// eventLoop 是一个用作队列的数组
// (先进,先出)
var eventLoop = [];
var event;
// “永远”执行
while (true) {
  // 一次 tick
  if (eventLoop.length > 0) {
    // 拿到队列中的下一个事件
    event = eventLoop.shift();
    // 现在,执行下一个事件
    try {
      event();
    }
    catch (err) {
      reportError(err);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这当然是一段极度简化的伪代码,只用来说明概念。不过它应该足以用来帮助大家有更好的理解。

可以看到,有一个用 while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。

一定要清楚,setTimeout(..) 并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来某个时刻的 tick 会摘下并执行这个回调

如果这时候事件循环中已经有 20 个项目了会怎样呢?你的回调就会等待。它得排在其他项目后面——通常没有抢占式的方式支持直接将其排到队首。这也解释了为什么 setTimeout(..) 定时器的精度可能不高。大体说来,只能确保你的回调函数不会在指定的时间间隔之前运行,但可能会在那个时刻运行,也可能在那之后运行,要根据事件队列的状态而定

所以换句话说就是,程序通常分成了很多小块,在事件循环队列中一个接一个地执行。严格地说,和你的程序不直接相关的其他事件也可能会插入到队列中。

# 📘 并行线程

术语“异步”和“并行”常常被混为一谈,但实际上它们的意义完全不同。

异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。

并行计算最常见的工具就是进程线程。进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。

与之相对的是,事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。

并行线程的交替执行和异步事件的交替调度,其粒度是完全不同的。

例如:

function later() {
  answer = answer * 2;
  console.log("Meaning of life:", answer);
}
1
2
3
4

尽管 later() 的所有内容被看作单独的一个事件循环队列表项,但如果考虑到这段代码是运行在一个线程中,实际上可能有很多个不同的底层运算。比如,answer = answer * 2 需要先加载 answer 的当前值,然后把 2 放到某处并执行乘法,取得结果之后保存回 answer 中。

在单线程环境中,线程队列中的这些项目是底层运算确实是无所谓的,因为线程本身不会被中断。但如果是在并行系统中,同一个程序中可能有两个不同的线程在运转,这时很可能就会得到不确定的结果。

考虑:

var a = 20;
function foo() {
  a = a + 1;
}
function bar() {
  a = a * 2;
}
// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar); 
1
2
3
4
5
6
7
8
9
10

根据 JavaScript 的单线程运行特性,如果 foo() 运行在 bar() 之前,a 的结果是 42,而如果 bar() 运行在 foo() 之前的话,a 的结果就是 41。

如果共享同一数据的 JavaScript 事件并行执行的话,那么问题就变得更加微妙了。考虑 foo() 和 bar() 中代码运行的线程分别执行的是以下两段伪代码任务,然后思考一下如果它们恰好同时运行的话会出现什么情况。

线程 1(X 和 Y 是临时内存地址):

foo():
  a. 把a的值加载到X
  b. 把1保存在Y
  c. 执行X加Y,结果保存在X
  d. 把X的值保存在a
1
2
3
4
5

线程 2(X 和 Y 是临时内存地址):

bar():
  a. 把a的值加载到X
  b. 把2保存在Y
  c. 执行X乘Y,结果保存在X
  d. 把X的值保存在a 
1
2
3
4
5

现在,假设两个线程并行执行。你可能已经发现了这个程序的问题,是吧?它们在临时步骤中使用了共享的内存地址 X 和 Y。

如果按照以下步骤执行,最终结果将会是什么样呢?

1a (把a的值加载到X ==> 20)
2a (把a的值加载到X ==> 20)
1b (把1保存在Y ==> 1)
2b (把2保存在Y ==> 2)
1c (执行X加Y,结果保存在X ==> 22)
1d (把X的值保存在a ==> 22)
2c (执行X乘Y,结果保存在X ==> 44)
2d (把X的值保存在a ==> 44)
1
2
3
4
5
6
7
8

a 的结果将是 44。但如果按照以下顺序执行呢?

1a (把a的值加载到X ==> 20)
2a (把a的值加载到X ==> 20)
2b (把2保存在Y ==> 2)
1b (把1保存在Y ==> 1)
2c (执行X乘Y,结果保存在X ==> 20)
1c (执行X加Y,结果保存在X ==> 21)
1d (把X的值保存在a ==> 21)
2d (把X的值保存在a ==> 21)
1
2
3
4
5
6
7
8

a 的结果将是 21。

所以,多线程编程是非常复杂的。因为如果不通过特殊的步骤来防止这种中断和交错运行的话,可能会得到出乎意料的、不确定的行为,通常这很让人头疼。

JavaScript 从不跨线程共享数据,这意味着不需要考虑这一层次的不确定性。但是这并不意味着 JavaScript 总是确定性的。回忆一下前面提到的,foo() 和 bar() 的相对顺序改变可能会导致不同结果(41 或 42)。

# 📌 完整运行

由于 JavaScript 的单线程特性,foo()(以及 bar())中的代码具有原子性

也就是说,一旦 foo() 开始运行,它的所有代码都会在 bar() 中的任意代码运行之前完成,或者相反。这称为完整运行(run-to-completion)特性

实际上,如果 foo() 和 bar() 中的代码更长,完整运行的语义就会更加清晰,比如:

var a = 1;
var b = 2;

function foo() {
  a++;
  b = b * a;
  a = b + 3;
}

function bar() {
  b--;
  a = 8 + b;
  b = a * 2;
}

// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

由于 foo() 不会被 bar() 中断,bar() 也不会被 foo() 中断,所以这个程序只有两个可能的输出,取决于这两个函数哪个先运行——如果存在多线程,且 foo() 和 bar() 中的语句可以交替运行的话,可能输出的数目将会增加不少!

块 1 是同步的(现在运行),而块 2 和块 3 是异步的(将来运行),也就是说,它们的运行在时间上是分隔的。

块 1:

var a = 1;
var b = 2;
1
2

块 2(foo()):

a++;
b = b * a;
a = b + 3;
1
2
3

块 3(bar()):

b--;
a = 8 + b;
b = a * 2;
1
2
3

块 2 和块 3 哪个先运行都有可能,所以如下所示,这个程序有两个可能输出。

输出 1:

var a = 1;
var b = 2;

// foo()
a++;
b = b * a;
a = b + 3;

// bar()
b--;
a = 8 + b;
b = a * 2;

a; // 11
b; // 22
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

输出 2:

var a = 1;
var b = 2;

// bar()
b--;
a = 8 + b;
b = a * 2;

// foo()
a++;
b = b * a;
a = b + 3;

a; // 183
b; // 180 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

同一段代码有两个可能输出意味着还是存在不确定性!但是,这种不确定性是在函数(事件)顺序级别上,而不是多线程情况下的语句顺序级别(或者说,表达式运算顺序级别)。换句话说,这一确定性要高于多线程情况。

在 JavaScript 的特性中,这种函数顺序的不确定性就是通常所说的竞态条件(race condition),foo() 和 bar() 相互竞争,看谁先运行。具体来说,因为无法可靠预测 a 和 b 的最终结果,所以才是竞态条件。

上次更新时间: 2021年11月30日 16:37:18