[] == ![] !? 浅析JS的类型系统和隐式类型转换

3,755 阅读10分钟

在开始本文之前我们一起来看看JavaScript神奇的隐式转换

0 + '1' === '01'            // true
true + true === 2           // true
false === 0                 // false
false + false === 0         // true
{} + [] === 0               // true
[] + {} === 0               // false

更多千奇百怪的例子相信大家在逛各种技术社区和日常工作的时候也见到不少,这里就不做更多介绍,如果你能充分理解上述隐式转化的过程,那基本可以点下右上角的x。

本文旨在梳理JS中的数据类型及其对应的转化关系,从本文你可以了解到:

  • 深入理解JavaScript的基础类型和引用类型
  • JavaScript的隐式转换内部机制
  • 在社区中谈及相关话题的时候,彰显自己的实力(误)

JavaScript的类型系统

要讲清楚隐式转换,不可避免要唠唠类型,JS中按大类分有两大类型,分别是基本类型和Object,说到这可能有小伙伴会质疑,明明还有Array、Date...本质上其JS中其他的高级类型都Object的子类型,本文后续统一将Array、Date等类型统称为Object类型。

包括ES6新增的symbol,JS中一共有6种基础类型:Symbol、null、undefined、number、string、boolean;加上Object,JS种一共有七种内置类型。

一般情况下我们可以使用typeof操作符去判断内置类型:

typeof Symbol() === 'symbol'          // true
typeof undefined === 'undefined'      // true
typeof true === 'boolean'             // true
typeof 42 === 'number'                // true
typeof '42' === 'string'              // true
typeof { bar: 42 } === 'object'       // true

// 但还有一个例外
typeof null === 'object'              // true
// 这个bug是由于typeof的底层实现,和null的底层表示有关系这里就不展开了

区分Object的子类型

