深入聊聊 js 中的 "+" 操作符规则

1,039 阅读9分钟

做前端开发的朋友应该都知道,js是一种弱类型的语言,这是我个人喜欢特别喜欢它的一个点,可能跟自己刚学编程的时候学的是java有关,总觉得强类型的语言约束太多。 但弱类型的语言也有个坏处,如果吃不透,有时候就会自己给自己挖坑。在js其中,"+" 号就特别需要注意,因为它就是典型的弱类型计算,存在隐性的值转换问题。对基础不扎实的同学,很容易造成一些意外的问题,比如 1 + 1 + "2" 和"1" + 1 + 2 ,它们得到的结果是不同的。如果你知道还好,但如果你还存在怀疑的话,是时候补补课啦。

js中的加号,可用于字符串相加,数字相加,也可用于字符串和数字相加。可能你不知道的:js中的加号,可用于布尔值相加,对象相加,数组相加,只是这些类型进行相加的时候,有自己的加法规则。

我们先从基础数据类型说起(也就是先抛开Object、Array和Function等引用对象类型的讨论),就如前面提到的, 1 + 1 + "2" 和"1" + 1 + 2 会出现不同的结果,为什么呢?因为都是相加,固然就不存在优先级的问题,所以问题在于字符串和数字的先后问题。

在js中,两个变量相加,如果两者都是数字,则会运用数学上的加法,即1+1等于2。但只要其中有一个不是数字,就会存在类型转换,比如:"2"+1。具体的转换规则如下:

  1. 当数字和字符串相加时,不分先后,两者都会转为字符串;
    所以:"1" + 2 和 1 + "2" 的结果都为 "12"

  2. 两者都是字符串时,为字符串拼接,并且会保留所有的空格和换行;

  3. 当数字和布尔值相加时,布尔值转换为数字,再相加。其中,true转为数字的值为1,false转为数字的值为0;
    所以:
    1 + true = 2,
    1 + false = 1

  4. 当字符串和布尔值相加时,布尔值转换为字符串,true为"true",false为"false";
    所以:
    "a" + true = "atrue",
    "a" + false = "afalse"

  5. 当布尔值和布尔值相加时,两者都转为数字再相加;
    所以:
    false + true = 1,
    false + false = 0,
    true + true = 2

  6. 当 null 和数字、布尔值相加时,null 转为数字0,当 null 和字符串相加时,null 先转为字符串"null",再相加;
    所以:
    null + 1 = 1,
    null + true = 1(true为1),
    null + false = 0(false为0),
    null + "a" = "nulla"

  7. 当 undefined 和数字、布尔值相加时,因为 undefined 不能转为数字,所以结果永远是NaN ,当 undefined 和字符串相加时,先转为字符串"undefined",再相加;
    所以:
    undefined + 1 = NaN,
    undefined + true = NaN(true为1),
    undefined + false = NaN(false为0),
    undefined + "a" = "undefineda"

注意,上面有个奇怪的现象,就是当字符串和数字相加时,得到的是字符串。说明对于"+"操作符而言,字符串比数字优先级高,可当我们只有布尔值,undefined和 null 进行"+"操作时,又是转成数字,而不是字符串,是不是很不好理解?

其实我们可以这样理解:对于"+"操作符而言,它会先尝试把两边的操作变量转换为数字,如果两边都转换成功,则进行数字加法。但当遇到一边字符串,一边数字时,字符串优先级更高,所以都转化为字符串再拼接。

通过上面这几条规则的讲解,相信你对 1 + 1 + "2" 不等于"1" + 1 + 2 也应该理解清楚了。

首先,对于 1 + 1 + "2" ,因为都是加法,所以不存在先后顺序,先计算 1 + 1,由于两者都是数字,所以 1 + 1 = 2,再计算2 + "2",由上面的(1)可知,等于 "22"。

然后对于"1" + 1 + 2,先计算"1" + 1,由(1)可知,等于"11",再"11" + 2,继续用(1)的规则,固然就得出了"112"。

我们再拿"1" + ( 1 + 2 )来举例,这次因为存在括号,有了先后顺序,所以很容易得出结果为"13"。

对于基础数据类型的 "+" 操作,简单做个总结就是:

a. 数字加数字,结果为数学上的相加,字符串"加"字符串,结果为字符串拼接,数字加字符串,结果为字符串拼接。

b. 当布尔值,null 和 undefined 和数字"相加"时,会自动转换为数字,再相加,由于undefined不能转为数字,所以不论跟什么数字相加,结果都为NaN。

c. 当布尔值,null 和 undefined 和字符串"相加"时,分别转换为对应的字符串,得到的结果再和字符串相拼接。

d. 当布尔值,null 和 undefined 中的一个或多个进行"相加"时,先转换为各自对应的数字,再相加。

补充说明:es6 新增了一个基础数据类型 symbol,但它和其他的基础数据类型有些不一样的地方,不能一概而论,有兴趣的朋友可自行探究一下。

好了,基础数据类型的到这里就算讲得差不多了,我们接着看引用对象类型。

1、Object 的"加法"。先看代码:

var a = {}
var b = {
 name: 1
}
var c = {
 name: 1,
 toString: function(){
   return 'object'
 }
}
console.log(a + b) // [object Object][object Object]
console.log(a + c) // [object Object]object

