JavaScript 中的数据类型

JavaScript 中的数据类型

最后更新于 2020-11-25

最近打算重新的梳理一下 JavaScript 中数据类型的相关内容,主要分为三部分『数据类型』,『类型转换』和『类型判断』,也算是做一个整合汇总,方便以后进行查阅或是复习,下面我们就先从『数据类型』开始看起

数据类型

在当下的 ECMAScript 标准 当中,总共定义了八种数据类型,分为『基本类型』和『引用类型』两大类,当然也有『原始类型』和『对象类型』的叫法,但是本文当中统一称为『基本类型』和『引用类型』,知道它们是同一个东西即可

『基本类型』包括

  • Null,只包含一个值 null
  • Undefined,只包含一个值 undefined
  • Boolean,包含两个值,truefalse
  • Number,整数或浮点数,还有一些特殊值(-Infinity+InfinityNaN
  • String,一串表示文本值的字符序列
  • Symbol,一种实例是唯一且不可改变的数据类型
  • BigIntES10 当中加入,现已被最新 Chrome 支持

『引用类型』包括

  • Object,常用的 ObjectArrayFunction 等都属于特殊的对象

基本类型

StringNumberBoolean 等这些我们平常经常使用的,这里我们就不多做提及了,关于 Symbol 的相关内容我们在之前已经梳理过了,而引用类型我们会在下面详细来进行介绍,所以这里我们就只简单的看看 NullUndefinedBigInt

Null

在基本类型中,有两个类型 NullUndefined,它们都有且仅有一个值,nullundefined,并且它们都代表无和空,我们一般这样来区分它们

表示被赋值过的对象,刻意把一个对象赋值为 null,故意表示其为空,不应有值,所以对象的某个属性值为 null 是正常的,并且 null 转换为数值时值为 0

Undefined

表示『缺少值』,即此处应有一个值,但还没有定义,如果一个对象的某个属性值为 undefined 是不正常的,例如 obj.name = undefined,但是建议不要这样使用,应该直接 delete obj.name,并且 undefined 转为数值时为 NaN

JavaScript 是一门动态类型语言,成员除了表示存在的空值外,还有可能根本就不存在(因为存不存在只有在运行期才知道),这就是 undefined 的意义所在

BigInt

BigInt 主要是用于解决使用 number 类型的限制,比如无法精确表示的非常大的整数等,因为在 JavaScript 当中缺少显式整数类型,所以它无法精确表示的非常大的整数,而会自动的四舍五入,比如

1
2
3
9007199254740992 === 9007199254740993    // true

console.log(9999999999999999) // 10000000000000000

其实在之前,JavaScript 也提供了 Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER 常量来表示最大(最小)安全整数,但是在进行计算的时候也存在一定的误差,如下

1
2
3
4
5
6
7
const minInt = Number.MIN_SAFE_INTEGER

console.log(minInt) // -9007199254740991

// 注意两者结果的对比
console.log(minInt - 5) // -9007199254740996
console.log(minInt - 4) // -9007199254740996

所以在这种情况之下,我们就可以考虑来使用 BigInt,使用了 BigInt 以后就可以在标准的 JavaScript 当中执行对大整数的算术运算,而不会有精度损失的风险,下面我们来看看如何进行使用,如果要创建 BigInt,只需在整数的末尾追加 n 即可,如下

1
2
console.log(9007199254740995n)  // 9007199254740995n
console.log(9007199254740995) // 9007199254740996

或者,可以调用 BigInt() 构造函数来进行实现

1
BigInt('9007199254740995')      // 9007199254740995n

但是需要注意的是,不能使用严格相等运算符将 BigInt 与常规数字进行比较,因为它们的类型不同

1
2
3
4
console.log(10n === 10)         // false

console.log(typeof 10n) // bigint
console.log(typeof 10) // number

相反,可以使用等号运算符,它在处理操作数之前执行隐式类型转换

1
console.log(10n == 10)          // true

最后我们就简单的总结一下在使用 BigInt 过程当中一些值得警惕的点,其它更多内容在这里也就不具体展开了,感兴趣的话可以自行查阅相关内容,主要以下这几点

  1. BigInt 不支持一元加号运算符,这可能是某些程序可能依赖于 + 始终生成 Number 的不变量,或者抛出异常
  2. 因为隐式类型转换可能丢失信息,所以不允许在 BigIntNumber 之间进行混合操作,当混合使用大整数和浮点数时,结果值可能无法由 BigIntNumber 精确表示
1
2
// TypeError
10 + 10n
  1. 不能将 BigInt 传递给 Web API 和内置的 JavaScript 函数,这些函数需要一个 Number 类型的数字,尝试这样做会报 TypeError 错误
1
2
// TypeError
Math.max(2n, 4n, 6n)
  1. Boolean 类型与 BigInt 类型相遇时,BigInt 的处理方式与 Number 类似,换句话说只要不是 0nBigInt 就被视为 truthy 的值
1
2
3
4
5
// 条件判断为 false
if (0n) { }

// 条件为 true
if (3n) { }
  1. 元素都为 BigInt 的数组可以进行 sort
  2. BigInt 可以正常地进行位运算,如 |&<<>>^
  3. 最后需要注意浏览器的兼容性,目前兼容性并不怎么好,只有 ChromeFirefoxOpera 这些主流实现,要正式成为规范,其实还有很长的路要走

不可变性

我们在上面所提到的基本类型,在 ECMAScript 标准中,它们被定义为 primitive values,即原始值,代表值本身是不可被改变的,以字符串为例,我们在调用操作字符串的方法时,没有任何方法是可以直接改变字符串的

1
2
3
4
5
6
7
8
9
var str = 'foo'

str.slice(1)
str.substr(1)
str.trim(1)
str.toLowerCase(1)
str[0] = 1

console.log(str) // foo

在上面的代码中我们对 str 调用了几个方法,无一例外,这些方法都在原字符串的基础上产生了一个新字符串,而非直接去改变 str,这就印证了字符串的不可变性,但是如果我们像下面这样操作的话

1
2
3
4
5
var str = 'foo'

str += 'bar'

console.log(str) // foobar

我们发现 str 的值被改变了,但是我们在上面又提到它不是不可变的吗?其实不然,我们从内存上来理解,我们都知道,在 JavaScript 中,每一个变量在内存中都需要一个空间来存储,而内存空间又被分为两种,即『栈内存』与『堆内存』

其中『栈内存』的特点是

  • 存储的值大小固定
  • 空间较小
  • 可以直接操作其保存的变量,运行效率高
  • 由系统自动分配存储空间

JavaScript 中的基本类型的值被直接存储在『栈』中,在变量定义时,栈就为其分配好了内存空间,由于栈中的内存空间的大小是固定的,那么注定了存储在栈中的变量就是不可变的

而在上面的代码中,我们执行了 str += 'bar' 的操作,实际上是在栈中又开辟了一块内存空间用于存储 'foobar',然后将变量 str 指向这块空间,所以这并不违背不可变性的特点

引用类型

而『堆内存』的特点是

  • 存储的值大小不定,可动态调整
  • 空间较大,运行效率低
  • 无法直接操作其内部存储,使用引用地址读取
  • 通过代码进行分配空间

相对于上面具有不可变性的基本类型,我们习惯于把对象称为引用类型,引用类型的值实际存储在堆内存中,它在栈中只存储了一个固定长度的地址,这个地址指向堆内存中的值

1
2
3
4
5
6
7
var obj1 = { name: 'foo' }

var obj2 = { age: 18 }

var obj3 = function () { }

var obj4 = [1, 2, 3, 4, 5, 6, 7, 8, 9]

当然,引用类型就不再具有『不可变性』了,所以我们可以轻易的改变它们

1
2
3
4
5
6
7
obj1.name = 'foo'
obj2.age = 18
obj4.length = 0

console.log(obj1) // { name: 'foo' }
console.log(obj2) // { age: 18 }
console.log(obj4) // []

以数组为例,它的很多方法都可以改变它自身

  • pop(),删除数组最后一个元素,如果数组为空,则不改变数组,返回 undefined,改变原数组,返回被删除的元素
  • push(),向数组末尾添加一个或多个元素,改变原数组,返回新数组的长度
  • shift(),把数组的第一个元素删除,若空数组,不进行任何操作,返回 undefined,改变原数组,返回第一个元素的值
  • unshift(),向数组的开头添加一个或多个元素,改变原数组,返回新数组的长度
  • reverse(),颠倒数组中元素的顺序,改变原数组,返回该数组
  • sort(),对数组元素进行排序,改变原数组,返回该数组
  • splice(),从数组中添加或是删除项目,改变原数组,返回被删除的元素

数据如何存储

我们在上面的探讨过程当中,其实漏掉了一种情况,那就是闭包的情况,根据我们之前所说的,如果变量存在『栈』中,那函数调用完『栈顶空间销毁』,闭包变量不就没了吗?

所以在这里我们就需要注意了『闭包变量其实存在堆内存中的』,具体而言,我们之前提到的『基本类型』都存储在栈中,而所有的『引用类型』存放在堆中,值得注意的是,对于『赋值』操作,基本类型的数据直接完整地复制变量值,而引用类型的数据则是复制引用地址,也因此会有下面这样的情况

1
2
3
4
5
let obj1 = { a: 1 }
let obj2 = obj1

obj2.a = 2
obj1.a // 2

之所以会这样,是因为 obj1obj2 是同一份堆空间的地址,改变 obj2,等于改变了共同的堆内存,这时候通过 obj1 来获取这块内存的值当然会改变,关于这部分内容我们会在下面详细来进行介绍,现在我们先来看另外一个问题,那就是为什么不全部使用栈来保存呢?

首先,对于系统栈来说,它的功能除了保存变量之外,还有创建并切换函数执行上下文的功能,举个例子

1
2
3
4
5
6
7
8
9
function f(a) {
console.log(a)
}

function func(a) {
f(a)
}

func(1)

假设我们使用 ESP 指针来保存当前的执行状态,在系统栈中会产生如下的过程

  • 调用 func,将 func 函数的上下文压栈,ESP 指向栈顶
  • 执行 func,又调用 f 函数,将 f 函数的上下文压栈,ESP 指针上移
  • 执行完 f 函数,将 ESP 下移,f 函数对应的栈顶空间被回收
  • 执行完 funcESP 下移,func 对应的空间被回收

也就如下图所示

因此可以发现,如果采用栈来存储相对基本类型更加复杂的对象数据,那么切换上下文的开销将变得巨大,不过堆内存虽然空间大,能存放大量的数据,但与此同时垃圾内存的回收会带来更大的开销,关于垃圾回收这部分内容,我们会在 V8 引擎机制 一节当中来详细进行介绍


下面我们通过几个操作来详细看看基本类型和引用类型的区别

复制

当我们把一个变量的值复制到另一个变量上时,基本类型和引用类型的表现是不一样的,先来看看基本类型

1
2
3
4
5
var name = 'foo'
var name2 = name

name2 = 'bar'
console.log(name) // foo

我们可以设想内存中有一个变量 name,它的值为 foo,我们从变量 name 复制出一个变量 name2,此时在内存中创建了一个块新的空间用于存储 foo,虽然两者值是相同的,但是两者指向的内存空间完全不同,这两个变量参与任何操作都互不影响,下面我们再来看看复制一个引用类型

1
2
3
4
5
var obj = { name: 'foo' }
var obj2 = obj

obj2.name = 'bar'
console.log(obj.name) // bar

当我们复制引用类型的变量时,实际上复制的是栈中存储的地址,所以复制出来的 obj2 实际上和 obj 指向的堆中同一个对象,因此我们改变其中任何一个变量的值,另一个变量都会受到影响,这就是为什么会有深拷贝和浅拷贝的原因

比较

当我们在对两个变量进行比较时,不同类型的变量的表现是不同的

1
2
3
4
5
6
7
var name = 'foo'
var name2 = 'foo'
console.log(name === name2) // true

var obj = { name: 'foo' }
var obj2 = { name: 'foo' }
console.log(obj === obj2) // false

对于基本类型,比较时会直接比较它们的值,如果值相等,即返回 true,对于引用类型,比较时会比较它们的引用地址,虽然两个变量在堆中存储的对象具有的属性值都是相等的,但是它们被存储在了不同的存储空间,因此比较值为 false

值传递和引用传递

先来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var a = 2
var b = a // b 是 a 的值的一个副本

b++

a // 2
b // 3

// ==>

var c = [1, 2, 3]
var d = c

d.push(4)

c // [1, 2, 3, 4]
d // [1, 2, 3, 4]

上例中的 2 是一个标量基本类型值,所以变量 a 持有该值的一个副本,b 持有它的另一个复本,所以 b 更改时,a 的值保持不变,而 cd 则分别指向同一个复合值 [1, 2, 3] 的两个不同引用,请注意,cd 仅仅是指向值 [1, 2, 3],并非持有,所以它们更改的是同一个值(比如调用 push() 方法),随后它们都指向了更改后的新值 [1, 2, 3, 4]

  • 简单值(即标量基本类型值,scalar primitive),总是通过值复制的方式来赋值(传递),包括 numberbooleanstringundefinednullES6 中的 SymbolBigInt
  • 复合值(compound value),对象(包括数组和封装对象)和函数,则总是通过引用复制的方式来赋值(传递)

由上可知,由于引用指向的是值本身而非变量,所以一个引用无法更改另外一个引用的指向

1
2
3
4
5
6
7
8
9
10
11
var a = [1, 2, 3]
var b = a

a // [1, 2, 3]
b // [1, 2, 3]

// ==>

b = [4, 5, 6]
a // [1, 2, 3]
b // [4, 5, 6]

从上面例子可以看出 b = [4, 5, 6] 并不影响 a 指向 [1, 2, 3],下面我们就来分别看看它们两者之间的区别

引用类型值的传递

我们首先需要明确一点,即

其实严格来说,在 JavaScript 中没有『引用传递』,比较严谨的说法是,如果传递的参数是一个值,是按值传递,如果传递的是一个对象,则传递的是一个对象的引用,JavaScript 不允许直接访问内存中的位置,不能直接操作对象的内存空间,实际上操作的是对象的引用,所以引用类型的值是按引用访问的

准确地说,引用类型的存储需要内存的栈区和堆区(堆区是指内存里的堆内存)共同完成,栈区内存保存变量标识符和指向堆内存中该对象的指针,也可以说是该对象在堆内存的地址

但是函数的参数就经常让人产生这样的疑惑,比如看下面这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo (x) {
x.push(4)
x // [1, 2, 3, 4]

// ==>

x = [4, 5, 6]
x.push(7)
x // [4, 5, 6, 7]
}

var a = [1, 2, 3]
foo(a)

a // [1, 2, 3, 4]

我们向函数传递 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 的指向,只能更改 ax 共同指向的值,如果要将 a 的值变为 [4, 5, 6, 7],那么就必须更改 x 指向的数组,而不是为 x 赋值一个新的数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo (x) {
x.push(4)
x // [1, 2, 3, 4]

// ==>

x.length = 0
x.push(4, 5, 6, 7)
x // [4, 5, 6, 7]
}

var a = [1, 2, 3]
foo(a)

a // [4, 5, 6, 7]

这样一来,在不创建新数组,而只是更改了当前的数组的情况下,a 的指向就变成了 [4, 5, 6, 7],所以这里也就有一个小技巧,那就是如何引用一个对象,但是不改变原有对象的值,解决方法就是在一个函数中去引用,所以简单总结就是

  • 对于保存基本类型值的变量,变量是按值访问的,因为我们操作的是变量实际保存的值
  • 对于保存引用类型值的变量,变量是按引用访问的,我们操作的是变量值所引用(指向)的对象
基本类型值的传递

相反,如果要将标量基本类型值传递到函数内并进行更改,这时候就需要将该值封装到一个复合值(对象,数组等)中,然后通过引用复制的方式传递

1
2
3
4
5
6
7
8
9
10
11
function foo (wrap) {
wrap.a = 22
}

var obj = {
a: 2
}

foo(obj)

obj.a // 22

这里的 obj 是一个封装了标量基本类型值 a 的封装对象,obj 引用的一个复本作为参数 wrap 被传递到 foo() 中,这样我们就可以通过 wrap 来访问该对象并更改它的属性,函数执行结束后 obj.a 的值就变为了 22,与预期不同的是,虽然传递的是指向数字对象的引用复本,但我们并不能通过它来更改其中的基本类型值

1
2
3
4
5
6
7
8
9
10
function foo (x) {
x = x + 1
x // 3
}

var a = 2
var b = new Number(a) // Objeact(a) 也是一样

foo(b)
b // 2,而不是 3

这是因为标量基本类型的值是不可更改的(字符串和布尔也是如此),如果一个数字对象的标量基本类型值是 2,那么该值就不能更改,除非创建一个包含新值的数字对象,x = x + 1 中,x 中的标量基本类型值 2 从数字对象中拆封(提取)出来以后,x 就从引用变成了数字对象,它的值为 2 + 1 等于 3,然而函数外的 b 仍然指向原来那个值为 2 的数字对象

包装类型

最后我们再来简单的了解一下包装类型,为了便于操作基本类型值,ECMAScript 还提供了几个特殊的引用类型,它们是基本类型的包装类型

  • Boolean
  • Number
  • String

但是我们需要注意包装类型和基本类型的区别

1
2
3
4
5
true === new Boolean(true)             // false
123 === new Number(123) // false
'foo' === new String('foo') // false
console.log(typeof new String('foo')) // object
console.log(typeof 'foo') // string

引用类型和包装类型的主要区别就是对象的生存期,使用 new 操作符创建的引用类型的实例,在执行流离开当前作用域之前都一直保存在内存中,而自基本类型则只存在于一行代码的执行瞬间,然后立即被销毁,这意味着我们不能在运行时为基本类型添加属性和方法

1
2
3
4
var name = 'foo'
name.color = 'red'

console.log(name.color) // undefined

既然提到了包装类型,那就不得不提我们经常听闻的两个相关操作,即装箱和拆箱

  • 装箱转换,把基本类型转换为对应的包装类型
  • 拆箱操作,把引用类型转换为基本类型

既然基本类型不能扩展属性和方法,那么我们是如何使用基本类型调用方法的呢?其实每当我们操作一个基础类型时,后台就会自动创建一个包装类型的对象,从而让我们能够调用一些方法和属性,例如下面的代码

1
2
var name = 'foo'
var name2 = name.substring(2)

实际上发生了以下几个过程

  • 创建一个 String 的包装类型实例
  • 在实例上调用 substring 方法
  • 销毁实例

也就是说,我们使用基本类型调用方法,就会自动进行装箱和拆箱操作,相同的我们使用 NumberBoolean 类型时,也会发生这个过程,而这个过程也就是我们下面将要介绍的类型转换的过程

类型转换

从引用类型到基本类型的转换,也就是拆箱的过程中,会遵循 ECMAScript 规范规定的抽象操作 [ToPrimitive],大致有以下几个步骤

  1. 如果存在 Symbol.toPrimitive() 方法,优先调用再返回
  2. 其次检查该值是否有 valueOf() 的方法,如果有并且返回基本类型值,就使用该值进行强制类型转换为这个原始值
  3. 如果没有,则调用 toString 方法,如果 toString 方法返回的是原始值(如果存在),则对象转换为这个原始值
  4. 如果 valueOftoString 方法均没有返回原始值,则抛出 TypeError 异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const obj = {
valueOf: () => {
console.log('valueOf')
return 123
},
toString: () => {
console.log('toString')
return 'foo'
},
}

const obj2 = {
[Symbol.toPrimitive]: () => {
console.log('toPrimitive')
return 123
},
}

const obj3 = {
valueOf: () => {
console.log('valueOf')
return {}
},
toString: () => {
console.log('toString')
return {}
},
}

console.log(obj - 1) // valueOf 122
console.log(`${obj}`) // toString foo
console.log(obj2 - 1) // valueOf 122
console.log(obj3 - 1) // valueOf/toString TypeError

当然除了程序中的自动拆箱和自动装箱,我们还可以手动进行拆箱和装箱操作,我们可以直接调用包装类型的 valueOftoString,实现拆箱操作

1
2
3
4
var num = new Number('123')

console.log(typeof num.valueOf()) // number
console.log(typeof num.toString()) // string

因为 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) 的结果
  • nullundefined 之间的比较
    • 如果 xnullyundefined,则结果为 true
    • 如果 xundefinedynull,则结果为 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
