初中级前端面试必备之JS数据类型(深入系列)

685 阅读23分钟

JavaScript中有哪些数据类型?

计算机世界中定义的数据类型其实就是为了描述现实世界中存在的事实而定义的。比如我们用人来举例:

  1. 有没有人在房间里?这里的有和没有就是是或者非的概念,在 JS 中对应 Boolean 类型,true 表示是,false 表示非;

  2. 有几个人在房间里?这里的几个表示的是一个量级概念,在 JS 中对应 Number 类型,包含整数和浮点数,还有一些特殊的值,比如:-Infinity 表示负无穷大、+Infinity 表示正无穷大、NaN 表示不是一个数字;

  3. 房间里的这些人都是我的朋友。这是一句陈述语句,这种文本类的信息将会以字符串形式进行存储,在 JS 中对应 String 类型;

  4. 房间里没有人。这里的没有代表无和空的概念,在 JSnullundefined 都可以表示这个意思;

  5. 现实世界中所有人都是独一无二的,这在 JS 中对应 Symbol 类型,表示唯一且不可改变;

  6. Number 所表示的整数是有范围的,超出范围的数据就没法用 Number 表示了,于是 ES10 中提出了一种新的数据类型 BigInt,能表示任何位数的整数;

  7. 以上提到的 BooleanNumberStringnullundefinedSymbolBigInt 等7种类型都是 JavaScript 中的原始类型,还有一种是非原始类型叫做对象类型;比如:一个人是对象,这个人有名字、性别、年龄等;

    let person = {
        name: 'bubuzou',
        sex: 'male',
        age: 26,
    }
    

为什么要区分原始类型和对象类型?他们之间有什么区别?

原始类型的不可变性

在回答这个问题之前,我们先看一下变量在内存中是如何存储的:

let name1 = 'bubuzou'
let name2 = name1.concat('.com')
console.log(name1)  // 'bubuzou'

执行完上面这段代码,我们发现变量 name1 的值还是不变,依然是 bubuzou。这就说明了字符串的不可变性。但是你看了下面的这段代码,你就会产生疑问了:

let name1 = 'bubuzou'
name1 += '.com'
console.log(name1)  // 'bubuzou.com'

你说字符串是不可变的,那现在不是变了嘛? 其实这只是变量的值变了,但是存在内存中的字符串依然不变。这就涉及到变量在内存中的存储了。 在 JavaScript 中,变量在内存中有2种存储方式:存在栈中和存在堆中。那么栈内存和堆内存有啥区别呢?

栈内存:

  • 顺序存储结构,特点是先进后出。就像一个兵乒球盒子一样,兵乒球从外面一个个的放入盒子里,最先取出来的一定是最后放入盒子的那个。
  • 存储空间固定
  • 可以直接操作其保存的值,执行效率高

堆内存:

  • 无序的存储结构
  • 存储空间可以动态变化
  • 无法直接操作其内部的存储,需要通过引用地址操作

了解完变量在内存中的存储方式有2种,那我们继续以上面那串代码为例,画出变量的存储结构图: js_datatype_01-w548 然后我们可以描述下当计算机执行这段代码时候的发生了什么?首先定义了一个变量 name1 并且给其赋值 bubuzou 这个时候就会在内存中开辟一块空间用来存储字符串 bubuzou,然后变量指向了这个内存空间。然后再执行第二行代码 let name2 = name1.concat('.com') 这里的拼接操作其实是产生了一个新字符串 bubuzou.com,所以又会为这个新字符串创建一块新内存,并且把定义的变量 name2 指向这个内存地址。 所以我们看到其实整个操作 bubuzou 这个字符串所在的内存其实是没有变化的,即使在第二段代码中执行了 name1 += '.com' 操作,其实也只是变量 name1 指向了新的字符串 bubuzou.com 而已,旧的字符串 bubuzou 依然存在内存中,不过一段时间后由于该字符串没有被变量所引用,所以会被当成垃圾进行回收,从而释放掉该块内存空间。

从而我们得出结论:原始类型的值都是固定的,而对象类型则是由原始类型的键值对组合成一个复杂的对象;他们在内存中的存储方式是不一样的,原始类型的值直接存在栈内存中,而对象类型的实际值是存在堆内存中的,在栈内存中保存了一份引用地址,这个地址指向堆内存中的实际值,所以对象类型又习惯被叫做引用类型。