不卖关子的说,通过上面的代码,我想你已经知道了他们的原理,就是 Object 对象在进行 "+" 操作时,会先调用自己的 toString 方法,再进行字符串拼接。对于 a 和 b 来说,我们没有给它们设置 toString 方法。它们就会调用自身的(原型上的) toString 方法,即 Object.prototype.toString()。

2、Function 的“加法”

var a = function(){}
var b = function(){
  return 'b'
}
var c = function(){
  return 'c'
}
c.toString = function(){
  return 'tostring'
}
 
console.log(a + b)
/* 打印结果如下:
function(){}function(){
  return 'b'
}
*/
 
console.log(a + c)
/* 打印结果如下:
function(){}tostring
*/

可以看出,Function 对象和 Object 对象一样,在进行 "+" 操作时,会先调用自己的 toString 方法,再进行字符串拼接,如果自定义了toString ,则调用自己的,否则调用原型上的。

但 Function 对象和 Object 对象又有区别,Function 的 toString 方法不是返回 [object Object],而是将整个整个方法体返回为字符串,连换行和缩进都完整保持。对于 Function 的 toString,我们继续看如下代码:

var fun1 = function funname(){
 console.log('a'+"b")
}

var fun2 = (a,b) => a + b

function fun3(){
 cnsole.log(0)
}

console.log(fun1.toString())
console.log(fun2.toString())
console.log(fun3.toString())
/*
打印结果如下
function funname(){
 console.log('a'+"b")
}

(a,b) => a + b

function fun3(){
 cnsole.log(0)
}
*/

可以看到,对于 fun2 方法,toString 之后带上了函数名,所以我们可以这么认为,Function 的 toString,返回的是定义函数时,等号右边的全部内容字符串,因为对于fun3 来说,它其实等价于:

var fun3 = function fun3(){
  cnsole.log(0)
}

3、Array 的 "加法"

var a = []
var b = [1,2]
var c = [1,2]
c.toString = function(){
  return 'tostring'
}
console.log(a+1) // '1'
console.log(b+1) // '1,21'
console.log(a+b) // '1,2'
console.log(a+c) // 'tostring'
console.log(b+c) // '1,2tostring'

console.log(b.toString()) // '1,2'

可以看到,Array 的"加法"也遵循先 toString ,再拼接的逻辑。而且我们不难发现, Array 自己默认的 toString 方法,其原理等同于 Array.join(",")。对于上面的 a + 1 来说, a toString 之后为 "",而字符串跟数字相加,得到的结果是字符串,故而结果为 "1" 而不是 1。

继续看下面代码:

console.log([1,2,['abc'],[4,5,{name:'abe'}]].toString()) 
// '1,2,abc,4,5,[object Object]'

我们发现,不管是几维数组,toString方法都是一样的逻辑,因为对于上面代码里面的二维数组 ['abc'] 和 [4,5,{name:'abe'}]来说,它们也是数组,数组中的对象{name:'abe'},因为和前面的字符串拼接,也调用了自己的 toString 方法,得到 '[object Object]',所以两个数组转为字符串就是 'abc' 和 '4,5,[object Object]',最后的结果也就是打印结果了。

通过上面3点,结合归纳总结思想,我们似乎可以得到这样一个猜想,就是: 非基础数据类型的对象,在遇到 "+" 操作时,都是先调用自己的 toString 方法,自己没有就找原型上的,得到结果之后再拼接。只是每种对象默认的 toString 方法实现不一样而已。

为了验证我们的猜想,我们再来试一下 Date 对象和 RegExp 对象,先看 toString 方法:

var date = new Date()
var reg = new RegExp()
console.log(date.toString())
// Thu Oct 31 2019 16:52:57 GMT+0800 (中国标准时间)
console.log(reg.toString()) 
// /(?:)/

再来验证一下我们之前的猜想:

var date1 = new Date()
var date2 = new Date()
date2.toString = () => 'date1自己的toString'

var reg1 = new RegExp()
var reg2 = new RegExp()
reg2.toString = () => 'reg2自己的toString'

console.log(date1 + '__abc')
console.log(date2 + '__abc')
console.log(reg1 + '__abc')
console.log(reg2 + '__abc')

// 运行结果如下
Thu Oct 31 2019 17:00:01 GMT+0800 (中国标准时间)__abc
date1自己的toString__abc
/(?:)/__abc
reg2自己的toString__abc

有没有发现,结果跟我们的猜想一模一样。

话虽如此,还有些对象我们没有验证,所以从严谨的角度讲,我们不能以偏概全。对于那些还没验证的(比如,set,map,class等),这里就不随便下结论了,有兴趣的朋友可以自行测试一下。

最后,来个大的概括总结:

在js中,"+" 操作符既可用于数字的加法,也用于字符串拼接;

数字的加法仅限于操作符两边都是数字和一边是数字,另一边是可转化为数字的其他值(字符串除外,因为字符串和数字在一起就拼接了);

除此之外的其他情形,都是字符串拼接,基础数据类型有自己的转换为字符串的规则,而引用对象类型的变量,都是先调用自己的 toString 方法,自己没有就找原型上的,得到结果之后再拼接。只是每种对象默认的 toString 方法实现不一样而已。

好了,关于 js 中的 "+" 操作符 就聊到这里。由于个人所知有限,阅读过程中,如果发现有笔误或者讲解错误的地方,还望指出或斧正为谢。