从【ECMA-262语言规范】出发,解读JS的强制数据类型转换规则

504 阅读13分钟

前言

js中的数据类型转换,一直都是这门语言中比较难掌握的一块知识,因为其中的转换规则多且杂,很容易记混和遗忘。

针对这种特点的知识,笔者最推荐的学习资料就是直接看ECMAScript标准文档,因为那里记录了最权威准确的js语法规则,本文可以看作是对此规范的解读,建议一块对照着原文档进行学习。

MDN之类的资料网站或者一些js语法书为了帮助读者更快的理解知识点,会有意无意地去简化知识结构,这经常会搬丢或搬错一些知识,而且常常会夹带上作者非常主观的理解(私货),比如在很多js书籍中隐式强制类型转换被说成是危险、晦涩和糟糕的设计,这导致笔者看到很多的公司级项目都用eslint的方式,禁止使用==。诚然,这在确保团队代码风格统一方面是非常有意义的,因为站在团队角度,不能假设大家都对==的转换规则十分清楚,所以一刀切的做法,能够避免滥用此类语法,但在实际的项目开发中,常常事与愿违,比如你的老板让你维护一个很有历史包袱的项目(屎山),其中就大量使用了这种语法,那你除了抱怨(跑路)以外,更应该做的就是要迎难而上,了解js这门语言的全貌,比如本文所讲的类型转换规则,这样才能做到心中有数,有备无患。

正文

首先,js中的强制类型转换,最终总是返回基本类型值,所以你从没有听过,一个字符串强制转换为对象这类的说法。
js中的强制类型转换,可分为显式强制类型转换(发生在运行时)及隐式强制类型转换(发生在编译时)。

let a = 1
let b = a + "2"; // 隐式强制类型转换
let c = String(a) + "2"; // 显式强制类型转换

转换规则

ES5 规范第 9 节中定义了一些“抽象操作”规则,这里我们着重介绍其中的 ToStringToNumberToBooleanToPrimitive

ToPrimitive

它负责处理对象类型到基本类型的强制转换。ES5 规范9.1
对象转基本类型是代码里的常见操作,具体实现就是在内部通过DefaultValue 操作执行转换规则, 具体步骤是:

  1. 检查对象是否有 valueOf() 方法。 有的话,就调用执行此方法,看返回是否是一个基本类型值,如果这些都满足,就使用该值进行强制类型转换,不满足继续走下一步。
  2. 检查对象是否有 toString() 方法。 有的话,就调用toString(), 如果有返回值,就用该值就行强制类型转换。 如果 valueOf()toString() 均不返回基本类型值,会抛出 TypeError 错误。 值得注意的一点是,使用 Object.create(null) 创建的对象 [[Prototype]] 属性为 null,没有继承 valueOf()toString()方法,无法进行强制类型转换。

image.png

ToString

它负责处理非字符串类型到字符串类型的强制类型转换。 ES5 规范9.8

  1. 针对基本类型值
    基本是字面量长啥样,就转为什么样子的字符串。

image.png

  1. 针对对象类型值 image.png
    对象转字符串,遵循 ToPrimitive 规范,先尝试将对象转为基本类型值,再进行强制类型转换。
const obj = {};
obj.valueOf({}) // 返回自身,依然是对象类型,非基本类型,继续调 toString
obj.toString() // 返回 “[object Object]",是基本类型值,并且类型是字符串,所以转换结果就是它了

我在之前写的JS数据类型检测那些事这篇文章中里讲到过,Object.prototype这个原型链的顶端对象中,定义了valueOftoString方法,这两个方法会被所有的实例对象所继承,但前边提到的 ToPrimitive 规范只是规定了从对象实例本身上去找这两个方法,而不是像这样Object.prototype.toString.call(xx)直接调用原型链顶端对象上的对应方法,那么在一个实例上如果重新定义这些同名方法,就可以修改ToPrimitive的结果了,事实上,js中常见的内置对象类型基本都实现了自己的专属toString方法, 所以会出现以下的测试情况。

image.png

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)+ xx * 1等,第二种因为最简洁,所以最流行。

  1. 基本类型值
    image.png
    其中 falsenull'' 转换为 0true转换为 1undefined 转换为 NaN 。 字符串转数字,遵循数字的语法规范,如果其中包含不是数字的字符会转换失败,返回NaN

  2. 对象类型值

image.png

大部分对象类型转数字类型都是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''null0NaN 这些都在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,其中xy是值,产生true或 false。这样的比较如下:

  1. 如果两个值的类型相同,就仅比较它们是否相等。
    例如,42等于 42"abc" 等于 "abc"。有几个非常规的情况需要注意。NaN 不等于 NaN, +0 等于 -0。最后定义了对象(包括函数和数组)的宽松相等 ==,两个对象指向同一个引用地址时即视为相等,不发生强制类型转换。
  2. 字符串数字之间的相等比较
  • 如果 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
    
    结论:字符串与数字的比较,会将字符串转换为数字后再进行比较
  1. 其他类型布尔类型之间的相等比较
    == 最容易出错的一个地方就是 truefalse 与其他类型之间的相等比较。
    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

    这也是== 最奇怪的地方,==两边的布尔值会被强制转换为数字

  1. nullundefined 之间的相等比较
  • 如果 x 为 null,y 为 undefined,则结果为 true
  • 如果 x 为 undefined,y 为 null,则结果为 true。 这也就是说在 ==nullundefined 是一回事,可以相互进行隐式强制类型转换。
    还有一点需要注意,nullundefined==判断时,除了自身还有彼此,不等于任何值~
    null == ''// false
    null == null // 当然是true
    null == undefined // true
    
  1. 对象和基本类型值之间的相等比较
  • 如果 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,所以,结局相等。
如果你实在记不住这样多的==规则,那么也是有个简化技巧的。

  • nullundefined像对情侣,坚贞且暧昧,只与他们自身和彼此相等。
  • 两个不同类型的值比较,如果其中有对象,就先转为基本值,然后看此刻两边类型是否一致,不一致,就转为数字后再进行比较。 如果你有判断错误的用例,可以再次翻阅上边讲的规则,万变不离其宗,一一进行分析,就会恍然大悟了。 最后送给大家一道之前面字节遇到的面试题及解法。
实现一个函数,运算结果可以满足如下预期结果:
add(1)(2// 3
add(1, 2, 3)(10// 16
add(1)(2)(3)(4)(5// 15

一开始看这题,觉得简单,这不是参数柯里化嘛,但是细品,发现不对,函数的参数竟然是不固定的,并且总是在最后一次调用后执行,而不是常规上的参数传够就执行。这道题的其中一种解法就会涉及到类型转换的知识,思路可以参考这位大神写的一道面试题引发的对javascript类型转换的思考,可以顺便巩固下上边学到的知识。

参考文献