Skip to content

类型、值和变量

概述与定义

JavaScript 类型可以分为两类: 原始类型和对象类型。
JavaScript 的原始类型包括数值、文本字符串(也称字符串)和布尔真值(也称布尔值)。

JavaScript 中的特殊值 null 和 undefined 也是原始值,但它们不是数值、字符串或布尔值。这两个值通常被认为是各自特殊类型的唯一成员。
ES6 新增了一种特殊类型 Symbol(符号),

普通 JavaScript 对象是一个命名值的无序集合。这门语言本身也定义一种特殊对象,称为数组。数组表示一个数字值的有序集合。
JavaScript 语言包括操作数组的特殊语法,而数组本身也具有区别于普通对象的行为。

除了基本的对象和数组之外,JavaScript 还定义了其他一些有用的对象类型。
Set 对象表示一组值的集合,Map 对象表示键与值的映射。各种 “定型数组”(typed array)类型便于对字节数组和其他二进制数据进行操作。
RegExp 类型表示文本模式,,可以实现对字符串的复杂匹配、搜索和替换操作。
Date 类型表示日期和时间,支持基本的日期计算。Error 及其子类型表示 JavaScript 代码运行期间可能发生的错误

JavaScript 与静态语言更大的差别在于,函数和类不仅仅是语言的语法,它们本身就是可以被 JavaScript 程序操作的值。
与其他 JavaScript 非原始值一样,函数和类也是特殊的对象。

在内存管理方面,JavaScript 解释器会执行自动垃圾收集。这意味着 JavaScript 程序员通常不用关心对象或其他值的析构与释放。
当一个值无法触达时,或者说当程序无法以任何方式引用这个值时,解释器就知道这个值已经用不到了,会自动释放它占用的内存(JavaScript 程序员有时候需要留意,不能让某些值在不经意间存续过长时间后仍可触达,从而导致它们无法被回收)

JavaScript 支持面向对象的编程风格。
粗略地说,这意味着不用定义全局函数去操作不同类型的值,而是由这些类型本身定义操作值的方法。
比如要对数组元素排序,不用把数组传给一个 sort() 函数,而是可以调用数组 a 的 sort 方法

1
a.sort()  // sort(a) 的面向独享版

从技术角度来讲,只有 JavaScript 对象才有方法。但数值、字符串、布尔值和符号表现得似乎它们也有方法。
在 JavaScript 中,只有 null 和 undefined 是不能调用方法的值

JavaScript 对象类型是可修改的(mutable),而它的原始类型是不可修改的(immutable)。
可修改类型的值可以改变,比如 JavaScript 程序可以修改对象属性和数组元素的值。
数值、布尔值、符号、null 和 undefined 是不可修改的,以数值为例,修改它是没有意义的。
字符串可以看成字符数组,你可能期望它们是可修改的。但在 JavaScript 中,字符串也是不可修改的。
虽然可以按索引访问字符串中的字符,但 JavaScript 没有提供任何方式去修改已有字符串的字符。

JavaScript 可以自由地转换不同类型的值。比如,程序期待一个字符串,而你提供了一个数值,这个数值会自动切换为字符串。
而如果你在一个期待布尔值的地方使用了非布尔值,JavaScript 也会相应地把它转换为布尔值。

常量和变量可以让我们在程序中使用名字来引用值。常量使用 const 声明,变量使用 let(或在较老的 JavaScript 代码中使用 var)声明。
JavaScript 常量和变量是无类型的,声明不会限定要赋何种类型的值。

数值

JavaScript 的主要数值类型 Number 用于表示整数和近似实数。
JavaScript 使用 IEEE 754 标准定义的 64 位浮点格式表示数值,这意味着 JavaScript 可以表示的最大整数是 正负 1.7976931348623157 * 10^{308},最小整数是 正负 5 * 10^{-324}

JavaScript 的这种数值格式可以让我们准确表示 -9007199254740992(-2^{53})9007199254740992(2^{53}) 之间的素有整数(含首尾值)。
如果你的数值超出了这个范围,那可能会在末尾的数字上损失一些精度。
但要注意,JavaScript 中的某些操作(如数组索引和位操作)是以 32 位整数计算的。

当数值真正出现在 JavaScript 程序中时,就叫做数值字面量(numeric literal)。

整数字面量

在 JavaScript 程序中,基数位 10 的整数可以直接写成整数序列。例如:

1
2
3
0
3
10000000

除了基数为 10 的整数字面量之外,JavaScript 也支持十六进制(基数是 16 的)值。
十六进制字面量以 0x0X 开头,后跟一个十六进制数字字符串。
十六进制数字是数字 0 到 9和字母 a(或 A)到 f(或F),a 到 f 表示 10 到 15.
下面是十六进制整数字面量的例子:

1
2
0xff  // => 255: (15 * 16 + 15)
0xBADCAFE  // => 195939070

在 ES6 及之后的版本中,也可以通过二进制(基数为 2)或八进制(基数为 8)表示整数,分别使用前缀 0b 和 0o(或 0B 和 0O)

1
2
0b10101  // => 21
0o377  // => 255

浮点字面量

浮点字面量可以包含小数点,它们对实数使用传统语法。实例值由数值的整数部分、小数点和数值的小数部分组成。

浮点字面量也可以使用指数记数法表示,即实数值后面可以跟字母 e(或 E),跟一个可选的加号或减号,再跟一个整数指数。
这种记数法表示的是实数值乘以 10 的指数次幂。

更简洁的语法形式为:

1
[digits][.digits][(E|e)[(+|-)]digits]

例如:

1
2
3
4
5
3.14
2345.6789
.333333333333333333
6.02e23  // 6.02 * 10^{23}
1.4738223E-32 // 1.473823 * 10^{-32}

数值字面量中的分隔符

可以用下划线将数值字面量分割为容易看清的数字段:

1
2
3
4
let billion = 1_000_000_000;  // 以下划线作为千分位分隔符
let bytes = 0x89_AB_CD_EF;  // 作为字节分隔符   
let bits = 0b0001_1101_0111;  // 作为半字节分隔符   
let fraction = 0.123_456_789;  // 也可以用在小数部分    

JavaScript 中的算术

JavaScript 程序使用语言提供的算术操作符来操作数值,包括表示加法的 +、表示减法的 -、表示乘法的 *、表示除法的 / 和表示取模(除法后的余数)的 %
ES2016 增加了取幂的 **

除了上述基本的算术操作符之外,JavaScript 还通过 Math 对象的属性提供了一组函数和航梁,以支持更复杂的数学运算

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Math.pow(2, 53)  // => 9007199254740992: 2 的 53 次方
Math.round(.6)  // => 1.0: 舍入到最接近的整数
Math.ceil(.6)  // => 1.0: 向上舍入到一个整数
Math.floor(.6)  // => 0.0: 向下舍入到一个整数
Math.abs(-5)   // => 5: 绝对值
Math.max(x, y, z)   // 返回最大的参数
Math.min(x, y, z)  // 返回最小的参数
Math.random()  // 伪随机数 x,其中 0 <= x < 1.0
Math.PI  // 圆周率
Math.E   // e: 自然对数的底数
Math.sqrt(3)  // => 3 ** 0.5: 3 的平方根
Math.pow(3, 1/3)  // => 3**(1/3): 3 的立方根
Math.sin(0)  // 三角函数: 还有 Math.cos、Math.atan 等
Math.log(10)  // 10 的自然对数
Math.log(100) / Math.LN10  // 以 10 为底 100 的对数
Math.log(512) / Math.LN2  // 以 2 为底 512 的对数
Math.exp(3)  //Math.E 的立方 

ES6 在 Math 对象上又定义了一批函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Math.cbrt(27)  // => 3: 立方根
Math.hypot(3, 4)  // => 5: 所有参数平方和的平方根
Math.log10(100)  // => 2: 以 10 为底的对数
Math.log2(1024)  // => 10: 以 2 为底的对数
Math.log1p(x)  // (1 + x) 的自然对数; 精确到非常小的 x
Math.expm1(x)   // Math.exp(x) = 1; Math.log1p() 的逆运算
Math.sign(x)  // 对 <、== 或 >0 的参数返回 -1、0 或 1
Math.imul(2, 3)  // => 6: 优化的 32 位整数乘法
Math.clz32(0xf)  // => 28: 32 位整数中前导 0 的位数
Math.trunc(3.9)  // => 3: 见到分数部分得到整数
Math.fround(x)  // 舍入到最接近的 32 位浮点数
Math.sinh(x)  // 双曲线正弦,还有 Math.cosh() 和 Math.tanh()
Math.asinh(x)  // 双曲线反正弦,还有 Math.acosh() 和 Math.atanh()   

JavaScript 中的算术在遇到上溢出、下溢出或被零除时不会发生错误。
在数值操作的结果超过最大可表示数值时(上溢出),结果是一个特殊的无穷值 Infinity。
类似地,当某个负数的绝对值超过了最大可表示负数的绝对值时,结果是负无穷值 -Infinity。
这两个无穷值的行为跟我们的预期一样: 任何数加、减、乘、除无穷值结果还是无穷值(只是符号可能相反)