2
3
4
1 - true       // 0
1 - null // 1
1 * undefined // NaN
2 * ['5'] // 10

但是需要注意 + 是个例外,执行 + 操作符时

  • 当一侧为 String 类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型
  • 当一侧为 Number 类型,另一侧为基本类型,则将基本类型转换为 Number 类型
  • 当一侧为 Number 类型,另一侧为引用类型,将引用类型和 Number 类型转换成字符串后拼接
1
2
3
4
123 + '123' // 规则一 123123
123 + null // 规则二 123
123 + true // 规则二 124
123 + {} // 规则三 123[object Object]
==

使用 == 时,若两侧类型相同,则比较结果和 === 相同,否则会发生隐式转换,使用 == 时发生的转换可以分为几种不同的情况(这里我们只考虑两侧类型不同)

NaN

NaN 和其它任何类型比较永远返回 false(包括和它自己)

1
NaN == NaN  // false
Boolean

Boolean 和其它任何类型比较,Boolean 首先被转换为 Number 类型

1
2
3
4
true == 1      // true 
true == '2' // false
true == ['1'] // true
true == ['2'] // false

但是这里需要注意一个可能会弄混淆的地方,那就是 undefinednullBoolean 比较,虽然 undefinednullfalse 都很容易被想象成假值,但是它们比较结果是 false,原因是 false 首先被转换成 0

