Skip to content

深入类型转换

前言

ES规范中定义了很多类型转换的函数,用于将任意的值转换为指定类型,其中重要的有以下几个:

  • ToBoolean 转换为布尔类型
  • ToNumber 转换为数值类型
  • ToString 转换为字符串类型
  • ToPrimitive 转换为基本数据类型

以上几种类型转换适用范围非常广,可以说我们无时无刻不在跟它们打交道:

js
// ToBoolean
if (3) {} // true
if ([]) {} // true
if ('') {} // false

// ToNumber
const a = '3' - 1 // 2

// ToString
'1' + 'hello' // 1hello
[] + 'hello' // hello
{} + 'hello' // [object Object]hello

// ToPrimitive
[1] == '1' // true
[1] == 1 // true
[1] == true //  true

如果你对上述代码执行的结果感到意外和困惑,请继续看下去,下面我将逐个解析每个转换函数执行的内在逻辑,最后将所有函数串联起来,深入理解JavaScript类型转换的内涵。

ToBoolean

ToBoolean对应的API是 Boolean(),它的规则非常简单,只要是以下值,便为 false (一般称为falsy),其他均为 true

  • undefined
  • null
  • +0, -0, 0n, NaN
  • ''

注意

有趣的是,document.all 这个对象进行布尔转换时的结果为 false 。(可在控制台打印 Boolean(document.all) 试试)

ES规范中特意讲了这个特殊情况:https://tc39.es/ecma262/multipage/additional-ecmascript-features-for-web-browsers.html#sec-IsHTMLDDA-internal-slot-to-boolean

场景

以下这些场景都会触发转换布尔值操作:

  • if (test) while (test) for(;test;) if/while/for语句的条件判断
  • foo ? a : b 三元表达式的条件语句
  • a || b a && b 两侧的条件语句

让我们用一些例子来练练手:

js
if ([]) {} // true
if ({}) {} // true
if ('') {} // false
({}) ? 1 : 2 // 1
0 || [] // []
1 || [] // 1
0 && [] // 0
1 && [] // []

参考

ToPrimitive

该函数用于将 非基本数据类型 的值转换为基本数据类型值,因为JavaScript中所有非基本数据类型都是对象,所以其实该函数就是将对象转换为基本数据类型值。

ToPrimitive 的规则是:

  1. 如果被转换的值是基本数据类型,则直接返回该值
  2. 如果该值是对象,则需要先分析该对象的转换偏好是数值还是字符串
  3. 根据转换偏好调用对应的函数,如果偏好是数值则先调用 valueOf ,如果是字符串则先调用 toString
  4. 如果两个函数调用的结果有一个是基本数据类型值,则返回,如果一个都没有,则报错

有两个需要注意的点,不管偏好是什么,valueOftoString 都可能会被调用,这取决于是否返回了基本数据类型值。举个例子:

将一个空对象 {} 转换为基本数据类型,因为空对象的转换偏好是字符串,则首先会调用 toString如果调用的结果不是基本数据类型,则会继续调用 valueOf 函数 ,直到获得一个基本数据类型的值。如果都没有,则会报错。请看下方代码:

js
const o = {
  toString() {
    return {}
  },
  valueOf() {
    return 1
  }
}

const foo = o + 1
console.log(foo) // 2

const foo = o + 1 这行代码会触发对象 oToPrimitive 操作,因为 o 是一个对象,并且转换偏好是字符串,因此会先调用 toString ,但是 toString 返回的结果不是一个基本数据类型值,则会继续调用 valueOf ,结果是基本数据类型 - 数值类型的1 ,则最后返回了1,导致 foo 的结果是2。

将这个例子稍微改动一下:

js
const o = {
  toString() {
    return {}
  },
  valueOf() {
    return {}
  }
}
const bar = o + 1
console.log(bar) // TypeError: Cannot convert object to primitive value

当两次尝试将对象 o 转换为基本数据类型值都失败后,最终抛出错误。

场景

我们还是从原理理解问题,因为 ToPrimitive 的本质是将对象转换为基本数据类型,因此 所有需要将对象转换为基本数据类型值的地方都需要它。这句话好像一句废话,我们把它更具化一些:当对象与基本数据类型同时出现,并且需要进行赋值、比较的时候,都需要它。

例如:

  1. [1] == 1 使用 == 进行比较时,会触发隐式类型转换,[1] 最终其实被转换为了 1 ,因此结果为 true
  2. [1] + 1 [1] - 1 使用运算操作时,同样会触发隐式转换

参考

ToString