下溢出发生在数值操作的结果比最小可表示数值更接近 0 的情况下。
此时,JavaScript 返回 0.如果下溢出来自负数,JavaScript 返回一个被称为“负零”的特殊值。
这个值与常规的零几乎完全无法区分,JavaScript 程序员极少需要检测它。

被零除在 JavaScript 中不是错误,只会简单地返回无穷或负无穷。
不过有一个例外: 0 除以 0 是没有意义的值,这个操作的结果是一个特殊的“非数值”(NaN,Not a Number)。
此外,无穷除无穷、负数平方根或者用无法转换为数值的非数值作为算术操作符的操作数,结果也都是 NaN

JavaScript 预定义了全局变量 Infinity 和 NaN 以对应正无穷的非数值。这些值也可以通过 Number 对象的属性获取:

 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
Infinity  // 因为太大而无法表示的正数   
Number.POSITIVE_INFINITY  // 同上
1 / 0   // => Infinity
Number.MAX_VALUE * 2   // => Infinity: 溢出
-Infinity  // 因为太大而无法表示的负数
Number.NEGATIVE_INFINITY  // 同上
-1 / 0   // => -Infinity
-Number.MAX_VALUE * 2  // => -Infinity

NaN  // 非数值
Number.NaN  // 同上,写法不同
0 / 0  // => NaN
Infinity / Infinity // => NaN

Number.MIN_VALUE / 2 // => 0: 下溢出
-Number.MIN_VALUE / 2 // => -0: 负零
-1 / Infinity   // => -0
-0

// ES6 定义了下列 Number 属性
Number.parseInt()  // 同全局的 parseInt() 函数
Number.parseFloat()  // 同全局 parseFloat() 函数
Number.isNaN(x)  // 判断 x 是不是 NaN
Number.isFinite(x)  // 判断 x 是数值还是无穷
Number.isInteger(x)  // 判断 x 是不是整数
Number.isSafeInteger(x)  // 
Number.MIN_SAFE_INTEGER  // => -(2 ** 53 - 1)
Number.MAX_SAFE_INTEGER  // => 2 ** 53 - 1
Number.EPSILON  // => 2 ** -52: 数值与数值之间最小的差

非数值在 JavaScript 中有一个不同寻常的特性: 它与任何值比较都不相等,也不等于自己。
这意味着不能通过 x === NaN 来确定某个变量 x 的值是 NaN。
相反,此时必须写成 x != xNumber.isNaN(x)。这两个表达式当切仅当 x 与全局变量 NaN 具有相同值时才返回 true

全局函数 isNaN() 与 Number.isNan() 类似。它会在参数是 Nan 时,或者在参数是无法转换为数值的非数值时返回 true。
相关的函数 Number.isFinite() 在参数不是 NaN,Infinity 或 -Infinity 时返回 true。
全局 isFinite() 函数在参数是有限数或者可以转换为有限数时返回 true

负零值也有点不同寻常。它与正零值相等(即使使用 JavaScript 的严格相等比较),这意味着除了作为除数使用,几乎无法区分这两个值

1
2
3
4
let zero = 0;  // 常规的零
let negz = -0;    // 负零
zero == negz   // => true: 零等于负零
1 / zero === 1/negz  // => false: Infinity 不等于 -Infinity

二进制浮点数与舍入错误

实数有无限多个,但 JavaScript 的浮点格式只能表示其中有限个(确切地说,是 18 437 736 874 454 810 627 个)。
这意味着在通过 JavaScript 操作实数时,数值表示的经常是实际数值的近似值。

JavaScript(以及所有现代编程语言)使用的 IEEE-754 浮点表示法是一种二进制表示法,这种表示法可以精确地表示如 1/2、1/8 和 1/1024 等分数。
然而,我们最常用的分数(特别是在进行财务计算时)是十进制分数: 1/10、1/100,等等。
二进制浮点表示法无法精确表示哪怕 0.1 这么简单的数

虽然 JavaScript 数值有足够大的精度,能够非常近似地表示 0.1,但无法精确表示。这可能导致一些问题。比如一下代码:

1
2
3
4
5
let x = .3 - .2;  // 30 美分减 20 美分
let y = .2 - .1;  // 20 美分减 10 美分
x === y  // => false: 这两个值不一样!
x === .1  // => flase: .3 - .2 不等于 .1
y === .1  // => true: .2 - .1 等于 .1

由于舍入错误,.3.2 近似值的差与 .2.1 近似值的差并不相等。这并不是 JavaScript 独有的问题,而是所有使用二进制浮点数的编程语言共同的问题。
同样,也要注意代码中 x 和 y 的值机器接近,它们也都机器接近正确的值。
这个计算得到的值完全能够满足任何需要,切记不要视图比较它们的相等性

如果浮点近似值对你的程序而言是个问题,可以考虑使用等量整数。
例如,计算与钱数有关的数值时可以使用整数形式的美分,而不是零点几美元。

通过 BigInt 表示任意精度整数

ES2020 为 JavaScript 定义了一种新的数值类型 BigInt。
2020 年年初,Chrome、Firefox、Edge 和 Node 都实现了这个类型,Safari 也在实现中。
顾名思义,BigInt 这种数值类型的值是整数。之所以增加这个类型,主要是为了表示 64 位整数,这对于兼容很多其他语言和 API 是必需的。
但 BigInt 值可能有数千甚至数百万个数字,可以满足对大数的需求(不过,BigInt 的实现并不适合加密,因为它们没有考虑防止时序攻击)

BigInt 字面量写作一串数字后跟小写字母 n。
默认情况下,基数是 10,但可以通过前缀 0b、0o 和 0x 来表示二进制,八进制和十六进制 BigInt

1
2
3
4
1234n   // 一个不太大的 BigInt 字面量
0b111111n  // 二进制 BigInt
0o777n  // 八进制 BigInt
0x8000000000000000n 

可以用 BigInt() 函数函数把常规 JavaScript 数值或字符串转换为 BigInt 值

1
2
3
BigInt(number.MAX_SAFE_INTEGER)  // => 9007199254740991n
let string = "1" + "0".repeat(100);   // 1 后面跟 100 个零
BigInt(string)  // => 10n ** 10n: 一个天文数字

BigInt 值的算术运算与常规 JavaScript 数值的算术运算类似,只不过除法会丢弃余数并且会向下(向零)舍入

1
2
3
4
5
6
1000n + 2000n // => 3000n
3000n - 2000n // => 1000n
2000n * 3000n // => 6000000n
3000n / 997n  // => 3n: 商是 3
3000n % 997n  // => 9n: 余数是 9
(2n ** 131071n) - 1n  // 有 39457 位数字的梅森素数

虽然标准的 +、-、*、/、%** 操作可以用于 BigInt、但不能混用 BigInt 操作数和常规数值操作数。
乍一看这个规则有点奇怪,但实际上是合理的。如果一种数值类型比另一种更通用,则比较容易定义混合操作数的计算并返回更通用的类型。
但上述两种类型都不比另一种通用: BigInt 可以表示超大值,因此它比常规数值更通用。
但 BigInt 只能表示整数,这样看常规 JavaScript 数值类型反而更通用。
这个问题无论如何也解决不了,因此 JavaScript 搁置了这个问题,只是简单地不允许在使用算术操作符时混用这两种类型的操作数。

相对来说,比较操作符允许混合操作数类型

1
2
3
4
1 < 2n    // => true
2 > 1n   // => true
0 == 0n   // => true
0 === 0n  // false

位操作符通常可以用于 BigInt 操作数。但 Math 对象的任何函数都不接收 BigInt 操作数

日期和时间

JavaScript 为表示和操作与日期及时间相关的数据而定义了简单的 Date 类。
JavaScript 的 Date 是对象,但也有数值表示形式,即自 1970 年 1 月 1 日起至今的毫秒数,也叫时间戳

1
2
3
4
let timestamp = Date.now()   // 当前时间的时间戳(数值)
let now = new Date()   // 当前时间的日期对象
let ms = now.getTime()    // 转换为毫秒时间戳
let iso = now.toISOString();   // 转换为标准格式的字符串

文本

JavaScript 中表示文本的类型是 String,即字符串。
字符串是 16 位值的不可修改的有序序列,其中每个值都表示一个 Unicode 字符。字符串的 length 属性是它包含的 16 位值的个数。
JavaScript 的字符串(以及数组)使用基于零的索引,因此第一个 16 位值的索引是 0,第二个值的索引是 1,以此类推。
空字符串是长度为 0 的字符串。JavaScript 没有表示单个字符串元素的专门类型。要表示一个 16 位值,使用长度为 1 的字符串即可

字符、码点和 JavaScript 字符串

