前言

JavaScript 中的类型转换一直都是让前端开发者最头疼的问题。前阵子,推特上有个人专门发了一张图说 JavaScript 让人不可思议。

image_1dm5s9qr814dvnsi96laugvg9.png-51.4kB

除了这个,还有很多经典的、让 JavaScript 开发者摸不着头脑的类型转换,譬如下面这些,你是否知道结果都是多少?

1 + {} === ?
{} + 1 === ?
1 + [] === ?
1 + '2' === ?

本文将带领你从 ECMA 规范开始,去深入理解 JavaScript 中的类型转换,让类型转换不再成为前端开发中的拦路虎。

数据类型

JS 中有六种简单数据类型:undefinednullbooleanstringnumbersymbol,以及一种复杂类型:object
但是 JavaScript 在声明时只有一种类型,只有到运行期间才会确定当前类型。在运行期间,由于 JavaScript 没有对类型做严格限制,导致不同类型之间可以进行运算,这样就需要允许类型之间互相转换。

类型转换

显式类型转换

显式类型转换就是手动地将一种值转换为另一种值。一般来说,显式类型转换也是严格按照上面的表格来进行类型转换的。

常用的显式类型转换方法有 NumberStringBooleanparseIntparseFloattoString 等等。
这里需要注意一下 parseInt,有一道题偶尔会在面试中遇到。

问:为什么 [1, 2, 3].map(parseInt) 返回 [1,NaN,NaN]?
答:parseInt函数的第二个参数表示要解析的数字的基数。该值介于 2 ~ 36 之间。

如果省略该参数或其值为 0,则数字将以 10 为基础来解析。如果它以 “0x” 或 “0X” 开头,将以 16 为基数。

如果该参数小于 2 或者大于 36,则 parseInt() 将返回 NaN。
一般来说,类型转换主要是基本类型转基本类型、复杂类型转基本类型两种。
转换的目标类型主要分为以下几种:

  1. 转换为 string
  2. 转换为 number
  3. 转换为 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 类型的规则:

  1. 如果字符串中只包含数字,那么就转换为对应的数字。
  2. 如果字符串中只包含十六进制格式,那么就转换为对应的十进制数字。
  3. 如果字符串为空,那么转换为0。
  4. 如果字符串包含上述之外的字符,那么转换为 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 的官方文档。

image_1dard6av87ir24p140nv5d1vq9.png-182.5kB

我来翻译一下这段话。

ToPrimitive 方法接受两个参数,一个是输入的值 input,一个是期望转换的类型 PreferredType

  1. 如果没有传入 PreferredType 参数,让 hint 等于"default"
  2. 如果 PreferredTypehint String,让 hint 等于"string"
  3. 如果 PreferredTypehint Number,让 hint 等于"number"
  4. exoticToPrim 等于 GetMethod(input, @@toPrimitive),意思就是获取参数 input@@toPrimitive 方法
  5. 如果 exoticToPrim 不是 Undefined,那么就让 result 等于 Call(exoticToPrim, input, « hint »),意思就是执行 exoticToPrim(hint),如果执行后的结果 result 是原始数据类型,返回 result,否则就抛出类型错误的异常
  6. 如果 hint 是"default",让 hint 等于"number"
  7. 返回 OrdinaryToPrimitive(input, hint) 抽象操作的结果

OrdinaryToPrimitive

OrdinaryToPrimitive 方法也接受两个参数,一个是输入的值O,一个也是期望转换的类型 hint

  1. 如果输入的值是个对象
  2. 如果 hint 是个字符串并且值为'string'或者'number'
  3. 如果 hint 是'string',那么就将 methodNames 设置为 toStringvalueOf
  4. 如果 hint 是'number',那么就将 methodNames 设置为 valueOftoString
  5. 遍历 methodNames 拿到当前循环中的值 name,将 method 设置为 O[name](即拿到 valueOftoString 两个方法)
  6. 如果 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?
我们还是要先看文档对加号的定义。

image_1davvk6ij3lnsisjsk1i8djf8p.png-243.3kB

首先会分别执行两个值的 toPrimitive 方法,因为 ab 都是原始类型,所以还是得到了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',那该怎么改呢?只需要修改 bvalueOf 方法就好了。

b.valueOf = function() {
    return 'world'
}
var c = a + b; // 'hello world'

也许你在面试题中看到过这个例子。

var a = [], b = [];
var c = a + b; // ''

这里为什么 c 最后是''呢?因为 ab 在执行 valueOf 之后,得到的依然是个 [] ,这并非原始类型,因此会继续执行 toString,最后得到'',两个''相加又得到了''。
我们再看一个指定了 preferredType 的例子。

var a = [1, 2, 3], b = {
    [a]: 111
}

由于 a 是作为了 b 的键值,所以 preferredTypestring,这时会调用 a.toString 方法,最后得到了'1,2,3'

总结

类型转换一直是学 JS 的时候很难搞明白的一个概念,因为转换规则比较复杂,经常让人觉得莫名其妙。
但是如果从 ECMA 的规范去理解这些转换规则的原理,那么就会很容易知道为什么最后会得到那些结果。


尹光耀
2.2k 声望103 粉丝