前言
js中的数据类型转换,一直都是这门语言中比较难掌握的一块知识,因为其中的转换规则多且杂,很容易记混和遗忘。
针对这种特点的知识,笔者最推荐的学习资料就是直接看ECMAScript标准文档,因为那里记录了最权威准确的js语法规则,本文可以看作是对此规范的解读,建议一块对照着原文档进行学习。
MDN之类的资料网站或者一些js语法书为了帮助读者更快的理解知识点,会有意无意地去简化知识结构,这经常会搬丢或搬错一些知识,而且常常会夹带上作者非常主观的理解(私货),比如在很多js书籍中隐式强制类型转换被说成是危险、晦涩和糟糕的设计,这导致笔者看到很多的公司级项目都用eslint的方式,禁止使用==
。诚然,这在确保团队代码风格统一方面是非常有意义的,因为站在团队角度,不能假设大家都对==
的转换规则十分清楚,所以一刀切的做法,能够避免滥用此类语法,但在实际的项目开发中,常常事与愿违,比如你的老板让你维护一个很有历史包袱的项目(屎山),其中就大量使用了这种语法,那你除了抱怨(跑路)以外,更应该做的就是要迎难而上,了解js这门语言的全貌,比如本文所讲的类型转换规则,这样才能做到心中有数,有备无患。
正文
首先,js中的强制类型转换,最终总是返回基本类型值,所以你从没有听过,一个字符串强制转换为对象这类的说法。
js中的强制类型转换,可分为显式强制类型转换(发生在运行时)及隐式强制类型转换(发生在编译时)。
let a = 1
let b = a + "2"; // 隐式强制类型转换
let c = String(a) + "2"; // 显式强制类型转换
转换规则
ES5 规范第 9 节中定义了一些“抽象操作”规则,这里我们着重介绍其中的 ToString
、ToNumber
和 ToBoolean
,ToPrimitive
。
ToPrimitive
它负责处理对象类型到基本类型的强制转换。ES5 规范9.1
对象转基本类型是代码里的常见操作,具体实现就是在内部通过DefaultValue 操作执行转换规则, 具体步骤是:
- 检查对象是否有
valueOf()
方法。 有的话,就调用执行此方法,看返回是否是一个基本类型值,如果这些都满足,就使用该值进行强制类型转换,不满足继续走下一步。 - 检查对象是否有
toString()
方法。 有的话,就调用toString()
, 如果有返回值,就用该值就行强制类型转换。 如果valueOf()
和toString()
均不返回基本类型值,会抛出TypeError
错误。 值得注意的一点是,使用Object.create(null)
创建的对象 [[Prototype]] 属性为 null,没有继承valueOf()
和toString()
方法,无法进行强制类型转换。
ToString
它负责处理非字符串类型到字符串类型的强制类型转换。 ES5 规范9.8 。
- 针对基本类型值
基本是字面量长啥样,就转为什么样子的字符串。
- 针对对象类型值
对象转字符串,遵循ToPrimitive
规范,先尝试将对象转为基本类型值,再进行强制类型转换。
const obj = {};
obj.valueOf({}) // 返回自身,依然是对象类型,非基本类型,继续调 toString
obj.toString() // 返回 “[object Object]",是基本类型值,并且类型是字符串,所以转换结果就是它了
我在之前写的JS数据类型检测那些事这篇文章中里讲到过,Object.prototype
这个原型链的顶端对象中,定义了valueOf
,toString
方法,这两个方法会被所有的实例对象所继承,但前边提到的 ToPrimitive
规范只是规定了从对象实例本身上去找这两个方法,而不是像这样Object.prototype.toString.call(xx)
直接调用原型链顶端对象上的对应方法,那么在一个实例上如果重新定义这些同名方法,就可以修改ToPrimitive
的结果了,事实上,js中常见的内置对象类型基本都实现了自己的专属toString
方法, 所以会出现以下的测试情况。
Array.prototype.toString.call([1,2,3]) // "1,2,3"
Object.prototype.toString.call([1,2,3]) // "[object Array]"
Array.prototype.toString === Object.prototype.toString // false
上边代码,证实了数组没有沿用Object.prototype原型对象的方法,而是实现了自己的toString
方法,会将字符串的元素用逗号拼接起来并返回,正则会返回字面量的表示字符串,日期会返回一个字符串的时间标记。
ToNumber
它负责处理非数字类型到数字类型的强制类型转换。ES5 规范9.3.1
转换的方法一般是调用Number(x)
、 + x
、x * 1
等,第二种因为最简洁,所以最流行。
-
基本类型值
其中false
、null
、''
转换为 0,true
转换为 1,undefined
转换为 NaN 。 字符串转数字,遵循数字的语法规范,如果其中包含不是数字的字符会转换失败,返回NaN
。 -
对象类型值
大部分对象类型转数字类型都是NaN
,但在上方的测试中数组与日期是例外,会正常的转为数字。咱们先按 ToPrimitive
规则分析一下普通对象类型是怎么转的?
const obj = {a:1};
obj.valueOf();//返回自身对象,非基本类型值,继续调toString
const strTag = obj.toString();//返回"[object Object]",是基本类型,是字符串类型,非数字类型,所以执行以下的强制类型转换
Number(strTag) // NaN 对进行字符串“[object Object]”执行ToNumber转换,最后结果为NaN
那为啥到数组身上,就会不一样呢?
const arr = [];
arr.valueOf(); // 返回自身,非基本类型值,继续调toString
arr.toString(); // 返回‘’空串,基本类型值,
Number(''); // 空串‘’转数字是0
空数组是这样,那数组里有元素呢
const arr = [1];
arr.valueOf(); // 返回自身,非基本类型值,继续调toString
const str = arr.toString(); // 返回‘1’,基本类型值
Number(‘1’); // 转数字是1
但是有多个元素就不行了,结果就成NaN了
const arr = [1,2];
arr.valueOf(); // 返回自身,非基本类型值,继续调toString
const str = arr.toString(); // 返回‘1,2’,基本类型值
Number(‘1,2’); // 字符串转数字因为中间有不合法的逗号,所以最终为NaN
这也是很经典的一道面试题 为什么空数组会被转换为数字0,下次遇到就一定明白该怎样回答面试官了~。
还遗留一个问题,那么日期为什么会正常的转为数字呢。因为日期对象重新定义了valueOf()
方法,调用会返回数字类型的时间戳,所以,在首先进行的ToPrimitive
的第一步(判断valueOf返回值)结束后,就直接转为了数字类型。
const now = new Date();
now.valueOf() // 1631102131908 现在时间的时间戳
Date.prototype.valueOf.call(now) // 1631102131908 现在时间的时间戳
Object.prototype.valueOf.call(now) // Wed Sep 08 2021 19:55:31 GMT+0800 (中国标准时间)
Date.prototype.valueOf === Object.prototype.valueOf // false
ToBoolean
它负责处理非布尔类型到布尔类型的强制类型转换。ES5 规范9.2
这个是最好记的,undefined
、''
、null
、0
、 NaN
这些都在js里称为假值,Boolean()
会转为false
, 除此之外,其他的值都是真值,都转为true
,也就是说所有的对象也都是真值。 值得注意的是(new Boolean(false)
这种包装对象类型,执行Boolean(new Boolean(false))
也会返回 true
,不过这正callback了前边提到的:对象都为true
这句话。
运算符及条件判断中的隐式转换
加号 +
1 + 2 // 2
1 + '2' // '12'
false + 1 // 1
简单来说就是,如果 + 的其中一个操作数是字符串,则执行字符串拼接;否则执行数字加法。
那么针对数字与字符串之外的类型呢?比如数组类型
[1] + [2,3] // '12,3'
老规矩,先用 ToPrimitive
规则,转为数字或字符串类的基础类型,'1' + '2,3'
,两边都是字符串,最后结果'12,3'
。
针对对象类型的+
号拼接,有一个坑常被提到
[] + {}; // "[object Object]"
{} + []; // 0
表面上看 +
运算符根据第一个操作数是([] 或 {})的不同会产生不同的结果,貌似跟+
号的左右书写顺序有关。实则不然,第一行代码中,{}
出现在 +
运算符表达式中,因此它被当作一个值(空对象)来处理。前边讲过[]
会被强制类型转换为 ""
,而 {}
会被强制类型转换为 "[object Object]"
。
但在第二行代码中,{}
被当作一个独立的空代码块(不执行任何操作)。代码块结尾不需要分号,所以这里不存在语法上的问题。最终执行的代码其实只有后边的 + []
,将 []
强制类型转换为了 0
。。。恍然大悟.jpg
取反 !
和前面讲过的+
类似,一元运算符 !
显式地将其他类型的值强制类型转换为布尔值,同时还将真值反转为假值(或者将假值反转为真值)。所以显式强制类型转换为布尔值最常用的方法是 !!
,因为第二个 !
会将结果再次反转。
在 if(..)..
这样的布尔值上下文中,如果没有使用 Boolean(..)
和 !!
,就会自动隐式地进行 ToBoolean
转换。建议使用 Boolean(..)
和 !!
来进行显式转换以便让代码更清晰易读。
|| 和 &&
js中的 ||
和 &&
与别的语言有所不同,它们最终返回的并不是布尔值,而是两个操作数其中一个的值。但是在判断过程中,还是发生了ToBoolean
的隐式强制类型转换。
let a = 42;
let b = null;
let c = "foo";
if (a && (b || c)) {
console.log( "yep" );
}
这里 a && (b || c)
的结果实际上是"foo"
而非 true
,然后再由 if 将 foo 强制类型转换为
布尔值 ,所以最后结果为 true
。
宽松相等 == vs 严格===
==
允许在相等比较中进行强制类型转换,而 ===
不允许。ES5 规范11.9.3
==
在比较两个不同类型的值时会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。
比较x
== y
,其中x
和y
是值,产生true或 false。这样的比较如下:
- 如果两个值的类型相同,就仅比较它们是否相等。
例如,42
等于42
,"abc"
等于"abc"
。有几个非常规的情况需要注意。NaN
不等于NaN
,+0
等于-0
。最后定义了对象(包括函数和数组)的宽松相等==
,两个对象指向同一个引用地址时即视为相等,不发生强制类型转换。 - 字符串和数字之间的相等比较
- 如果
Type(x)
是数字,Type(y)
是字符串,则返回x == ToNumber(y)
的结果。 - 如果
Type(x)
是字符串,Type(y)
是数字,则返回ToNumber(x) == y
的结果。
结论:字符串与数字的比较,会将字符串转换为数字后再进行比较。let a = 42; let b = "42"; a === b; // false a == b; // true
- 其他类型和布尔类型之间的相等比较
==
最容易出错的一个地方就是true
和false
与其他类型之间的相等比较。let x = true; let y = "42"; x == y; // false
"42"
是一个真值,为什么==
的结果不是true
呢? 规范 11.9.3.6-7 是这样说的:
-
如果
Type(x)
是布尔类型,则返回ToNumber(x) == y
的结果; -
如果
Type(y)
是布尔类型,则返回x == ToNumber(y)
的结果。仔细分析例子,首先:
Type(x)
是布尔值,所以ToNumber(x)
将true
强制类型转换为1
,变成1 == "42"
,二者的类型仍然不同,"42"
根据规则被强制类型转换为42
,最后变成1 == 42
,结果为false
。
再看一个例子if([]){alert('haha')}` alert语句会执行 if([] == true){alert('haha')}` alert语句不会执行,因为 左侧[] ToPrimitive转换为‘’,ToNumber()为 0 ,不等于右侧 true 转为的 1。
这也是
==
最奇怪的地方,==
两边的布尔值会被强制转换为数字。
null
和undefined
之间的相等比较
- 如果 x 为
null
,y 为undefined
,则结果为true
。 - 如果 x 为
undefined
,y 为null
,则结果为true
。 这也就是说在==
中null
和undefined
是一回事,可以相互进行隐式强制类型转换。
还有一点需要注意,null
、undefined
在==
判断时,除了自身还有彼此,不等于任何值~null == ''// false null == null // 当然是true null == undefined // true
- 对象和基本类型值之间的相等比较
-
如果
Type(x)
是字符串或数字,Type(y)
是对象,则返回x == ToPrimitive(y)
的结果; -
如果
Type(x)
是对象,Type(y)
是字符串或数字,则返回ToPromitive(x) == y
的结果。
这条规则的基本类型值没有包括布尔类型的原因,是刚单独讲过了,它是个例外,任何类型值(包括对象类型)与布尔类型进行==
比较,都其实是与布尔值转化的数字在进行比较。==
的规则基本讲完了,现在可以自测一下下边的用例,了解下自己的掌握程度"0" == null; // false "0" == undefined; // false "0" == false; // true -- 晕! "0" == NaN; // false "0" == 0; // true "0" == ""; // false false == null; // false false == undefined; // false false == NaN; // false false == 0; // true -- 晕! false == ""; // true -- 晕! false == []; // true -- 晕! false == {}; // false "" == null; // false "" == undefined; // false "" == NaN; // false "" == 0; // true -- 晕! "" == []; // true -- 晕! "" == {}; // false 0 == null; // false 0 == undefined; // false 0 == NaN; // false 0 == []; // true -- 晕! 0 == {}; // false ```
怎么样,你学废了嘛?还有更晕的
[] == ![] // true
这也是一道常考的面试题,判断前,首先执行右侧的!
运算,将 []
转为布尔值,同时取反,根据上边的ToBoolean
规则,任何对象转为布尔类型都为true,所以 ![]
= false
, 根据上边讲到的false
的特殊规则,判断前,先转为数字 0
,接下来,左侧的[]
执行 ToPrimitive
操作,返回空串,字符串与数字进行比较,按规则,将空串转为数字0
,现在等号左右都为数字0
,所以,结局相等。
如果你实在记不住这样多的==
规则,那么也是有个简化技巧的。
null
与undefined
像对情侣,坚贞且暧昧,只与他们自身和彼此相等。- 两个不同类型的值比较,如果其中有对象,就先转为基本值,然后看此刻两边类型是否一致,不一致,就转为数字后再进行比较。 如果你有判断错误的用例,可以再次翻阅上边讲的规则,万变不离其宗,一一进行分析,就会恍然大悟了。 最后送给大家一道之前面字节遇到的面试题及解法。
实现一个函数,运算结果可以满足如下预期结果:
add(1)(2) // 3
add(1, 2, 3)(10) // 16
add(1)(2)(3)(4)(5) // 15
一开始看这题,觉得简单,这不是参数柯里化嘛,但是细品,发现不对,函数的参数竟然是不固定的,并且总是在最后一次调用后执行,而不是常规上的参数传够就执行。这道题的其中一种解法就会涉及到类型转换的知识,思路可以参考这位大神写的一道面试题引发的对javascript类型转换的思考,可以顺便巩固下上边学到的知识。
参考文献
- 《你不知道的js》中卷
- ECMA262文档