JavaScript 使用 Unicode 字符集的 UTF-16 编码,因此 JavaScript 字符串是无符号 16 位值的序列。
最常用的 Unicode 字符(即 “基本多语言平面” 中的字符)的码点(codepoint)是 16 位的,可以用字符串中的一个元素来表示。
码点超出 16 位的 Unicode 字符使用 UTF-16 规则编码为两个16 位值的序列(称为 surrogate pair,即 “代理对”)。
这意味着一个长度为 2(两个 16 位值)的 JavaScript 字符串可能表示的只是一个 Unicode 字符;

1
2
3
4
5
6
let x = "a"
let y = "🍑"
let z = "😯"
let a = "我"

console.log(x.length, y.length, z.length, a.length)

JavaScript 的字符串操作方法一般操作的是 16 位值,而不是字符。
换句话说,它们不会特殊对待代理对,不对字符串进行归一化,甚至不保证字符串是格式正确的 UTF-16。

但在 ES6 中,字符串是可迭代的,如果对字符串使用 for/of 循环或 ... 操作符,迭代的是字符而不是 16 位值

字符串字面量

要在 JavaScript 程序中包含字符串,可以把字符串放到一对匹配的单引号、双引号或者反引号中。
双引号字符和反引号可以出现在由单引号定界的字符串中,同理由双引号和反引号定界的字符串里也可以包含另外两种引号。
下面是几个字符串字面量的例子:

1
2
3
4
5
6
7
""   // 空字符串,即有零个字符    
'testing'
"3.14"
'name="myform"'
"Would't you perfer O'Reill's book?"
"t is the ratio of a circle's circumferenece tot ites radius"
`"She adis 'hi'", he said.`

使用反引号定界字符串是 ES6 的特性,允许在字符串字面量中包含(或插入) JavaScript 表达式

JavaScript 最早的版本要求字符串字面量必须卸载一行,使用 + 操作符把单行字符串拼接成长字符串的 JavaScript 代码随处可见。
到了 ES5,我们可以在每行末尾加一个反斜杠(\)从而把字符串字面量写到多行上。
这个反斜杠和它后面的行终结符都不属于字符串字面量。
如果需要在单引号或双引号字符串中包含换行符,需要使用字符序列 \n
ES6 的反引号语法支持跨行字符串,而行终结符也是字符串字面量的一部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 写在一行但表示两行的字符串:    
'two\nlines'

// 写在三行但只有一行的字符串
"one\
long\
line"

// 写在两行实际也是两行的字符串
`the newline character at the end of this line
is included literally in this string`

注意,在使用单引号定界字符串时,必须注意英文中的缩写和所有格,比如 can'tO'Reilly 中的单引号。
因为这里的撇号就是单引号,所以必须使用反斜杠字符(\) “转义” 单引号中出现的所有撇号

在客户端 JavaScript 编程中,JavaScript 代码中可能包含 HTML 代码的字符串,而 HTML 代码中也可能包含 JavaScript 代码。
与 JavaScript 类似,HTML 使用单引号或双引号来定界字符串。
为此,如果要将 JavaScript 和 HTML 代码混合在一起,最好 JavaScript 和 HTML 分别使用不同的引号。
在下面的例子中,JavaScript 表达式中的字符串 "Thank you" 使用了单引号,而 HTML 事件处理程序属性则使用了双引号

1
<button onclick="alert('Thank you')">Click Me</button>

字符串字面量中的转义序列

反斜杠在 JavaScritp 字符串中有特殊的作用: 它与后面的字符组合在一起, 可以在字符串中表示一个无法直接表示的字符。例如,\n 是一个表示换行符的转义序列。

前面还提到了另一个例子 \',表示单引号(或撇号)字符。
这种转义序列在以单引号定界字符串时,可以用来在字符串中包含撇号。
子所以称之为转义序列,就是反斜杠转换了通常意义上单引号的含义。转义之后,它不再表示字符串结束,而是表示撇号:

1
'You\'re right, it can\'t be a quote'

下面列出了 JavaScript 中的转义序列及它们表示的字符。
其中 3 个转义序列是通用的,可以指定十六进制数字形式的 Unicode 字符编码来表示任何字符。
例如,\xA9表示版权符号,其中包含十六进制数字形式的 Unicode 编码。
类似地,\u 表示通过 4 位十六进制数字指定的任意 Unicode 字符,如果数字包含在一对花括号中,则是 1 到 6 位数字。
例如,\u03c0 表示字符 π,\u{1f600} 表示 "开头笑" 表情符号

序列 表示的字符
\0 NULL字符(\u0000)
\b 退格符(\u0008)
\t 水平制表符(\u0009)
\n 换行符(\u000A)
\v 垂直制表符(\u000B)
\f 进纸符(\u000C)
\r 回车符(\u000D)
\" 双引号(\u0022)
\' 单引号或撇号(\u0027)
\\ 反斜杠(\u005C)
\xnn 由 2 位十六进制数字 nn 指定的 Unicode 字符
\unnnn 由 4 位十六进制数字 nnnn 指定的 Unicode 字符
u{n} 由码点 n 指定的Unicode 字符,其中 n 是介于 0 和 10FFFF 之间的 1 到 6 位十六进制数字(ES6)

如果字符 \ 位于任何表之外的字符前面,则这个反斜杠会被忽略(当然,语言将来的版本有可能定义新转义序列)。
例如,\# 等同于 #。最后,如前所述,ES5 允许把反斜杠放在换行符前面从而将一个字符串字面量拆成多行。

使用字符串

拼接字符串是 JavaScript 的一个内置特性。如果对数值使用 + 操作符,那数值会相加。
如果对字符串使用 + 操作符,那字符串会拼接起来(第二个在第一个后面)。例如:

1
2
let msg = "Hello, " + "world";   // 产生字符串 "Hello, world"
let greeting = "Welcome to my blog," + " " + name;

可以使用标准的全等 === 和不全等 !== 操作符比较字符串。只有当两个字符串具有完全相同的 16 位值的序列时才相等。
字符串也可以使用 <、<=、> 和 >= 操作符来比较。字符串比较是通过比较 16 位值完成的。

要确定一个字符串的长度(即字符串包含的 16 位值的个数),可以使用字符串的 length 属性:

1
s.length

除了 length 属性之外,JavaScript 还提供了操作字符串的丰富 API:

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
let s = "Hello, world";   // 先声明一个字符串

// 取得字符串的一部分
s.substring(1, 4)   // => "ell": 第 2~4 个字符
s.slice(1, 4)   // => "ell": 同上
s.slice(-3)   // => "rld": 最后三个字符
s.split(", ")   // => ["Hello", "world"]: 从定界符处拆开

// 搜索字符串
s.indexOf("l")  // => 2: 第一个字母 l 的位置
s.indexOf("l", 3)   // => 3: 位置 3 后面第一个 "l" 的位置
s.indexOf("zz")    // => -1: s 并不包含子串 "zz"
s.lastIndexOf("l")  // => 10: 最后一个字母 l 的位置

// ES6 及之后版本中的布尔值搜索函数
s.startsWith("Hell")   // => true: 字符串是以这些字符开头的
s.endWith("!")   // => false: s 不是以它结尾的
s.includes("or")    // => true: s 包含子串 "or"

// 创建字符串的修改版本
s.replace("llo", "ya")   // => "Heya, world"
s.toLowerCase()   // => "hello, world"
s.toUpperCase()   // => "HELLO, WORLD"
s.normalize()   // Unicode NFC 归一化: ES6 新增    
s.normalize("NFD")    // NFD 归一化。还有 "NFKC" 和 "NFKD"    

// 访问字符串中的个别(16 位值)字符
s.charAt(0)   // => "H": 第一个字符
s.charAt(s.length - 1)  // => "d": 最后一个字符
s.charCodeAt(0)  // => 72: 指定位置的 16 位数值
s.codePointAt(0)   // => 72: ES6,适用于码点大于 16 位的情形    

// ES2017 新增的字符串填充函数
"x".padStart(3)   // => "  x": 在左侧添加空格,让字符串长度变成 3
"x".padEnd(3)   // => "x  ": 在右侧添加空格,让字符串长度变成 3
"x".padStart(3, "*")   // => "**x": 在左侧添加星号,让字符串长度变成 3
"x".padEnd(3, "-")   // => "x--": 在右侧添加破折号,让字符串长度变成 3

// 删除空格函数。trim() 是 ES5 就有的,其他是 ES2019 增加的
" test ".trim()   // => "test": 删除开头和末尾的空格
" test ".trimStart()   // => "test ": 删除左侧空格。也叫 trimLeft
" test ".trimEnd()   // => " test": 删除右侧空格。也叫 trimRight

// 未分类字符串方法
s.concat("!")   //=> "Hello, world!": 可以用 + 操作符代替
"<>".repeat(5)  // => "<><><><><>": 拼接 n 次。ES6 新增

记住,JavaScript 中的字符串是不可修改的。像 replace() 和 toUpperCase() 这样的方法都返回新字符串,它们并不会修改调用它们的字符串

字符串也可以被当成只读数组,使用方括号而非 charAt() 方法访问字符串中个别的字符(16 位值)

1
2
3
let s = "hello, world"       
s[0]     // => "h"
s[s.length - 1]   // => "d"

模板字面量

