3.3 数据类型
3.3.1 内存空间
在讲数据类型之前,先来了解一下JavaScript中的内存空间。
JavaScript并没有区分栈内存与堆内存,但为了理解JavaScript的数据结构,可以将JavaScript的内存空间看作是由栈内存与堆内存组成的。
栈内存中存储的是基本数据类型与引用数据类型的地址。
堆内存中存储的是引用数据类型的值,JavaScript不允许直接访问堆内存中的数据,只能通过栈内存中存储的引用数据类型的地址来访问这些值,如图3-1所示。
3.3.2 基本数据类型与引用数据类型
JavaScript中有7种数据类型,具体如下。
- Number
- String
- Boolean
- null
- undefined
- Object
- Symbol
其中Number、String、Boolean、null、undefined属于基本数据类型,基本数据类型的数据是按值操作的,操作的是保存在变量中的实际值;Object和Symbol属于引用数据类型,常见的数组、对象、函数等都是引用数据类型,操作的是保存在变量中的地址。
图3-1 内存空间
例如,我们参加面试时经常遇到的一个问题,具体如下。
let a = {name:1}; let b = a; console.log(b); // > {name:1}
a.name = 2; console.log(b); // > {name:2}
上述示例中,将变量a的值赋值给变量b,实际上是将a中所存的地址赋值给变量b,这两个地址在堆内存中对应的是一个值,因此,对变量a的属性name进行修改时,实际上修改的是堆内存中的值,尽管没有直接操作变量b,但变量b的值却发生了改变。
再看下面的示例。
let a = {name:1}; let b = a; console.log(b); // > {name:1}
b = {name:1}; a.name = 2; console.log(b); // > {name:1}
上述代码中,对变量a的属性name进行修改前,为变量b重新赋值了另外一个值,此时变量b与变量a中所存的地址已经不同,因此,对变量a的修改不影响变量b。
实际上,在ECMAscript中还有一种数据类型——Reference。Reference类型在JavaScript中并不存在,其作用是用来描述解释诸如delete、typeof和赋值操作符之类的操作符的行为的。
3.3.3 浅拷贝与深拷贝
在上面的示例中,我们直接把变量a的值复制给变量b,这样会导致一个问题——修改变量a的值也会对变量b的值造成影响。这就需要我们去复制一个对象,而不是直接赋值。
复制分为浅复制与深复制,通常称作“浅拷贝”与“深拷贝”。
浅拷贝只对对象的第一层键值进行复制,如果其中某个键存储的是引用类型的数据,复制的将会是该键所存储的地址,那么,很显然,以下面的示例为例,浅拷贝会导致a.tag和b.tag指向的是同一个地址。
而深拷贝不同,深拷贝将复制对象中的所有键值,以保证得到的对象图中不包含原有对象图中对任何对象的引用。
深拷贝是将所有键值都进行复制,所以,在遇到引用类型的数据时,再次调用浅拷贝方法即可。这种在函数内调用函数本身的方式,称作“递归”,后面的章节中还会讲到。现在,修改上面的代码,具体如下。
由于数组的concat方法是浅拷贝,因此需要将它替换成for循环。
对于深拷贝,其实有一种极为简单的方式,示例如下。
let a = {name:1, tag:['js', 'html', 'css']}; let b = JSON.parse(JSON.stringify(a));
console.log(b); // > {name:1, tag:['js', 'html', 'css']};
a.tag[0] = 'javascript';
console.log(b); // > {name:1, tag:['js', 'html', 'css']};
console.log(a.tag === b.tag); // > false
这个方法要求被复制的对象必须是一个标准的JSON字符串。此外,这个方法将会忽略target中包含的undefined、正则表达式、函数等数值,示例如下。
let a = {name:/1/, tag:[undefined, 'html', 'css']}; let b = JSON.parse(JSON.stringify(a));
console.log(b); // > {name:{}, tag:[null, 'html', 'css']};
3.3.4 typeof与instanceof
在上面的浅拷贝函数中,我们用到了两个操作符进行类型检查——typeof和instanceof。其中,typeof操作符是最常用的类型检查方式,该操作符返回一个字符串,表示被操作值的数据类型,示例如下。
对于基本数据类型与Symbol数据类型的检查,typeof操作符完全可以胜任,但对于引用类型的数据,typeof就不那么可靠了。
JavaScript中内置的对象和自定义的对象,使用typeof返回的都是'object',示例如下。
因此,在上文的浅拷贝函数中,我们借助了instanceof运算符来判断其原型,instanceof运算符返回一个布尔值,表示一个对象的原型是否存在于另一个构造函数(ES6中的class声明的类也是构造函数,关于构造函数与原型的问题会在后续的章节中讲到,目前可暂不理会)的原型链中。
语法:
object instanceof constructor;
object为要检查的对象,constructor为构造函数。
示例代码:
console.log([] instanceof Array); // > true console.log([] instanceof Number); // > false
由此,我们就可以判断这个对象到底是何种类型,示例如下。
你可能已经发现,除了基本数据类型、null、undefined和Symbol类型,target instanceof Object总是返回true,这是因为原型链的机制引起的,同样,在这里你不需要深入思考原型和原型链的问题。
3.3.5 类型转换
类型转换分为两种——隐式类型转换和强制类型转换。隐式类型转换发生在不同类型的数据运算时,例如常见的算术运算(不包含递增递减),比较运算中,有些函数也会对入参进行隐式类型转换。这里主要以相等(==)为例讲解,便于稍后与严格相等(===)进行区分,使大家对相等与严格相等有更清晰的认识。
1. ==的比较
a == b,比较a、b两个表达式的值,会在比较过程中对两个表达式的值进行隐式类型转换(null除外),比较后返回true或false表示两个表达式的值是否相等。
其比较过程如下。
① 如果类型相同,则返回严格相等比较后的结果。
② null和undefined比较返回true。
③ 将其中的String类型转换为Number后再次进行==比较,并返回比较后的结果。
④ 将其中的Boolean类型转换为Number后再次进行==比较,并返回比较后的结果。
⑤ 如果a类型为String/Number/Symbol,b类型为Object,则将b转化为基础数据类型后再次进行==比较,并返回比较后的结果。
⑥ 如果b类型为String/Number/Symbol,a类型为Object,则将a转化为基础数据类型后再次进行==比较,并返回比较后的结果。
⑦ 否则返回false。
其中,对象转化为基础数据类型的方式如下。
① 转化为字符串时,如果有toString方法,则调用后返回,否则调用valueOf方法并返回。
② 转化为数字时,如果有valueOf方法,则调用后返回,否则调用toString方法并返回。
示例代码如下。
各类型的数据按表3-1的方式转换为数字、布尔值、字符串方式。
表3-1 数据类型转换
这里有一道有趣的题目,试着思考下面的代码输出的是true还是false,相信这个题目会加深你对==比较的认识。
答案已经很明显了,最终输出是true,首先a == 1,左边的表达式是一个对象,右边的表达式是一个数字,因此会调用对象的valueOf()方法,valueOf()方法将a.value的值自增后,返回自增前的值,此时valueOf()返回1,1 == 1返回true,a.value的值已经自增为2,然后进行a == 2的比较,过程同上,最终,整个表达式的返回值为true。
2. ===的比较
a === b,比较a、b两个表达式的类型和值是否相等,比较后返回true或false表示两个表达式的值是否相等,其比较过程如下。
① 如果类型不同,返回false。
② 类型相同,均为Number。
A其中含有NaN,返回false。
B其中不含NaN,值相同,返回true。
C+0、-0和0之间比较返回true。
D否则返回false。
③ 均为undefined,返回true。
④ 均为null,返回true。
⑤ 均为String,如果两个表达式的值长度相同,且相应索引对应的编码单元相同,返回true,否则返回false。
⑥ 均为Boolean,如果两个表达式的值都是true或false,返回true,否则返回false。
⑦ 均为Symbol,如果两个表达式的值都是相同的Symbol值,返回true,否则返回false。
⑧ 均为Object,如果两个表达式的值指向同一个对象,则返回true,否则返回false。
示例代码如下:
现在来看一个常见的面试题。
除了隐式类型转换,还可以使用强制类型转换来处理转换值的类型,ECMAScript中可用的3种强制类型转换如下。
- Boolean(value):把给定的值转换成布尔值。
- Number(value):把给定的值转换成数字。
- String(value):把给定的值转换成字符串。
其结果与上面的数据类型转换表结果一致。
示例代码:
Number(false); // -> 0 Boolean(""); // -> false String(null); // => "null"
此外,ECMAScript还提供了两种字符串转换成数字的方法——parseInt()和parseFloat(),前者把字符串转换成整数后返回,后者把字符串转换成一个浮点数后返回。
语法:
parseInt(string, radix); parseFloat(string);
string表示需要被转换为数字的值,这两个方法会对这个值从头到尾进行测试,在遇到非有效的字符时停止(对于parseFloat来说,只有遇到的第一个小数点是有效字符,如果遇到两个小数点,第二个小数点将被作为非有效字符处理),此时,再将之前测试成功的字符转换成数字后返回,如果没有测试成功的字符,则返回NaN,示例如下。
如果传入的string不是一个字符串,这两个方法都会尝试将其转换为字符串后再进行数字的转换,示例如下。
上面的示例都是基于十进制的,parseInt()支持指定返回数值的进制,radix就表示这个进制,又称为“基数”,它的取值区间为[2,36]之间的整数,默认为10,表示十进制数值系统。例如,想要把一个数值转换成二进制,示例如下。
parseInt(12.11, 2); // -> 1
parseFloat()方法是不支持radix的。
3.3.6 基本包装类型
在上面的代码中,有如下一些代码。
... typeof "" === 'string'; // -> true ... typeof new String() === 'object'; // -> true ...
不要被new String()中的Number迷惑,这里的Number并不是数据类型,而是一个构造函数,因此其通过new创建的实例是一个对象。
那么,new String()与""有什么区别呢?示例如下。
let a = ""; let b = new String();
console.log(typeof a); // > "string" console.log(typeof b); // > "object"
console.log(a == b); // > true console.log(a === b); // > false
观察上面的示例可以发现,两者的类型不同,一个是字符串,一个是对象,但两者的值相等,这是因为对象b隐式转换后为0,字符串a转换后也为0,因此两者相等,但不严格相等。
现在,尝试操作一下这两个变量,看看这两个变量会发生什么改变,示例如下。
上面的示例中,尽管a是一个空字符串,但依然可以通过length属性去获取它的长度,就好像它是一个对象(例如b)。既然它是一个“对象”,接下来尝试给它添加一些自定义的属性(例如name),在对a和b分别赋予了name属性后,我们发现a依然是一个空字符串,似乎没有发生任何变化,但对象b中却多出了一个name属性,那么,是不是这个name属性和length一样,可以访问,但是不可见呢?然后,我们访问a和b的name属性,发现a中并没有被添加的name属性,但之前对a添加name属性时,确实成功且返回了被添加的值,那么对a添加的name属性去了哪里呢?
实际上,对a添加name属性时,执行了如下操作:
console.log(a.name = 'js'); // > "js"
//上述代码可以看作创建String的一个实例,在实例上添加属性,因此返回"js",并在执行结束 后销毁这个实例console.log(new String(a).name = 'js'); // > "js"
再看一个示例。
每当操作一个基本数据类型的时候,会创建一个对应的基本包装类型的对象,以便于在基本数据类型上直接调用其属性和方法,拥有这种特性的数据类型称为“基本包装类型”。在JavaScript中,基本包装类型包括3个特殊的引用类型——Boolean、Number和String。
操作基本数据类型时,其创建对应的基本包装类型的对象是在后台直接调用的,因此,即便你对构造函数String做出修改,也不影响基本数据类型上拥有的属性和方法,示例如下。
这也是字符串字面量的一个优点,关于字符串字面量将会在下一节中讲解。
练习
- 假设有变量a,尝试对其数据类型进行判断。
- 了解==与===的区别。