我们都知道 JavaScript 是弱类型语言,即 JS 变量没有预先确定的类型,变量的类型就是其值的类型。
然而在进行某些操作时,不同类型的变量是可以用运算符连接做运算的,那 JS 会怎样处理不同类型变量运算呢?
先来看下面这个表达式:
1 + '2'
有经验的前端同学应该都知道输出是 '12'
,
实际结果也是 '12'
。那既然是一个数字加一个字符串,输出为什么不是3
或 '3'
或 12
?
1 + '2' // '12'
那 1 + 2 + '3'
输出又是什么呢?
1 + 2 + '3' // '33'
这个问题虽然问的很蠢,但是主要目的是为了引出本篇的主题:JS 中的类型系统与类型转换,其实本文只是狭隘地介绍了 JS 中的 “+” 运算符的操作。
何为类型系统?
在所有电脑中,基于数字电子学的底层数据,都是以比特(0
或 1
)表示,机器语言中并没有类型的概念。而在高级语言中,我们却往往会为操作的数据赋予指定的类型。
类型系统:
计算机科学中,类型系统(type system)用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何互相作用。
所谓 强类型 ,是指禁止错误类型的参数继续运算。
而 弱类型 是指一个语言可以隐式的转换类型(或直接转型)。
而即使都是弱类型语言,不同语言对于同样的表达式的处理结果可能并不相同,比如开头的例子,1 + '2' = ?
,在 JS 中得到的结果是 '12'
,但是在 PHP 中却是 3
。这是基于这两种语言的规则是以两个操作数为考量的。而在部分语言,如 AppleScript 中,这个表达式的值却会以最左边的操作数的类型所决定。这意味着:在 AppleScript 中,1 + '2'
和 '1' + 2
输出的结果可能是不一样的!
所以要想掌握一门语言,最基本的还是 看标准。
这篇文章就带大家走进 JS 中关于 +
操作符的标准定义。
JS 中的 ‘+’ 运算符标准解读
本节是粗暴的标准解(fan)读(yi),看不下去的同学可以直接略过,下一节会简单归纳一下。
首先,简单粗暴上 标准 的伪代码:
要计算 AdditiveExpression : AdditiveExpression + MultiplicativeExpression
// 获取左右两个表达式的值
let lref = AdditiveExpression
let lval = GetValue(lref)
ReturnIfAbrupt(lval)
let rref = MultiplicativeExpression
let rval = GetValue(rref)
ReturnIfAbrupt(rval)
// 获取左右两个表达式的基本类型表示的值
let lprim = ToPrimitive(lval)
ReturnIfAbrupt(lprim)
let rprim = ToPrimitive(rval)
ReturnIfAbrupt(rprim)
// 如果有一个基本类型是String,则按照字符串拼接
if (Type(lprim) === String || Type(rprim) === String) {
let lstr = ToString(lprim)
ReturnIfAbrupt(lstr)
let rstr = ToString(rprim)
ReturnIfAbrupt(rstr)
return lstr + rstr // 返回字符串连接
}
// 否则按照数字加和
let lnum = ToNumber(lprim)
ReturnIfAbrupt(lnum)
let rnum = ToNumber(rprim)
ReturnIfAbrupt(rnum)
return lnum + rnum // 返回加法计算结果
我们看到其中有几个函数或术语,先简单介绍一下:
Completion Record:
指值和控制流的运行时状态记录,如下表:
Field | Value | Meaning |
---|---|---|
[[type]] | normal, break, continue, return, throw 之一 | 完成状态 |
[[value]] | 任何 ES language value 或 empty | 产生的值 |
[[target]] | 任何 ES 字符串或 empty | 控制流转移的目标 |
abrupt completion 指具有 [[type]] 值的 completion 状态
GetValue(val)
- 简单理解,就是返回 val 的值: 如果 val 本来就是个纯值,直接返回;如果 val 是个变量名,或属性(在对象中的引用),就获取对应值后返回;
ReturnIfAbrupt(val)
- 如果 val 是 abrupt completion,返回 val
- 否则,如果 val 是 Completion Record,val = argument.[[value]
ToPrimitive ( input [, PreferredType] ):
- 参数:
- input 表示要处理的输入值;
- PreferredType 非必填,表示希望转换成的类型,有两个值可选: Number 或 String。此参数不传时,通常会将其视为 Number。可以通过定义
@@toPrimite
方法来覆盖此默认行为。标准中只有 Date 对象和 Symbol 对象覆盖了此默认行为。Date 对象将把 PreferredType 默认设置为 String。
- 处理步骤:
- 如果 input 类型是
Undefined
、Null
、Boolean
、Number
、String
类型,直接返回该值; - 如果 PreferredType 是 String, let methodNames = ['toString', 'valueOf'],
- 否则: PreferredType 是 Number, let methodsNames = ['valueOf', 'toString']
- 依次调用 methodsNames 中的函数 method,如果结果是原始值,直接返回
- 抛出 TypeError 异常
- 如果 input 类型是
总的来说,就是对于 Object 类型的 input,toPrimitive 会根据传入的 PreferredType 判断优先调用 'toString'(对 String) 还是 'valueOf'(对 Number)。如果两个都拿不到原始类型的结果才会抛出异常。
Type(val):
获取 val 的类型
ToString(val):
根据下表将 val 转换成 String 类型的值:
val Type | 结果 |
---|---|
Completion Record | 如果 val 是 abrupt completion,返回 val;否则,返回 ToString(val.[[value]]) |
Undefined | 返回 "undefined" |
Null | 返回 "null" |
Boolean | 如果 val === true, 返回 "true"; 如果 val === false, 返回 "false" |
Number | 参加下文 |
String | 返回 val |
Symbol | 抛出 TypeError 异常 |
Object | let primValue = ToPrimitive(val, hint String); return ToString(primValue); |
Number 类型 ToString(val) 步骤:
- 如果 val 是 NaN,返回字符串 “NaN”
- 如果 val 是 +0 或 -0,返回字符串 “0”
- 如果 val 比 0 小,返回字符串 "-" 和 ToString(-m) 的连接结构
- 如果 val 是 +∞ ,返回字符串 "Infinity"
- 否则,正数的转换方法(本文略,具体看标准)
整体来讲,ToString 的操作和我们直觉上的结果是一致的;
ToNumber(val)
根据下表将 val 转换成 Number 类型的值:
val Type | 结果 |
---|---|
Completion Record | 如果 val 是 abrupt completion,返回 val;否则,返回 ToValue(val.[[value]]) |
Undefined | 返回 NaN |
Null | 返回 +0 |
Boolean | 如果 val 是 true,返回 1;如果 val 是 false,返回 +0 |
Number | 返回 val |
String | 本文略,参见标准 |
Symbol | 抛出 TypeError 异常 |
Object | let primValue = ToPrimitive(val, hint Number); return ToNumber(primValue); |
总结一下“+”操作符的运算步骤
虽然上一节搬标准也比较清晰,而且最为详细,但是鉴于其中调用了太多的函数,所以还是简单总结一下:
- let lprim = ToPrimitive(value1)
- let rprim = ToPrimitive(value2)
- 如果 lprim 和 rprim 中有一个是字符串,那么则返回 ToString(lprim) 和 ToString(lprim) 的拼接结果
- 返回 ToNumber(lprim) 和 ToNumber(rprim) 的运算结果
ToStirng
,ToNumber
和 ToPrimitive
的运算结果参见上一节。
光说不练假把式,下面看几个例子:
1. 字符串 + 数字
'1' + 2
解读: 把本文开头的例子稍微改了下,结果还是不变:两个值之间有一个是字符串则会返回字符串的拼接结果
'1' + 2 // '12'
2. undefined + 数字
undefined + 1
解读: undefined 和 1 都是基本类型,toPrimitive 返回自身。因为两者都不是字符串,所以会使用 ToNumber(undefined) + ToNumber(1) 加法运算。而 ToNumber(undefined) 会返回 NaN。NaN + 1 结果还是 NaN,因此:
undefined + 1 // NaN
3. null + 数字
null + 1
解读:前面和 undefined + 1
的分析 类似,但是要注意: ToNumber(null)
结果是 0,因此:
null + 1 // 1
4. 日期 + 数字
new Date() + 1
解读:日期对象调用 ToPrimitive 方法会默认将第二个参数视为 String,因此会返回 字符串。所以会按照字符串方式来拼接:
new Date() + 1 //"Sat Apr 11 2020 18:42:20 GMT+0800 (中国标准时间)1"
5. 数组 + 对象
[] + {}
解读:依然按照规范:
- toPrimitive([]),因为 [] 不是基本类型也不是 Date, PreferredType 默认按照 Number 解析。首先调用 valueOf() 方法,返回其本身,不是基本类型;然后调用 toString 方法,返回空字符串""
- toPrimitive({}),类似
[].valueOf() //[]
[].toString() //""
({}).valueOf() //{}
({}).toString() //"[Object Object]"
这样,+
左右的操作值都是字符串,按拼接操作,结果:
[] + {} // "[Object Object]"
6. 对象 + 数组
{} + []
我本来以为结果肯定和上面的一样,结果输出却是:

what?

但如果把这个表达式包裹在 console.log
中是没问题的:

分析原因,是因为 {}
在开头出现时,被当成空的代码块,所以 {} + []
就变成了 +[]
,结果就变成了0
那么 {} + {}
呢?
{} + {} // "[object Object][object Object]"
emmmm
我是在 chrome 中测试的,参考 mqyqingfeng 大佬的 文章 在火狐里还会有不同的结果。这大概就属于在标准未明确定义(或标准定义了但有浏览器有bug)的领域各浏览器实现上的差异了。
好在实际开发中并不会产生类似的歧义。
以上就是从 1 + '2' = ?
引出的 JS 类型转换相关的知识。这里只介绍了 +
符号引发的隐式转换,实际还需要了解类型转换的内容有更多。如果有兴趣,大家可以在参考资料里找到更多有待挖掘的知识点。