既然typeof无法区分Array和Date,那我们如何区分Object的子类型呢,在JS实现这些子类型时候为它们增加了一个内部属性[[Class],我们可以通过Object.prototype.toString()进行查看。

Object.prototype.toString.call(/i/g)         // "[object RegExp]"
Object.prototype.toString.call(Date.now())   // "[object Date]"

需要注意的是Object.prototype.toString应该只用来区分已经判定了Object的类型:

var num = 42
var numObj = new Number(42)
typeof num    // number
typeof numObj // object
Object.prototype.toString.call(num)         // "[object Number]"
Object.prototype.toString.call(numObj)      // "[object Number]"
// 可以看到Object.prototype.toString并不能很好的区分基础类型和Object
// 这是因为num toString的过程中会被包装成封装对象,结束后解封为基础类型

类型之间的强制类型转换

所有的隐式转换都是基于强制类型转换的,所以我们要搞清楚JS中强制转换是如何运作的。

抽象操作ToString

在ECMAScript第五版规范中定义了抽象操作ToString,规范定义了其他类型强制转化为string类型的过程,JS中强制转化为string类型的方法一般是:String(...)

我们看下下面的例子:

String(4)                    // "4"
String(false)                // "false"
String(true)                 // "true"
String(null)                 // "null"
String(undefined)            // "undefined"
String(Symbol('s'))          // "Symbol(s)"
// 基础类型强制转string类型在规范中明确说明了,也比较符合我们的直觉

// 但是Object类型就有些许差别
String({ a: 2 })             // "[object Object]"
String([1, 2])               // "1,2"
String(/reg/g)               // "/reg/g"
// 可以看到Object的子类型之间toString并不一致
// 实际上在对Object类型进行toString转换的时候,
// 会调用原型链上的toString方法,并作为结果返回
var arr = [1, 2];

arr.toString()             // "1,2"
String(arr)                // "1,2"
// 重写toString
arr.toString = function() { return this.join('/') };
String(arr)                // "1/2"
// 可见Object类型在强制转换为string类型的时候,
// 实际是调用了该类型原型上的toString方法,
// 而Object的各个子类型基本都重写了toString方法
// 所以在进行toString操作的时候表现有差异

抽象操作ToNumber

JS规范同样还定义了其他类型强制转换为number类型的抽象过程,我们观察下面的例子:

Number("4")                  // 4
Number("4a")                 // NaN
Number("")                   // 0
Number(false)                // 0
Number(true)                 // 1
Number(null)                 // 0
Number(undefined)            // NaN
Number(Symbol('s'))          // TypeError...

对于基本类型的强制转换都是在规范中写死,需要注意的是Symbol类型在强制转number的过程中会报TypeError,算是一个坑。我们重点关注一下Object类型转number的过程,对象在转number之前,会先转换为基础类型,再转换为number类型,这个过程称为ToPrimitive

ToPrimitive过程先回检查对象是否存在valueOf方法,如果存在并且valueOf返回基本类型的值,则使用该值进行强制类型转换,如果没有,则使用toString方法返回的值进行强制类型转换

var arr = [1, 2]
Number(arr)    // NaN
// 因为arr.toString()等于"1,2",强制转换后为NaN

arr.toString = function() { return '43' }
Number(arr)    // 43

arr.valueOf = function() { return '42' }
Number(arr)    // 42

var obj1 = {}
Number(obj1)   // NaN

var obj2 = {
    valueOf: function () {
        return '99'
    }
}
Number(obj2)   // 99

JavaScript中是如何进行隐式转换的

刚刚我们讨论了很多,JS中强制转换的规则,那其实和隐式类型有什么关系呢?

JS在进行隐式转换的过程中,其实式遵守强式转换的规则的,所以我们探讨隐式类型转换本质是探讨[] + {} 是怎么样通过一系列的类型转换变成"[object Object]"。

在隐式转换中最令人迷惑的应该就是+操作符和==操作符导致的隐式转换因为对于其他类型的操作符,类型四则运算的-、*、÷和位运算符&、^、|在设计目标就是对数字进行操作。

我们观察下列代码:

10 / '2'       //  5 对字符串2进行了ToNumber操作
'10' / '5'     //  2 对操作符两边进行了ToNumber操作

var obj = {
    valueOf: function() { 
        return '10'
    }
}

100 / obj     
// 10 对obj进行了ToNumber操作,感到迷惑的同学可以翻上去看看抽象操作ToNumber的执行过程

// 对于位运算也是一致的
0b011 | '0b111'      // 7

说完简单的,我们来看看真正恶心人的。

操作符+两边的隐式转换规则

对于JavaScript来说,+号除了传统意义的四则运算,还有连接字符串的功能。

1 + 2  // 3
'hello' + ' ' + 'world'   // hello world

有歧义就会令人迷惑,那么到底什么时候适用字符串连接,什么时候是加法呢?

观察下列代码:

1 + '1'    // "11"
1 + true   // 2
1 + {}     // "1[object Object]"
'1' + {}   // "1[object Object]"
1 + []     // "1"

var obj = {
    valueOf: function() { return 1 }
}
1 + obj   // 2

var obj2 = {
    toString: function() { return 3 }
}

1 + obj2  // 4

var obj3 = {
    toString: function() { return '4' }
}

1 + obj3 // "14"

看完上面的例子,应该是有点晕的,总结下来就是,如果其中一个操作数是字符串;或者其中一个操作数是对象,且可以通过ToPrimitive操作转换为字符串,则执行字符串连接操作;其他情况执行加法操作。

// 通过伪码描述过程大概就是
x + y 
=> if (type x === string || type y === string ) return join(x, y)
=> if (type x === object && type ToPrimitive(x) === string) return join(x, y)
=> if (type y === object && type ToPrimitive(y) === string) return join(x, y)
=> else return add(x, y)

对于执行加法操作的情况,如果操作数有一边不是number,则执行ToNumber操作,将操作数转换为数字类型。

我们一起来分析两个例子:

// 例子1
[1, 2] + {}    // "1,2[object Object]"
/**
  * [1, 2]和{}均不是字符串,但是[1, 2]和{}均可以通过ToPrimitive操作
  * 但是[1, 2]和{}均可以通过ToPrimitive操作转换为字符串
  * 所以这里执行字符串连接操作,根据ToPrimitive的规则
  * [1, 2].valueOf()的值不是基础类型,所以我们使用[1, 2].toString()的值
  * 这时候就变成了 "1,2" + {}
  * 显然{}也可以通过ToPrimitive操作转换为"[object Object]"
  * 所以最后的结果是"1,2[object Object]"
  **/
  
  
 // 例子2
 var obj = {
     valueOf: function() { return 12 }
 }
 true + obj   // 13
 /**
   * true和变量obj均不是字符串,且obj不能通过ToPrimitive转换为字符串
   * 所以这里执行加法操作
   * 对true执行ToNumber操作得到1
   * 对obj执行ToPrimitive操作得到12
   * 最后1 + 12 输出12
   **/

通过上面的例子相信大家已经对+号两边的隐式转换有一定了解了,但是一些同学肯定会说那为啥{} + [] === 0呢,这个明显不符合上述过程,这的确是一个坑,这个坑在于编译器并不会想我没预想的那般将{}解析成对象,而是解析成代码块。

{} + []
/**
  * 对于编译器而言,代码块不会返回任何的值
  * 接着+[]就变成了一个强制转number的过程
  * []通过oPrimitive变成'',最后''通过ToNumber操作转换成0
  **/
{}; +[];

说完这些,相信大家对本文开始的几个例子输出的结果不会迷惑了,除了+号两边令人迷惑,最令人迷惑的莫过于==,以致于大部分前端团队都会通过eslint禁止使用==操作,下面我们一起来揭开==之谜。

操作符==两边的隐式转换规则

==操作符被称为抽象相等,也是够抽象的,一般来说我们会建议禁止在业务代码中使用抽象相等。

但有时候用起来却很方便,比如antd中的下拉框选项中即使我们拉取的数据是number类型,在onChange回调中value的值却是字符串,这时候使用抽象相等就挺舒服的。

实际开始讨论抽象相等的转换规则之前,我们先看下特例:

NaN == NaN        // false,这算是个坑吧,没啥聊的
null == undefined // true,属于ecma规范

说完特例,我们看看其他情况下==的表现是如何的:

[1] == 1      // true
false == '0'  // true
false == ''   // true
'' == '0'     // false
true == 1     // true
false == 0    // true
true == []    // false
[] == {}      // false

var obj = {
    valueOf: function() { return 1 }
}

obj == 1     // true
// 绝望
[] == ![]    // true

看着好像和之前的类型转换有些一致,但跟多是懵逼,我们一起来看看ecma规范中是如何描述抽象相等的比较过程的:

  1. 对于数字和字符串的抽象比较,将字符串进行ToNumber操作后再进行比较
  2. 对于布尔值和其他类型的比较,将其布尔类型进行ToNumber操作后再进行比较
  3. 对于对象和基础类型的比较,将对象进行ToPrimitive操作后在进行比较
  4. 对象之间的比较,引用同一个对象则为true,否则为false

说完规则,我们来根据规则分析几个例子:

true == '1'       // true
/**
  * 布尔类型和其他类型比较适用规则2,true通过ToNumber操作转换为1
  * 这时候1 == '1',这时候适用规则1,将'1'通过ToNumber操作转换为1
  * 1 == 1 所以输出为true
  **/

var obj = {
    valueOf: function() { return '1' }
}

true == obj      // true
/**
  * 首先适用规则2,将true转换为1,此时1 == obj
  * 此时适用规则3,将obj转换为'1',此时1 == '1'
  * 此时适用规则1,将'1'转换为1,此时1 == 1,所以输出true
  **/
  
// 我们分析下世纪难题 [] == ![]的心路历程
[] == ![]      // true

/**
  * 一般直觉这明细是false,但我们仔细看一下
  * ![]先对[]进行强制boolean转换,所以实际上应该是[] == false
  * 这样就又回到我们刚刚的规则上了,适用规则2所以[] == 0
  * 接着适用规则3,所以 '' == 0
  * 最后ToNumber('')  == 0
  **/

到这里,基本上JS上比较常见的隐式类型覆盖和坑都覆盖到了,其实可以看到隐式类型并不是无迹可寻,除了少数特例,基本上都是依据一些规则进行转换的,我们只需要记住转换规则,就能够收放自如了。

写在最后

隐式类型转换是新手学习前端的时候经常碰到的坑,我们常常推荐使用===,放弃对==的理解,但是即使我们不使用,在学习社区上的一些代码的时候,不可避免的会遇到有人使用的情况,所以即使自己不使用==,看别人代码也不可避免要看,所以知道原理还是有必要的。

最后的最后留一些小小的练习题:

var obj = {
    valueOf: function() { return 42 },
    toString: function() { return '42' },
}

var arr = [1, 2]

1 + obj
arr + obj
0 == []
"" == []
obj == '42'

真的最后了,感谢各位同学的阅读,如果有错误希望能够在评论区指出,万分感谢。

欢迎阅读我的其他文章: