温故知新,判断数据类型的四种方法

共勉
数据类型
JavaScript原始类型:Undefined、Null、Boolean、Number、String、Symbol
JavaScript引用类型:Object
关于原始类型与引用类型
在 ECMAScript 规范中,共定义了 7 种数据类型,分为 基本类型 和 引用类型 两大类,如下所示:
基本类型:String、Number、Boolean、Symbol、Undefined、Null
引用类型:Object
基本类型也称为简单类型,由于其占据空间固定,是简单的数据段,为了便于提升变量查询速度,将其存储在栈中,即按值访问。
引用类型也称为复杂类型,由于其值的大小会改变,所以不能将其存放在栈中,否则会降低变量查询速度,因此,其值存储在堆(heap)中,而存储在变量处的值,是一个指针,指向存储对象的内存处,即按址访问。引用类型除 Object 外,还包括 Function 、Array、RegExp、Date 等等。
原始类型
原始类型又被称为基本类型,原始类型保存的变量和值直接保存在栈内存(Stack)中,且空间相互独立,通过值来访问,说到这里肯定一同懵逼,不过我们可以通过一个例子来解释.
1 | var person = 'Messi'; |
上述代码在栈内存的示意图是这样的,可以看到,虽然person
赋值给了person1
.但是两个变量并没有指向同一个值,而是person1
自己单独建立一个内存空间,虽然两个变量的值相等,但却是相互独立的.
值得一提的是,虽然原始类型的值是储存在相对独立空间,但是它们之间的比较是按值比较的.
1 | var person = 'Messi'; |
引用类型
剩下的就是引用类型了,即Object 类型,再往下细分,还可以分为:Object 类型、Array 类型、Date 类型、Function 类型 等。
与原始类型不同的是,引用类型的内容是保存在堆内存中,而栈内存(Heap)中会有一个堆内存地址,通过这个地址变量被指向堆内存中Object
真正的值,因此引用类型是按照引用访问的.
1 | var a = {name:"percy"}; |
我们可以逐行分析: 1. b = a
,如果是原始类型的话,b
会在栈内自己独自创建一个内存空间保存值,但是引用类型只是b
的产生一个对内存地址,指向堆内存中的Object
. 2.a.name = "zyj"
,这个操作属于改变了变量的值,在原始类型中会重新建立新的内存空间,而引用类型只需要自己在堆内存中更新自己的属性即可. 3.最后创建了一个新的对象c
,看似跟b
a
一样,但是在堆内存中确实两个相互独立的Object
,引用类型是按照引用比较,由于a
c
引用的是不同的Object
所以得到的结果是false
.
判断Js数据类型的四种方法
typeof
typeof 是一个操作符,其右侧跟一个一元表达式,并返回这个表达式的数据类型。返回的结果用该类型的字符串(全小写字母)形式表示,包括以下 7 种:number、boolean、symbol、string、object、undefined、function 等。
1 | typeof ''; // string 有效 |
有些时候,typeof 操作符会返回一些令人迷惑但技术上却正确的值:
- 对于基本类型,除 null 以外,均可以返回正确的结果。
- 对于引用类型,除 function 以外,一律返回 object 类型。
- 对于 null ,返回 object 类型。
- 对于 function 返回 function 类型。
其中,null 有属于自己的数据类型 Null , 引用类型中的 数组、日期、正则 也都有属于自己的具体类型,而 typeof 对于这些类型的处理,只返回了处于其原型链最顶端的 Object 类型,没有错,但不是我们想要的结果。
instanceof
instanceof 是用来判断 A 是否为 B 的实例,表达式为:A instanceof B,如果 A 是 B 的实例,则返回 true,否则返回 false。 在这里需要特别注意的是:instanceof 检测的是原型,我们用一段伪代码来模拟其内部执行过程:
1 | instanceof (A,B) = { |
关于__proto__
和prototype的区别
__proto__
属性(前后各两个下划线),用来读取或设置当前对象的prototype对象,他本质是一个内部属性,不是一个正式对外的api,由于浏览器的广泛支持,才加入了es6,__proto__
调用的是Object.prototype.proto
当我们new Person()的时候到底发生了什么?
new一个构造函数,相当于实例化一个对象,这期间其实进行了这三个步骤:
创建对象,设为o,即: var o = {};
上文提到了,每个对象都有__proto__
属性,该属性指向一个对象,这里,将o对象的__Proto__
指向构造函数Person的原型对象(Person.prototype;
将o作为this去调用构造函数Person,从而设置o的属性和方法并初始化。
当这3步完成,这个o对象就与构造函数Person再无联系,这个时候即使构造函数Person再加任何成员,都不再影响已经实例化的o对象了。
此时,o对象具有了name和age属性,同时具有了构造函数Person的原型对象的所有成员,当然,此时该原型对象是没有成员的。
现在大家都明白了吧,简单的总结下就是:
js在创建对象的时候,都有一个叫做__proto__
的内置属性,用于指向创建它的函数对象的原型对象prototype
那么一个对象的__proto__
属性究竟怎么决定呢?答案显而易见了:是由构造该对象的方法决定的。
下面讲解三种常见的创建对象方法。
对象字面量
比如:
1 | var Person = { |
这种形式就是对象字面量,通过对象字面量构造出的对象,其__proto__
指向Object.prototype。
所以,其实Object是一个函数也不难理解了。Object、Function都是是js自带的函数对象。
可以跑下面的代码看看:
1 | console.log(typeof Object); function |
构造函数
就如我前面讲的,形如:
1 | function Person(){} |
这种形式创建对象的方式就是通过构造函数创建对象,这里的构造函数是Person函数。上面也讲过了,通过构造函数创建的对象,其__proto__
指向的是构造函数的prototype属性指向的对象。
Object.create
1 | var person1 = { |
这种情况下,person2的__proto__
指向person1。在没有Object.create函数的时候,人们大多是这样做的:
1 | Object.create = function(p) { |
首先来说说prototype
属性,不像每个对象都有__proto__
属性来标识自己所继承的原型,只有函数才有prototype
属性。当你创建函数时,JS会为这个函数自动添加prototype
属性,值是空对象 值是一个有 constructor 属性的对象,不是空对象。而一旦你把这个函数当作构造函数(constructor
)调用(即通过new
关键字调用),那么JS就会帮你创建该构造函数的实例,实例继承构造函数prototype
的所有属性和方法(实例通过设置自己的__proto__
指向承构造函数的prototype
来实现这种继承)。
__proto__
是es6加入的内部属性,不是正式对外的api
JS正是通过__proto__
和prototype
的合作实现了原型链,以及对象的继承。
构造函数,通过prototype
来存储要共享的属性和方法,也可以设置prototype
指向现存的对象来继承该对象。
对象的__proto__
指向自己构造函数的prototype
。obj.__proto__.__proto__...
的原型链由此产生,包括我们的操作符instanceof
正是通过探测obj.__proto__.__proto__... === Constructor.prototype
来验证obj
是否是Constructor
的实例。
two = new Object()
中Object
是构造函数,所以two.__proto__
就是Object.prototype
。至于one
,ES规范定义对象字面量的原型就是Object.prototype
。
从上述过程可以看出,当 A 的 __proto__
指向 B 的 prototype 时,就认为 A 就是 B 的实例,我们再来看几个例子:
1 | [] instanceof Array; // true |
我们发现,虽然 instanceof 能够判断出 [ ] 是Array的实例,但它认为 [ ] 也是Object的实例,为什么呢?
我们来分析一下 [ ]、Array、Object 三者之间的关系:
从 instanceof 能够判断出 [ ].__proto__
指向 Array.prototype,而 Array.prototype.__proto__
又指向了Object.prototype,最终 Object.prototype.__proto__
指向了null,标志着原型链的结束。因此,[]、Array、Object 就在内部形成了一条原型链
从原型链可以看出,[] 的 __proto__
直接指向Array.prototype,间接指向 Object.prototype,所以按照 instanceof 的判断规则,[] 就是Object的实例。依次类推,类似的 new Date()、new Person() 也会形成一条对应的原型链 。因此,instanceof 只能用来判断两个对象是否属于实例关系, 而不能判断一个对象实例具体属于哪种类型。
constructor
当一个函数 F被定义时,JS引擎会为F添加 prototype 原型,然后再在 prototype上添加一个 constructor 属性,并让其指向 F 的引用。
1 | ''.constructor == String |
以上全员均为true
null 和 undefined 是无效的对象,因此是不会有 constructor 存在的,这两种类型的数据需要通过其他方式来判断。
函数的 constructor 是不稳定的,这个主要体现在自定义对象上,当开发者重写 prototype 后,原有的 constructor 引用会丢失,constructor 会默认为 Object
1 | function F() {} |
因为 prototype 被重新赋值的是一个 { }, { } 是 new Object() 的字面量,因此 new Object() 会将 Object 原型上的 constructor 传递给 { },也就是 Object 本身。
因此,为了规范开发,在重写对象原型时一般都需要重新给 constructor 赋值,以保证对象实例的类型不被篡改。
toString
toString() 是 Object 的原型方法,调用该方法,默认返回当前对象的 [[Class]] 。这是一个内部属性,其格式为 [object Xxx] ,其中 Xxx 就是对象的类型。
对于 Object 对象,直接调用 toString() 就能返回 [object Object] 。而对于其他对象,则需要通过 call / apply 来调用才能返回正确的类型信息。
1 | Object.prototype.toString.call('') ; // [object String] |
数组中的坑
稀疏数组:指的是含有空白或空缺单元的数组
1 | var a = []; |
这里有几个坑需要注意:
- 一开始建立的空数组
a
的长度为0,这可以理解,但是在a[4] = a[5]
之后出现了问题,a
的长度居然变成了5,此时a
数组是[,,,,undefined]
这种形态. - 我们通过遍历,只得到了
undefined
这一个值,这个undefind
是由于a[4] = a[5]
赋值,由于a[5]
没有定义值为undefined
被赋给了a[4]
,可以等价为a[4] = undefined
字符串索引
1 | var a = []; |
数组不仅可以通过数字索引,也可以通过字符串索引,但值得注意的是,字符串索引的键值对并不算在数组的长度里.
数字中的坑 :二进制浮点数
JavaScript 中的数字类型是基于“二进制浮点数”实现的,使用的是“双精度”格式,这就带来了一些反常的问题,我们那一道经典面试提来讲解下.
1 | var a = 0.1 + 0.2; |
这是个出人意料的结果,实际上a的值约为0.30000000000000004
这并不是一个整数值,这就是二进制浮点数
带来的副作用.
1 | var a = 0.1 + 0.2; |
NaN
1 | var a = 1/new Object(); |
NaN
属于特殊的Number
类型,我们可以把它理解为坏数值
,因为它属于数值计算中的错误,更加特殊的是它自己都不等价于自己NaN === NaN //false
,我们只能用isNaN()
来检测一个数字是否为NaN
.
类型转换原理
类型转换指的是将一种类型转换为另一种类型,例如:
1 | var b = 2; |
奇葩题:
1 | {}+[] //0 |
是什么原因造成了上述结果呢?那么我们得从ECMA-262中提到的转换规则和抽象操作说起,有兴趣的童鞋可以仔细阅读下这浩如烟海的语言规范,如果没这个耐心还是往下看.
这是JavaScript种类型转换可以从原始类型转为引用类型,同样可以将引用类型转为原始类型,转为原始类型的抽象操作为ToPrimitive
,而后续更加细分的操作为:ToNumber ToString ToBoolean
如果想应付面试,我觉得这张表就差不多了,但是为了更深入的探究JavaScript引擎是如何处理代码中类型转换问题的,就需要看 ECMA-262详细的规范,从而探究其内部原理,我们从这段内部原理示意代码开始
类型转换主要分为两大类:ToPrimitive
和ToObject
。其中ToPrimitive
又分为了:ToNumber
、ToString
和ToBoolean
1 | // ECMA-262, section 9.1, page 30. Use null/undefined for no hint, |
上面有一个
hint
的参数,当没有传入hint
参数时,且x
不是Date
对象时会通过%DefaultNumber(x)
来转换,否则通过%DefaultString(x)
。这里也可以看到日期类型的对象转换为原始类型时的不同。
上面代码的逻辑是这样的:
- 如果变量为字符串,直接返回.
- 如果
!IS_SPEC_OBJECT(x)
,直接返回. - 如果
IS_SYMBOL_WRAPPER(x)
,则抛出异常. - 否则会根据传入的
hint
来调用DefaultNumber
和DefaultString
,比如如果为Date
对象,会调用DefaultString
. DefaultNumber
:首先x.valueOf
,如果为primitive
,则返回valueOf
后的值,否则继续调用x.toString
,如果为primitive
,则返回toString
后的值,否则抛出异常DefaultString
:和DefaultNumber
正好相反,先调用toString
,如果不是primitive
再调用valueOf
.
那讲了实现原理,这个ToPrimitive
有什么用呢?实际很多操作会调用ToPrimitive
,比如加、相等或比较操。在进行加操作时会将左右操作数转换为primitive
,然后进行相加。
下面来个实例,({}) + 1(将{}放在括号中是为了内核将其认为一个代码块)会输出啥?可能日常写代码并不会这样写,不过网上出过类似的面试题。
加操作只有左右运算符同时为String或Number
时会执行对应的%_StringAdd或%NumberAdd
,
下面看下({}) + 1
内部会经过哪些步骤:
{}
和1
首先会调用ToPrimitive {}
会走到DefaultNumber
,首先会调用valueOf
,返回的是Object
{}
,不是primitive类型,从而继续走到toString
,返回[object Object]
,是String
类型 最后加操作,结果为[object Object]1
再比如有人问你[] + 1
输出啥时,你可能知道应该怎么去计算了,先对[]
调用ToPrimitive
,返回空字符串,最后结果为”1”。
({}).valueOf() // {}
({}).toString() // “[object Object]”
[].toString() // “”
附上ADD源码:
1 | // ECMA-262, section 11.6.1, page 50. |
可以看到在操作数有一个不为number或string时,ADD
操作就会将相应的操作数转换为原始类型,然后再进行相应的加法操作,可以看到上面的{} + 1
,{}
不为原始类型,所以就会调用ToPrimitive({})
和ToPrimitive(1)
,ToPrimitive({})
调用的结果为[object Object]
,所以最后会进行_StringAdd
操作,最后的结果是:[object Object]1
。
ToNumber:
1 | // ECMA-262, section 9.3, page 31. |
ToString:
1 | // ECMA-262, section 9.8, page 35. |
toString()与valueOf()
8.31日补充:Number()以及String()方法不同于ValueOf、ToString,
前者为数据转换,当调用Add方法(+a)的时候,此时涉及到的转换数据格式,而不是ToPrimitive,所以在+a的时候,当a为数字字符串,则会转成数字,当 a为空对象的时候,则为 NaN, 当 a 为空数组的时候,则为 0
toString( ):返回对象的字符串表示。
valueOf( ):返回对象的字符串、数值或布尔值表示。
好了,写几个例子就明白返回结果了(undefined 和 null 的值就不举例了,因为它们都没有这两个方法,所以肯定会报错的):
1 | //先看看toString()方法的结果 |
1 | //再看看valueOf()方法的结果 |
很清楚了,toString( )就是将其他东西用字符串表示,比较特殊的地方就是,表示对象的时候,变成”[object Object]”,表示数组的时候,就变成数组内容以逗号连接的字符串,相当于Array.join(‘,’)。 而valueOf( )就返回它自身了。
至于迷惑的地方,就在于它们在什么时候被调用,举个例子:
1 | var a = '3'; |
当然了,+a打印结果是数字3(不是字符串‘3’),因为一元加操作符接在字符串前面就将其转换为数字了(字符串转化为数字的一种方式,相当于Number( )方法),但是如果它应用在对象上,过程是怎样的呢,再举例子:
1 | //例子一 |
通过例子一和例子二的比较,我们可以知道,一元加操作符在操作对象的时候,会先调用对象的valueOf方法来转换,最后再用Number( )方法转换,而通过例子二和例子三的比较,我们可以知道,如果只改写了toString方法,对象则会调用toString方法,证明valueOf的优先级比toString高。
好了,如果是alert呢?
1 | //例子一 |
虽然上面结果我用双引号了,但是你知道弹窗不会将字符串的双引号表示出来的。通过上面几个例子,我们就知道了,alert它对待对象,就和字符串和对象相加一样,就是调用它的toString( )方法,和 valueOf方法无关
好了,总结一下,一般用操作符单独对对象进行转换的时候,如果对象存在valueOf或toString改写的话,就先调用改写的方法,valueOf更高级,如果没有被改写,则直接调用对象原型的valueOf方法。如果是弹窗的话,直接调用toString方法。至于其他情况,待续……
我们尝试把第一步返回的值改成一个对象 {}
1 | var a={ |
从结果可以看到,当toString不可用的时候,系统会再尝试valueOf方法,我们继续修改valueOf方法,把valueOf方法也改成返回对象 {}
1 | var a={ |
可以发现,如果toString和valueOf方法均不可用的情况下,系统会直接返回一个错误。
1 | var a={ |
可以看到,这里我们改写了valueOf和toString方法,系统在调用valueOf方法之后发现返回的不是“原始类型”数据,于是又尝试调用了toString方法,并返回了该方法返回的值12,最后+1变成了13。
1 | {} 的 valueOf 结果为 {} ,toString 的结果为 "[object Object]" |
1 | Object.prototype.toString.call(null); // "[object Null]" |
1 | var obj={name:'Mofei'} |
大吃一惊
“0” 转换成 Boolean为 true
NaN转换成 Boolean为 false
{} 转换成 Boolean 为 true
[] 转换成 Boolean 为 true, 数组本身就是Object
1 | parseInt(0.0000004) // 4 |
关于Boolean
1 | // ECMA-262, section 9.2, page 30 |
Boolean转换
在进行布尔比较的时候,比如 if(obj) , while(obj)等等,会进行布尔转换,布尔转换遵循如下规则:
值 | 布尔值 |
---|---|
true/false | true/false |
undefined,null | false |
Number | 0,NaN 对应 false, 其他的对应 true |
String | “”对应false,其他对应true(’0’对应的是true) |
Object | true |
注意:我们知道,初始化后,即使数组arr中没有元素,也是一个object。既然是object,用于判断条件时就会被转化为true
1 | if(arr)console.log("it's true"); |
但是,如果将arr与布尔值比较:
1 | arr == false; |
可是,如果把arr转化为Boolean,的确是true:
1 | Boolean(arr); |
那arr与布尔值比较时,到底发生了什么?
这是因为,任意值与布尔值比较,都会将两边的值转化为Number。
如arr与false比较,false转化为0,而arr为空数组,也转化为0:
1 | Number(false) |
所以,当空数组作为判断条件时,相当于true。当空数组与布尔值直接比较时,相当于false。
举个比较典型的例子
1 | [] == ![] //true |
发现对象确实不可valueOf
1 | var num = 123; //通过对象字面量声明console.log(typeof num); //输出:'number' |
注意:比如parseInt(“0057”) 结果为57 ,而parseInt(”0058”)结果为5,parseInt(“007”)结果为7,而parseInt(“008”)结果为0
parseInt()函数的完整形式是:parseInt(string, radix)的作用是将string转换为整数,第二个参数是设置string的格式,常用的有2、8、10、16,表示string是多少进制的数。 不要把这里的Int理解成十进制数
radix 可取值的范围是2~36,如果不在这个范围内,将返回NaN
如果设置radix的值是0或者不设置时,会自动识别string的格式:
以 “0x” 开头,parseInt() 会把 string 除0x外的其余部分当作十六进制数,
以 “0” 开头,parseInt() 会把 string 除0外的字符当作八进制或十六进制数,
以 1 ~ 9 的数字开头,parseInt() 将把它当作十进制数。
所以产生上述原因是因为我们没有设置第二个类型参数,以致以”0”开头的字符串数据被当做了八进制。
所以以后在使用parseInt(String,radix)函数时,把第二个进制参数加上,以防出错。
parseInt(”0058”,10)结果为58,就是我们想要的了
这表示把”0058”以十进制表示
parseInt():不认识小数点,是从左到右一个一个解析,看到数字就通过,非数字就停止解析。
1 | var a = '100px123456'; |
parseInt认识加号、减号、空格等,遇到加号减号等会认为是数字的一部分,越过这些符号继续解析
1 | var a = '+100px'; |