1
2
undefined == false  // false
null == false // false
String 和 Number

StringNumber 比较,先将 String 转换为 Number 类型

1
2
123 == '123'  // true
'' == 0 // true
Null 和 Undefined

除了 null == undefined 比较结果是 true 以外,nullundefined 和其它任何结果的比较值都为 false

1
2
3
4
5
6
7
null == undefined   // true
null == '' // false
null == 0 // false
null == false // false
undefined == '' // false
undefined == 0 // false
undefined == false // false
基本类型和引用类型

当基本类型和引用类型做比较时,引用类型会依照 ToPrimitive 规则转换为基本类型,这个我们在上面已经介绍过了

1
2
'[object Object]' == {}  // true
'1, 2, 3' == [1, 2, 3] // true
[] == ![]

这个涉及到的转换就比较多了,这里我们简单的梳理一下,它的流程如下

1
2
3
4
5
6
7
[] == ![]    // 因为 ! 优先级较高,[] 转布尔值是 true,所以就变成了 [] == false
[] == false // 当 == 号两边其中一个是布尔值的话,先把它转换为数字,也就成了 [] == 0
[] == 0 // 对象和非对象之间的比较,针对对象执行 ToPrimitive() 操作
[] == 0 // [].valueOf() 返回还是对象自身
[] == 0 // [].toString() 返回 ''
'' == 0 // '' 会转成数字为 0
0 == 0 // true