该函数用于将任意值转换为字符串类型的值,它的规则如下所示:

  1. 如果传入参数为一个字符串,返回这个参数
  2. 如果参数为 undefined null true false,分别返回 'undefined' 'null' 'true' 'false'
  3. 如果参数类型为数值(number/bigint)类型,调用数值包装对象的 toString() 方法返回字符串
  4. 如果参数是对象,则调用 ToPrimitive 获得基本数据类型值,如果返回的不是基本数据类型值,则报错(注意!不会递归调用 ToString
  5. 如果返回的是基本数据类型值,则继续调用该值的 ToString 操作

ToNumber

该函数用于将任意值转换为数值类型的值,规则如下:

  1. 如果参数为数值类型,返回这个参数
  2. 如果参数类型为 symbolbigint ,则抛出错误
  3. 如果参数为 undefined ,则返回 NaN
  4. 如果参数为 null,返回 0
  5. 如果参数为 truefalse ,分别返回 10
  6. 如果参数为字符串类型,则调用 StringToNumber 将字符串转为数值
  7. 如果参数为对象类型,则调用 ToPrimitive 将对象转为基本数据类型值
  8. 如果 ToPrimitive 返回的结果还是一个对象,则抛出错误
  9. 否则说明返回的是基本数据类型值,继续调用 ToNumber

来看点例子:

js
Number(undefined) // NaN
Number(null) // 0
Number(true) // 1
Number(false) // 0
Number(Symbol.iterator) // TypeError
Number('123') // 123
Number('hello') // NaN
Number([]) // 0
Number([1]) // 1
Number([1, 2]) // NaN
Number({}) // NaN
Number({
  valueOf() { return 1; }
}) // 1

1 - null // 1
1 + null // 1
1 - [] // 1
1 - {} // NaN
1 - [1] // 0

场景

  • 数值运算时,例如 2 - '1' 会将字符串 '1' 转换为数值的 1

参考

深入理解类型转换

JavaScript的类型转换分为两种类型:

  • 基本数据类型互相转换,例如:字符串 -> 数值,数值 -> 布尔
  • 对象转基本数据类型,例如:对象 -> 数值,对象 -> 字符串

看下面的例子:

js
[1] - 1 // 0

不同的编程语言处理上述代码的结果是不同的:

  • 在一些强类型语言中,将数组与数值进行运算是不合法的,会在编译阶段就发生错误
  • 一些弱类型的语言可能无法再编译期间发现错误,但是会在运行时判定操作不合法而抛出错误
  • 在JavaScript这种语言中,运行时会进行隐式类型转换,尽可能不抛出错误

在JavaScript中,数组 [1] 会先进行 ToPrimitive 操作,试图将该数组转换为一个基本数据类型值,转换时,先调用 valueOf 函数,得到了数组自身 [1] ,由于返回值还是一个对象,因此会调用 toString 函数,最后得到字符串的 '1' (数组的 toString 会将元素使用 , 进行拼接),所以,上面的代码相当于这行代码:

js
'1' - 1

接下来,将进行两个基本数据类型值的减法运算,由于字符串没有减法运算,因此会将字符串转成数值,再与数值类型的值相减。字符串 '1' 被转换为数值的 1 ,最后,1 - 1 得到了 0

以上例子只是举了一个减法操作的场景,在JavaScript中,不同的场景触发的隐式类型转换是有差异的 ,但都脱离不了 ToBoolean / ToNumber / ToString / ToPrimitive 函数。

接下来,我将一一讲解每个场景隐式转换的情况。

+ 加法运算

请原谅我要将加法运算单独作为一个小节来讲,因为 + 运算符不仅仅是数学意义上的加法,还可以是字符串的拼接操作。(JavaScript借鉴了Java中的字符串拼接语法)

关于加法运算符,读者只需要记住一点:

字符串拼接优先于数学运算。

也就是说,如果 + 两侧的值中,有一个是字符串类型,则执行字符串拼接操作,否则就是数值加法运算。请看下方例子:

js
'1' + 1 // '11'
1 + 1 // 2
[1] + 1 // '11'

第一例中,由于左侧有一个字符串类型的值,所以执行字符串拼接;第二例中,由于左右两侧都是数值类型,因此直接进行加法运算;第三例中,数组 [1] 会分别进行 valueOftoString 操作,最后返回了字符串的 '1',等同于第一例,因此执行字符串拼接操作。

其他普通运算符

除了 + 之外,- / * 等常用运算符,都执行数值运算。看几个简单例子:

js
'1' - 1 // 0
[1] - 1 // 0
[1] * 1 // 1
[1] / 1 // 1

条件语句

js
if (test) {}
for (;test;) {}
while (test) {}
foo ? a : b

以上场景中,会对参数进行 ToBoolean 操作,而不会对参数进行隐式转换为数值或字符串类型!! 这一点千万要注意,请看这个例子:

js
if ([0]) {
  console.log('pass') // √
} else {
  console.log('unpass')
}

有些人会以为, [0] 会执行 ToPrimitive 操作得到字符串的 '0' ,然后再转换为数值的 0 ,因为 0 转换为布尔值为 false ,所以应该打印 unpass

实则不然,条件语句只会判断当前参数是否是 Truthy !!