在 ES6 及之后的版本中,字符串字面量可以用反引号来定界

1
let s = `hello world`;

不过,这不仅仅是一种新的字符串字面量语法,因为模板字面量可以包含任意 JavaScript 表达式。
反引号中字符串字面量最终值的计算,涉及对其中包含的所有表达式求值、将这些表达式的值转换为字符串,然后再把这些字符串与反引号中的字面量组合:

1
2
let name = "Bill";
let greeting = `Hello ${ name }.`   // greeting == "Hello Bill."

位于 ${ 和对应的 } 之间的内容都被当作 JavaScript 表达式来解释
而位于这对花括号之外的则是常规字符串字面量。括号内的表达式会被求值,然后转换为字符串并插入模板中,替换美元字符、花括号以及花括号中的所有内容

模板字面量可以包含任意数量的表达式,可以包含任何常规字符串中可以出现的转义字符,也可以跨任何多行而无须特殊转义。
下面的模板字面量包含 4 个 JavaScript 表达式、1 个 Unicode 转义序列和至少 4 个换行符(表达式的值也可能包含换行符)

1
2
3
4
5
6
let errorMessage = `\
\u2718 Test failure at ${filename}:${linenumber}:
${exception.message}
Stack trace:
${exception.stack}
`;

这里第一行末尾的反斜杠转义了第一个换行符,因此最终字符串的第一个字符是 Unicode 字符 "✘"(\u2718)而非换行符

标签化模板字面量

模板字面量有一个强大但不太常用的特性: 如果在开头的反引号前面有一个函数名(标签),那么模板字面量中的文本和表达式的值将作为参数传给这个函数。
“标签化模板字面量”(tagged template literal) 的值就是这个函数的返回值。
这个特性可以用于先对某些值进行 HTML 或 SQL 转义,然后再把它们插入文本。

ES6 提供了一个内置的标签函数: String.raw()。这个函数返回反引号中未经处理的文本,即不会处理任何反斜杠转义:

1
2
`\n`.length   // =>: 字符串中只包含一个换行符
String.raw`\n`.length  // =>: 一个反斜杠字符和一个字母 n

注意,即使标签化模板字面量的标签部分是函数,在调用这个函数时也没有圆括号。
在这种非常特别的情况下,反引号字符充当开头和末尾的圆括号。

可以自定义模板标签函数是 JavaScript 的一个非常强大的特性。
这些函数不需要返回字符串,可以被当成构造函数使用,就像为语言本身定义了一种新的字面量语法一样。

模式匹配

JavaScript 定义了一种被称为正则表达式(或 RegExp)的数据类型,用于描述和匹配文本中的字符串模式。
RegExp 不是 JavaScript 中的基础类型,但具有类似数值和字符串的字面量语法,因此它们有时候看起来像是基础类型。
正则表达式字面量的语法很复杂,它们定义的 API 也没那么简单。

一对斜杠之间的文本构成正则表达式字面量。这对斜杠中的第二个后面也可以跟一个或多个字母,用于修改模式的含义。例如:

1
2
3
/^HTML/;     // 匹配字符串开头的字母 HTML
/[1-9][0-9]*/  // 匹配非 0 数字,后面跟着任意数字   
/\bjavascript\b/i;    // 匹配 "javascript" 这个词,不区分大小写

RegExp 对象定义了一些有用的方法,而字符串也有接收 RegExp 参数的方法。例如:

1
2
3
4
5
6
7
let text = "testing: 1, 2, 3";   // 示例文本
let pattern = /\d+/g;  // 匹配一个或多个数字
pattern.test(text)    // => true: 存在匹配项
test.search(pattern)    // => 9: 第一个匹配项的位置
text.match(pattern)   // => ["1", "2", "3"]: 所有匹配项的数组
text.replace(pattern, "#")    // => "testing: #, #, #"
text.split(/\D+/)   // => ["", "1", "2", "3"]: 基于非数字拆分    

布尔值

布尔值表示真或假,开或关、是或否。这个类型只有两个值: true 和 false

布尔值在 JavaScript 中通常是比较操作的结果。例如:

1
a === 4

以上代码测试变量 a 的值是否等于数值 4.如果是,则返回 true;否则返回 false

布尔值在 JavaScript 常用于控制结构。例如,JavaScript 中的 if/else 语句在布尔值为 true 时会执行一种操作,而在值为 false 时会执行另一种操作.
我们经常把产生布尔值的比较表达式直接放在使用布尔值的语句中。结果类似如下:

1
2
3
4
5
if (a === 4) {
    b = b + 1;
} else {
    a = a + 1;
}

JavaScript 的任何值都可以转换为布尔值。下面的这些值都会转换为(因而可以被用作)布尔值 false:

1
2
3
4
5
6
undefined
null
0
-0
NaN
""    // 空字符串

所有其他值,包括所有对象(和数组)都转换为(可以被用作)布尔值 true。
false 和可以转换为它的 6 个值有时候也被称为假性值(falsy),而所有其他值责备称为真性值(truthy)。
在任何 JavaScript 期待布尔值的时候,假性值都可以当作 false,而真性值都可以当作 true

例如,假设变量 o 要么保存一个对象,要么是值 null。可以通过一个 if 语句像下面这样检测 o 是否为空:

1
if (o !== null) ...

使用不全等操作符 !== 比较 o 和 null,求值结果要么是 true 要么是 false。
不过,也可以省略比较,直接依赖 null 是假性值而对象是真性值这个事实:

1
if (o) ...

第一种情况下,if 语句的主体只有在 o 不是 null 时才会执行。
第二种情况没那么严格,只要 o 不是 flase 或任何其他假性值(如 null 或 undefined),if 语句的主体就会执行。
哪种 if 语句适合你的程序取决于你期待 o 中保存什么值。如果需要区分 null 和 0、"",那么就应该使用比较表达式

布尔值有一个 toString() 方法,可用于将自己转换为字符串 "true" 或 "false"。
除此之外,布尔值再没有其他有用的方法了。除了这个极其简单的 API,还有三种重要的布尔值操作符

null 与 undefined

null 是一个语言关键字,求值为一个特殊值,通常用于表示某个值不存在。
对 null 使用 typeof 操作符返回字符串 "object",表明可以将 null 看成一种特殊对象,表示 "没有对象"。
但在实践中,null 通常被当作它自己类型的唯一成员,可以用来表示数值、字符串以及对象 "没有值"。
多数编程语言都有一个与 JavaScript 的 null 等价的值,比如 NULL、nil 或 None

JavaScript 中的 undefined 也表示值不存在,但 undefined 表示一种更深层次的不存在。
具体来说,变量的值未初始化时就是 undefined,在查询不存在的对象属性或数组元素时也会得到 undefined。
另外,没有明确返回值的函数返回的值是 undefined,没有传值的函数参数的值也是 undefined。
undefined 是一个预定义的全局变量(而非像 null 那样的语言关键字,不过在实践中这个区别并不重要),这个常量的初始化值就是 undefined。
对 undefined 应用 typeof 操作符会返回 "undefined",表示这个值是该特殊类型的唯一成员。

抛开细微的差别,null 和 undefined 都可以表示某个值不存在,经常被混用。
相等操作符 == 认为它们相等(要区分它们,必须使用全等操作符 ===)。因为它们俩都是假性值,在需要布尔值的情况下,它们都可以当作 false 使用。
null 和 undefined 都没有属性或方法。事实上,使用 . 或者 [] 访问这两个值的属性或方法会导致 TypeError

可以用 undefined 表示一种系统级别、意料之外或类似错误的没有值,可以用 null 表示程序级别、正常或意料之中的没有值。
实际编码中,尽量避免使用 null 和 undefined,如果需要给某个变量或属性赋这样一个值,或者需要向函数传入或从函数中返回这样一个值,我通常使用 null。
有些程序员则极力避免使用 null,而倾向于使用 undefined

符号

符号(Symbol)是 ES6 新增的一种原始类型,用作非字符串的属性名。
要理解符号,需要了解 JavaScript 的基础类型 Object 是一个属性的无序集合,其中每个属性都有一个名字和一个值。
属性名通常是(在 ES6 之前一直必须是)字符串。但在 ES6 和之后的版本中,符号也可以作为属性名。

1
2
3
4
5
6
7
8
9
let strname = "string name";   // 可以用作属性名的字符串
let symname = Symbol("propname");   // 可以用作属性名的符号
typeof strname   // => "string": strname 是字符串
typeof symname   // => "symbol": symname 是符号
let o = {};   // 创建一个新对象
o[strname] = 1;   // 使用字符串名定义一个属性
o[symname] = 2;   // 使用符号名定一个属性    
o[strname]   // => 1: 访问字符串名字的属性
o[symname]   // => 2: 访问符号名字的属性

Symbol 类型没有字面量语法。要获取一个 Symbol 值,需要调用 Symbol() 函数。
这个函数永远不会返回相同的值,即使每次传入的参数都一样。
这意味着可以将调用 Symbol() 取得的符号值安全地用于为对象添加新属性,而无须担心可能重写已有的的同名属性。
类似地,如果定义了符号属性但没有共享相关符号,也可以确信程序中的其他代码不会意外重写这个属性。