想一个问题为什么引用类型的值要存储到堆内存中?能不能存到栈内存中呢?答案一:因为引用类型大小不固定,而栈的大小是固定的,堆空间的大小是可以动态变化的,所以引用类型的值适合存在堆中;答案二:在代码执行过程中需要频繁的切换执行上下文的时候,如果把引用类型的值存到栈中,将会造成非常大的内存开销。

比较

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

let str1 = 'hello'
let str2 = 'hello'
console.log( str1 === str2 ) // true

let person1 = {
    name: 'bubuzou'
}
let person2 = {
    name: 'bubuzou'
}
console.log( person1 === person2 )  // false

我们定义了2个字符串变量和2个对象变量,他们都长一模一样,但是字符串变量会相等,对象变量却不相等。这是因为在 JavaScript 中,原型类型进行比较的时候比较的是存在栈中的值是否相等;而引用类型进行比较的时候,是比较栈内存中的引用地址是否相等。
如上几个变量在内存中的存储模型如图所示: js_datatype_02

复制

变量进行复制的时候,原始类型和引用类型变量也是有区别的,来看下面的代码:

let str1 = 'hello'
let str2 = str1
str2 = 'world'
console.log( str1 ) // 'hello'

js_datatype_02

  1. let str1 = 'hello': 复制前,定义了一个变量 str1,并且给其赋值 hello,这个时候 hello 这个字符串就会在栈内存中被分配一块空间进行存储,然后变量 str1 会指向这个内存地址;
  2. let str2 = str1:复制后,把 str1 的值赋值给 str2,这个时候会在栈中新开辟一块空间用来存储 str2 的值;
  3. str2 = 'world':给 str2 赋值了一个新的字符串 world,那么将新建一块内存用来存储 world,同时 str2 原来的值 hello 的内存空间因为没有变量所引用,所以一段时间后建被当成垃圾回收;
  4. console.log( str1 ):因为 str1str2 的栈内存地址是不一样的,所以即使 str2 的值被改变,也不会影响到 str1

然后我们继续往下,看下引用类型的复制:

let person1 = {
    name: 'bubuzou',
    age: 20
}
let person2 = person1
person2.name = 'bubuzou.com'
console.log( person1.name)  // 'bubuzou.com'

js_datatype_02 原始类型进行复制的时候是变量的值进行重新赋值,而如上图所示:引用类型进行复制的时候是把变量所指向的引用地址进行赋值给新的变量,所以复制后 person1person2 都指向堆内存中的同一个值,所以当改变 person2.name 的时候, person1.name 也会被改变就是这个原因。

值传递和引用传递

先说一下结论,在 JavaScript 中,所有函数的参数传递都是按值进行传递的。看如下代码:

let name = 'bubuzou'
function changeName(name) {
    name = 'bubuzou.com'
}
changeName(name)
console.log( name )  // 'bubuzou'

定义了一个变量 name,并赋值为 bubuzou,函数调用的时候传入 name,这个时候会在函数内部创建一个局部变量 name 并且把全局变量的值 bubuzou 传递给他,这个操作其实是在内存里新建了一块空间用来存放局部变量的值,然后又把局部变量的值改成了 bubuzou.com,这个时候其实内存中会有3块地址空间分别用来存放全局变量的值 bubuzou、局部变量原来的值 bubuzou、和局部变量新的值 bubuzou.com;一旦函数调用结束,局部变量将被销毁,一段时间后由于局部变量新旧值没有变量引用,那这两块空间将被回收释放;所以这个时候全局 name 的值依然是 bubuzou

再来看看引用类型的传参,会不会有所不同呢?

let person = {
    name: 'bubuzou'
}
function changePerosn(person) {
    person.name = 'bubuzou.com'
}
changePerosn( person )
console.log( person.name )  // 'bubuzou.com'

引用类型进行函数传参的时候,会把引用地址复制给局部变量,所以全局的 person 和函数内部的局部变量 person 是指向同一个堆地址的,所以一旦一方改变,另一方也将被改变,所以至此我们是不是可以下结论说:当函数进行传参的时候如果参数是引用类型那么就是引用传递嘛?

