ECMAScript类型转换

ECMAScript 中显式与隐式类型转换的主要规则,包括 ToBoolean、ToNumber、ToString、ToPrimitive 等抽象操作,以及常见陷阱和设计原理。

#type / concept #status / evergreen #resource / javascript #resource / ecmascript

[!info] related notes

ECMAScript类型转换

一句话定义

JavaScript 的类型转换是语言在需要时自动或手动将值从一种类型转换为另一种类型的机制,包括显式转换(如 Number())和隐式转换(如 + 运算符触发的转换)。

为什么 JS 会有这么多“自动类型转换”

这是理解一切怪现象的关键。

JavaScript 诞生时有几个现实目标:

  • 语法要对网页开发者友好
  • 写起来要快
  • 要尽量“容错”
  • 和 HTML 表单、浏览器输入、字符串数据打交道要方便

而网页里的很多数据天然就是字符串,比如:

  • 用户输入框输入 "123"
  • URL 参数 "42"
  • HTML 属性值
  • JSON / 表单数据

所以 JS 的设计思路不是“强制你手动转换一切”,而是:

在很多场景下,语言帮你猜一下你想要什么。

这就带来了所谓 coercion(强制类型转换 / 隐式转换)

底层逻辑其实很朴素:

  • 需要布尔时,转成布尔
  • 需要数字时,转成数字
  • 需要字符串时,转成字符串
  • 需要原始值时,从对象里“取出”一个原始值

所以 JS 的很多怪现象,其实都来自一句话:

运算符先决定“我要什么类型”,然后操作数被迫往那个方向转换。

两种大类

  • 显式转换: Boolean()Number()String() 等,开发者主动调用
  • 隐式转换: 比较、算术、字符串拼接、条件判断时自动发生

核心抽象操作

ECMAScript 规范里并不是简单写成 Boolean()Number() 这些函数,而是抽象成内部算法:

  • ToBoolean:转布尔值
  • ToNumber:转数字
  • ToString:转字符串
  • ToPrimitive:对象转原始值

你可以把它们看成语言的底层转换引擎。

ToBoolean:转布尔值

这是最简单但最常用的转换规则。

会变成 false 的值(falsy):

false
0
-0
0n
NaN
''
null
undefined

还有浏览器里一个历史遗留的特殊对象 document.all,这里先不展开。

除此之外基本都是真(truthy):

Boolean('false') // true
Boolean('0')     // true
Boolean([])      // true
Boolean({})      // true

因为:

  • 非空字符串是真
  • 所有普通对象都是真

所以:

if ('false') {
  // 会进来
}

很多初学者误以为 'false' 表示 false,其实它只是一个非空字符串。

ToNumber:转数字

这个规则是很多题目的来源。

常见结果:

Number('1')        // 1
Number(' 02 ')     // 2
Number('')         // 0
Number(' ')        // 0
Number(null)       // 0
Number(false)      // 0
Number(true)       // 1
Number(undefined)  // NaN
Number('number')   // NaN

规律:

字符串转数字

  • 去掉首尾空白
  • 如果能解析成合法数字,就转成数字
  • 否则 NaN
Number('3')      // 3
Number('  3  ')  // 3
Number('3a')     // NaN

null 转数字是 0

这是很多人不习惯的一点,但规范就是这么规定的。

undefined 转数字是 NaN

因为它代表“缺失的值”,而不是一个可数值。

false -> 0true -> 1

这很好理解,很多语言也类似。

ToString:转字符串

String(123)        // '123'
String(true)       // 'true'
String(null)       // 'null'
String(undefined)  // 'undefined'
String(NaN)        // 'NaN'

对象转字符串时,通常也会先 ToPrimitive,再变成字符串。

比如:

String([1, 2])   // '1,2'
String([])       // ''
String({})       // '[object Object]'

ToPrimitive:对象转原始值

这是最关键但最容易被忽略的步骤。

当对象需要参与:

  • 算术运算
  • 字符串拼接
  • 宽松比较 ==

通常都要先转 primitive。

规则大意:

JS 会尝试调用对象的这些方法来取“原始值”:

  1. obj[Symbol.toPrimitive](hint),如果有,优先用它

  2. 否则看 hint:

    • string:通常先 toString()valueOf()
    • number / default:通常先 valueOf()toString()

例如数组:

[].toString()      // ''
[1,2].toString()   // '1,2'

普通对象:

({}).toString()    // '[object Object]'
({}).valueOf()     // 本身对象,不是 primitive

所以:

[] == ''

本质上是:

  • []ToPrimitive
  • [].toString() 得到 ''
  • 然后比较 '' == ''
  • 结果 true

不同运算符触发的转换逻辑

+:可能是加法,也可能是字符串拼接