另外再看下面这两个比较特殊的

1
2
[null] == false       // true
[undefined] == false // true

根据数组的 ToPrimitive 规则,数组元素为 nullundefined 时,该元素被当做空字符串处理,所以 [null][undefined] 都会被转换为 0

所以,我们在上面总结了这么多,还是始终推荐使用 === 来判断两个值是否相等,最后我们再以一道有意思的面试题来进行收尾这个部分的内容,题目是如何让 a == 1 && a == 2 && a == 3 的结果返回 true,这里我们就可以根据上面的拆箱转换,以及 == 的隐式转换得到最终的结果

1
2
3
4
5
6
7
8
const a = {
value: [3, 2, 1],
valueOf: function () {
return this.value.pop()
}
}

a == 1 && a == 2 && a == 3 // true

类型判断

我们在上面梳理了数据类型与类型转换的相关内容,下面我们再来看看类型判断的相关内容,我们就先从使用最为广泛的 typeof 开始看起

typeof

typeof 操作符可以准确判断一个变量是否为下面几个基本类型

1
2
3
4
5
typeof 'foo'        // string
typeof 123 // number
typeof true // boolean
typeof Symbol() // symbol
typeof undefined // undefined

也可以用它来判断函数类型

1
typeof function(){} // function

但是 typeof 也是存在一些局限性的,尤其是当我们使用 typeof 来判断引用类型时显得更为乏力