将上面的例子改造下:

let person = {
    name: 'bubuzou'
}
function changePerosn(person) {
    person.name = 'bubuzou.com'
    person = {
        name: 'hello world'
    }
}
changePerosn( person )  
console.log( person.name )  // 'bubuzou.com'

如果 person 是引用传递的话,那就会自动指向值被改为 hello world 的新对象;事实上全局变量 person 的引用地址自始至终都没有改变,倒是局部变量 person 的引用地址发生了改变。

null 和 undefined 傻傻分不清?

nullJavaScript 中自成一种原始类型,只有一个值 null,表示无、空、值未知等特殊值。可以直接给一个变量赋值为 null

let s = null

undefinednull 一样也是自成一种原始类型,表示定义了一个变量,但是没有赋值,则这个变量的值就是 undefined:

let s
console.log( s)  // undefined

虽然可以给变量直接赋值为 undefined 也不会报错,但是原则上如果一个变量值未定,或者表示空,则直接赋值为 null 比较合适,不建议给变量赋值 undefinednullundefined 在进行逻辑判断的时候都是会返回 false 的:

let a = null, b
console.log( a ? 'a' : b ? 'b' : 'c') // 'c'

null 在转成数字类型的时候会变成 0,而 undefined 会变成 NaN:

let a = null, b
console.log( +null )  // 0
console.log( + b )  // NaN

认识新的原始类型 Symbol

Symbol 值表示唯一标识符,是 ES6 中新引进的一种原始类型。可以通过 Symbol() 来创建一个重要的值,也可以传入描述值;其唯一性体现在即使是传入一样的描述,他们两者之间也是不会相等的:

let a = Symbol('bubuzou')
let b = Symbol('bubuzou')
console.log( a === b )  // false

全局的 Symbol

那还是不是任意2个描述一样的 Symbol 都是不相等的呢?答案是否定的。可以通过 Symbol.for() 来查找或新建一个 Symbol

let a = Symbol.for('bubuzou')
let b = Symbol.for('bubuzou')
console.log( a === b )  // true

使用 Symbol.for() 可以在根据传入的描述在全局范围内进行查找,如果没找到则新建一个 Symbol,并且返回;所以当执行第二行代码 Symbol.for('bubuzou') 的时候,就会找到全局的那个描述为 bubuzouSymbol,所以这里 ab 是会绝对相等的。

居然可以通过描述找到 Symbol, 那是否可以通过 Symbol 来找到描述呢?答案是肯定的,但是必须是全局的 Symbol,如果没找到则会返回 undefined:

let a = Symbol.for('bubuzou')
let desc = Symbol.keyFor( a )
console.log( desc )  // 'bubuzou'

但是对于任何一个 Symbol 都有一个属性 description,表示这个 Symbol 的描述:

let a = Symbol('bubuzou')
console.log( a.description )  // 'bubuzou'

Symbol 作为对象属性

我们知道对象的属性键可以是字符串,但是不能是 Number 或者 BooleanSymbol 被设计出来其实最大的初衷就是用于对象的属性键:

let age = Symbol('20')
let person = {
    name: 'bubuzou',
    [age]: '20',  // 在对象字面量中使用 `Symbol` 的时候需要使用中括号包起来
}

这里给 person 定义了一个 Symbol 作为属性键的属性,这个相比于用字符串作为属性键有啥好处呢?最明显的好处就是如果这个 person 对象是多个开发者进行开发维护,那么很容易再给 person 添加属性的时候出现同名的,如果是用字符串作为属性键那肯定是冲突了,但是如果用 Symbol 作为属性键,就不会存在这个问题了,因为它是唯一标识符,所以可以使对象的属性受到保护,不会被意外的访问或者重写。

注意一点,如果用 Symbol 作为对象的属性键的时候,for inObject.getOwnPropertyNames、或 Object.keys() 这里循环是无法获取 Symbol 属性键的,但是可以通过 Object.getOwnPropertySymbols() 来获取;在上面的代码基础上:

for (let o in person) {
    console.log( o ) // 'name'
}
console.log (Object.keys( person )) // ['name']
console.log(Object.getOwnPropertyNames( person ))  // ['name']
console.log(Object.getOwnPropertySymbols( person ))  // [Symbol(20)]

