一文带你详解js的类型转化

512 阅读13分钟

本文分析了js类型的分类,对js类型的转化的多种场景进行了详细剖析,讲解了ToNumber、ToString、ToBoolean、ToPrimitive的机制和调用规律;分析了常见的隐式类型转化原理。

一、数据类型的分类

原始数据类型

number string boolean undefined null ,symbol(ES6) bigint(ES10)

引用数据类型

引用类型只有一种就是object.
这种类型有一些固定特殊的实现,继承自Object 添加自己的特性方法,成为一种新的’子类型‘ 。
Array
Date
RegExp
Function
基本包装类型:Boolean String Number 单体内置对象: Global对象 Math对象
键集合类型: Map WeakMap Set WeakSet
反射类型: Proxy Reflect
控制抽象类型: Iteration接口 Promise 类型

image.png 通过Object.prototype.toString.call()可以区分这些不同的类型。

二、类型转换

js本身是一门弱类型:类型之间是可以进行运算转换的

let a = 1 + "2"; // "12"

类型转换分为:显式类型转换隐式类型转换 显示类型转换:

let a = 1;
let b = String(a); // "1"

隐式类型转换

let a = 1;
let b = a + ""; // "1"

显式和隐式是相对的,知道他要转换的,对于你来说它就是显式的。

作为基石的几种类型转换

ToNumber、ToString、ToBoolean 这三种方法,是在规范实现的,我们并不能直接调用。然而,语言也有暴露出一些接口,可以让我们调用到这些语言内部实现的接口。 所有的类型转换操作,本质上都是这几种类型的转换。

1.ToNumber

顾名思义,这就是将类型值转换为数字类型的方法。 它的一些转换例子转换如下:

ValueNumber
null0
undefinedNaN
true1
false0
""0
"1"1
"abc"NaN
10n10
{}NaN
“Infinity”Infinity
[1]1
["1"]1
[1, 2]NaN
/123/NaN

ToNumber操作,返回的值一定是数字类型的,数字类型的值,有这样这几种取值的可能:

  • 整数
  • 浮点数
  • NaN
  • Infinity/-Infinity

ToNumber操作的规律是这样子的:

如果值为数字类型,那么就不变
如果值为null,则为0;如果值为undefined,则为NaN
如果值为布尔值,则如果为true,则为1,为false,则为0
如果值为BigInt,那么就转换为相应的Number类型
如果值为字符串,那么就有多种可能的结果:

如果值为空字符串,或者包含有多个空格的字符串,则为0,如“” => 0,“ ” => 0
如果值为一个整数或浮点数的字符串形式,则转换为相应的数值,如:“1.2” => 1.2
如果值为“Infinity”或“-Infinity”,则转换为相应的Infinity或-Infinity
如果值为其他,则为NaN
如果值为对象,则执行ToPrimitive操作,再将返回值转换为Number类型

常见的触发ToNumber的方式有:

let a = "1";
let b = Number(a); // 1
let c = +a; // 1

2. ToString

ToString操作,是返回一个值的字符串形式。
规则如下:

ValueString
1"1"
Infinity"Infinity"
NaN"NaN"
null"null"
undefined"undefined"
true"true"
false"false"
1n"1"
[1, 2, null, undefined]"1,2,,"
{}"[object Object]"
/123/"/123/"

ToString操作的返回值一定是字符串,它的规律比较简单明了,规律如下:
1.对于基本类型(null、undefined、Number、Boolean、BigInt、String)以及正则表达式,都是直接返回其字符串形式
2.对于对象来说,则调用其toString方法,获得其返回值,将其转换为String类型,但是这里是需要分情况讨论的:

  • 首先对于普通对象来说,其原型链上的对象原型(也就是Object.prototype)上,有一个toString方法,其返回值为"[object Object]"
  • 对于不继承对象原型的对象,比如使用Object.create(null)方法创建的对象,没有toString方法,则会报语法错误
  • 对于数组类型Array,其toString方法是经过重写的,返回的是其各项值的ToString值,之后用逗号“,”进行拼接。这里需要注意的是,null值和undefined值会转换为空字符串""
  • 对于日期类型Date,则直接返回一个类似"Tue Nov 17 2020 21:58:53 GMT+0800 (中国标准时间)"格式的字符串
  • 对于函数类型,则直接返回其函数定义的字符串形式 触发ToString的操作有:
let a = 1;
let b = String(a); // "1"
let c = a + ""; // "1"

3. ToBoolean

ToBoolean操作,返回一个值的布尔值形式。
返回true的值,称之为truthy;返回false的值,称之为falsy。
实际上,truthy列表是无限长的,而falsy值则是可枚举的。我们只需要记住falsy值即可。

ValueBoolean
0false
-0false
0nfalse
""/''/``false
NaNfalse
nullfalse
undefinedfalse
falsefalse

其规律如下:
1.对象(包括其所有子类型)都是truthy
2.数字类型中,0、-0和NaN都是falsy
3.空字符串是falsy,但是包含空格的空字符串则不是
4.BigInt的0n值是falsy
5.布尔值false本身也是falsy

触发ToBoolean的操作有