1
2
3
4
5
6
7
8
typeof []           // object
typeof { } // object
typeof new Date() // object
typeof /^\w$/ // object

// 两个特殊的情况
typeof NaN // number
typeof null // object

instanceof

而使用 instanceof 操作符就可以帮助我们判断引用类型具体是什么类型的对象

1
2
3
[] instanceof Array             // true
new Date() instanceof Date // true
new RegExp() instanceof RegExp // true

instanceof 的一个有意思的特征是它不仅检测构造这个对象的构造器,还检测原型链,原型链包含了很多信息,包括定义对象所采用的继承模式,这里我们先来回顾下原型链的几条规则

  • 所有引用类型都具有对象特性,即可以自由扩展属性
  • 所有引用类型都具有一个 __proto__(隐式原型)属性,是一个普通对象
  • 所有的函数都具有 prototype(显式原型)属性,也是一个普通对象
  • 所有引用类型 __proto__ 值指向它构造函数的 prototype
  • 当试图得到一个对象的属性时,如果变量本身没有这个属性,则会去它的 __proto__ 中去找

我们在上面示例当中的 [] instanceof Array 实际上是判断 Array.prototype 是否在 [] 的原型链上,所以使用 instanceof 来检测数据类型,不会很准确,这不是它设计的初衷