实践中,符号通常用作一种语言扩展机制。ES6 新增了 for/of 循环和可迭代对象,为此就需要定义一种标准的机制让类可以实现,从而把自身变得可迭代。
但选择任何特定的字符串作为这个迭代器方法的名字都有可能破坏已有的代码。为此,符号名应运而生。
Symbol.iterator 是一个符号值,可用作一个方法名,让对象变得可迭代。

Symbol() 函数可选地接收一个字符串参数,返回唯一的符号值。
如果提供了字符串参数,那么调用返回符号值的 toString() 方法得到的结果中会包含该字符串。
不过要注意,以相同的字符串调用两次 Symbol() 会产生两个完全不同的符号值。

1
2
let s = Symbol("sym_x");
s.toString()   // => "Symbol(sym_x)"

符号唯一有趣的方法就是 toString()。不过,还应该知道两个符号相关的函数。
在使用符号时,我们有时希望它们对代码是私有的,从而可以确保你的代码的属性永远不会与其他代码的属性发生冲突。
但有时我们也希望定义一些可以与其他代码共享的符号值。
例如,我们定义了某种扩展,希望别人的代码也可以使用,就像前面提到的 Symbol.iterator 机制一样

为了定义一些可以与其他代码共享的符号值,JavaScript 定义了一个全局符号注册表。
Symbol.for() 函数接收一个字符串参数,返回一个与该字符串关联的符号值。
如果没有符号与该字符串关联,则会创建并返回一个新符号;否则,就会返回已有的符号。
换句话说,Symbol.for() 与 Symbol() 完全不同: Symbol() 永远不会返回相同的值,而在以相同的字符串调用时 Symbol.for() 始终返回相同的值。
传给 Symbol.for() 的字符串会出现在 toString()(返回符号值)的输出中。而且,这个字符串也可以通过将返回的符号传给 Symbol.keyFor() 来得到:

1
2
3
4
5
let s = Symbol.for("shared");
let t = Symbol.for("shared");
s === t    // => true
s.toString()   // => "Symbol(shared)"
Symbol.keyFor(t)   // => "shared"

全局对象

全局对象的属性是全局性定义的标识符,可以在 JavaScript 程序的任何地方使用。
JavaScript 解释器启动后(或每次浏览器加载新页面时),都会创建一个新的全局对象并为其添加一组初始的属性,定义了:

  • undefined、Infinity 和 NaN 这样的全局常量;
  • isNaN()、parseInt() 和 eval() 这样的全局函数
  • Date()、RegExp()、String()、Object() 和 Array() 这样的构造函数
  • Math 和 JSON 这样的全局对象

全局对象的初始属性并不是保留字,但它们应该都被当成保留字。

在 Node 中,全局对象有一个名为 global 的属性,其值为全局对象本身,因此在 Node 程序中始终可以通过 global 来引用全局对象

在浏览器中,Window 对象对浏览器窗口中的所有 JavaScript 代码而言,充当了全局对象的角色。
这个全局的 Window 对象有一个自引用的 window 属性,可以引用全局对象。
Window 对象定义了核心全局属性,也定义了其他一些特定于浏览器和客户端 JavaScript 的全局值。

ES2020 最终定义了 globalThis 作为任何上下文中引用全局对象的标准方式。2020 年初,所有现代浏览器和 Node 都实现了这个特性

不可修改的原始值与可修改的对象引用

JavaScript 中的原始值(undefined、null、布尔值、数值和字符串)与对象(包括数值和函数)有一个本质的区别。
原始值是不可修改的,即没有办法改变原始值。对于数值和布尔值,这一点很好理解: 修改一个数值的值没有什么用。
可是,对于字符串,这一点就不太好理解了。因此字符串类似字符数组,我们或许认为可以修改某个索引位置的字符。
事实上,JavaScript 不允许这么做。所有看起来返回一个修改后字符串的字符串方法,实际上返回的都是一个新字符串。例如:

1
2
3
let s = "hello";   // 一个全局小写的字符串
s.toUpperCase();    // 返回 "HELLO",但不会修改 s
s // => "hello": 原始字符串并未改变

原始值是按值比较的,即两个值只有在它们的值相同的时候才是相同的。对于数值、布尔值、null 和 undefined 来说,这话听起来确实有点绕。
其实很好理解,例如,在比较两个不同的字符串时,当且仅当这两个字符串长度相同并且每个索引的字符也相同时 JavaScript 才认为它们相等。

对象不同于原始值,对象是可修改的,即它们的值可以改变:

1
2
3
4
5
6
7
let o = { x: 1 };    // 先声明一个对象
o.x = 2;     // 修改: 改变它的一个属性的值
o.y = 3;    // 修改: 为它添加一个新属性

let a = [1, 2, 3];   // 数组也是可修改的
a[0] = 0;     // 修改: 改变数组中一个元素的值
a[3] = 4;   // 修改: 为数组添加一个新元素

对象不是按值比较的,两个不同的对象即使拥有完全相同的属性和值,它们也不想等。
同样,两个不同的数组,即使每个元素都相同,顺序也相同,它们也不相等:

1
2
3
4
let o = {x: 1}, p = {x: 1};  // 两个对象,拥有相同的属性
o === p   // => false: 不同的对象永远也不会相等
let a = [], b = [];    // 两个不同的空数组
a === b   // => false: 不同的数组永远也不会相等      

对象有时候被称作引用类型(reference type),以区别于 JavaScript 的原始类型。基于这一术语,对象值就是引用,对象是按引用比较的。
换句话说,两个对象值当且仅当它们引用同一个底层对象时,才是相等的。

1
2
3
4
5
let a = [];   // 这个变量引用一个空数组
let b = a;   // 现在 b 引用了同一个数组
b[0] = 1;   // 修改变量 b 引用的数组
a[0]   // => 1: 变化也能通过变量 a 看到
a === b // => true: a 和 b 引用同一个对象,所以它们相等    

从上面的代码可以看出,把对象(或数组)赋值给一个变量,其实是在赋值引用,并不会创建新对象的新副本。
如果想创建对象或数组的新副本,必须显式复制对象的属性或数组的元素。下面的例子使用 for 循环厌食了这个过程:

1
2
3
4
5
6
let a = ["a", "b", "c"];     // 想要复制的源数组
let b = [];      // 要复制到的另一个数组
for (let i = 0; i < a.length; i++) {
    b[i] = a[i];
}
let c = Array.from(b);     // 在 ES6 中,可以使用 Array.from() 复制数组

类似地,如果要比较两个不同的对象或数组,必须比较它们的属性或元素。一下代码定义了一个比较两个数组的函数:

1
2
3
4
5
6
7
8
function equalArrays(a, b) {
    if (a === b) return true;    // 同一个数组相等
    if (a.length != b.length) return false;   
    for (let i = 0; i < a.length; i++) {
        if (a[i] !== b[i]) return false;
    }
    return true;
}

类型转换

JavaScript 对待自己所需值的类型非常灵活。这一点我们在介绍布尔值时已经看到了。
JavaScript 需要一个布尔值,而你可能提供了其他类型的值,JavaScript 会根据需要转换这个值。
有些值(真性值)转换为 true,有些值(假性值)转换为 false。
对其他类型也是如此: 如果 JavaScript 想要字符串,它就会把你提供的任何值都转换为字符串。
如果 JavaScript 想要数值,它也会尝试把你给的值转换为一个数值(如果无法进行有意义的转换就转换为 NaN)

看几个例子:

1
2
3
4
10 + "objects"  // => "10 objects": 数值 10 转换为字符串
"7" * "4"    // => 28: 两个字符串都转换为数值
let n = 1 - "x";    // n == NaN; 字符串 "x" 无法转换为数值
n + "objects"    // => "NaN objects": NaN 转换为字符串 "NaN"

下表总结了 JavaScript 中类型之间的转换关系。表中加粗的内容是可能会让人觉得意外的转换目标。
空单元格表示没有转换必要,因此什么操作也不会发生。

转换为字符串 转换为数值 转换为布尔值
undefined "undefined" NaN false
null "null" 0 false
true "true" 1
false "false" 0
""(空字符串) 0 false
"1.2"(非空,数值) 1.2 true
"one"(非空、非数值) NaN true
0 "0" false
-0 "0" false
1(有限、非零) "1" true
Infinity "Infinity" true
-Infinity "-Infinity" true
NaN "NaN" false
{}(任何对象) true
[](空数组) "" 0 true
[9](一个数值元素) "9" 9 true
['a'](任何其他数组) 使用 join() 方法 NaN true
Function(){}(任何函数) NaN true

表中展示的原始值的转换相对容易理解。转换为字符串的情况对素有原始值都是有明确定义的。转换为数值就稍微有点微妙了。
可以解析为数值的字符串会转换为对应的数值。字符串开头和末尾可以有空格,但开头或末尾任何不属于数值字面量的非空格字符,都会导致字符串到数值的转换产生 NaN。
有些数值转换的结果可能会让人不可肆意,比如 true 转换为 1,而 false 和空字符串都转换为 0

