JavaScript神奇的隐式转换

233 阅读7分钟

开始之前先来看一下下面的栗子

    console.log(0 + '1' ==='01');  //true
    console.log(true + true);      //2
    console.log(false === 0);      //false
    console.log(false + false ===0); //true
    console.log({} + [] === 0);      //false 
    

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

JavaScript的类型系统

要讲清楚隐式转换,不可避免要唠唠类型,JS中按大类分有两大类型,分别是基本类型和Object,说到这可能有小伙伴会质疑,明明还有Array、Date...本质上其JS中其他的高级类型都Object的子类型,本文后续统一将Array、Date等类型统称为Object类型。
包括ES6新增的symbol,JS中一共有6种基础类型:Symbol、null、undefined、number、string、boolean;加上Object,JS种一共有七种内置类型。

一般情况下我们用typeof操作符判断内置类型

    console.log(typeof Symbol() === 'symbol');
    console.log( typeof undefined === 'undefined' );
    console.log(typeof true === 'boolean');
    console.log(typeof '42' === 'string');
    console.log( typeof {bar:42} === 'object');

yh以上代码全部为true

区分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)
    console.log(typeof num);  //number  
    console.log(typeof numObj); //Object  
    //可以看到Object.prototype.toString并不能很好的区分基础类型和Object 
    //这是因为num tostring的过程中会被包装成封装对象,结束后解封为基础类型 

类型之间的强制类型转换

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

抽象操作ToString

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

 console.log(String(4));//4
    console.log(String(false)); //false
    console.log(String(true)); //true
    console.log(String(null)); //null
    console.log(String(undefined)); //undefined
    console.log(String(Symbol('s'))); //Symbol(s)
    //基础类型强制转string类型在规范中明确说明了 也比较符合我们的直觉 
    //但是Object类型就有些差别
    console.log(String({a:2})); //[Object Object]
    console.log(String([1,2])); // 1,2 
    console.log(String(/reg/g)); ///reg/g
    //可以看到Object的子类型之间tostring并不一致
    //实际上在对Object类型进行toString转换的时候
    //会调用原型链上的toString方法 并作为结果返回  
    var arr = [1,3];
   console.log( arr.toString()); //1.3
    console.log(String(arr)); //1,3  
    //重写toString  
    arr.toString = function(){
      return this.join('/');
    }
    console.log(String(arr));//1/3  
    //可见Object 类型在强制转换为String类型的时候
    //实际是调用了该类型原型上的tostring方法  
    //而Object的各个子类型基本都重写了toString方法
    //所以在进行toString操作的时候有差异  

抽象操作ToNumber

JS规范同样还定义了其他类型强制转换为number类型的抽象过程,

 console.log(Number("4")); //4 
    console.log(Number("4a")); //NaN 
    console.log(Number("")); //0  
    console.log(Number(false)); //0 
    console.log(Number(true)); //1  
    console.log(Number(null)); //0  
    console.log(Number(undefined)); //NaN 
    console.log(Number(Symbol('s')));  //报错 

对于基本类型的强制转换都是在规范中写死,需要注意的是Symbol类型在强制转number的过程中会报TypeError,算是一个坑。我们重点关注一下Object类型转number的过程,对象在转number之前,会先转换为基础类型,再转换为number类型,这个过程称为ToPrimitive
ToPrimitive过程先回检查对象是否存在valueOf方法,如果存在并且valueOf返回基本类型的值,则使用该值进行强制类型转换,如果没有,则使用toString方法返回的值进行强制类型转换

 var arr   = [1,2]
    console.log(Number(arr));  //NaN
    //因为arr.toString()等于"1,2",强制转换后为NaN 
    arr.toString = function(){
      return '43'
    }
    console.log(Number(arr)); //43  
    arr.valueOf = function(){
      return'42'
    }
    console.log(Number(arr)); //42
    var obj1 = {} 
    console.log(Number(obj1)); //NaN
    var obj2 = {
      valueOf :function(){
        return'99'
      }
    }
    console.log(Number(obj2)); //99

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

JS在进行隐式转换的过程中,其实式遵守强式转换的规则的,所以我们探讨隐式类型转换本质是探讨[] + {} 是怎么样通过一系列的类型转换变成"[object Object]"
在隐式转换中最令人迷惑的应该就是+操作符和==操作符导致的隐式转换因为对于其他类型的操作符,类型四则运算的-、*、÷和位运算符&、^、|在设计目标就是对数字进行操作

  
     console.log(10/'2'); //5 对字符串2进行了ToNumber操作 
     console.log('10'/'5'); //2 对操作符两边进行了ToNumber操作  

     var obj = {
       valueOf :function(){
         return'10'
       }
     }
     console.log('100'/obj);  //100 对obj进行了ToNumber操作
     console.log(0b011 | '0b111'); //7 对于位运算也是一致的 

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

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

console.log(1+2); //3 
       console.log("hello" + '' + "world"); //hello world 

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

 console.log(1+'1'); //11 
       console.log(1+true); //2 (数字类型)
       console.log(1+{}); //1[object object] 
       console.log('1'+{}); //1[object object] 
       console.log(1+[]); //1 

       var obj = {
         valueOf:function(){return 1}
       }
       console.log(1+obj); //2  

       var obj1 = {
         valueOf:function(){return "3"}
       }
       console.log(1+obj1); //13

看完上面的例子,应该是有点晕的,总结下来就是,如果其中一个操作数是字符串;或者其中一个操作数是对象,且可以通过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操作,将操作数转换为数字类型。

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

通过上面的例子相信大家已经对+号两边的隐式转换有一定了解了,

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

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

console.log(NaN == NaN); //false 这个就是坑
        console.log(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
  **/