1
2
[] instanceof Object                // true
(function () { }) 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 方法被调用的时候,下面的步骤会被执行

  1. 如果 this 值是 undefined,就返回 [object Undefined]
  2. 如果 this 的值是 null,就返回 [object Null]
  3. O 成为 ToObject(this) 的结果
  4. class 成为 O 的内部属性 [[Class]] 的值
  5. 最后返回由 '[object'class']' 三个部分组成的字符串

但是这里需要注意的是,那就是我们在上面提到的『如果此方法在自定义对象中未被覆盖,toString 才会达到预想的效果』,事实上大部分引用类型比如 ArrayDateRegExp 等都重写了 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
2
3
4
5
6
7
8
9
10
11
12
13
14
NaN == NaN             // false 但是我们期待它返回 true
NaN === NaN // false 但是我们期待它返回 true

+0 == -0 // true 但是我们期待它返回 false
+0 === -0 // true 但是我们期待它返回 false

Object.is(NaN, NaN) // true
Object.is(+0, -0) // false

// 特例
Object.is(0, -0) // false
Object.is(0, +0) // true
Object.is(-0, -0) // true
Object.is(NaN, 0 / 0) // true

可以发现,Object.is() 判断两个值是否相同跟我们的主观感受一致,即两个值是一样的,它们就应该相等(特例除外),所以这里需要我们注意它与 === 之间的区别