转换与相等

JavaScript 有两个操作符用于测试两个值是否相等。一个是严格相等操作符 ===,如果两个值不是同一种类型,那么这个操作符就不会判定它们相等。
但由于 JavaScript 在类型转换上很灵活,所以它也定义了 == 操作符,这个操作符判定相等的标准相当灵活。
比如说,下列所有比较的结果都是 true:

1
2
3
4
null == undefined   // => true: 这两个值被判定为相等
"0" == 0   // => true: 字符串在比较钱会转换为数值
0 == false    // => true: 布尔值在比较前会转换为数值
"0" == false    // => true: 两个操作符在比较钱都转换为 0

但要记住,一个值可以转换为另一个值并不意味着这两个值是相等的。
比如,如果 undefined 用在了期待布尔值的地方,那它会被转换为 false。但这并不意味着 undefined == false。
JavaScript 操作符如语句期待不同类型的值,因此会执行以这些类型为目标类型的转换。
if 语句将 undeifned 转换为 false,但 == 操作符永远不会将其操作数转换为布尔值

显式转换

尽管 JavaScript 会自动执行很多类型的转换,但有时候我们也需要进行显式转换,或者有意进行显式转换以保证代码清晰

执行显式转换的最简单方法就是使用 Boolean()、Number() 和 String() 函数:

1
2
3
Number("3")   // =>3
String(false)   // => "false": 或者使用 false.toString()
Boolean([])   // => true

除 null 和 undefined 之外的所有值都有 toString() 方法,这个方法返回的结果通常与 String() 函数返回的结果相同。

顺便说一下,Boolean()、Number() 和 String() 函数也可以被当作构造函数通过 new 关键字来使用。
如果你这样使用它们,那会得到一个与原始布尔值、数值和字符串值类似的“包装”对象。
这种包装对象是早期 JavaScript 的历史遗存,已经没有必要再使用它们了。

某些 JavaScript 操作符会执行隐式类型转换,有时候可以利用这一点完成类型转换。
如果 + 操作符有一个操作数是字符串,那它会把另一个操作数转换为字符串。一元操作符 + 会把自己的操作数转换为数值。
而一元操作符 ! 会把自己的操作数转换为布尔值,然后再取反。这些事实导致我们常常会在某些代码中看到如下类型转换的用法:

1
2
3
4
x + ""   // => String(x)
+x    // => Number(x)
x - 0  // => Number(x)
!!x   // => Boolean(x): 注意两次取反

格式化和解析数值是计算机在程序中常见的错误来源,而 JavaScript 为数值到字符串和字符串到数值的转换提供了特殊函数和方法,能够对转换进行更精确的控制。

Number 类定义的 toString() 方法接收一个可选的参数,用于指定一个基数或底数。如果不指定这个参数,默认基数为 10.当然也可以按照其他基数(2 到 36)来转换数值。例如:

1
2
3
4
let n = 17;
let binary = "0b" + n.toString(2);   // binary == "0b10001"
let octal = "0o" + n.toString(8);   // octal = "0o21"
let hex = "0x" + n.toString(16);   // hex == "0x11"

在使用金融或科学数据时,可能需要控制转换后得到的字符串的小数位的个数或者有效数字的个数,或者需要控制是否采用指数记数法。
Number 类为这些数值到字符串的转换定义了 3 种方法。
toFixed() 把数值转换为字符串时可以指定小数点后面的位数。这个方法不使用指数记数法。
toExponential() 使用指数记数法将数值转换为字符串,结果是小数点前 1 位,小数点后为指定位数(意味着有效数字个数比你指定的值多 1 位)。
toPrecision() 按照指定的有效数字个数将数值转换为字符串。如果有效数字个数不是以显式数值的整数部分,它会使用指数记数法。
注意,这三种方法必要时都会舍去末尾的数字或者补零。来看下面的例子:

1
2
3
4
5
6
7
8
9
let n = 123456.789;   
n.toFixed(0)    // => "123457"
n.toFixed(2)   // => "123456.79"
n.toFixed(5)   // => "123456.78900"
n.toExponential(1)   // => "1.2e+5"
n.toExponential(3)   // => "1.235e+5"
n.toPercision(4)   // => "1.235e+5"
n.toPrecision(7)   // => "123456.8"
n.toPrecision(10)   // => "123456.7890"

如果把字符串传给 Number() 转换函数,它会尝试把字符串当成整数或浮点数字面量来解析。这个函数只能处理基数为 10 的整数,不允许末尾出现无关字符。
parseInt() 和 parseFloat() 函数(都是全局函数,不是任何类的方法)则更灵活一些。
parseInt() 只解析整数,而 parseFloat() 既解析整数也解析浮点数。
如果字符串以 0x 或 0X 开头,parseInt() 会将其解析为十六进制数值。
parseInt() 和 parseFloat() 都会跳过开头的空格,尽量多地解析数字字符,忽略后面的无关字符。
如果第一个非空格字符不是有效的数值字面量,它们会返回 NaN:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
parseInt("3 blind mice")   // => 3
parseFloat(" 3.14 meters")   // => 3.14
parseInt("-12.34")   // => -12
parseInt("0xFF")    // => 255
parseInt("0xff")   // => 255
parseInt("-0xFF")  // => -255
parseFloat(".1")   // => 0.1
parseInt("0.1")   // => 0
parseInt(".1")  // => NaN: 整数不能以 "." 开头
parseFloat("$72.47")   // => NaN: 数值不能以 "$" 开头

parseInt() 接收可选的第二个参数,用于指定要解析数值的底(基)数,合法的只是 2 到 36.例如:

1
2
3
4
5
parseInt("11", 2)   // => 3: (1*2 + 1)
parseInt("ff", 16)   // => 255: (15 * 16 + 15)
parseInt("zz", 36)   // => 1295: (35 * 36 + 35)
parseInt("077", 8)   // => 63: (7 * 8 + 7)
parseInt("077", 10)  // => 77: (7 * 10 + 7)

对象到原始值的转换

JavaScript 对象到原始值转换的复杂性,主要原因在于某些对象类型有不止一种原始值的表示。
比如,Date 对象可以用字符串表示,也可以用时间戳表示。JavaScript 规范定义了对象到原始值转换的 3 种基本算法

  • 偏字符串: 该算法返回原始值,而且只要可能就返回字符串
  • 偏数值: 该算法返回原始值,而且只要可能就返回数值
  • 无偏好: 该算法不倾向于任何原始值类型,而是由类定义自己的转换规则。JavaScript 内置类型除了 Date 类都实现了偏数值算法。Date 类实现了偏字符串算法

对象转换为布尔值

对象到布尔值的转换很简单: 所有对象都转换为 true。注意,这个转换不需要使用前面介绍的对象到原始值的转换算法,而是直接适用于所有对象。
包括空数组,甚至包括 new Boolean(false) 这样的包装对象

对象转换为字符串

在将对象转换为字符串时,JavaScript 首先使用偏字符串算法将它转换为一个原始值,然后将得到的原始值再转换为字符串

这种转换会发生在把对象传给一个接收字符串参数的内置函数时,比如将 String() 作为转换函数,或者将对象插入模板字面量中时就会发生这种转换

对象转换为数值

当需要把对象转换为数值时,JavaScript 首先使用偏数值算法将它转换为一个原始值,然后将得到的原始值再转换为数值

接收数值参数的内置 JavaScript 函数和方法都以这种方法将对象转换为数值,而除数值操作符之外的多数 JavaScript 操作符也按照这种方式把对象转换为数值

操作符转换特例

首先,JavaScript 中的 + 操作符执行数值加法和字符串拼接。
如果一个操作数是对象,那 JavaScript 会使用无偏好算法将对象转换为原始值。
如果两个操作数都是原始值,则会先检查它们的类型。如果有一个参数是字符串,则把另一个原始值也转换为字符串并拼接两个字符串。
否则,把两个参数都转换为数值并把它们相加

其次,== 和 ! 操作符允许类型转换的宽松方式执行相等和不相等测试。
如果一个操作数是对象,另一个操作数是原始值,则这两个操作符会使用无偏好算法将对象转换为原始值,然后再比较两个原始值。

最后,关系操作符 <<=>>= 比较操作数的顺序,既可以比较数值,也可以比较字符串。
如果操作数中有一个是对象,则会使用偏数值算法将对象转换为原始值。
不过要注意,与对象到数值转换不同,这个偏数值算法返回的原始值不会再被转换为数值。

注意,Date 对象的数值表示是可以使用 <> 进行有意义的比较的,但它的字符串表示则不行。
对于 Date 对象,无偏好算法会将其转换为字符串,而 JavaScript 中这两个操作符会使用偏数值算法的事实意味着我们可以比较两个 Data 对象的顺序。

toString() 和 valueOf() 方法

所有对象都会继承两个在对象到原始值转换时使用的方法,在接下来解释偏字符串、偏数值和无偏好转换算法前,我们必须先解释这两个方法。

