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

在开始本文以前咱们一块儿来看看JavaScript神奇的隐式转换javascript

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

复制代码

更多千奇百怪的例子相信你们在逛各类技术社区和平常工做的时候也见到很多,这里就不作更多介绍,若是你能充分理解上述隐式转化的过程,那基本能够点下右上角的x。前端

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

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

JavaScript的类型系统

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

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

通常状况下咱们可使用typeof操做符去判断内置类型:antd

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的类型:post

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(...)ui

咱们看下下面的例子:

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'
复制代码

真的最后了,感谢各位同窗的阅读,若是有错误但愿可以在评论区指出,万分感谢。

欢迎阅读个人其余文章:

相关文章
相关标签/搜索