从 ECMA 规范彻底理解 JavaScript 类型转换

1,519 阅读8分钟

前言

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

image_1dm5s9qr814dvnsi96laugvg9.png-51.4kB

除了这个,还有很多经典的、让 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。 一般来说,类型转换主要是基本类型转基本类型、复杂类型转基本类型两种。 转换的目标类型主要分为以下几种:

  1. 转换为 string
  2. 转换为 number
  3. 转换为 boolean

我参考了 ECMA-262 的官方文档来总结一下这几种类型转换。ECMA 文档链接:ECMA-262

ToNumber

其他类型转换到 number 类型的规则见下方表格:

原始值转换结果
UndefinedNaN
Null0
true1
false0
String根据语法和转换规则来转换
SymbolThrow 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

原始值转换结果
Undefinedfalse
Booleantrue or false
Number0和NaN返回false,其他返回true
Symboltrue
Objecttrue
我们也可以使用 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对应的字符串类型
StringString
SymbolThrow 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. 如果 PreferredType 是 hint String,让 hint 等于"string"
  3. 如果 PreferredType 是 hint 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 设置为 toString、valueOf
  4. 如果 hint 是'number',那么就将 methodNames 设置为 valueOf、toString
  5. 遍历 methodNames 拿到当前循环中的值 name,将 method 设置为 O[name](即拿到 valueOf 和 toString 两个方法)
  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 方法,因为 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'

问题汇总

之前发在其他平台一段时间了,有别人提出的一些问题,这里也顺便贴上。

image.png

总结

类型转换一直是学 JS 的时候很难搞明白的一个概念,因为转换规则比较复杂,经常让人觉得莫名其妙。

但是如果从 ECMA 的规范去理解这些转换规则的原理,那么就会很容易知道为什么最后会得到那些结果。

感兴趣的可以关注我的 Github,这里有我最新的博客:github.com/yinguangyao…