let a = 1;
let b = Boolean(a); // true
let c = !!a; // true

4. ToPrimitive

将对象类型转化为原始数据类型的机制。 在熟悉ToPrimitive之前需要先熟悉对象上的几个方法。

obj[Symbol.toPrimitive]();
obj.toString();
obj.valueOf();
1. [Symbol.toPrimitive]

这是一个新增的对象方法。默认的对象是不存在这个方法的。需要我们手动设置。它的写法一般如下:

let a = {
    [Symbol.toPrimitive](hint){
        if(hint === "string"){
               return "string"
        }
        if(hint === "number"){
            return 1;
        }
        if(hint === 'default'){
            return "any"
        }
    }
};

console.log(Number(a)); // 1
console.log(String(a)); // "string"
console.log(a + 1); // "any1"

首先,[Symbol.toPrimitive]接收一个字符串参数,其取值的可能为:

1."string",表示要转换为string类型的值
2."number",表示要转换为number类型的值
3."default",表示语言不知道要转换为何种类型的值
当我们明确调用String内建函数的时候,ToPrimitive函数会传入一个"string"参数,返回一个值。
这里需要注意的是,这里必须返回一个基本类型的值,如果返回一个引用类型的值,会返回一个报错信息。
之后,当返回一个基本类型的值后,再对他执行String方法,转换成字符串。举个例子:

let a = {
    [Symbol.toPrimitive](hint){
        if(hint === "string"){
            return {}; // 这里返回一个对象
            }
    }
}

String(a); // VM247:1 Uncaught TypeError: Cannot convert object to primitive value

let a = {
    [Symbol.toPrimitive](hint){
        if(hint === "string"){
            return true; // 这里返回一个布尔值
            }
    }
}

String(a); // "true" // 结果还是得到了一个字符串

可以看到,[Symbol.toPrimitive]方法非常明了地定义了一个对象类型转换的所有行为,如果我们需要自定义对象的类型转换规则,应该优先使用该方法。

2. valueOf

valueOf方法,执行的是一个拆封的操作,他的行为会根据对象的不同而发生变化。 这里我们把对象分为普通对象与包装对象。

    1. 包装对象 所谓包装对象,就是指基本类型值的对象形式。
      那么什么是包装对象呢?为了说明这个问题,我们首先举一个例子:
let a = "1";
a.toString === String.prototype.toString; // true

在这里我们可以看到,我们首先声明了一个字符串。然后我们发现这个字符串上的toString方法,实际上就是String内建函数原型上的toString方法。
问题在于,方法是对象的方法,而不是字符串的。为什么基本类型的值可以调用到对象上的方法呢?这里实际上是引擎内部帮我们新建了一个包装对象,再执行解封:

let a = "1";

function toString(val){
    var _temp = new String(val); // 这里使用new操作符新建了一个字符串类型的包装类型对象,也就是所谓包装
    var result = _temp.valueOf(); // 这里使用了valueOf操作把字符串包装对象里面的原始值字符串提取出来,也就是所谓拆封
    _temp = null;
    return result;
}

a.toString(); // "1"
toString(a); // "1"

因此,对于包装类型而言,其valueOf方法就是一个解封的操作,提取出其基本类型值。

new String("1").valueOf(); // "1"
new Number(1).valueOf(); // 1
new Boolean(true).valueOf(); // true
  • 2.普通对象

但是对于普通对象而言,valueOf操作返回的是对象本身,这其中就包括普通对象、函数、数组、正则表达式。
这里有一个例外,就是Date对象,其valueOf返回的是其数字类型的毫秒值。
还有一点要注意的是,valueOf方法,是存在于Object.prototype上面的。因此,准确来讲,只有继承了对象原型的对象,才能拥有valueOf方法。类似于Object.create(null)方法创建的对象,是不存在valueOf方法的,调用的时候会报错。

3. toString

toString方法,是将对象转换成字符串的方法。在不同的内建函数原型上有不同的体现。

  1. Object.prototype.toString
    首先我们最常用的,就是对象原型上的toString:
let a = {};
a.toString === Object.prototype.toString; // true

这个方法,返回的是一个对象的类型表示字符串[object type],如"[object Object]","[object Array]","[object RegExp]"。 2. Array.prototype.toString
数组原型上的toString方法覆盖了对象原型上的toString方法,它的行为是这样的: 对数组中的每一项执行String方法,然后将结果值用逗号","进行拼接。
需要注意的是,null值和undefined会转成空字符串""而不是相应的"null"和"undefined"。比如:

let a = [null, undefined, {}, function(){}, 2, [3, 5], /123/, new Date(), true, "string"];

Array.prototype.toString.call(a); // ",,[object Object],function(){},2,3,5,/123/,Wed Nov 18 2020 14:19:17 GMT+0800 (中国标准时间),true,string"

数组的toString方法,可以用于字符串数组的拍平(数组扁平化),因为其会递归地对内部所有数组调用toString。
3. Function.prototype.toString
函数原型的toString方法,会返回该函数的源代码字符串,比如:

function fn(a, b){
    return a + b;
}