你可能不知道的 Number 类型

JavaScript 中的数字涉及到了两种类型:一种是 Number 类型,以 64 位的格式 IEEE-754 存储,也被称为双精度浮点数,就是我们平常使用的数字,其范围是 2522^{52} 到 -2522^{52};第二种类型是 BigInt,能够表示任意长度的整数,包括超出 2522^{52} 到 -2522^{52} 这个范围外的数。这里我们只介绍 Number 数字。

常规数字和特殊数字

对于一个常规的数字,我们直接写即可,比如:

let age = 20

但是还有一种位数特别多的数字我们习惯用科学计数法的表示方法来写:

let billion = 1000000000;
let b = 1e9

以上两种写法是一个意思, 1e9 表示 1 x 10910^9;如果是 1e-3 表示 1 / 10310^3 = 0.001。 在 JavaScript 中也可以用数字表示不同的进制,比如:十进制中的 10 在 二、八和十六进制中可以分别表示成 0b10100o120xa;其中的 0b 是二进制前缀,0o 是八进制前缀,而 ox 是十六进制的前缀。

我们也可以通过 toString(base) 方法来进行进制之间的转换, base 是进制的基数,表示几进制,默认是 10 进制的,会返回一个转换数值的字符串表示。比如:

let num = 10
console.log( num.toString( 2 ))  // '1010'
console.log( num.toString( 8 ))  // '12'
console.log( num.toString( 16 ))  // 'a'

数字也可以直接调用方法,10..toString( 2 ) 这里的 2个 . 号不是写错了,而是必须是2个,否则会报 SyntaxError 错误。第一个点表示小数点,第二个才是调用方法。点符号首先会被认为是数字常量的一部分,其次再被认为是属性访问符,如果只写一个点的话,计算机无法知道这个是表示一个小数呢还是去调用函数。数字直接调用函数还可以有以下几种写法:

(10).toString(2)  // 将10用括号包起来
10.0.toString(2)  // 将10写成10.0的形式
10 .toString(2)   // 空格加上点符号调用

Number 类型除了常规数字之外,还包含了一些特殊的数字:

  • NaN:表示不是一个数字,通常是由不合理的计算导致的结果,比如数字除以字符串 1 / 'a'; NaN 和任何数进行比较都是返回 false,包括他自己: NaN == NaN 会返回 false; 如何判断一个数是不是 NaN 呢?有四种方法:

    方法一:通过 isNaN() 函数,这个方法会对传入的字符串也返回 true,所以判断不准确,不推荐使用:

    isNaN( 1 / 'a')`  // true
    isNaN( 'a' )  // true
    

    方法二:通过 Number.isNaN(),推荐使用:

    Number.isNaN( 1 / 'a')`  // true
    Number.isNaN( 'a' )  // false
    

    方法三:通过 Object.is(a, isNaN):

    Object.is( 0/'a', NaN) // true
    Object.is( 'a', NaN) // false
    

    方法四:通过判断 n !== n,返回 true, 则 nNaN :

    let s = 1/'a'
    console.log( s !== s )  // true
    
  • +Infinity:表示正无穷大,比如 1/0 计算的结果, -Infinity 表示负无穷大,比如 -1/0 的结果。

  • +0-0JavaScript 中的数字都有正负之分,包括零也是这样,他们会绝对相等:

    console.log( +0 === -0 )  // true
    

为什么 0.1 + 0.2 不等于 0.3

console.log( 0.1 + 0.2 == 0.3 )  // false

有没有想过为什么上面的会不相等?因为数字在 JavaScript 内部是用二进制进行存储的,其遵循 IEEE 754 标准的,用 64 位来存储一个数字,64 位又被分隔成 11152 位来分别表示符号位、指数位和尾数位。 js_datatype_05 比如十进制的 0.1 转成二进制后是多少?我们手动计算一下,十进制小数转二进制小数的规则是“乘2取整,顺序排列”,具体做法是:用2乘十进制小数,可以得到积,将积的整数部分取出,再用2乘余下的小数 部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的小数部分为零,或者达到所要求的精度为止。