+ 在 JS 里有三种角色:

  1. 二元加号 a + b:数值加法或字符串拼接
  2. 一元加号 +a:把值转成数字(等价于 Number(a)

一元 +:显式转数字

+2           // 2
+'2'         // 2
+true        // 1
+false       // 0
+null        // 0
+undefined   // NaN
+''          // 0
+'  123  '   // 123(会 trim)

二元 +:加法还是拼接?

规则: 如果任一侧在转换后是字符串,+ 走字符串拼接。

'3' + 1   // '31'
1 + '3'   // '13'

而:

'3' - 1

- 不支持字符串拼接,所以它会强制双方转数字:

3 - 1 = 2

同理:

'3' * ' 02 '  // 6

因为 * 也是纯数值运算。

一元 + 与二元 + 混用

连续 + 号时,先分清哪个是一元、哪个是二元:

1 + + '2'
// => 1 + (+'2')
// => 1 + 2
// => 3

'1' + + true
// => '1' + (+true)
// => '1' + 1
// => "11"

'1' + + null
// => '1' + (+null)
// => '1' + 0
// => "10"

'1' + + undefined
// => '1' + (+undefined)
// => '1' + NaN
// => "1NaN"

规律:从右往左看,最右边的 + 先作为一元运算符生效,再和左边的二元 + 合并计算。

完整对照表

1 + 2                 // 3      数字加法
1 + + 2               // 3      1 + (+2) → 1 + 2
1 + + + 2             // 3      1 + (+(+2)) → 1 + 2
1 + '2'               // "12"   字符串拼接
1 + + '2'             // 3      1 + (+'2') → 1 + 2
'1' + 2               // "12"   字符串拼接
'1' + + 2             // "12"   '1' + (+2) → '1' + 2 → 拼接
1 + true              // 2      true → 1
1 + + true            // 2      1 + (+true) → 1 + 1
'1' + true            // "1true" 字符串拼接
'1' + + true          // "11"   '1' + (+true) → '1' + 1 → 拼接
1 + null              // 1      null → 0
1 + + null            // 1      1 + (+null) → 1 + 0
'1' + null            // "1null" 字符串拼接
'1' + + null          // "10"   '1' + (+null) → '1' + 0 → 拼接
1 + undefined         // NaN    undefined → NaN
1 + + undefined       // NaN    1 + (+undefined) → 1 + NaN
'1' + undefined       // "1undefined" 字符串拼接
'1' + + undefined     // "1NaN" '1' + (+undefined) → '1' + NaN → 拼接

比较运算 > < >= <=

关系比较通常会先把两侧转成 primitive。

如果两侧最终都是字符串,就按字典序比较:

'2' > '10' // true

因为比较的是字符顺序,不是数字大小。

否则一般走数字比较:

'2' > 1 // true

ifwhile、逻辑运算触发 ToBoolean

if ([]) { ... }   // true
if ({}) { ... }   // true
if ('') { ... }   // false

逻辑运算符 &&|| 很特别:

  • 它们不一定返回布尔值
  • 它们返回的是“原始操作数之一”

例如:

'a' && 'b'   // 'b'
'' && 'b'    // ''
'a' || 'b'   // 'a'
'' || 'b'    // 'b'

原因是:

  • a && b:如果左边是假,返回左边;否则返回右边
  • a || b:如果左边是真,返回左边;否则返回右边

这体现 JS 的一个设计取向:

逻辑运算不仅用于布尔逻辑,也用于“默认值”“短路求值”。

对象转 primitive 的机制再深一层

为什么对象不能直接拿来做数字计算?

因为对象是复杂结构,不天然对应一个唯一数字或字符串。

比如:

{}
[]
new Date()

它们在参与加法、比较时,语言必须问:

“你这个对象,想被当成什么原始值?”

所以规范定义了 ToPrimitive(input, preferredType)

preferredType 是什么?

上下文会告诉对象:我更想把你当成哪类 primitive。

常见 hint:

  • number
  • string
  • default

例如:

1 + obj

通常更偏向 default

String(obj)

明确偏向 string

默认转换顺序

普通对象一般:

hint 为 string:

toString(),后 valueOf()

hint 为 number/default:

valueOf(),后 toString()

为什么数组很特别?

数组的 toString() 不是 [object Array],而是把元素 join 成字符串:

[].toString()      // ''
[1].toString()     // '1'
[1,2].toString()   // '1,2'

所以很多数组在宽松比较里会显得“像字符串”。

例如:

[1] == '1'  // true

因为:

[1] -> '1'
'1' == '1'

为什么普通对象常常变成 "[object Object]"

因为普通对象默认的 toString() 就是这个:

({}).toString() // '[object Object]'

所以:

{} + ''

如果按表达式上下文处理为加法,往往会得到:

'[object Object]'

数值体系里几个特殊值

Infinity

1 / 0  // Infinity
-1 / 0 // -Infinity

JS number 是双精度浮点数,不是“整数/小数”分开,所以它能表示无穷大。

-0

0 === -0 // true
Object.is(0, -0) // false
1 / 0    // Infinity
1 / -0   // -Infinity

为什么有 -0

因为 IEEE 754 中,0 也带符号位。 这对某些极限计算、数值分析有意义。

JS 延续了这个设计。

NaN

NaNNot-a-Number,但更准确地说,它是:

IEEE 754 浮点标准中的“无效数值结果标记”

比如:

0 / 0
Number('abc')
Math.sqrt(-1) // 在 JS 普通 number 里也是 NaN

这些都不是一个有效实数结果,于是产生 NaN

为什么它不等于自己?

这不是 JS 独创,而是沿用了 IEEE 754 的语义:

NaN 代表“这个结果不可比较 / 未定义 / 无效”,所以它不应与任何具体数值建立相等关系,甚至包括它自身。

于是:

NaN == NaN     // false
NaN === NaN    // false

那怎么判断一个值是不是 NaN?

不要用:

x === NaN // 永远 false

而要用:

Number.isNaN(x)

或者利用 NaN 的自反性缺失:

x !== x   // 只有 NaN 才为 true

因为只有 NaN 不等于自己。

最常见的坑

  • == 会触发隐式转换,规则复杂
  • + 既可能是数字加法,也可能是字符串拼接
  • new Boolean(false) 是对象,在条件判断里仍然为真
  • []{} 参与比较时结果常常违背直觉
  • NaN 不等于任何值,包括自己
  • nullundefined== 下相等,但不等于 0false

实践建议

  • 需要明确布尔值时,用 Boolean(value)!!value
  • 比较时默认优先 ===
  • 遇到隐式转换疑问时,先拆成显式步骤再理解
  • 判断 NaNNumber.isNaN(x),不要用 x === NaN
  • 需要同时判断 nullundefined 时,可以用 x == null

设计底层逻辑

这些规则背后的“设计底层逻辑”到底是什么?

1. 早期脚本语言定位:优先易用,不优先严格

JavaScript 诞生时的目标,不是做今天这种大型前后端工程语言,而是:

  • 给网页加交互
  • 让非系统程序员也能快速写脚本
  • 对输入比较宽容

所以它天然偏向:

  • 自动转换
  • 少报错
  • 容忍不同类型混合运算

这解释了为什么 '3' - 1 不报错。

2. 借鉴 C / Java 风格,但又更动态

比如:

  • true -> 1
  • false -> 0
  • 对象和 primitive 区分
  • 引用相等 vs 值相等

这些不是无源之水。

但 JS 又更动态,所以自动转换更多,规则也更复杂。

3. 数值层继承 IEEE 754

这解释了:

  • NaN
  • Infinity
  • -0
  • 浮点误差

这些不是“JS 脑抽”,而是现代很多语言共享的浮点模型。

4. 对象是“带身份的引用实体”

所以对象比较看 identity,而不是结构。

这是几乎所有主流动态语言 / 面向对象语言都会面对的问题。

5. 后向兼容压倒一切

JS 最特殊的一点是:

历史包袱极重,老代码不能坏。

所以哪怕某些规则今天看起来不理想,也很难改。

例如:

  • typeof null === 'object'
  • '' == 0
  • [] == false

这些结果很怪,但已经有海量旧代码依赖它们,规范几乎不可能推翻。

从工程角度应该怎么用这些规则

理解原理是一回事,写代码时建议是另一回事。

1. 绝大多数场景优先用 ===

因为它最稳定、最可预测。

if (x === 0) {}
if (x === null) {}

2. 唯一常见可以接受 == 的场景

就是同时判断 nullundefined

if (x == null) {
  // x 是 null 或 undefined
}

因为这是 == 里少数非常有用且可控的设计。

3. 判断 NaNNumber.isNaN

Number.isNaN(x)

不要写:

x === NaN

4. 不要依赖奇技淫巧式隐式转换

例如别写:

if (arr == false) ...

而要明确写:

if (arr.length === 0) ...

5. 需要转类型时,主动显式转换

Number(x)
String(x)
Boolean(x)

这样代码意图清晰。

脑内推导图

以后看到奇怪表达式,可以按这个顺序想:

先问 1:这是什么运算符?

  • +
  • - * /
  • ==
  • ===
  • if

再问 2:两边是什么类型?

  • primitive vs primitive
  • object vs primitive
  • object vs object

再问 3:会触发哪种抽象操作?

  • ToBoolean
  • ToNumber
  • ToPrimitive

再问 4:对象转 primitive 会得到什么?

  • 数组通常像字符串:[] -> ''[1] -> '1'
  • 普通对象通常:'[object Object]'

一旦这么拆,90% 的诡异题都能推出来。

信息参考

创建于 2025/1/1 更新于 2026/5/27