当 JavaScript 试图做加法:一场混乱的“相亲”大会

0 阅读5分钟

引言

“为什么 [] + [] 结果是空字符串?”

“为什么 [] + {} 结果是 [object Object]?”

“为什么 {} + [] 在浏览器里结果是 0?等等,难道 JavaScript 还会看心情办事?”

我第一次遇到这些问题的时候,正坐在面试官的对面。他微笑着说:“小伙子,你猜猜 [] + ![] 的结果是什么?”我当时心想,这不是欺负老实人吗?结果我报了个答案,面试官摇了摇头,然后给我上了一课。

从那以后,我终于明白:JavaScript 的加法运算符,简直就是一场混乱的相亲大会——两个不同类型的值被撮合在一起,过程充满了眼泪和规则。

今天,我们就来扒一扒这个“+”运算符背后的秘密。

一、先看几个离谱的例子

打开浏览器控制台,输入以下代码,看看结果:

[] + []           // ""  (空字符串)
[] + {}           // "[object Object]"
{} + []           // 0   (什么鬼?)
{} + {}           // NaN (更离谱了)
true + true       // 2   (爱情让人变数学天才)
false + false     // 0
false + true      // 1
[] + ![]          // "false"   (哦?)
!![] + !![]       // 2   (两个真值相加等于 2?)

是不是一脸懵逼?别急,我们一起来当侦探,揭开这层迷雾。

二、加法运算符的相亲规则

在 JavaScript 中,加法运算符 + 主要有两个作用:

  1. 数值加法(如果两边都是数值)
  2. 字符串拼接(如果至少有一边是字符串)

但问题来了:当两边都不是数值也不是字符串,或者类型不一样时,JavaScript 会启动一套复杂的隐式转换规则,就像给两个不同世界的人安排相亲。

2.1 第一步:先看有没有对象

如果任一操作数是对象(包括数组、函数、普通对象),JavaScript 会尝试把它转换成原始值(primitive)。这个过程叫 ToPrimitive

ToPrimitive 大致规则:

  • 如果对象有 Symbol.toPrimitive 方法,调用它。
  • 否则,如果是日期对象,优先调用 toString
  • 否则,优先调用 valueOf,如果没得到原始值,再调用 toString

数组和普通对象怎么转原始值?

  • 数组的 valueOf 返回数组本身(不是原始值),所以会接着调用 toString[].toString() 结果是空字符串 ""[1,2,3].toString() 结果是 "1,2,3"
  • 普通对象的 valueOf 也返回对象本身,所以会调用 toString,得到 "[object Object]"

2.2 第二步:原始值相加

经过 ToPrimitive 后,两边都变成了原始值(可能是字符串、数字、布尔值、null、undefined、Symbol 等)。然后按照以下顺序:

  • 如果其中一个原始值是字符串,那么就把另一个也转成字符串,然后拼接。
  • 否则,把两个都转成数字,然后相加。

如果转数字时出现 NaN,结果就是 NaN

是不是很简单?好,我们拿几个例子来实战演习。

三、实战演习:案例拆解

3.1 [] + []

左边:[] 是数组,ToPrimitive 走起:valueOf 返回自身,不是原始值;toString() 返回 ""(空字符串)。所以左边变成 ""。 右边同理,也是 ""

现在两边都是字符串 ""。根据规则,有一个是字符串,执行字符串拼接:"" + "" = ""

所以结果是空字符串。

3.2 [] + {}

左边:[]""(同上)。 右边:{} 是普通对象,valueOf 返回自身,toString() 返回 "[object Object]"

两边变成:"""[object Object]"。至少一边是字符串,执行拼接:"" + "[object Object]" = "[object Object]"

没问题。

3.3 {} + []

这个就有趣了。在浏览器控制台里输入 {} + [],你可能会得到 0。为什么?难道规则变了吗?

其实,这里的 {} 被解析成了一个代码块(block),而不是空对象。JavaScript 引擎把这一行理解成:一个空的代码块,后面跟着 + []。而 + [] 是一元正号运算符,它会把数组转成数字:[] 先转成空字符串 "",然后空字符串转数字是 0。所以结果是 0

如果你这样写:({}) + [],加上括号,那么 {} 就被解析成对象,结果就是 "[object Object]"

所以,{} + [] 的怪异结果是语法解析的锅,不是类型转换的错。

3.4 {} + {}

同理,第一个 {} 被当成代码块,后面是 + {}+ {} 把对象转数字:{} ToPrimitive 得到 "[object Object]",然后 + "[object Object]" 转数字,得到 NaN

所以结果是 NaN

3.5 true + true

布尔值 true 转数字是 1,所以 1 + 1 = 2

3.6 [] + ![]

先算右边:![],数组是对象,对象转布尔值是 true(因为所有对象都是真值),所以 ![] = false。 左边 [] 转成 ""。 现在两边是 ""false。至少一边是字符串(""),所以把 false 转成字符串 "false",拼接:"" + "false" = "false"

3.7 !![] + !![]

!![] 就是两次取反:![]false,再 !falsetrue。所以两个 true 相加,数字 1 + 1 = 2

四、还有更奇葩的吗?加法的“左右为难”

除了对象,还有 nullundefined 也要注意:

  • null 转数字是 0,转字符串是 "null"
  • undefined 转数字是 NaN,转字符串是 "undefined"

看看这个:

null + 1          // 1 (null 转数字 0)
undefined + 1     // NaN
null + "hello"    // "nullhello"
undefined + "hi"  // "undefinedhi"

还有更骚的:

1 + 2 + "3"       // "33"  (从左到右:1+2=3,然后 3+"3"="33")
"1" + 2 + 3       // "123" (字符串拼接优先级高)

五、如何避免这些坑?

  1. 尽量使用显式转换,比如 Number()String()Boolean(),或者直接用模板字符串 ${}
  2. === 代替 ==,避免隐式类型转换带来的意外。
  3. 对数组和对象进行运算前,想清楚它们会转成什么。比如你想拼接数组元素,可以用 join();想数字相加,先把它们转成数字。
  4. 括号优先,不要让 {} 被误当成代码块。

六、总结:+ 运算符是面镜子,照出了 JavaScript 的隐式转换本质

加法运算符就像一场混乱的相亲,各种类型被强行拉郎配,结果往往出人意料。但只要你掌握了 ToPrimitive 和两阶段的转换规则(对象→原始值,原始值→数字/字符串),就能预测出绝大部分结果。

下次面试官再问你 [] + ![] 等于什么,你可以微微一笑:“等于 "false",因为左边数组变空串,右边布尔取反变 false 再变字符串,然后拼接。”

然后你还可以反问他:“那 {} + [] 等于多少呢?”看他是不是掉进代码块的坑里。


每日一问:你知道 !![] 为什么是 true 吗?评论区说说你的理解,顺便考考你的小伙伴!