0.1 * 2 = 0.2  // 第1步:整数为0,小数0.2
0.2 * 2 = 0.4  // 第2步:整数为0,小数0.4
0.4 * 2 = 0.8  // 第3步:整数为0,小数0.8
0.8 * 2 = 1.6  // 第4步:整数为1,小数0.6
0.6 * 2 = 1.2  // 第5步:整数为1,小数0.2
0.2 * 2 = 0.4  // 第6步:整数为0,小数0.4
0.4 * 2 = 0.8  // 第7步:整数为0,小数0.8
...

我们这样依次计算下去之后发现得到整数的顺序排列是 0001100110011001100.... 无限循环,所以理论上十进制的 0.1 转成二进制后会是一个无限小数 0.0001100110011001100...,用科学计数法表示后将是 1.100110011001100... x 242^{-4} ,但是由于 IEEE 754 标准规定了一个数字的存储位数只能是 64 位,有效位数是 52 位,所以将会对 1100110011001100.... 这个无限数字进行舍入总共 52 位作为有效位,然后二进制的末尾取舍规则是看后一位数如果是 1 则进位,如果是 0 则直接舍去。那么由于 1100110011001100.... 这串数字的第 53 位刚好是 1 ,所以最终的会得到的数字是 1100110011001100110011001100110011001100110011001101,即 1.100110011001100110011001100110011001100110011001101 x 242^{-4}。 十进制转二进制也可以用 toString 来进行转化:

console.log( 0.1.toString(2) )  // '0.0001100110011001100110011001100110011001100110011001101'

我们发现十进制的 0.1 在转化成二进制小数的时候发生了精度的丢失,由于进位,它比真实的值更大了。而 0.2 其实也有这样的问题,也会发生精度的丢失,所以实际上 0.1 + 0.2 不会等于 0.3:

console.log( 0.1 + 0.2 )  // 0.30000000000000004

那是不是没办法判断两个小数是否相等了呢?答案肯定是否定的,想要判断2个小数 n1n2 是否相等可以如下操作:

  • 方法一:两小数之差的绝对值如果比 Number.EPSILON 还小,那么说明两数是相等的。

    Number.EPSILONES6 中的误差精度,实际值可以认为等于 2522^{-52}

    if ( Math.abs( n1 - n2 ) < Number.EPSILON ) {
        console.log( 'n1 和 n2 相等' )
    }
    
  • 方法二:通过 toFixed(n) 对结果进行舍入,toFixed() 将会返回字符串,我们可以用 一元加 + 将其转成数字:

    let sum = 0.1 + 0.2
    console.log( +sum.toFixed(2) === 0.3 )  // true
    

数值的转化

对数字进行操作的时候将常常遇到数值的舍入和字符串转数字的问题,这里我们巩固下基础。先来看舍入的:

  • Math.floor(),向下舍入,得到一个整数:

    Math.floor(2.2)  // 2
    Math.floor(2.8)  // 2
    
  • Math.ceil(),向上舍入,得到一个整数:

    Math.ceil(2.2)  // 3
    Math.ceil(2.8)  // 3
    
  • Math.round(),对第一位小数进行四舍五入:

    Math.round(2.26)  // 2
    Math.round(2.46)  // 2
    Math.round(2.50)  // 3
    
  • Number.prototype.toFixed(n),和 Math.round() 一样会进行四舍五入,将数字舍入到小数点后 n 位,并且以字符串的形式返回:

    12..toFixed(2)  // '12.00'
    12.14.toFixed(1)  // '12.1'
    12.15.toFixed(1)  // '12.2'
    

    为什么 6.35.toFixed(1) 会等于 6.3 ?因为 6.35 其实是一个无限小数:

    6.35.toFixed(20)  // "6.34999999999999964473"
    

    所以在 6.35.toFixed(1) 求值的时候会得到 6.3

再来看看字符串转数字的情况:

  • Number(n)+n,直接将 n 进行严格转化:

    Number(' ')  // 0
    console.log( +'') // 0
    Number('010')  // 10
    console.log( +'010' )  // 10
    Number('12a')  // NaN
    console.log( +'12a' )  // NaN
    
  • parseInt(),非严格转化,从左到右解析字符串,遇到非数字就停止解析,并且把解析的数字返回:

    parseInt('12a')  // 12
    parseInt('a12')  // NaN
    parseInt('')  // NaN
    parseInt('0xA')  // 10,0x开头的将会被当成十六进制数
    

    parseInt() 默认是用十进制去解析字符串的,其实他是支持传入第二个参数的,表示要以多少进制的 基数去解析第一个参数:

    parseInt('1010', 2)  // 10
    parseInt('ff', 16)  // 255
    

