前言
JavaScript 中的类型转换一直都是让前端开发者最头疼的问题。前阵子,推特上有个人专门发了一张图说 JavaScript 让人不可思议。
除了这个,还有很多经典的、让 JavaScript 开发者摸不着头脑的类型转换,譬如下面这些,你是否知道结果都是多少?
1 + {} === ?
{} + 1 === ?
1 + [] === ?
1 + '2' === ?
本文将带领你从 ECMA 规范开始,去深入理解 JavaScript 中的类型转换,让类型转换不再成为前端开发中的拦路虎。
数据类型
JS 中有六种简单数据类型:undefined
、null
、boolean
、string
、number
、symbol
,以及一种复杂类型:object
。
但是 JavaScript 在声明时只有一种类型,只有到运行期间才会确定当前类型。在运行期间,由于 JavaScript 没有对类型做严格限制,导致不同类型之间可以进行运算,这样就需要允许类型之间互相转换。
类型转换
显式类型转换
显式类型转换就是手动地将一种值转换为另一种值。一般来说,显式类型转换也是严格按照上面的表格来进行类型转换的。
常用的显式类型转换方法有 Number
、String
、Boolean
、parseInt
、parseFloat
、toString
等等。
这里需要注意一下 parseInt
,有一道题偶尔会在面试中遇到。
问:为什么 [1, 2, 3].map(parseInt) 返回 [1,NaN,NaN]?
答:parseInt 函数的第二个参数表示要解析的数字的基数。该值介于 2 ~ 36 之间。如果省略该参数或其值为 0,则数字将以 10 为基础来解析。如果它以“0x”或“0X”开头,将以 16 为基数。
如果该参数小于 2 或者大于 36,则 parseInt() 将返回 NaN。
一般来说,类型转换主要是基本类型转基本类型、复杂类型转基本类型两种。
转换的目标类型主要分为以下几种:
- 转换为
string
- 转换为
number
- 转换为
boolean
我参考了 ECMA-262 的官方文档来总结一下这几种类型转换。ECMA 文档链接:ECMA-262
ToNumber
其他类型转换到 number
类型的规则见下方表格:
原始值 | 转换结果 |
---|---|
Undefined | NaN |
Null | 0 |
true | 1 |
false | 0 |
String | 根据语法和转换规则来转换 |
Symbol | Throw a TypeError exception |
Object | 先调用 toPrimitive,再调用 toNumber |
String
转换为 Number
类型的规则:
- 如果字符串中只包含数字,那么就转换为对应的数字。
- 如果字符串中只包含十六进制格式,那么就转换为对应的十进制数字。
- 如果字符串为空,那么转换为 0。
- 如果字符串包含上述之外的字符,那么转换为 NaN。
使用 +
可以将其他类型转为 number
类型,我们用下面的例子来验证一下。
+undefined // NaN
+null // 0
+true // 1
+false // 0
+'111' // 111
+'0x100F' // 4111
+''// 0'b'+'a'+ +'a'+'a'//'baNaNa'
+Symbol() // Uncaught TypeError: Cannot convert a Symbol value to a number
ToBoolean
原始值 | 转换结果 |
---|---|
Undefined | false |
Boolean | true or false |
Number | 0 和 NaN 返回 false,其他返回 true |
Symbol | true |
Object | true |
我们也可以使用 Boolean
构造函数来手动将其他类型转为 boolean
类型。
Boolean(undefined) // false
Boolean(1) // true
Boolean(0) // false
Boolean(NaN) // false
Boolean(Symbol()) // true
Boolean({}) // true
ToString
原始值 | 转换结果 | |
---|---|---|
Undefined | ‘Undefined’ | |
Boolean | ‘true’ or ‘false’ | |
Number | 对应的字符串类型 | |
String | String | |
Symbol | Throw a TypeError exception | |
Object | 先调用 toPrimitive,再调用 toNumber |
转换到 string
类型可以用模板字符串来实现。
`${undefined}` // 'undefined'
`${true}` // 'true'
`${false}` // 'false'
`${11}` // '11'
`${Symbol()}` // Cannot convert a Symbol value to a string
`${{}}`
隐式类型转换
隐式类型转换一般是在涉及到运算符的时候才会出现的情况,比如我们将两个变量相加,或者比较两个变量是否相等。
隐式类型转换其实在我们上面的例子中已经有所体现。对于对象转原始类型的转换,也会遵守 ToPrimitive
的规则,下面会进行细说。
从 ES 规范来看类型转换
ToPrimitive
在对象转原始类型的时候,一般会调用内置的 ToPrimitive
方法,而 ToPrimitive
方法则会调用 OrdinaryToPrimitive
方法,我们可以看一下 ECMA 的官方文档。
我来翻译一下这段话。
ToPrimitive
方法接受两个参数,一个是输入的值 input
,一个是期望转换的类型 PreferredType
。
- 如果没有传入
PreferredType
参数,让hint
等于 ”default” - 如果
PreferredType
是hint String
,让hint
等于 ”string” - 如果
PreferredType
是hint Number
,让hint
等于 ”number” - 让
exoticToPrim
等于GetMethod(input, @@toPrimitive)
,意思就是获取参数input
的@@toPrimitive
方法 - 如果
exoticToPrim
不是Undefined
,那么就让result
等于Call(exoticToPrim, input, « hint »)
,意思就是执行exoticToPrim(hint)
,如果执行后的结果result
是原始数据类型,返回result
,否则就抛出类型错误的异常 - 如果
hint
是 ”default”,让hint
等于 ”number” - 返回
OrdinaryToPrimitive(input, hint)
抽象操作的结果
OrdinaryToPrimitive
而 OrdinaryToPrimitive
方法也接受两个参数,一个是输入的值 O,一个也是期望转换的类型 hint
。
- 如果输入的值是个对象
- 如果
hint
是个字符串并且值为 ’string’ 或者 ’number’ - 如果
hint
是 ’string’,那么就将methodNames
设置为toString
、valueOf
- 如果
hint
是 ’number’,那么就将methodNames
设置为valueOf
、toString
- 遍历
methodNames
拿到当前循环中的值name
,将method
设置为O[name]
(即拿到valueOf
和toString
两个方法) - 如果
method
可以被调用,那么就让result
等于method
执行后的结果,如果result
不是对象就返回result
,否则就抛出一个类型错误的报错。
ToPrimitive 的代码实现
如果只用文字来描述,你肯定会觉得过于晦涩难懂,所以这里我就自己用代码来实现这两个方法帮助你的理解。
// 获取类型
const getType = (obj) => {return Object.prototype.toString.call(obj).slice(8,-1);
}
// 是否为原始类型
const isPrimitive = (obj) => {const types = ['String','Undefined','Null','Boolean','Number'];
return types.indexOf(getType(obj)) !== -1;
}
const ToPrimitive = (input, preferredType) => {
// 如果 input 是原始类型,那么不需要转换,直接返回
if (isPrimitive(input)) {return input;}
let hint = '',
exoticToPrim = null,
methodNames = [];
// 当没有提供可选参数 preferredType 的时候,hint 会默认为 "default";if (!preferredType) {hint = 'default'} else if (preferredType === 'string') {hint = 'string'} else if (preferredType === 'number') {hint = 'number'}
exoticToPrim = input.@@toPrimitive;
// 如果有 toPrimitive 方法
if (exoticToPrim) {
// 如果 exoticToPrim 执行后返回的是原始类型
if (typeof (result = exoticToPrim.call(O, hint)) !== 'object') {
return result;
// 如果 exoticToPrim 执行后返回的是 object 类型
} else {throw new TypeError('TypeError exception')
}
}
// 这里给了默认 hint 值为 number,Symbol 和 Date 通过定义 @@toPrimitive 方法来修改默认值
if (hint === 'default') {hint = 'number'}
return OrdinaryToPrimitive(input, hint)
}
const OrdinaryToPrimitive = (O, hint) => {
let methodNames = null,
result = null;
if (typeof O !== 'object') {return;}
// 这里决定了先调用 toString 还是 valueOf
if (hint === 'string') {methodNames = [input.toString, input.valueOf]
} else {methodNames = [input.valueOf, input.toString]
}
for (let name in methodNames) {if (O[name]) {result = O[name]()
if (typeof result !== 'object') {return result}
}
}
throw new TypeError('TypeError exception')
}
总结一下,在进行类型转换的时候,一般是通过 ToPrimitive
方法将引用类型转为原始类型。如果引用类型上有 @@toPrimitive
方法,就调用 @@toPrimitive
方法,执行后的返回值为原始类型就直接返回,如果依然是对象,那么就抛出报错。
如果对象上没有 toPrimitive
方法,那么就根据转换的目标类型来判断先调用 toString
还是 valueOf
方法,如果执行这两个方法后得到了原始类型的值,那么就返回。否则,将会抛出错误。
Symbol.toPrimitive
在 ES6 之后提供了 Symbol.toPrimitive
方法,该方法在类型转换的时候优先级最高。
const obj = {toString() {return '1111'},
valueOf() {return 222},
[Symbol.toPrimitive]() {return 666}
}
const num = 1 + obj; // 667
const str = '1' + obj; // '1666'
例子
也许上面关于 ToPrimitive
的代码讲解你还是会觉得晦涩难懂,那我接下来就举几个例子来说明对象的类型转换。
var a = 1,
b = '2';
var c = a + b; // '12'
也许你会好奇,为什么不是将后面的 b
转换为 number
类型,最后得到 3?
我们还是要先看文档对加号的定义。
首先会分别执行两个值的 toPrimitive
方法,因为 a
和 b
都是原始类型,所以还是得到了 1 和 ’2’。
从图上看到如果转换后的两个值的 Type
有一个是 String
类型,那么就将两个值经过 toString
转换后串起来。因此最后得到了 ’12’,而不是 3。
我们还可以再看一个例子。
var a = 'hello', b = {};
var c = a + b; // "hello [object Object]"
这里还会分别执行两个值的 toPrimitive
方法,a
还是得到了 ’hello ‘,而 b 由于没有指定 preferredType,所以会默认被转为 number
类型,先调用 valueOf
,但 valueOf
还是返回了一个空对象,不是原始类型,所以再调用 toString
,得到了 '[object Object]'
,最后将两者连接起来就成了 "hello [object Object]"
。
如果我们想返回 'hello world'
,那该怎么改呢?只需要修改 b
的 valueOf
方法就好了。
b.valueOf = function() {return 'world'}
var c = a + b; // 'hello world'
也许你在面试题中看到过这个例子。
var a = [], b = [];
var c = a + b; // ''
这里为什么 c
最后是 ” 呢?因为 a
和 b
在执行 valueOf
之后,得到的依然是个 []
,这并非原始类型,因此会继续执行 toString
,最后得到 ”,两个 ” 相加又得到了 ”。
我们再看一个指定了 preferredType
的例子。
var a = [1, 2, 3], b = {[a]: 111
}
由于 a
是作为了 b
的键值,所以 preferredType
为 string
,这时会调用 a.toString
方法,最后得到了 ’1,2,3′
总结
类型转换一直是学 JS 的时候很难搞明白的一个概念,因为转换规则比较复杂,经常让人觉得莫名其妙。
但是如果从 ECMA 的规范去理解这些转换规则的原理,那么就会很容易知道为什么最后会得到那些结果。