最后更新于
2020-11-25
最近打算重新的梳理一下 JavaScript
中数据类型的相关内容,主要分为三部分『数据类型』,『类型转换』和『类型判断』,也算是做一个整合汇总,方便以后进行查阅或是复习,下面我们就先从『数据类型』开始看起
数据类型
在当下的 ECMAScript 标准 当中,总共定义了八种数据类型,分为『基本类型』和『引用类型』两大类,当然也有『原始类型』和『对象类型』的叫法,但是本文当中统一称为『基本类型』和『引用类型』,知道它们是同一个东西即可
『基本类型』包括
Null
,只包含一个值null
Undefined
,只包含一个值undefined
Boolean
,包含两个值,true
和false
Number
,整数或浮点数,还有一些特殊值(-Infinity
、+Infinity
、NaN
)String
,一串表示文本值的字符序列Symbol
,一种实例是唯一且不可改变的数据类型BigInt
,ES10
当中加入,现已被最新Chrome
支持
『引用类型』包括
Object
,常用的Object
,Array
、Function
等都属于特殊的对象
基本类型
像 String
,Number
,Boolean
等这些我们平常经常使用的,这里我们就不多做提及了,关于 Symbol 的相关内容我们在之前已经梳理过了,而引用类型我们会在下面详细来进行介绍,所以这里我们就只简单的看看 Null
,Undefined
和 BigInt
Null
在基本类型中,有两个类型 Null
和 Undefined
,它们都有且仅有一个值,null
和 undefined
,并且它们都代表无和空,我们一般这样来区分它们
表示被赋值过的对象,刻意把一个对象赋值为 null
,故意表示其为空,不应有值,所以对象的某个属性值为 null
是正常的,并且 null
转换为数值时值为 0
Undefined
表示『缺少值』,即此处应有一个值,但还没有定义,如果一个对象的某个属性值为 undefined
是不正常的,例如 obj.name = undefined
,但是建议不要这样使用,应该直接 delete obj.name
,并且 undefined
转为数值时为 NaN
JavaScript
是一门动态类型语言,成员除了表示存在的空值外,还有可能根本就不存在(因为存不存在只有在运行期才知道),这就是 undefined
的意义所在
BigInt
BigInt
主要是用于解决使用 number
类型的限制,比如无法精确表示的非常大的整数等,因为在 JavaScript
当中缺少显式整数类型,所以它无法精确表示的非常大的整数,而会自动的四舍五入,比如
1 | 9007199254740992 === 9007199254740993 // true |
其实在之前,JavaScript
也提供了 Number.MAX_SAFE_INTEGER
和 Number.MIN_SAFE_INTEGER
常量来表示最大(最小)安全整数,但是在进行计算的时候也存在一定的误差,如下
1 | const minInt = Number.MIN_SAFE_INTEGER |
所以在这种情况之下,我们就可以考虑来使用 BigInt
,使用了 BigInt
以后就可以在标准的 JavaScript
当中执行对大整数的算术运算,而不会有精度损失的风险,下面我们来看看如何进行使用,如果要创建 BigInt
,只需在整数的末尾追加 n
即可,如下
1 | console.log(9007199254740995n) // 9007199254740995n |
或者,可以调用 BigInt()
构造函数来进行实现
1 | BigInt('9007199254740995') // 9007199254740995n |
但是需要注意的是,不能使用严格相等运算符将 BigInt
与常规数字进行比较,因为它们的类型不同
1 | console.log(10n === 10) // false |
相反,可以使用等号运算符,它在处理操作数之前执行隐式类型转换
1 | console.log(10n == 10) // true |
最后我们就简单的总结一下在使用 BigInt
过程当中一些值得警惕的点,其它更多内容在这里也就不具体展开了,感兴趣的话可以自行查阅相关内容,主要以下这几点
BigInt
不支持一元加号运算符,这可能是某些程序可能依赖于+
始终生成Number
的不变量,或者抛出异常- 因为隐式类型转换可能丢失信息,所以不允许在
BigInt
和Number
之间进行混合操作,当混合使用大整数和浮点数时,结果值可能无法由BigInt
或Number
精确表示
1 | // TypeError |
- 不能将
BigInt
传递给Web API
和内置的JavaScript
函数,这些函数需要一个Number
类型的数字,尝试这样做会报TypeError
错误
1 | // TypeError |
- 当
Boolean
类型与BigInt
类型相遇时,BigInt
的处理方式与Number
类似,换句话说只要不是0n
,BigInt
就被视为truthy
的值
1 | // 条件判断为 false |
- 元素都为
BigInt
的数组可以进行sort
BigInt
可以正常地进行位运算,如|
、&
、<<
、>>
和^
- 最后需要注意浏览器的兼容性,目前兼容性并不怎么好,只有
Chrome
、Firefox
、Opera
这些主流实现,要正式成为规范,其实还有很长的路要走
不可变性
我们在上面所提到的基本类型,在 ECMAScript
标准中,它们被定义为 primitive values
,即原始值,代表值本身是不可被改变的,以字符串为例,我们在调用操作字符串的方法时,没有任何方法是可以直接改变字符串的
1 | var str = 'foo' |
在上面的代码中我们对 str
调用了几个方法,无一例外,这些方法都在原字符串的基础上产生了一个新字符串,而非直接去改变 str
,这就印证了字符串的不可变性,但是如果我们像下面这样操作的话
1 | var str = 'foo' |
我们发现 str
的值被改变了,但是我们在上面又提到它不是不可变的吗?其实不然,我们从内存上来理解,我们都知道,在 JavaScript
中,每一个变量在内存中都需要一个空间来存储,而内存空间又被分为两种,即『栈内存』与『堆内存』
其中『栈内存』的特点是
- 存储的值大小固定
- 空间较小
- 可以直接操作其保存的变量,运行效率高
- 由系统自动分配存储空间
JavaScript
中的基本类型的值被直接存储在『栈』中,在变量定义时,栈就为其分配好了内存空间,由于栈中的内存空间的大小是固定的,那么注定了存储在栈中的变量就是不可变的
而在上面的代码中,我们执行了 str += 'bar'
的操作,实际上是在栈中又开辟了一块内存空间用于存储 'foobar'
,然后将变量 str
指向这块空间,所以这并不违背不可变性的特点
引用类型
而『堆内存』的特点是
- 存储的值大小不定,可动态调整
- 空间较大,运行效率低
- 无法直接操作其内部存储,使用引用地址读取
- 通过代码进行分配空间
相对于上面具有不可变性的基本类型,我们习惯于把对象称为引用类型,引用类型的值实际存储在堆内存中,它在栈中只存储了一个固定长度的地址,这个地址指向堆内存中的值
1 | var obj1 = { name: 'foo' } |
当然,引用类型就不再具有『不可变性』了,所以我们可以轻易的改变它们
1 | obj1.name = 'foo' |
以数组为例,它的很多方法都可以改变它自身
pop()
,删除数组最后一个元素,如果数组为空,则不改变数组,返回undefined
,改变原数组,返回被删除的元素push()
,向数组末尾添加一个或多个元素,改变原数组,返回新数组的长度shift()
,把数组的第一个元素删除,若空数组,不进行任何操作,返回undefined
,改变原数组,返回第一个元素的值unshift()
,向数组的开头添加一个或多个元素,改变原数组,返回新数组的长度reverse()
,颠倒数组中元素的顺序,改变原数组,返回该数组sort()
,对数组元素进行排序,改变原数组,返回该数组splice()
,从数组中添加或是删除项目,改变原数组,返回被删除的元素
数据如何存储
我们在上面的探讨过程当中,其实漏掉了一种情况,那就是闭包的情况,根据我们之前所说的,如果变量存在『栈』中,那函数调用完『栈顶空间销毁』,闭包变量不就没了吗?
所以在这里我们就需要注意了『闭包变量其实存在堆内存中的』,具体而言,我们之前提到的『基本类型』都存储在栈中,而所有的『引用类型』存放在堆中,值得注意的是,对于『赋值』操作,基本类型的数据直接完整地复制变量值,而引用类型的数据则是复制引用地址,也因此会有下面这样的情况
1 | let obj1 = { a: 1 } |
之所以会这样,是因为 obj1
和 obj2
是同一份堆空间的地址,改变 obj2
,等于改变了共同的堆内存,这时候通过 obj1
来获取这块内存的值当然会改变,关于这部分内容我们会在下面详细来进行介绍,现在我们先来看另外一个问题,那就是为什么不全部使用栈来保存呢?
首先,对于系统栈来说,它的功能除了保存变量之外,还有创建并切换函数执行上下文的功能,举个例子
1 | function f(a) { |
假设我们使用 ESP
指针来保存当前的执行状态,在系统栈中会产生如下的过程
- 调用
func
,将func
函数的上下文压栈,ESP
指向栈顶 - 执行
func
,又调用f
函数,将f
函数的上下文压栈,ESP
指针上移 - 执行完
f
函数,将ESP
下移,f
函数对应的栈顶空间被回收 - 执行完
func
,ESP
下移,func
对应的空间被回收
也就如下图所示
因此可以发现,如果采用栈来存储相对基本类型更加复杂的对象数据,那么切换上下文的开销将变得巨大,不过堆内存虽然空间大,能存放大量的数据,但与此同时垃圾内存的回收会带来更大的开销,关于垃圾回收这部分内容,我们会在 V8 引擎机制 一节当中来详细进行介绍
下面我们通过几个操作来详细看看基本类型和引用类型的区别
复制
当我们把一个变量的值复制到另一个变量上时,基本类型和引用类型的表现是不一样的,先来看看基本类型
1 | var name = 'foo' |
我们可以设想内存中有一个变量 name
,它的值为 foo
,我们从变量 name
复制出一个变量 name2
,此时在内存中创建了一个块新的空间用于存储 foo
,虽然两者值是相同的,但是两者指向的内存空间完全不同,这两个变量参与任何操作都互不影响,下面我们再来看看复制一个引用类型
1 | var obj = { name: 'foo' } |
当我们复制引用类型的变量时,实际上复制的是栈中存储的地址,所以复制出来的 obj2
实际上和 obj
指向的堆中同一个对象,因此我们改变其中任何一个变量的值,另一个变量都会受到影响,这就是为什么会有深拷贝和浅拷贝的原因
比较
当我们在对两个变量进行比较时,不同类型的变量的表现是不同的
1 | var name = 'foo' |
对于基本类型,比较时会直接比较它们的值,如果值相等,即返回 true
,对于引用类型,比较时会比较它们的引用地址,虽然两个变量在堆中存储的对象具有的属性值都是相等的,但是它们被存储在了不同的存储空间,因此比较值为 false
值传递和引用传递
先来看一个例子
1 | var a = 2 |
上例中的 2
是一个标量基本类型值,所以变量 a
持有该值的一个副本,b
持有它的另一个复本,所以 b
更改时,a
的值保持不变,而 c
和 d
则分别指向同一个复合值 [1, 2, 3]
的两个不同引用,请注意,c
和 d
仅仅是指向值 [1, 2, 3]
,并非持有,所以它们更改的是同一个值(比如调用 push()
方法),随后它们都指向了更改后的新值 [1, 2, 3, 4]
- 简单值(即标量基本类型值,
scalar primitive
),总是通过值复制的方式来赋值(传递),包括number
,boolean
,string
,undefined
,null
和ES6
中的Symbol
和BigInt
- 复合值(
compound value
),对象(包括数组和封装对象)和函数,则总是通过引用复制的方式来赋值(传递)
由上可知,由于引用指向的是值本身而非变量,所以一个引用无法更改另外一个引用的指向
1 | var a = [1, 2, 3] |
从上面例子可以看出 b = [4, 5, 6]
并不影响 a
指向 [1, 2, 3]
,下面我们就来分别看看它们两者之间的区别
引用类型值的传递
我们首先需要明确一点,即
其实严格来说,在
JavaScript
中没有『引用传递』,比较严谨的说法是,如果传递的参数是一个值,是按值传递,如果传递的是一个对象,则传递的是一个对象的引用,JavaScript
不允许直接访问内存中的位置,不能直接操作对象的内存空间,实际上操作的是对象的引用,所以引用类型的值是按引用访问的准确地说,引用类型的存储需要内存的栈区和堆区(堆区是指内存里的堆内存)共同完成,栈区内存保存变量标识符和指向堆内存中该对象的指针,也可以说是该对象在堆内存的地址
但是函数的参数就经常让人产生这样的疑惑,比如看下面这个例子
1 | function foo (x) { |
我们向函数传递 a
的时候,实际是将引用 a
的一个复本赋值给 x
,而 a
仍然指向 [1, 2, 3]
,在函数中我们可以通过引用 x
来更改数组的值(如上,数组在 push(4)
后变为了 [1, 2, 3, 4]
)
但 x = [4, 5, 6]
并不影响 a
的指向,所以 a
仍然指向 [1, 2, 3, 4]
,我们不能通过引用 x
来更改引用 a
的指向,只能更改 a
和 x
共同指向的值,如果要将 a
的值变为 [4, 5, 6, 7]
,那么就必须更改 x
指向的数组,而不是为 x
赋值一个新的数组
1 | function foo (x) { |
这样一来,在不创建新数组,而只是更改了当前的数组的情况下,a
的指向就变成了 [4, 5, 6, 7]
,所以这里也就有一个小技巧,那就是如何引用一个对象,但是不改变原有对象的值,解决方法就是在一个函数中去引用,所以简单总结就是
- 对于保存基本类型值的变量,变量是按值访问的,因为我们操作的是变量实际保存的值
- 对于保存引用类型值的变量,变量是按引用访问的,我们操作的是变量值所引用(指向)的对象
基本类型值的传递
相反,如果要将标量基本类型值传递到函数内并进行更改,这时候就需要将该值封装到一个复合值(对象,数组等)中,然后通过引用复制的方式传递
1 | function foo (wrap) { |
这里的 obj
是一个封装了标量基本类型值 a
的封装对象,obj
引用的一个复本作为参数 wrap
被传递到 foo()
中,这样我们就可以通过 wrap
来访问该对象并更改它的属性,函数执行结束后 obj.a
的值就变为了 22
,与预期不同的是,虽然传递的是指向数字对象的引用复本,但我们并不能通过它来更改其中的基本类型值
1 | function foo (x) { |
这是因为标量基本类型的值是不可更改的(字符串和布尔也是如此),如果一个数字对象的标量基本类型值是 2
,那么该值就不能更改,除非创建一个包含新值的数字对象,x = x + 1
中,x
中的标量基本类型值 2
从数字对象中拆封(提取)出来以后,x
就从引用变成了数字对象,它的值为 2 + 1
等于 3
,然而函数外的 b
仍然指向原来那个值为 2
的数字对象
包装类型
最后我们再来简单的了解一下包装类型,为了便于操作基本类型值,ECMAScript
还提供了几个特殊的引用类型,它们是基本类型的包装类型
Boolean
Number
String
但是我们需要注意包装类型和基本类型的区别
1 | true === new Boolean(true) // false |
引用类型和包装类型的主要区别就是对象的生存期,使用 new
操作符创建的引用类型的实例,在执行流离开当前作用域之前都一直保存在内存中,而自基本类型则只存在于一行代码的执行瞬间,然后立即被销毁,这意味着我们不能在运行时为基本类型添加属性和方法
1 | var name = 'foo' |
既然提到了包装类型,那就不得不提我们经常听闻的两个相关操作,即装箱和拆箱
- 装箱转换,把基本类型转换为对应的包装类型
- 拆箱操作,把引用类型转换为基本类型
既然基本类型不能扩展属性和方法,那么我们是如何使用基本类型调用方法的呢?其实每当我们操作一个基础类型时,后台就会自动创建一个包装类型的对象,从而让我们能够调用一些方法和属性,例如下面的代码
1 | var name = 'foo' |
实际上发生了以下几个过程
- 创建一个
String
的包装类型实例 - 在实例上调用
substring
方法 - 销毁实例
也就是说,我们使用基本类型调用方法,就会自动进行装箱和拆箱操作,相同的我们使用 Number
和 Boolean
类型时,也会发生这个过程,而这个过程也就是我们下面将要介绍的类型转换的过程
类型转换
从引用类型到基本类型的转换,也就是拆箱的过程中,会遵循 ECMAScript
规范规定的抽象操作 [ToPrimitive]
,大致有以下几个步骤
- 如果存在
Symbol.toPrimitive()
方法,优先调用再返回 - 其次检查该值是否有
valueOf()
的方法,如果有并且返回基本类型值,就使用该值进行强制类型转换为这个原始值 - 如果没有,则调用
toString
方法,如果toString
方法返回的是原始值(如果存在),则对象转换为这个原始值 - 如果
valueOf
和toString
方法均没有返回原始值,则抛出TypeError
异常
1 | const obj = { |
当然除了程序中的自动拆箱和自动装箱,我们还可以手动进行拆箱和装箱操作,我们可以直接调用包装类型的 valueOf
或 toString
,实现拆箱操作
1 | var num = new Number('123') |
因为 JavaScript
是弱类型的语言,所以类型转换发生非常频繁,上面我们说的装箱和拆箱其实就是一种类型转换,类型转换分为两种
- 『隐式转换』即程序自动进行的类型转换
- 『强制转换』即我们手动进行的类型转换
强制转换我们就不再过多提及了,下面我们就来简单的梳理一下类型转换规则和来看一些让人头疼的隐式转换场景
类型转换规则
我们在上面我们只是简单的提及了 toPrimitive
原则,其实这其中还包含着其它一些内容,具体我们可以参考 ECMAScript
规范当中的 类型转换与测试,梳理下来以后是下面这样的
- 字符串 和 数字 之间的比较(字符串
x
==>ToNumber(x)
)- 如果
type(x)
是数字,type(y)
是字符串,则返回x == ToNumber(y)
的结果 - 如果
type(x)
是字符串,type(y)
是数字,则返回ToNumber(x) == y
的结果
- 如果
- 其它类型 和 布尔类型 之间的比较(布尔
x
==>ToNumber(x)
)- 如果
type(x)
是布尔类型,则返回ToNumber(x) == y
的结果 - 如果
type(y)
是布尔类型,则返回x == ToNumber(y)
的结果
- 如果
null
和undefined
之间的比较- 如果
x
是null
,y
是undefined
,则结果为true
- 如果
x
是undefined
,y
是null
,则结果为true
- 如果
- 对象 和 非对象 之间的比较(对象 ==>
ToPrimitive(obj)
)- 如果
type(x)
是字符串或数字,type(y)
是对象,则返回x == ToPrimitive(y)
的结果 - 如果
type(x)
是对象,type(y)
是字符串或数字,则返回ToPrimitive(x) == y
的结果
- 如果
上述规则转换成我们比较好理解的方式可以参考下表
转换前类型 | 转换前值 | 转换后(Boolean) | 转换后(Number) | 转换后(String) |
---|---|---|---|---|
Boolean |
ture |
- | 1 |
'true' |
Boolean |
false |
- | 0 |
'false' |
Number |
123 |
true |
- | '123' |
Number |
Infinity |
true |
- | 'Infinity' |
Number |
0 |
false |
- | '0' |
Number |
NaN |
false |
- | 'NaN' |
String |
'' |
false |
0 |
- |
String |
'123' |
true |
123 |
- |
String |
'123foo' |
true |
NaN |
- |
String |
'foo' |
true |
NaN |
- |
Symbol |
Symbol() |
true |
TypeError |
TypeError |
Null |
null |
false |
0 |
'null' |
Undefined |
undefined |
false |
NaN |
'undefined' |
Function |
function(){} |
true |
NaN |
'function(){}' |
Object |
{} |
true |
NaN |
[object Object] |
Array |
[] |
true |
0 |
'' |
Array |
['foo'] |
true |
NaN |
'foo' |
Array |
['123', 'foo'] |
true |
NaN |
'123, foo' |
下面我们再来看几个实际场景当中的隐式转换
if 语句和逻辑语句
在 if
语句和逻辑语句中,如果只有单个变量,会先将变量转换为 Boolean
值,只有下面几种情况会转换成 false
,其余被转换成 true
null
undefined
''
NaN
0
false
数学运算符
我们在对各种非 Number
类型运用数学运算符时,会先将非 Number
类型转换为 Number
类型
1 | 1 - true // 0 |
但是需要注意 +
是个例外,执行 +
操作符时
- 当一侧为
String
类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型 - 当一侧为
Number
类型,另一侧为基本类型,则将基本类型转换为Number
类型 - 当一侧为
Number
类型,另一侧为引用类型,将引用类型和Number
类型转换成字符串后拼接
1 | 123 + '123' // 规则一 123123 |
==
使用 ==
时,若两侧类型相同,则比较结果和 ===
相同,否则会发生隐式转换,使用 ==
时发生的转换可以分为几种不同的情况(这里我们只考虑两侧类型不同)
NaN
NaN
和其它任何类型比较永远返回 false
(包括和它自己)
1 | NaN == NaN // false |
Boolean
Boolean
和其它任何类型比较,Boolean
首先被转换为 Number
类型
1 | true == 1 // true |
但是这里需要注意一个可能会弄混淆的地方,那就是 undefined
、null
和 Boolean
比较,虽然 undefined
、null
和 false
都很容易被想象成假值,但是它们比较结果是 false
,原因是 false
首先被转换成 0
1 | undefined == false // false |
String 和 Number
String
和 Number
比较,先将 String
转换为 Number
类型
1 | 123 == '123' // true |
Null 和 Undefined
除了 null == undefined
比较结果是 true
以外,null
、undefined
和其它任何结果的比较值都为 false
1 | null == undefined // true |
基本类型和引用类型
当基本类型和引用类型做比较时,引用类型会依照 ToPrimitive
规则转换为基本类型,这个我们在上面已经介绍过了
1 | '[object Object]' == {} // true |
[] == ![]
这个涉及到的转换就比较多了,这里我们简单的梳理一下,它的流程如下
1 | [] == ![] // 因为 ! 优先级较高,[] 转布尔值是 true,所以就变成了 [] == false |
另外再看下面这两个比较特殊的
1 | [null] == false // true |
根据数组的 ToPrimitive
规则,数组元素为 null
或 undefined
时,该元素被当做空字符串处理,所以 [null]
、[undefined]
都会被转换为 0
所以,我们在上面总结了这么多,还是始终推荐使用 ===
来判断两个值是否相等,最后我们再以一道有意思的面试题来进行收尾这个部分的内容,题目是如何让 a == 1 && a == 2 && a == 3
的结果返回 true
,这里我们就可以根据上面的拆箱转换,以及 ==
的隐式转换得到最终的结果
1 | const a = { |
类型判断
我们在上面梳理了数据类型与类型转换的相关内容,下面我们再来看看类型判断的相关内容,我们就先从使用最为广泛的 typeof
开始看起
typeof
typeof
操作符可以准确判断一个变量是否为下面几个基本类型
1 | typeof 'foo' // string |
也可以用它来判断函数类型
1 | typeof function(){} // function |
但是 typeof
也是存在一些局限性的,尤其是当我们使用 typeof
来判断引用类型时显得更为乏力
1 | typeof [] // object |
instanceof
而使用 instanceof
操作符就可以帮助我们判断引用类型具体是什么类型的对象
1 | [] instanceof Array // true |
instanceof
的一个有意思的特征是它不仅检测构造这个对象的构造器,还检测原型链,原型链包含了很多信息,包括定义对象所采用的继承模式,这里我们先来回顾下原型链的几条规则
- 所有引用类型都具有对象特性,即可以自由扩展属性
- 所有引用类型都具有一个
__proto__
(隐式原型)属性,是一个普通对象 - 所有的函数都具有
prototype
(显式原型)属性,也是一个普通对象 - 所有引用类型
__proto__
值指向它构造函数的prototype
- 当试图得到一个对象的属性时,如果变量本身没有这个属性,则会去它的
__proto__
中去找
我们在上面示例当中的 [] instanceof Array
实际上是判断 Array.prototype
是否在 []
的原型链上,所以使用 instanceof
来检测数据类型,不会很准确,这不是它设计的初衷
1 | [] instanceof Object // true |
另外,使用 instanceof
也不能检测基本数据类型,所以 instanceof
并不是一个很好的选择
toString
我们在上面拆箱操作中提到了 toString
函数,我们可以调用它实现从引用类型的转换,因为每一个引用类型都有 toString
方法,默认情况下,toString()
方法被每个 Object
对象继承,如果此方法在自定义对象中未被覆盖,toString()
返回 '[object type]'
,其中 type
是对象的类型,比如下面这个示例
1 | ({}).toString() // [object Object] |
关于 toString
方法的内部原理,我们可以根据 ECMAScript
规范 15.2.4.2 Object.prototype.toString() 来进行了解,根据规范可知,当 toString
方法被调用的时候,下面的步骤会被执行
- 如果
this
值是undefined
,就返回[object Undefined]
- 如果
this
的值是null
,就返回[object Null]
- 让
O
成为ToObject(this)
的结果 - 让
class
成为O
的内部属性[[Class]]
的值 - 最后返回由
'[object'
,class
和']'
三个部分组成的字符串
但是这里需要注意的是,那就是我们在上面提到的『如果此方法在自定义对象中未被覆盖,toString
才会达到预想的效果』,事实上大部分引用类型比如 Array
、Date
、RegExp
等都重写了 toString
方法,所以在这种情况下,我们可以直接调用 Object
原型上未被覆盖的 toString()
方法,使用 call
来改变 this
指向来达到我们想要的效果
调用 | 结果 |
---|---|
Object.prototype.toString.call(true) |
[object Boolean] |
Object.prototype.toString.call(123) |
[object Number] |
Object.prototype.toString.call('foo') |
[object String] |
Object.prototype.toString.call(null) |
[object Null] |
Object.prototype.toString.call(undefined) |
[object Undefined] |
Object.prototype.toString.call(Symbol()) |
[object Symbol] |
Object.prototype.toString.call({}) |
[object Object] |
Object.prototype.toString.call(function(){}) |
[object Function] |
Object.prototype.toString.call([]) |
[object Array] |
Object.prototype.toString.call(new Error()) |
[object Error] |
Object.prototype.toString.call(new ReaExp()) |
[object RegExp] |
Object.prototype.toString.call(new Date()) |
[object Date] |
Object.prototype.toString.call(Math) |
[object Math] |
Object.prototype.toString.call(JSON) |
[object JSON] |
Object.prototype.toString.call(window) |
[object global] |
Object.prototype.toString.call(arguments) |
[object Arguments] |
Object.is()
ES6
中对象的扩展里面添加了一个 Object.is
方法,用于比较两个值是否严格相等,内部计算方式与 ===
行为基本一致,但是还是存在一定的区别的,比如下面这几个示例
1 | NaN == NaN // false 但是我们期待它返回 true |
可以发现,Object.is()
判断两个值是否相同跟我们的主观感受一致,即两个值是一样的,它们就应该相等(特例除外),所以这里需要我们注意它与 ===
之间的区别
那么问题来了,我们如何才能在不支持这个方法的 ES5
中来进行实现呢?其实简单来说,我们还是可以直接使用 ===
来实现,只不过需要单独处理一下上面提到的两种特殊情况即可,实现如下
1 | if (!Object.is) { |
这样就使得 Object.is()
总是返回我们需要的结果,它在下面六种情况下,都会返回 true
- 两个值都是
undefined
- 两个值都是
null
- 两个值都是
true
或者都是false
- 两个值是由相同个数的字符按照相同的顺序组成的字符串
- 两个值指向同一个对象
- 两个值都是数字并且
- 都是正零
+0
- 都是负零
-0
- 都是
NaN
- 都是除零和
NaN
外的其它同一个数字
- 都是正零
可以看出 Object.is
可以对基本数据类型做出非常精确的比较,但是对于引用数据类型是没办法直接比较的
jQuery
这里我们稍微扩展一点,来看看 jQuery
源码中是如何进行类型判断的
1 | var class2type = {} |
基本类型直接使用 typeof
,引用类型使用 Object.prototype.toString.call
取得类型,借助一个 class2type
对象将字符串多余的代码过滤掉,例如 [object Function]
将得到 array
,然后在后面的类型判断,如 isFunction
直接可以使用 jQuery.type(obj) === 'function'
这样的判断
空对象检测
最后我们来看一种比较特殊的情况,那就是空对象检测,通常而言一般有下面几种方式
for-in
一般最为常见的思路就是使用 for-in
遍历属性,为真则为非空数组,否则为空数组
1 | // 如果不为空,则会执行到这一步,返回 true |
JSON.stringify()
通过 JSON
自带的 stringify()
方法来判断,JSON.stringify()
方法用于将 JavaScript
值转换为 JSON
字符串,所以一般可以直接与 {}
来进行比较
1 | if (JSON.stringify(data) === '{}') { |
Object.keys()
ES6
新增的 Object.keys()
方法会返回所有可枚举属性,不包括原型中属性和 Symbol
属性,如果我们的对象为空,它会返回一个空数组
1 | var a = {} |
isEmptyObject()
jQuery
当中提供了 isEmptyObject()
方法用来判断空对象,但是其本质也是通过 for-in
循环来进行判断的,源码如下
1 | isEmptyObject: function(obj) { |
这里有个需要注意的地方,就是我们在上面提及的这几个方法均不能检测出非枚举属性,比如如下示例
1 | var obj = {} |
所以我们还需要寻找一些其它方法来进行解决,接着往下看
Object.getOwnPropertyNames()
该方法会返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括 Symbol
值作为名称的属性)组成的数组
1 | let obj = {} |
利用这个方法可以检测出非枚举属性
1 | var obj = {} |
Reflect.ownKeys(obj)
这个是个无敌的方法,返回所有的自身属性,不管是否可枚举,不管是不是 Symbol
,一律返回
1 | var test = Symbol() |
所以简单的总结一下,判断一个变量是不是空对象的比较完善的方法
1 | obj !== null |