如何判断一个数是不是整数?介绍两种方法:

  • 方法一:通过 Number.isInteger():

    Number.isInteger(12.0)  // true
    Number.isInteger(12.2)  // false
    
  • 方法二:typeof num == 'number' && num % 1 == 0

    function isInteger(num) {
        return typeof num == 'number' && num % 1 == 0
    }
    

引用类型

除了原始类型外,还有一个特别重要的类型:引用类型。高程里这样描述他:引用类型是一种数据结构, 用于将数据和功能组织在一起。到目前为止,我们看到最多的引用类型就是 Object,创建一个 Object 有两种方式:

  • 方式一:通过 new 操作符:

    let person = new Object()
    person.name = 'bubuzou'
    person.age = 20
    
  • 方式二:通过对象字面量,这是我们最喜欢用的方式:

    let person = {
        name: 'bubuzou',
        age: 20
    }
    

内置的引用类型

除了 Object 外,在 JavaScript 中还有别的内置的引用类型,比如:

  • Array 数组
  • Date 日期
  • RegExp 正则表达式
  • Function 函数

他们的原型链的顶端都会指向 Object:

let d = new Date()
console.log( d.__proto__.__proto__.constructor )  // ƒ Object() { [native code] }

包装类型

先来看一个问题,为什么原始类型的变量没有属性和方法,但是却能够调用方法呢?

let str = 'bubuzou'
str.substring(0, 3)  // 'bub'

因为 JavaScript 为了更好地操作原始类型,设计出了几个对应的包装类型,他们分别是:

  • Boolean
  • Number
  • String

上面那串代码的执行过程其实是这样的:

  1. 创建 String 类型的一个实例;
  2. 在实例上调用指定的方法;
  3. 销毁这个实例

用代码体现一下:

let str = new String('bubuzou')
str.substring(0, 3)
str = null

原始类型调用函数其实就是自动进行了装箱操作,将原始类型转成了包装类型,然后其实原始类型和包装类型是有本质区别的,原始类型是原始值,而包装类型是对象实例:

let str1 = 'bubuzou'
let str2 = new String('bubuzou')
console.log( str1 === str2 )  // fasle
console.log( typeof str1 )  // 'string'
console.log( typeof str2 )  // 'object'

居然有装箱操作,那肯定也有拆箱操作,所谓的拆箱就是包装类型转成原始类型的过程,又叫 ToPromitive,来看下面的例子:

let obj = {
    toString: () => { return 'bubuzou' },
    valueOf: () => { return 20 },
}
console.log( +obj )  // 20
console.log( `${obj}` )  // 'bubuzou'

在拆箱操作的时候,默认会尝试调用包装类型的 toString()valueOf() 方法,对于不同的 hint 调用顺序会有所区别,如果 hintstring 则优先调用 toString(),否则的话,则优先调用 valueOf()。 默认情况下,一个 Object 对象具有 toString()valueOf() 方法:

let obj = {}
console.log( obj.toString() )  // '[object Object]'
console.log( obj.valueOf() )  // {},valueOf会返回对象本身

类型装换

Javascript 是弱类型的语音,所以对变量进行操作的时候经常会发生类型的转换,尤其是隐式类型转换,可能会让代码执行结果出乎意料之外,比如如下的代码你能理解其执行结果嘛?

[] + {}  // '[object Object]'
{} + []  // 0

类型转换规则

所以我们需要知道类型转换的规则,以下整理出一个表格,列出了常见值和类型以及转换之后的结果,仅供参考。

