一般来说, 加减法应该是我们学习生涯中接触到的第一个运算符,通常意义下它也是最简单的运算符。
在程序语言中,加减法的情况一般也比较简单,但是在 JavaScript 中加法的情况却比较奇怪,因为它有着大量特殊的情况。
我们举个简单的例子:
1 + '1' = '11';
1 + 'a' = '1a';
1 + [] = '';
从基础数据类型的加法开始,我们得到的结果就变的奇怪了起来。究其根由,其实是 JavaScript 的隐式转换在做怪。
一.什么是 JavaScript 隐式转换?
在讲隐式转换之前,咱们先得回顾一下 JavaScript 的 5 种基础数据类型,3种引用数据类型和 3 个特殊值:
基础数据类型:
- number 类型
- string 类型
- boolean 类型
- null 类型
- undefined 类型
引用数据类型:
- object
- function
- array
特殊值:
- NaN
- +Infinity 和 -Infinity
- +0 和 -0
ok,我们回到隐式转换中,正如我开始举的那个例子一样,JavaScript 的隐式转换总是发生在各种运算符以及特殊的真值判断中(比如说 if),对与 java 来说,一个 Number 类型值 + 一个 Boolean 类型值 是肯定会报错的,但是在 JavaScript 中却不会,因为隐式转换会将 Boolean 类型的值转换为 Number 值。
二. JavaScript 的加法中隐式转换是怎么工作的?
你在百度上搜索 JavaScript 加法特性,你也许会看到这种图解:
或者是这种:
他们是很有用处的内容总结,但是光靠它们并不能帮我们更好的去理解,所以我们需要实例来支撑理论。
我们先来看看一个有趣的例子:
// number + ?
1 + 1 = 2; // number
1 + '1' = "11"; // string
1 + true = 2; // number
1 + null = 1; //number
1 + undefined = NaN // NaN
1 + {} = "1[object Object]" // string
1 + [] = "1"; // string
// string + ?
'1' + 1 = "11"; // string
'1' + '1' = "11"; // string
'1' + true = "1true"; // string
'1' + null = "1null"; // string
'1' + undefined = "1undefined"; // string
'1' + {} = "1[object object]"; // string
'1' + [] = "1"; // string
看到这大家是不是有点晕?我之所以举了这两个例子,是因为在 JavaScript 的加法中其实只有两种规则:一种是 number + number,另一种是 string + string。
我们从 string + ?
的例子中很容易的可以得出一个结论,只要在某一个加法中,某一方是字符串,那么最后的结果一定是字符串。
但是这个结论只能解决我们很小的一部分疑惑,你可能存在类似下面的问题:
number + boolean = ?
number + null = ?
number + undefined = ?
number + object = ?
我觉得,想要理解这其中的转换,先得理解的是在一个加法运算中,隐式转换的潜规则到底是什么。
首先,在 JavaScript 的加法中,会发生三种转换:
1. 原始类型转换 ToPrimitive
2. 数字类型转换 ToNumber
3. 字符串类型转换 ToString
而转换的顺序永远是 ToPrimitive
也就是原始类型转换优先。
那么什么是 原始类型转换?
我们可以把原始类型转换分为两种,第一种是简单基本类型之间互转,比如:
number -> string
string -> number
null -> number
...
这其中的转换没什么要注意的地方,大家把我在上面借用过来的图里的内容记下来就行。
关键点在于第二种,复杂基本类型到简单基本类型的转换:
object -> number
object -> string
array -> number
array -> string
function -> number
function -> string
okok,有了上面的基础,我们再来讲讲原始类型转换的工作原理,老规矩,我们打开ECMAScript的官网ecmascript 5.1规范,找到 9.1 节,里面就有关于 ~ToPrimitive
我给大家放下原版内容:
大概解释如下:
ToPrimitive
接受一个值 input
,和一个可选的期望类型 PreferredType
作参数。
可选参数 PreferredType
可以是 Number
或者 String
。
如果对象有能力被转换为不止一种原语类型,可以使用可选的 PreferredType 类型 来暗示那个类型,但转换结果一定是一个原始值。
如果 PreferredType 被标志为 Number,则会进行下面的操作来转换input:
- 如果 input 是个原始值,则直接返回它。 否则,如果 input 是一个对象。则调用 obj.valueOf() 方法。
- 如果返回值是一个原始值,则返回这个原始值。 否则,调用 obj.toString() 方法。 如果返回值是一个原始值,则返回这个原始值。
- 否则,抛出 TypeError异常。如果PreferredType被标志为String,则转换操作的第二步和第三步的顺序会调换。
如果没有 PreferredType 这个参数,则PreferredType的值会按照这样的规则来自动设置:
- Date 类型的对象会被设置为 String
- 其它类型的值会被设置为 Number
OK,现在我们知道了 原始数据类型转换的大致工作流程,我们来看看在一个 加法 中会发生什么?
有两个参数 value1
和 value2
,有以下算式:
value1 + value2
这时在 JavaScript 引擎内部会发生以下这三步:
- 通过
ToPrimitive
将value1
和value2
转化为原始值,此时PreferredType
参数是被忽略的,所以除了 Date 类型外其他类型都会按照 Number 参数来处理。 - 此时如果
value1
和value2
中有一个string
类型,调用ToString
方法将另一个参数转化为string
类型并进行字符串的拼接。 - 如果
value1
和value2
中一个string
类型都没有,那么则通过ToNumber
方法将两个参数都转化为number
类型。
我举几个例子帮助大家理解下:
1 + '2' = '12' ;
1被 ToPrimitive
返回其自身的原始值 number
, '2’的原始值为 string
,命中条件2,所以1被 ToString
转化为 ‘1’, ‘1’ 与 ‘2’ 进行拼接后得到 ‘12’;
再看一个例子:
1 + [] = '1';
1被 ToPrimitive
返回其自身的原始值 number
, [] 则不太一样,首先它会调用 valueOf()函数[].valueOf() === []
得到 array
,这并不是原始值,所以它会进行 toString()
转换,得到空字符串 ""
。然后""
与 1 相加显然就是拼接了,最终得到'1'
;
再来一个:
1 + {} = 1;
1被 ToPrimitive
返回其自身的原始值 number
,{} 与 [] 又不太一样,{} 为对象,它会进行ToPrimitive({}, string)
操作,此时它与 Number
类型中的步骤2,3是相反的,也就是说它会直接调用 toString()
方法,得到 "[object object]"
,类型为 string
,此时又命中条件2,1被转化为'1'
,进行字符串拼接后得到"1[object object]"
。
现在是不是清晰多了?整个工作原理,我们可以把它分为三部分:
1.简单基本类型之间的转化规则:
2.原始基本类型的转化原则。
3.加法中的执行顺序。
三. 害人的的语句优先
有这么一种情况,会让人疑惑:
在浏览器控制台输入以下代码:
{} + 1 // return 1 number;
1 + {} //return '1[object object]' string
如图:
还有更让人疑惑的:
{} + 1 // return 1 number;
console.log({} + 1) // logs '1[object object]' string
如图:
这一切的根源就在于 {}
的特殊表现,实际上在 JavaScript 的执行上下文中,{}
有以下三种含义:
- 语句块
- 函数
- 对象字面量
而其中 语句块 这个含义的优先级是最高的。
所以,以下这段代码中的{}
相当于全局环境中的 label
语句了:
{} + 1
实际上等于:
{};+1
即:
+1