Function.prototype.toString.call(fn);
/*
"function fn(a, b){
    return a + b;
}"
*/
  1. Date.prototype.toString
    日期对象原型的toString方法返回一个字符串,表示该Date对象,比如:
let a = new Date();
Date.prototype.toString.call(a); // "Wed Nov 18 2020 14:25:41 GMT+0800 (中国标准时间)"
  1. RegExp.prototype.toString
    正则表达式对象原型的toString方法返回一个字符串,表示该正则表达式对象,比如:
let a = /123/;
RegExp.prototype.toString.call(a); // "/123/"
  1. 自定义toString
    除了以上这些在原型上预先定义好的toString方法外,我们也能给自己的对象自己定义一个toString方法:
let a = {
    toString(){
        return "toString";
    }
}
String(a); // "toString"

需要注意的是,返回的值必须为基本类型值,如果为引用类型值,会抛出一个语法错误:

let a = {
    toString(){
        return [];
    }
}
String(a); // Uncaught TypeError: Cannot convert object to primitive value
4. 调用规律

对于ToPrimitive操作,它必须返回一个基本类型值,否则会抛出类型错误。
规律如下:
1.如果存在[Symbol.toPrimitive]方法,则调用该方法,如果该方法返回引用类型值,则抛出类型错误
2.如果不存在[Symbol.toPrimitive]方法,则再做判断

1.如果指明调用字符串类型转换,则优先调用toString
    1.如果toString返回一个引用类型值,则转而调用valueOf
    2.如果valueOf也返回一个引用类型值,则抛出类型错误
2.如果不指明字符串类型转换,则默认优先调用valueOf
    1.如果valueOf返回一个引用类型值,则转而调用toString
    2.如果toString也返回一个引用类型值,则抛出类型错误

隐式类型转换

隐式类型转换,在很多场景下都会发生,这里我们只讲最常见的几种:

相等操作符==

在JavaScript中,判断相等性有两种操作符:相等操作符==与全等操作符===。

有一部分的开发者认为,全等操作符===,不仅判断值,还要判断类型。而相等操作符==,则仅仅判断值。 实际上这样子的理解是错误的。

正确的解释是:相等操作符会对两边的表达式进行隐式类型转换,而全等操作符则直接判断相等与否。

相等操作符的相等判断操作如下:
1.首先判断类型是否相同,类型相同则直接执行全等判断

1.如果是基本类型值,则判断两个值是否相同
2.如果是引用类型值,则判断两个对象的内存地址是否相同,简单来讲就是判断是否为同一个对象(判断不了的走下面的类型转化)

2.如果类型不同,则进行类型转换,转换规则如下:

1.如果存在对象,首先对对象执行ToPrimitive操作,然后再进行相等判断
2.这个时候,如果存在字符串与布尔值,则对值执行ToNumber操作,转成数字类型,然后再进行相等判断

有了以上的规律,我们就可以看看以下例子:

"42" == true; // false

咋一看,我们会觉得很奇怪,字符串"42"既不是真,也不是假,给人一种非常奇怪的感觉。
但实际上我们应该对其做这样子的转换:

Number("42") == Number(true);
// =>
42 == 1 // false

将两边的值都转换为数字类型,再进行比较,就一目了然了。
另外还有一道很容易踩坑的题目:

[] == ![]; // true

咋一看,一个值与它的逻辑非值应该是相反的,然而在这里确实相等的,为什么呢?实际上应该做如下转换:

[] == false;
// =>
"" == false;
// =>
Number("") == Number(false);
// =>
0 == 0 // true

另外还有一道与之类似的题目:

{} == !{}; // false
// =>
{} == false;
// =>
"[object Object]" == false;
// =>
Number("[object Object]") == Number(false);
// =>
NaN == 0; // false

二元加操作符+

另一个要介绍的操作符就是二元加。

二元加操作符,可以执行的操作有两种:
1.字符串拼接
2.数值相加
二元加操作符的操作规律如下:
1.如果两端有对象,首先对对象执行ToPrimitive操作,转换成基本类型值
2.如果一端有字符串类型,则对另一端执行ToString操作,然后执行字符串拼接操作
3.如果没有字符串类型,则对两端执行ToNumber操作,然后进行相加
接下来我们看几个例子:

let a = {} + 3; // "[object Object]3"
// =>
let a = "[object Object]" + 3;
// =>
let a = "[object Object]" + "3"; // "[object Object]3"

这里注意,前面的赋值表达式=不可以去掉。我们经常还会见到这样的陷阱:

{} + "3"; // 3

这样的问题,让我们匪夷所思。实际上,花括号在Javascript中的语义是多样的。在这样的一行中,没有赋值操作,引擎将其理解为一个代码块。因此,实际上这里的代码应该被理解为如下:

{};
+"3"; // 3

换句话说,这里是一个陷阱,这里的加号并不是一个二元加,而是一个一元加。对于一元加表达式,执行的是ToNumber操作。

注: 一般情况下开发中使用全等操作费符号,不容易出现问题,避免发生奇怪的类型转化。

鸣谢

本文主要学习和引用了Edward大佬的文章,自己整理复述了一下 增强印象。 segmentfault.com/a/119000003…
在此向Edward大佬再次致敬!