转换前的值转换前类型toBooleantoNumbertoString
trueBoolean-1"true"
falseBoolean-0"false"
nullNullfalse0"null"
undefinedUndefinedfalseNaN"undefined"
123Numbertrue-"123"
InfinityNumbertrue-"Infinity"
0Numberfalse-"0"
NaNNumberfalse-"NaN"
""Stringfalse0-
" "Stringtrue0-
"0"Stringtrue0-
"123"Stringtrue123-
"123abc"StringtrueNaN-
Symbol()SymboltrueTypeErrorTypeError
{}ObjecttrueNaN"[object Object]"
[]Objecttrue0""
["0"]Objecttrue0"0"
["0", "a"]ObjecttrueNaN"0,a"
["0", undefined, "a"]ObjecttrueNan"0,,a"

显示类型转换

我们平时写代码的时候应该尽量让写出来的代码通俗易懂,让别人能阅读后知道你是要做什么,所以在对类型进行判断的时候应该尽量显示的处理。 比如将字符串转成数字,可以这样:

Number( '21' )  // 21
Number( '21.8' )  // 21.8
+'21'  // 21 

将数字显示转成字符串可以这样:

String(21)  // '21'
21..toString()  // '21'

显示转成布尔类型可以这样:

Boolean('21')  // true
Boolean( undefined )  // false
!!NaN  // false
!!'21'  // true

除了以上之外,还有一些关于类型转换的冷门操作,有时候也挺管用的: 直接用一元加操作符获取当前时间的毫秒数:

+new Date()  // 1595517982686

~ 配合 indexOf() 将操作结果直接转成布尔类型:

let str = 'bubuzou.com'
if (~str.indexOf('.com')) {
    console.log( 'str如果包含了.com字符串,则会打印这句话' )
}

使用 ~~ 对字符或数字截取整数,和 Math.floor() 有稍许不同:

~~21.1  // 21
~~-21.9  // -21
~~'1.2a'  // 0
Math.floor( 21.1 )  // 21
Math.floor( -21.9 )  // -22

隐式类型转换

隐式类型转换发生在 JavaScript 的运行时,通常是由某些操作符或语句引起的,有下面这几种情况:

  • 隐式转成布尔类型:

    1. if (..)语句中的条件判断表达式。
    2. for ( .. ; .. ; .. )语句中的条件判断表达式(第二个)。
    3. while (..)do..while(..) 循环中的条件判断表达式。
    4. ? :中的条件判断表达式。
    5. 逻辑运算符 || (逻辑或)和 && (逻辑与)左边的操作数(作为条件判断表达式)
    if (42) {
        console.log(42)
    }
    while ('bubuzou') {
        console.log('bubuzou')
    }
    const c = null ? '存在' : '不存在'  // '不存在'
    

    上例中的非布尔值会被隐式强制类型转换为布尔值以便执行条件判断。 需要特别注意的是 ||&& 操作符。|| 的操作过程是只有当左边的值返回 false 的时候才会对右边进行求值且将它作为最后结果返回,类似 a ? a : b 这种效果:

    const a = 'a' || 'b'  // 'a'
    const b = '' || 'c'  // 'c'
    

    && 的操作过程是只有当左边的值返回 true 的时候才对右边进行求值且将右边的值作为结果返回,类似 a ? b : a 这种效果:

    const a = 'a' && 'b'  // 'b'
    const b = '' && 'c'  // ''
    
  • 数学操作符 - * / 会对非数字类型的会优先转成数字类型,但是对 + 操作符会比较特殊:

    1. 当一侧为 String 类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型。
    2. 当一侧为 Number 类型,另一侧为原始类型,则将原始类型转换为 Number 类型。
    3. 当一侧为 Number 类型,另一侧为引用类型,将引用类型和 Number 类型转换成字符串后拼接。
    42 + 'bubuzou'  // '42bubuzou'
    42 + null  // 42
    42 + true  // 43
    42 + []  // '42'
    42 + {}  // '42[object Object]'
    
  • 宽松相等和严格相等 宽松相等(==)和严格相等(===)在面试的时候经常会被问到,而回答一般是 == 是判断值是否相等,而 === 除了判断值会不会相等之外还会判断类型是否相等,这个答案不完全正确,更好的回答是:== 在比较过程中允许发生隐式类型转换,而 === 不会。 那 == 是怎么进行类型转换的呢?

    1. 数字和字符串比,字符串将转成数字进行比较:

      20 == '20'  // true
      20 === '20'  // false
      
    2. 别的类型和布尔类型比较,布尔类型将首先转成数字进行比较,true 转成数字 1, false 转成数字 0,注意这个是非常容易出错的一个点:

      'bubuzou' == true  // false
      '0' == false  // true
      null == false  // false,
      undefined == false  // false
      [] == true  // false
      ['1']  == true  // true 
      

      所以写代码进行判断的时候一定不要写成 x == truex == false 这种,而应该直接 if (x) 判断。

    3. nullundefined: null == undefined 比较结果是 true,除此之外,nullundefined 和其他任何结果的比较值都为 false。可以认为在 == 的情况下,nullundefined 可以相互的进行隐式类型转换。

      null == undefined // true
      null == '' // false
      null == 0 // false
      null == false // false
      undefined == '' // false
      undefined == 0 // false
      undefined == false // false
      
    4. 原始类型和引用类型比较,引用类型会首先进行 ToPromitive 转成原始类型然后进行比较,规则参考上面介绍的拆箱操作:

      '42'  == [42]  // true
      '1,2,3'  == [1, 2, 3]  // true
      '[object Object]' == {}  // true
      0 == [undefined]  // true
      
    5. 特殊的值

      NaN == NaN  // false
      +0 == -0  // true
      [] == ![]  // true,![]的优先级比==高,所以![]先转成布尔值变成false;即变成[] == false,false再转成数字0,[]转成数字0,所以[] == ![]
      0 == '\n'  // true
      
      