那么问题来了,我们如何才能在不支持这个方法的 ES5 中来进行实现呢?其实简单来说,我们还是可以直接使用 === 来实现,只不过需要单独处理一下上面提到的两种特殊情况即可,实现如下

1
2
3
4
5
6
7
8
9
10
11
if (!Object.is) {
Object.is = function (x, y) {
if (x === y) {
// 针对 +0 不等于 -0
return x !== 0 || 1 / x === 1 / y
} else {
// 针对 NaN 等于 NaN
return x !== x && y !== y
}
}
}

这样就使得 Object.is() 总是返回我们需要的结果,它在下面六种情况下,都会返回 true

  • 两个值都是 undefined
  • 两个值都是 null
  • 两个值都是 true 或者都是 false
  • 两个值是由相同个数的字符按照相同的顺序组成的字符串
  • 两个值指向同一个对象
  • 两个值都是数字并且
    • 都是正零 +0
    • 都是负零 -0
    • 都是 NaN
    • 都是除零和 NaN 外的其它同一个数字

可以看出 Object.is 可以对基本数据类型做出非常精确的比较,但是对于引用数据类型是没办法直接比较的

jQuery

这里我们稍微扩展一点,来看看 jQuery 源码中是如何进行类型判断的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var class2type = {}

jQuery.each('Boolean Number String Function Array Date RegExp Object Error Symbol'.split(' '),
function (i, name) {
class2type['[object ' + name + ']'] = name.toLowerCase()
})

type: function(obj) {
if (obj == null) {
return obj + ''
}
return typeof obj === 'object' || typeof obj === 'function' ?
class2type[Object.prototype.toString.call(obj)] || 'object' :
typeof obj
}