第一个方法 toString() 的任务是返回对象的字符串表示。默认情况下,toString() 方法不会返回特别的值

1
({x: 1, y: 2}).toString()     // => "[object Object]"

很多类都定义了自己特有的 toString() 版本。
比如,Array 类的 toString() 方法会将数组的每个元素转换为字符串,然后再使用逗号作为分隔符将它们拼接起来。
Function 类的 toString() 方法会将用户定义的函数转换为 JavaScript 源代码的字符串。
Date 类定义的 toString() 方法返回一个人类友好(且 JavaScript 可解析)的日期和时间字符串。
RegExp 类定义的 toString() 方法会将 RegExp 对象转换为一个看起来像 RegExp 字面量的字符串:

1
2
3
4
5
[1,2,3].toString()   // => "1,2,3"
(function(x) { f(x); }).toString()    // => "function(x) { f(x); }"
/\d+/g.toString()   // "/\\d+/g"
let d = new Date(2020,0,1)
d.toString()  // => "Web Jan 01 2020 00:00:00 GMT-0800(Pacific Standard Time)"

另一个对象转换函数叫 valueOf()。这个方法的任务并没有太明确的定义,大体上可以认为它是把对象转换为代表对象的原始值(如果存在这样一个原始值)。
对象是复合值,且多数对象不能真正通过一个原始值来表示,因此 valueOf() 方法默认情况下只返回对象本身,而非返回原始值。
String、Number 和 Boolean 这样的包装类定义的 valueOf() 方法也只是简单地返回被包装的原始值。
Array、Function 和 RegExp 简单地继承默认方法,在这些类型的实例上调用 valueOf() 会返回对象本身。
Date 对象定义的 valueOf() 方法返回日期的内部表示形式,自 1970 年 1 月 1 鈤至今的毫秒数:

1
2
let d = new Data(2010, 0, 1);    // January 1, 2010, (Pacific time)
d.valueOf()     // => 1262332800000

对象到原始值转换算法:

解释完 toString() 和 valueOf() 方法后,现在我们可以大致地解释前面三个对象到原始值转换算法的实现了

  • 偏字符串算法首先尝试 toString() 方法。如果这个方法有定义且返回原始值,则 JavaScript 使用该原始值(即使这个值不是字符串)。
    如果 toString() 不存在,活着存在但返回对象,则 JavaScript 尝试 valueOf() 方法。
    如果这个方法存在且返回原始值,则 JavaScript 使用该值。否则,转换失败,报 TypeError
  • 偏数值算法与偏字符串算法类似,只不过是先尝试 valueOf() 方法,再尝试 toString() 方法
  • 无偏好算法取决于被转换对象的类。如果是一个 Date 对象,则 JavaScript 使用偏字符串算法。如果是其他类型的对象,则 javaScript 使用偏数值算法

以上规则适用于所有内置 JavaScript 类型,也是我们所有自定义类的默认规则。

在结束类型转换的讨论之前,有必要补充说明一下。偏数值转换规则的细节可以解释为什么空数组转换为数值 0,而单元素数组也可以转换为数值:

1
2
console.log(Number([]))    // => 0; 这有点让人出乎意料!
console.log(Number([99]))    // => 99;

对象到数值的转换首先使用偏数值算法把对象转换为一个原始值,然后再把得到的原始值转换为数值。
偏数值算法先尝试 valueOf(), 将 toString() 作为备用。Array 类继承了默认的 valueOf() 方法,该方法不返回原始值。
因此在尝试将数组转换为数值时,最终会调用 toString() 方法。空数组转换为字符串。而空字符串转换为数值 0。
只有一个元素的数组转换为该元素对应的字符串。如果数组只包含一个数值,则该数值先转换为字符串,再转换回数值

变量声明与赋值

计算机编程中最基本的一个技术就是使用名字(或标识符)表示值。绑定名字和值为我们提供了一种引用值和在程序中使用值的方式。
对于绑定名字和值,我们通常会说把值赋给变量。术语 "变量" 意味着可以为其赋予新值,也就是说与变量关联的值在程序运行时可能会变化。
如果把一个值永久地赋给一个名字,那么可以称该名字为常量而不是变量。

在 JavaScript 中使用变量或常量前,必须先声明它。在 ES6 及之后的版本中,这是通过 let 和 const 关键字来完成的

使用 let 和 const 声明

在现代 JavaScript(ES6 及之后)中,变量是通过 let 关键字声明的:

1
2
let i;
let sum;

也可以使用一条 let 语句声明多个变量:

1
let i, sum;

声明变量的同时(如果可能)也为其赋予一个初始值是个好的编程习惯:

1
2
3
let message = "hello";
let i = 0, j = 0, k = 0;
let x = 2, y = x * x;   // 初始化语句可以使用前面声明的变量

如果在 let 语句中不为变量指定初始值,变量也会被声明。但在被赋值之前它的值是 undefined。

要声明常量而非变量,则要使用 const 而非 let。const 与 let 类似,区别在于 const 必须在声明时初始化常量:

1
2
3
const H0 = 74;    // 哈勃常数(kn/s/Mpc)
const C = 299792.458;   // 真空中的光速(km/s)
const AU = 1.496E8;    // 天文单位: 地球与太阳间的平均距离(km)

顾名思义,常量的值是不能改变的,尝试给常量重新赋值会抛出 TypeError

声明常量的一个常见(但并非普遍性)的约定是全部字母大写,如 H0 或 HTTP_NOT_FOUND,以区别于变量

在后面,我们会学习 JavaScript 中的 for,for/in 和 for/of 循环语句。其中每种循环都包含一个循环变量,在循环的每次迭代中都会取得一个新值。
JavaScript 允许在循环语法中声明这个循环变量,这也是 let 另一个常见的使用场景:

1
2
3
for (let i = 0, len = data.length; i < len; i++) console.log(data[i]);
for (let datum of data) console.log(datum);
for (let property in object) console.log(property);

虽然看起来有点怪,但也可以使用 const 声明 for/in 和 for/of 中的这些循环 "变量",只要保证在循环体内不给它重新赋值即可。此时,const 声明的只是一次循环迭代期间的常量值:

1
2
for (const datum of data) console.log(datum);
for (const property in object) console.log(property);

变量与常量作用域:

变量的作用域(scope)是程序源代码中的一个区域,在这个区域内变量有定义。通过 let 和 const 声明的变量和常量具有块作用域。这意味着它们只在 let 和 const 语句所在的代码块中有定义。
JavaScript 类和函数的函数体是代码块,if/else 语句的语句体、while 和 for 循环的循环体都是代码块。
粗略地讲,如果变量或常量声明在一对花括号中,那这对花括号就限定了该变量或常量有定义的代码区域(当然,在声明变量或常量的 let 或 const 语句之前的代码行中引用这些变量或常量也是不合法的)。
作为 for、for/in 或 for/of 循环的一部分声明的变量和常量,以循环体作为它们的作用域,即使它们实际上位于花括号外部。

如果声明位于顶级,在任何代码块外部,则称其为全局变量或常量,具有全局作用域。在 Node 和客户端 JavaScript 模块中,全局变量的作用域是定义它们的文件。
但在传统客户端 JavaScript 中,全局变量的作用域是定义它们的 HTML 文档。换句话说,如果有 <script> 标签声明了一个全局变量或常量,则该变量或常量在同一个文档的任何 <script> 元素中(或者至少在 let 和 const 语句执行之后执行的所有脚本中)都有定义

重复声明:

在同一个作用域中使用多个 let 或 const 声明同一个名字是语法错误。在嵌套作用域中声明同名变量是合法的(尽管实践中最好不要这么做):

1
2
3
4
5
6
7
const x = 1;   // 声明 x 为全局变量
if (x === 1) {
    let x = 2;   // 在同一个代码块中,x 可以引用不同的值
    console.log(x);   // 打印 2
}
console.log(x);  // Prints 1: 现在又回到了全局作用域
let x = 3;   // 错误! 重新声明 x 会导致语法错误

声明与类型:

如果你使用过静态类型语言(如 C 或 Java),可以认为变量声明的主要目的是为变量指定可以赋予它的值的类型。但我们也看到了,JavaScript 的变量声明与值的类型无关。
JavaScript 变量可以保存任何类型的值。例如,在 JavaScript 中,给一个变量赋一个数值,然后再给它赋一个字符串是合法的:

1
2
let i = 0;
i = "ten"

使用 var 的变量声明

在 ES6 之前的 JavaScript 中,声明变量的唯一方式是使用 var 关键字,无法声明常量。var 的语法与 let 的语法相同:

1
2
3
var x;
var data = [], count = data.length;
for (var i = 0; i < count; i++) console.log(data[i]);