类型检测

用typeof检测原始类型

JavaScript 中有 nullundefinedbooleannumberstringSymbol 等六种原始类型,我们可以用 typeof 来判断值是什么原始类型的,会返回类型的字符串表示:

typeof undefined // 'undefined'
typeof true  // 'boolean'
typeof 42  // 'number'
typeof "42"  // 'string'
typeof Symbol()  // 'symbol'

但是原始类型中有一个例外,typeof null 会得到 'object',所以我们用 typeof 对原始值进行类型判断的时候不能得到一个准确的答案,那如何判断一个值是不是 null 类型的呢?

let o = null
!o && typeof o === 'object' // 用于判断 o 是否是 null 类型

undefinedundeclared 有什么区别?前者是表示在作用域中定义了但是没有赋值的变量,而后者是表示在作用域中没有定义的变量;分别表示 undefined 未定义、undeclared 未声明。

typeof 能够对原始类型进行判断,那是否也能判断引用类型呢?

typeof []  // 'object'
typeof {}  // 'object'
typeof new Date()  // 'object'
typeof new RegExp()  // 'object'
typeof new Function()  // 'function'

从上面的结果我们可以得到这样一个结论: typeof 对引用类型判断的时候只有 function 类型可以正确判断,其他都无法正确判断具体是什么引用类型。

用instanceof检测引用类型

我们知道 typeof 只能对部分原始类型进行检测,对引用类型毫无办法。JavaScript 提供了一个操作符 instanceof,我们来看下他是否能检测引用类型:

[] instanceof Array  // true
[] instanceof Object  // true 

我们发现数组即是 Array 的实例,也是 Object 的实例,因为所以引用类型原型链的终点都是 Object,所以 Array 自然是 Object 的实例。那么我们得出结论:instanceof 用于检测引用类型好像也不是很靠谱的选择。

用toString进行类型检测

我们可以使用 Object.prototype.toString.call() 来检测任何变量值的类型:

Object.prototype.toString.call(true)  // '[object Boolean]'
Object.prototype.toString.call(undefined)  // '[object Undefined]'
Object.prototype.toString.call(null)  // '[object Null]'
Object.prototype.toString.call(20)  // '[object Number]'
Object.prototype.toString.call('bubuzou')  // '[object String]'
Object.prototype.toString.call(Symbol())  // '[object Symbol]'
Object.prototype.toString.call([])  // '[object Array]'
Object.prototype.toString.call({})  // '[object Object]'
Object.prototype.toString.call(function(){})  // '[object Function]'
Object.prototype.toString.call(new Date())  // '[object Date]'
Object.prototype.toString.call(new RegExp())  // '[object RegExp]'
Object.prototype.toString.call(JSON)  // '[object JSON]'
Object.prototype.toString.call(MATH)  // '[object MATH]'
Object.prototype.toString.call(window)  // '[object RegExp]'

参考文章