在 JavaScript 的世界里,数据类型之间的转换无处不在。即使你没有主动显式地去转换,但 JavaScript 在私底下“偷偷地”帮我们做了很多类型转换的工作。那么,它们究竟是按照什么规则去转换的呢?我们试图在本文中找到答案,来解开谜底。
ECMAScript 是负责制定标准的,而 JavaScript 则是前者的一种实现。
在 ECMAScript 标准中定义一组转换抽象操作,常见的抽象操作(Abstract Operation)有 ToPrimitive
、ToBoolean
、ToNumber
、ToString
、ToObject
等等。
一、ToPrimitive
1. ToPrimitive
在 ECMAScript 标准中,使用 ToPrimitive 抽象操作将引用类型转换为原始类型。(详情看 #sec-7.1.1)
ToPrimitive(input[, PerferredType])
参数 input
是文章开头提到的 8 种数据类型的值(Undefined、Null、Boolean、String、Symbol、Number、BigInt、Object)。参数 PreferredType
是可选的,表示要转换到的原始值的预期类型,取值只能是字符串 "default"
(默认)、"string"
、"number"
之一。
ToPrimitive 操作,可概括如下:
-
input
是 ECMAScript 语言类型的值。 - 如果
input
是引用类型,那么- 如果没有传递
PreferredType
参数,使hint
等于"default"
。 - 如果参数
PreferredType
提示 String 类型,使hint
等于"string"
。 - 否则,参数
PreferredType
提示 Number 类型,使hint
等于"number"
。 - 使
exoticToPrim
等于GetMethod(input, @@toPrimitive)
的结果(大致意思是,获取input
对象的@@toPrimitive
属性值,并将其赋给exoticToPrim
)。 - 如果
exoticToPrim
不等于undefined
(即input
对象含@@toPrimitive
属性),那么- 使
result
等于Call(exoticToPrim, input, « hint »)
的结果(大致意思是,执行@@toPrimitive
方法,即exoticToPrim(hint)
)。 - 如果
result
不是引用类型,则返回result
。 - 否则抛出
TypeError
类型错误。
- 使
- 如果
hint
是"default"
,则将hint
设为"number"
。 - 返回
OrdinaryToPrimitive(input, hint)
。
- 如果没有传递
- 返回
input
(即原始类型的值直接返回)。
注意:当不带
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
对象。
用口水话再总结一下,如下(哈哈):
- 如果
input
是原始类型,直接返回input
(不做转换操作)。 - 如果参数
PreferredType
是 String(Number)类型,那么使得hint
等于"string"
("number"
),否则hint
等于默认的"default"
。 - 如果
input
中存在@@toPrimitive
属性(方法),若@@toPrimitive
方法的返回值为原始类型,则 ToPrimitive 的操作结果就是该返回值,否则抛出TypeError
类型错误。 - 如果经过以上步骤之后
hint
是"default"
,则使hint
等于"number"
。 - 返回
OrdinaryToPrimitive(input, hint)
操作的结果。
提醒:
关于
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
的操作是怎样的呢?我们接着往下看...
2. OrdinaryToPrimitive
详情看:#sec-7.1.11
OrdinaryToPrimitive(O, hint)
参数 O
为引用类型。参数 hint
为 String 类型,其值只能是字符串 "string"
、"number"
之一。
(官话)OrdinaryToPrimitive 操作,可概括如下:
-
O
是引用类型。 -
hint
是 String 类型,且hint
的值只能是"string"
或"number"
之一。 - 如果
hint
为"string"
,使methodNames
等于« "toString", "valueOf" »
(其中«»
表示规范中的 List,类似于数组)。 - 如果
hint
为"number"
,使methodNames
等于« "valueOf", "toString" »
。 - 遍历
methodNames
,使name
等于每个迭代值,并执行:- 使
method
等于Get(O, name)
(即获取对象O
的name
属性,相当于获取对象的toString
或valueOf
属性,具体执行顺序视hint
而定)。 - 如果
IsCallable(method)
结果为true
,那么:- 使
result
等于Call(method, O)
结果(即调用method()
方法)。 - 如果
result
为原始类型,则返回result
。
- 使
- 使
- 抛出
TypeError
类型错误。
其中
IsCallable(argument)
操作,大致内容是:当参数argument
为引用类型且argument
对象包含内部属性[[Call]]
时返回true
, 否则返回false
。话句话说,就是用于判断是否为函数。
(口水话)再总结一下:
- 当经过 ToPrimitive 操作,然后执行
OrdinaryToPrimitive(input, hint)
操作,那么步骤如下: - 如果
hint
为"string"
,它会先调用input.toString()
方法,- 若
toString()
结果为原始类型,则直接返回该结果。 - 否则,继续调用
input.valueOf()
方法,若结果为原始类型,则返回该结果,否则抛出TypeError
类型错误。
- 若
- 若
hint
为"number"
,它先调用input.valueOf()
方法,- 若
valueOf()
结果为原始类型,则直接返回该结果。 - 否则,继续调用
input.toString()
方法,若结果为原始类型,则返回该结果,否则抛出TypeError
类型错误。
- 若
到这里,已经完整地讲述了 ToPrimitive 操作的全部过程。文笔不太好,我不知道你们有没看明白,倘若仍有疑惑,请反复斟酌或直接查看 ECMAScript 标准。
3. 一些示例
-
-
、*
、/
、%
这四种操作符都会把符号两边的操作数先转换为数字再进行运算。 -
+
的作用可以是数值求和,也可以是字符串拼接。- 若符号两边操作数都是数字,则进行数字运算。
- 若符号一边是字符串,则会把另一端转换为字符串进行拼接操作。
一元加运算符
+
(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"
二、ToBoolean
将一个操作数转换为布尔值,这应该是最简单的了。(详情看 #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)
三、ToNumber
将一个操作数转换为数字值。(详情看 #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
四、ToString
将一个操作数转换为字符串类型的值。(详情看 #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 的顶层对象的属性。
五、ToObject
将一个操作数转换为引用类型的值。(详情看 #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
关键字创建实例对象的原因。