虽然 var 和 let 有相同的语法,但它们也有重要的区别

  • 使用 var 声明的变量不具有块作用域。这种变量的作用域仅限于包含函数的函数体,无论它们在函数中嵌套的层次有多深
  • 如果在函数体外部使用 var,则会声明一个全局变量。但通过 var 声明的全局变量与通过 let 声明的全局变量有一个重要区别。
    通过 var 声明的全局变量被实现为全局对象的属性。全局对象可以通过 globalThis 引用。因此,如果你在函数外部写了var x = 2;,就相当于写了 globalThis.x = 2; 不过要注意,这么类比并不完全恰当。因为通过全局 var 创建的这个属性不能使用 delete 操作符删除。通过 let 和 const 声明的全局变量和常量不是全局对象的属性
  • 与通过 let 声明的变量不同,使用 var 多次声明同名变量是合法的。而且由于 var 变量具有函数作用域而不是块作用域,这种重新声明实际上是很常见的。
    变量 i 经常用于保存整数值,特别是经常用作 for 循环的索引变量。在有多个 for 循环的函数中,每个循环通常都以 for (var i = 0; ... 开头。 因为 var 并不会把这些变量的作用域限定在循环体内,每次循环都会(无害地)重新声明和重新初始化同一个变量
  • var 声明的一个最不同寻常的特性是作用域提升(hoisting)。在使用 var 声明变量时,该声明会被提高(或提升)到包含函数的顶部。
    但变量的初始化仍然在代码所在位置完成,只有变量的定义转移到了函数顶部。因此对使用 var 声明的变量,可以在包含函数内部的任何地方使用而不会报错。
    如果初始化代码尚未运行,则变量的值可能是 undefined,但在初始化之前是可以使用变量而不报错的(这会成为一个 bug 来源,也是 let 纠正的一个最重要的错误特性。如果使用 let 声明了一个变量,但试图在 let 语句运行前使用该变量则会导致错误,而不是得到 undefined 值)

使用未声明的变量

在严格模式下,如果试图使用未声明的变量,那代码运行时会触发引用错误。但在严格模式外部,如果将一个值赋给尚未使用 let、const 或 var 声明的名字,则会创建一个新全局变量。
而且,无论这个赋值语句在函数或代码块中被嵌套了多少次,都会创建一个全局变量。这肯定不是我们想要的,非常容易招致缺陷,也是推荐使用严格模式一个最好的理由。

以这种意外方式创建的全局变量类似使用 var 声明的全局变量,都定义全局对象的属性。但与通过恰当的 var 声明定义的属性不同,这些属性可以通过 delete 操作删除

解构赋值

ES6 实现了一种复合声明与赋值语法,叫做解构赋值(destructuring assignment)。
在解构赋值中,等号右手端的值是数组或对象("结构化"的值),而左手端通过模拟数组或对象字面量语法指定一个或多个变量。
在解构赋值发生时,会从右侧的值中提取(解构)出一个或多个值,并保存到左侧列出的变量中。
解构赋值可能是最常用于在 const、let 或 var 声明语句中初始化变量,但也可以在常规赋值表达式中使用(给已声明的变量赋值)。而且,解构也可以在定义函数参数时使用

下面是解构数组值的一段示例代码:

1
2
3
4
let [x, y] = [1, 2];   // 相当于 let x = 1, y = 2
[x, y] = [x + 1, y + 1];    // 相当于 x = x + 1, y = y + 1
[x, y] = [y, x]    // 交换两个变量的值
[x, y]  // => [3, 2]: 递增和交换后的值

解构赋值让使用返回数组的函数变得异常便捷

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 将 [x,y] 左边转换为 [r,theta] 极坐标
function toPolar(x, y) {
    return [Math.sqrt(x*x+y*y), Math.atan2(y, x)];
}

// 将极坐标转换为笛卡儿坐标
function toCartesian(r, theta) {
    return [r*Math.cos(theta), r*Math.sin(theta)]
}

let [r,theta] = toPolar(1.0, 1.0)   // r=Math.sqrt(2); theta == Math.PI/4
let [x, y] = toCartesian(r, theta)   // [x, y] == [1,0 1.0]

前面我们看到了,可以在 JavaScript 的各种 for 循环中声明变量和常量。同样也可以在这个上下文中使用变量解构赋值。
下面这段代码循环遍历了一个对象所有属性的名/值对,并使用解构赋值将两个元素的数组转换为单个变量:

1
2
3
4
let o = { x: 1, y: 2 };  // 要遍历的对象
for (const [name, value] of Object.entries(o)) {
    console.log(name, value) // 打印 "x 1" 和 "y 2"
}

解构赋值左侧变量的个数不一定与右侧数组中元素的个数相同。左侧多余的变量会被设置为 undefined,而右侧多余的值会被忽略。
左侧的变量里诶包可以包含额外的逗号,以跳过右侧的某些值:

1
2
3
let [x, y] = [1];    // x== 1; y == undefined
[x,y] = [1, 2, 3]  // x == 1; y == 2
[,x,,y] = [1,2,3,4]  // x==2; y == 4

在解构赋值时,如果你想把所有未使用或剩余的值收集到一个变量中,可以在左侧最后一个变量名前面加上 3 个点(...)

1
let [x, ...y] = [1,2,3,4];   // y == [2,3,4]

解构赋值可用于嵌套数组。此时,赋值的左侧看起来也应该像一个嵌套的数组字面量:

1
let [a, [b, c]] = [1, [2,2,5], 3];   // a == 1; b==2; c==2.5

数组解构的一个强大特性是它并不要求必须是数组!实际上,赋值的右侧可以是任何可迭代对象,任何可以在 for/of 循环中使用的对象也可以被解构:

1
let [first, ...rest] = "Hello";   // first = "H"; rest = ["e", "l", "l", "o"]

解构赋值在右侧是对象值的情况下也可以执行。此时,赋值的左侧看起来就像一个对象字面量,即一个包含在花括号内的逗号分割的变量名列表:

1
2
let transparent = {r: 0.0, g: 0.0, b: 0.0, a: 1.0};   // 一个 RGBA 颜色对象
let {r, g, b} = transparent;   // r == 0.0; g == 0.0;  b == 0.0

下面这个例子展示了如何把 Math 对象的全局函数复制到变量中,这样可以简化需要大量三角计算的代码:

1
2
// 相当于 const sin=Math.sin, cos=Math.cos, tan=Math.tan
const {sin, cos, tan} = Math;

注意,代码中 Math 对象的属性远远不止解构赋值给个别变量的这 3 个。那些没有提到名字的属性都被忽略了。
如果赋值的左侧包含一个不是 Math 属性的变量名,该变量将被赋值 undefined

在上面每个对象解构的例子中,我们都选择了与要解构对象的属性一致的变量名。这样可以保持语法简单且容易理解,但这并不是必需的。
对象解构赋值左侧的每个标识符都可以是一个冒号分隔的标识符对,其中第一个标识符是要解构其值的属性名,第二个标识符是要把值赋给它的变量名:

1
2
// 相当于 const cosine = Math.cos, tangent = Math.tan;    
const { cos: cosine, tan: tangent } = Math;

我发现如果变量名和属性名不一样,对象解构语法会变得过于复杂,反而导致用户不大。所以在这种情况下我通常不会使用简写形式。
如果你选择使用,要记住属性名一定是在冒号左侧,无论是在对象字面量中,还是在对象解构赋值的左侧。

在使用嵌套对象、对象的数组、或数组的对象时,解构赋值甚至会变得更复杂,但都是合法的:

1
2
3
let points = [{x: 1, y: 2}, {x: 3, y: 4}];   // 两个坐标点对象的数组
let [{x: x1, y: y1}, {x: x2, y: y2}] = points;  // 解构到 4 个变量中
(x1 === 1 && y1 === 2 && x2 === 3 && y2 === 4)  // => true

如果不是解构对象的数组,也可以解构数组的对象:

1
2
3
let points = { p1: [1,2], p2: [3,4] };     // 有两个数组属性的对象
let { p1: [x1, y1], p2: [x2, y2] } = points;   // 解构到 4 个变量中
(x1 === 1 && y1 === 2 && x2 === 3 && y2 === 4)  // => true

类似这样的复杂解构语句既难写又难理解,甚至还不如使用类似 let x1 = points.p1[0]; 这样的传统代码更简单易懂

理解复杂解构:

如果你发现自己维护的代码中使用了复杂的解构赋值,可以通过一些规律来应对这种复杂性。
首先,想象一下常规(单值)赋值。在赋值之后,你可以从赋值的左侧取得变量名,然后在自己的代码中作为表达式使用,这个表达式会被求值为赋给它的值。
解构赋值其实也一样,解构赋值的左侧看起来像是一个数组字面量或对象字面量。在赋值之后,左侧也类似于一个有效的数组字面量或对象字面量。
为了验证你写的解构赋值是正确的,可以尝试在另一个赋值表达式的右侧使用解构赋值的左侧:

1
2
3
4
5
6
// 先定义一个数据结构并进行复杂的解构赋值
let points = [{x: 1, y: 2}, {x: 3, y: 4}];
let [{x: x1, y: y1}, {x: x2, y: y2}] = points;

// 通过翻转赋值的两端来验证你的解构语法
let points = [{x: x1, y: y1}, {x: x2, y: y2}];  // points2 == points