isFunction: function(obj) {
return jQuery.type(obj) === 'function'
}

基本类型直接使用 typeof,引用类型使用 Object.prototype.toString.call 取得类型,借助一个 class2type 对象将字符串多余的代码过滤掉,例如 [object Function] 将得到 array,然后在后面的类型判断,如 isFunction 直接可以使用 jQuery.type(obj) === 'function' 这样的判断

空对象检测

最后我们来看一种比较特殊的情况,那就是空对象检测,通常而言一般有下面几种方式

for-in

一般最为常见的思路就是使用 for-in 遍历属性,为真则为非空数组,否则为空数组

1
2
3
4
5
6
7
// 如果不为空,则会执行到这一步,返回 true
for (var i in obj) {
return true
}

// 如果为空,返回 false
return false
JSON.stringify()

通过 JSON 自带的 stringify() 方法来判断,JSON.stringify() 方法用于将 JavaScript 值转换为 JSON 字符串,所以一般可以直接与 {} 来进行比较

1
2
3
4
5
6
7
if (JSON.stringify(data) === '{}') {
// 如果为空,返回 false
return false
}

// 如果不为空,则会执行到这一步,返回 true
return true
Object.keys()

ES6 新增的 Object.keys() 方法会返回所有可枚举属性,不包括原型中属性和 Symbol 属性,如果我们的对象为空,它会返回一个空数组

1
2
3
var a = {}

Object.keys(a) // []
isEmptyObject()

jQuery 当中提供了 isEmptyObject() 方法用来判断空对象,但是其本质也是通过 for-in 循环来进行判断的,源码如下

1
2
3
4
5
6
7
isEmptyObject: function(obj) {
var name
for (name in obj) {
return false
}
return true
}

这里有个需要注意的地方,就是我们在上面提及的这几个方法均不能检测出非枚举属性,比如如下示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var obj = {}

Object.defineProperty(obj, 'key', {
enumerable: false,
configurable: false,
writable: false,
value: '123'
})


for (var i in obj) {
console.log(i)
}

JSON.stringify(obj) === '{}' // true

Object.keys(obj) // []

$.isEmptyObject(obj) // true

所以我们还需要寻找一些其它方法来进行解决,接着往下看

Object.getOwnPropertyNames()

该方法会返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括 Symbol 值作为名称的属性)组成的数组

1
2
3
4
5
6
7
8
9
let obj = {}

Object.getOwnPropertyNames(obj) // []

// ES5 版本当中参数不是基本类型会引起错误
Object.getOwnPropertyNames('foo') // TypeError: 'foo' is not an object

// ES6 版本当中非对象参数会被强制转换为对象
Object.getOwnPropertyNames('foo') // ['length', '0', '1', '2']

利用这个方法可以检测出非枚举属性

1
2
3
4
5
6
7
8
9
10
var obj = {}

Object.defineProperty(obj, 'key', {
enumerable: false,
configurable: false,
writable: false,
value: '123'
})

Object.getOwnPropertyNames(obj) // ['key']
Reflect.ownKeys(obj)

这个是个无敌的方法,返回所有的自身属性,不管是否可枚举,不管是不是 Symbol,一律返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var test = Symbol()
var obj = {
[test]: 123
}

Object.defineProperty(obj, 'key', {
enumerable: false,
configurable: false,
writable: false,
value: '123'
})

Object.getOwnPropertyNames(obj) // ['key']
Object.keys(obj) // []
Reflect.ownKeys(obj) // ['key', Symbol()]

所以简单的总结一下,判断一个变量是不是空对象的比较完善的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
obj !== null
&& typeof obj === 'object'
&& !Array.isArray(obj)
&& (Object.getOwnPropertyNames(obj).length === 0)
&& (Object.getOwnPropertySymbols(obj).length === 0)

// or
(Object.prototype.toString.call(obj) === '[object Object]')
&& (Object.getOwnPropertyNames(obj).length === 0)
&& (Object.getOwnPropertySymbols(obj).length === 0)

// or
(String(obj) === '[object Object]') && (Reflect.ownKeys(obj).length === 0)

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×