在 JavaScript 的世界里,数据类型之间的转换无处不在。即使你没有主动显式地去转换,但 JavaScript 在私底下“偷偷地”帮我们做了很多类型转换的工作。那么,它们究竟是按照什么规则去转换的呢?我们试图在本文中找到答案,来解开谜底。
ECMAScript 是负责制定标准的,而 JavaScript 则是前者的一种实现。
在 ECMAScript 标准中定义一组转换抽象操作,常见的抽象操作(Abstract Operation)有 ToPrimitive、ToBoolean、ToNumber、ToString、ToObject 等等。
在 ECMAScript 标准中,使用 ToPrimitive 抽象操作将引用类型转换为原始类型。(详情看 #sec-7.1.1)
ToPrimitive(input[, PerferredType])
参数 input 是文章开头提到的 8 种数据类型的值(Undefined、Null、Boolean、String、Symbol、Number、BigInt、Object)。参数 PreferredType 是可选的,表示要转换到的原始值的预期类型,取值只能是字符串 "default"(默认)、"string"、"number" 之一。
ToPrimitive 操作,可概括如下:
注意:当不带 hint 去调用 ToPrimitive 抽象操作时,通常它的行为就像 hint 是 Number 类型一样。但是,(派生)对象可以通过定义 @@toPrimitive 方法来替代此行为。在本规范中定义的对象里,只有 Date 对象(详情看 #sec-20.4.4.45)和 Symbol 对象(详情看 #sec-19.4.3.5)会覆盖默认的 ToPrimitive 行为。
其他:
- 以上提到的 @@toPrimitive 方法,即属性名称为 [Symobl.toPrimitive] 的方法。
- Date 对象的 @@toPrimitive 方法定义:当 hint 为 "default" 时,会使 hint 等于 "string"。所以这也是 Date 对象转换为原始值时,会先调用 instance.toString() 方法,而不是 instance.valueOf() 方法的原因。
- 目前 JavaScript 的内置对象中,含有 @@toPrimitive 方法的,只有 Date 和 Symbol 对象。
用口水话再总结一下,如下(哈哈):
提醒:
- 关于 Date 对象的 Date.prototype[@@toPrimitive] 内部方法实现,其实是将 hint 为 "default" 的情况改为 "string",然后执行第 5 步的 OrdinaryToPrimitive 操作。(详情看 #sec-20.4.4.45)
- 关于 Symbol 对象的 Symbol.prototype[@@toPrimitive] 内部方法实现,如果传递给该方法的是一个 Symbol 类型的值,则直接返回该值。如果该值是引用类型,且含有属性 [[SymbolData]],而且该属性值为 Symbol 类型,则返回该属性值,否则会抛出 TypeError 类型错误。(详情看 #sec-19.4.3.5)
那么 OrdinaryToPrimitive 的操作是怎样的呢?我们接着往下看...
详情看:#sec-7.1.11
OrdinaryToPrimitive(O, hint)
参数 O 为引用类型。参数 hint 为 String 类型,其值只能是字符串 "string"、"number" 之一。
(官话)OrdinaryToPrimitive 操作,可概括如下:
其中 IsCallable(argument) 操作,大致内容是:当参数 argument 为引用类型且 argument 对象包含内部属性 [[Call]] 时返回 true, 否则返回 false。话句话说,就是用于判断是否为函数。
(口水话)再总结一下:
到这里,已经完整地讲述了 ToPrimitive 操作的全部过程。文笔不太好,我不知道你们有没看明白,倘若仍有疑惑,请反复斟酌或直接查看 ECMAScript 标准。
一元加运算符 +(unary plus)是将操作数转换为数字的最快且首选的方式,因为它不对该数字执行任何其他运算。
区分一元加运算符(+)和算术运算符(+)的方法,就是前者只有一个操作数,而后者是两个操作数。
- // 运算符: x + y
-
- // Number + Number -> 数字相加
- 1 + 2 // 3
-
- // Boolean + Number -> 数字相加
- true + 1 // 2
-
- // Boolean + Boolean -> 数字相加
- false + false // 0
-
- // Number + String -> 字符串连接
- 5 + 'foo' // "5foo"
-
- // String + Boolean -> 字符串连接
- 'foo' + false // "foofalse"
-
- // String + String -> 字符串连接
- 'foo' + 'bar' // "foobar"
- const obj = {
- [Symbol.toPrimitive]: hint => {
- if (hint === 'number') {
- return 1
- } else if (hint === 'string') {
- return 'string'
- } else {
- return 'default'
- }
- }
- }
-
- +obj // 1 hint is "number"
- `${obj}` // "string" hint is "string"
- obj + '' // "default" hint is "default"
- obj + 1 // "default1" hint is "default"
- Number(obj) // 1 hint is "number"
- String(obj) // "string" hint is "string"
将一个操作数转换为布尔值,这应该是最简单的了。(详情看 #sec-7.1.2)
所以总结下来就是:
操作数 | 结果 |
---|---|
undefined、null、false、+0、-0、NaN、''、0n | false |
除以上这些(falsy)值之外 | true |
在 JavaScript 中,如果一个操作数 argument 通过 ToBoolean(argument) 操作后被转换为 true,那么这些操作数称为真值(truthy),否则为虚值(falsy)。
- // 转换为 Boolean 值的两种方式
- !!x
- Boolean(x)
将一个操作数转换为数字值。(详情看 #sec-7.1.4)
参数类型 | 结果 |
---|---|
Undefined | NaN |
Null | +0 |
Boolean | true 转换为 1,false 转换为 +0。 |
Number | 直接返回,不做类型转换。 |
String | 1. 纯数字的字符串转换为相应的数字; 2. 空字符串 '' 转为 +0; 3. 否则为 NaN; 其中 0x 开头的字符串被当成 16 进制。 |
Symbol | 无法转换,抛出 TypeError 错误。 |
BigInt | 无法转换,抛出 TypeError 错误。 |
Object | 两个步骤: 1. 将引用类型转化为原始值 ToPrimitive(argument, 'number'); 2. 转化为原始值后,进行 ToNumber(primValue) 操作,即按上面的类型转换。 |
需要注意的是
- Number(undefined) 结果为 NaN,而 Number(null) 结果为 0。
- 含有前导和尾随空白符(\n、\r、\t、\v、\f)的字符串,在转换为数字类型的时候空白符会被忽略。
- 上面也提到过,使用一元加运算符(+) 是将其他类型转换为数值的常用方式。
- Number(undefined) // NaN
- Number(null) // 0
-
- '\n 123 \t' == 123 // true
-
- +'string' // NaN
- +true // 1
- +[] // 0
- +{} // NaN
将一个操作数转换为字符串类型的值。(详情看 #sec-7.1.17)
参数类型 | 结果 |
---|---|
Undefined | undefined |
Null | null |
Boolean | true 转换为 "true",false 转换为 "false"。 |
Number | 1. NaN 转换为 "NaN"; 2. +0 或 -0 转换为 "0"; 3. 其中 Infinity 和 -Infinity 分别转换为 "Infinity"、"-Infinity"; 4. 若 x 是小于 0 的负数,则返回 "-x";若 x 是大于 0 的正数,则返回 "x"; 5. 其他不常用的数值,请看 #sec-6.1.6.1.20。 |
String | 直接返回,不做类型转换。 |
Symbol | 无法转换,抛出 TypeError 错误。 |
BigInt | 10n 转换为 "10"。 |
Object | 两个步骤: 1. 将引用类型转化为原始值 ToPrimitive(argument, 'string'); 2. 转化为原始值后,进行 ToString(primValue) 操作,即按上面的类型转换。 |
需要注意的是,Symbol 原始值不能转换为字符串,只能将其转换成对应的包装对象,再调用 Symbol.prototype.toString() 方法。
- // 下面会导致 Symbol('foo') 进行隐式转换,即 ToString(Symbol),按以上规则,是会抛出异常的
- console.log(Symbol('foo') + 'bar' ) // TypeError: Cannot convert a Symbol value to a string
-
- // Symbol('foo') 结果是 Symbol 的原始值,再调用其包装对象的属性时,会自动转化为包装对象再调用其 toString() 方法
- console.log(Symbol('foo').toString() + 'bar' ) // "Symbol(foo)bar"
抛一个有趣的问题:
- // 运行出错
- var name = Symbol() // TypeError: Cannot convert a Symbol value to a string
-
- // 正常运行,不会抛出错误
- let name = Symbol()
-
- // 为什么呢?
答案我不写了,感兴趣的可以自行搜索。对此有疑问的可以先看下文章:关于 var、let 的顶层对象的属性。
将一个操作数转换为引用类型的值。(详情看 #sec-7.1.18)
参数类型 | 结果 |
---|---|
Undefined | 无法转换,抛出 TypeError 错误。 |
Null | 无法转换,抛出 TypeError 错误。 |
Boolean | 返回 new Boolean(argument)。 |
Number | 返回 new Number(argument)。 |
String | 返回 new String(argument)。 |
Symbol | 返回 Object(Symbol(argument))。 |
BigInt | 返回 Object(BigInt(argument))。 |
Object | 直接返回,不做类型转换。 |
需要注意都是,JavaScript 内置的 Symbol、BigInt 对象不能使用 new 关键字去创建实例对象,只能通过 Object() 函数来创建一个包装对象(wrapper object)。
- // 错误示例
- const sym = new Symbol() // TypeError: Symbol is not a constructor
-
- // 正确示例
- const sym = Symbol()
- console.log(typeof sym) // "symbol"
- const symObj = Object(sym)
- console.log(typeof symObj) // "object"
-
- // BigInt 同理
需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如 new Boolean、new Number、new String)仍可使用。这也是 ES6+ 新增的 Symbol、BigInt 数据类型无法通过 new 关键字创建实例对象的原因。