类型系统,主要讲的是一门编程语言中的各种数据类型以及它们之间的相互转换。讲到类型,就需要提下静态语言、动态语言、强类型语言和弱类型语言。
静态语言 VS 动态语言、强类型语言 VS 弱类型语言
根据声明变量的时候是否需要指定变量类型,可以将编程语言分为静态语言和动态语言。
比如Java
是一门静态语言,在声明一个变量的时候需要先指定其类型:
int num = 1;
String str = "runoob";
而JavaScript
是一门动态语言,在声明变量的时候不需要指定其数据类型,而是在运行过程中JavaScript
引擎自动推断数据类型。也因为JavaScript
是动态语言,变量的类型是动态改变的,所以我们一般说的数据类型是说值的类型,而不是变量的类型。
根据是否支持隐式类型转换,可以将编程语言分为不支持隐式类型转换的强类型语言和支持隐式转换的弱类型语言。
比如Java
是一门强类型语言,运行如下的代码会报错:
int res = 1;
if (res) {
System.out.println("强转成功!");
}else {
System.out.println("强转失败");
}
# 报错:error: incompatible types: int cannot be converted to boolean
而JavaScript
是一门弱类型语言,各种数据类型之间支持隐式转换。
那么,静态语言就是强类型语言,动态语言就是弱类型语言吗?答案见下图:
回到主题,本次主要是讲JavaScript
的类型系统,JavaScript
的类型系统是一个有争论的话题,本文先引用我比较支持的一种。来自周爱民的《JavaScript
的语言精髓与编程实践(第三版)》,内容如下:
JavaScript中存在两套类型系统,其一是基础类型系统(Base types),是由typeof运算来检测的,按照约定,该类型系统包括7种类型(undefined、number、boolean、string、symbol、function和object);其二是对象类型系统(Object types),对象类型系统是“对象基础类型(object)”中的一个分支。
注: 对象类型系统包括:原生对象、宿主对象和引擎扩展对象。点击查看对象类型系统详情
这个观点中的类型分类与ECMAScript
规范中的标准的出入点有两个:
null
属于对象类型,而ECMAScript
中将null
划分在基本数据类型当中function
属于基本数据类型吗,而ECMAScript
将function
作为对象类型中的一个子类型
先说说null
和undefined
,关于两者的区别以及typeof null === 'object'
的原因网上有很多资料,大家比较能接受的一个观点是这是当时的一个设计缺陷,包括JavaScript
的作者后来也承认了这个缺陷,但是如果你接受JavaScript
中是有两套类型系统的,那就能合理化null
和undefined
并存的现象:undefined
表示基本数据类型中的没有,而null
表示对象类型系统中的没有,比如document.getElementById('一个不存在的id') === null
。
再来说说另一个争议点:function
也属于一种基本数据类型,在其它的语言中,function
是独立于类型系统之类的,但是在JavaScript
中,function
是属于Object
的一个子类型,因为JavaScript
中的function
的本质就是一个对象,只是比对象多了一个[[call]]
而已,在V8
中,function
也是用一个对象来表示的。但是typeof
一个函数的话输出的是function
,而且function
足够复杂,并且在JavaScript
中,函数是一等公民,所以在《JavaScript
的语言精髓与编程实践(第三版)》中作者将function
也归属于一种基本数据类型。对此有兴趣的可以参考知乎上的一篇文章:JavaScript里Function也算一种基本类型?
介绍完JavaScript
中的类型系统,现在来看下几种平时开发过程中常见的基本数据类型以及它们之间的转换(本文不涉及Symbol
和BigInt
)。
类型转化涉及的三个方法
在类型转换的过程中,会涉及到ToPrimitive()
、toString()
和valueOf()
三个方法,其中ToPrimitive()
是对象的内部方法,我们无法直接调用,在需要的时候,JavaScript
引擎会自动调用,而toString()
和valueOf()
是Object.prototype
上的方法,然后每个类型(除了Undefined
和Null
)都重写了这两个方法,这三个方法的详细介绍如下:
ToPrimitive(
input [,
PreferredType])
该函数用于将一个值转成一个基本数据类型,它接受两个入参,input
是需要转换的值,PreferredType
可选,可选值有string
、number
和default
(默认值就是default
,default
在函数内部赋值为number
)。当PreferredType = string
的时候,该函数的逻辑如下图所示:
注:图中的原始值是指ECMAScript
中的基本数据类型的值。比如返回值可以是null
。
当PreferredType = number
的时候,该函数的逻辑如下图所示:
toString()
该方法用于返回一个表示该对象的字符串。各种数据类型的toString()
的返回结果见下表:
类型 | 描述 | 例子 |
---|---|---|
String | 返回对象的字符串形式 | str.toString() === 'str' |
Number | 返回对象的字符串形式 | (123).toString() === '123';NaN.toString() === 'NaN' |
Boolean | 根据对象的值返回true 或者false | true.valueOf() === 'true' |
Object | 返回 "[object type]"(type 是对象的类型) | {}.toString() === '[object Object]' |
Array | 返回用逗号连接数组元素的字符串 | [1,2,3].toString() === '1,2,3' |
Function | 返回当前函数源代码的字符串 | function test() {console.log('hello')} test.toString() === 'function test() {console.log('hello')}' |
valueOf()
该方法用于返回值为该对象的原始值,如果对象没有原始值,则返回对象本身。各种数据类型的valueOf()
的返回结果见下表:
类型 | 描述 | 例子 |
---|---|---|
String | 返回String 对象的原始值 | str.toString() === 'str' |
Number | 返回被Number 对象包装的原始值 | 1..valueof() === 1 |
Boolean | 返回Boolean 对象的原始值 | true.valueOf() === true |
Object | 返回对象本身 | obj.valueOf() === obj |
Array | 返回数组本身 | arr.valueOf() === arr |
Function | 返回函数本身 | fn.valueOf === fn |
String
创建字符串
先来看几种我们常用的创建字符串的方式:
const str1 = 'hello' // 用字面量的方式声明一个字符串
const str2 = String(1) // 强转
/**
记得这里的1要加括号,因为.运算符是一个有效的数字字符,会被优先识别为数字字面量的一部分
如果不加括号的时候,JS引擎会将1.0作为一个整体
然后就缺少属性访问运算符'.'来调用toString()而报错Uncaught SyntaxError: Invalid or unexpected token
如果你不觉得奇怪的话,你可以这样写1..toString()
*/
const str = (1).toString()
const str4 = new String('hello')
用字面量的方式创建一个字符串是我们开发中常用的方式,一个小问题,V8
会把str1
是存在栈中还是堆中的呢?
我们常说:基本数据类型存放在栈中,而引用数据类型存在于堆中。其实不然,V8
会把字符串常量存放在堆中,在堆中,有一个叫常量池的区域。这里存放着V8
解析到的字符串常量。
回到上面的代码,当V8
解析到这行代码的时候,会根据该字符串计算出一个hash
值,然后去常量池中查找key
值等于该hash
值的字符串并比较内容是否相同,如果找到了且内容相同就直接返回,不再分配空间。否则,就会在老生代的常量池中申请空间,将该hash
值写入常量池中。
可以通过一个小demo
来测试一下:
从上图可以看到:
- 字符串是存在于老生区的常量池中
- 值相同的字符串会共用一个地址
String
强制/显示类型转换
用String(value)
和value.toStirng()
可以强制类型转换得到一个字符串,先来看看String(value)
的转换规则:
如果value
是个空串,那么直接返回''
;如果value
有值,那么调用内部的ToString()
方法,根据入参value
的类型不同ToString()
有不同的处理方式,ToString()
的转换规则如下:
Undefined
类型,返回"undefiend"
Null
类型,返回'null'
Boolean
类型,如果是true
就返回'true'
,如果是false
就返回'false'
String
类型,直接返回对应的value
即可Number
类型,分多种情况处理,常见的情况如下:
String(NaN) === 'NaN'
String(+0) === '0'
String(-0) === '0'
String(Infinity) === 'Infinity'
String(-1) === '-1' // 强转负数返回值会带上负号
String(1000000000000000000000) // 1e+21
String(0.0000001) // 1e-7
Object
类型,调用ToString()
。 当String()
的入参是一个对象的时候,可以用下面的例子验证下内部调用顺序:
function Student(name = 'unknown', age = 10) {
this.name = name;
this.age = age;
}
Student.prototype.toString = function() {
console.log('调用toString')
return Boolean(1)
}
Student.prototype.valueOf = function() {
console.log('调用valueOf')
return {}
}
const stu = new Student('hello')
console.log(String(stu));
注意:如果没重写对象的toString()
的话,则需要判断是否重写了valueOf()
,如果重写了就调用valueOf()
,如果没有的话就调用父类Object
的toString()
。
那么value.toString()
和String(value)
有什么区别呢?
Null
和Undefined
类型的数据可以作为入参被String()
强转,得到'null'
或者'undefined'
。但是用Null
和Undefined
类型的数据没有toString()
方法(调用toString()
会报错TypeErro: Cannot read property 'toString' of null/undefined
)。toString()
可以进行进制的转换。比如(12).toString(2)
输出'1100'
Number
JavaScript
中的数字只有一种类型,那就是Number
,而Number
是双精度浮点数,一个数字用八个字节表示,其中1位符号位 + 11位指数位 + 52位小数位。也就是说在JavaScript
中没有真正意义上的整数。比如const num = 1
,在num
的小数点后面还有52个0。
而用双精度浮点数最大的问题就是精度丢失,也就是我们常见的那个经典问题:0.1 + 0.2 !== 0.3
。那么在程序里面,我们要怎么避开这个精度丢失的问题让0.1 + 0.2 === 0.3
呢?在ES6
中,在Number
上有个常量EPSILON
(机器精度),Number.EPSILON === 2.220446049250313e-16
,也就是2的负52次方,如果0.1 + 0.2 - 0.3 < Number.EPSILON
为真,就判断0.1 + 0.2 === 0.3
。
Number
中还有一个平时开发中需要注意的小细节是:Number.isNaN()
和isNaN()
不是一个东西,isNaN()
是ES5
中的全局函数,它的判断标准是当入参不是NaN
或者不是数字的时候都会返回true
。而Number.isNaN()
是ES6
中Number
上的函数,它是用来判断入参是否严格等于NaN
的可靠方法,可以参看下MDN
中的介绍:
可以通过下面的代码看下两者的区别:
isNaN({}) // true
Number.isNaN({}) // false
创建Number
看看我们平时创建数字常用方式:
const num1 = 1;
const num2 = Number(true)
还是那个问题,V8
中的Number
类型变量是存放在堆中和栈中,是不是和字符串一样,也存放在堆中的常量池里面?看下图:
从上图可以看到,有的在堆中的常量池中,有的在栈中,那存放规则是什么呢?其实在V8
中,数据分为三种:小整数、浮点数和其他,对于小整数V8
是将它们放在栈中的,而对于非小整数,V8
是存放在堆中的,和字符串一样的处理。
Number显示/强制类型转换
通过Number(value)
强转可以得到一个Number
类型的变量,我们来看下ESMAScript
中Number(value)
的转换规则:
如果没有入参,就直接返回0
,如果有入参,则调用内部的ToNumner()
方法,ToNumber()
的转换规则如下:
Undefined
类型,返回NaN
Null
类型,返回0
Boolean
类型,入参是true
,返回1
;入参是false
,返回0
Number
类型,直接返回入参即可Object
类型,调用ToNumber()
方法String
类型,分多种情况处理,常见的情况如下:
# 入参是字符串的话,情况比较复杂,但是统一的原则就是尽可能的转成功,比如`0001`中的三个`0`可以全部忽略。
Number('-1') // -1
Number('1.2') // 1.2
Number('0001') // 1
Number('-00001') // -1
Number('1 1') // NaN
Number('5E10') // 50000000000
Number('5E21') // 5e+21 指数超过20就输出科学计数法
Number('5E-6') // 0.000005
Number('5E-7') // 5e-7 指数小于-6就用科学计数法
Boolean
Boolean
类型比较简单,直接看下用Boolean(value)
强制转换返回false
的六种情况:
Boolean()
Boolean(Null)
Boolean(Undefinde)
Boolean(0)
Boolean(NaN)
Boolean('')
除了上述几种情况,其他情况下Boolean()
都会返回true
。
包装类
一切皆对象,为了体现这一思想,JavaScript
给除了Null
和Undefined
的基本数据类型都内置了包装类。基本数据类型转对象很简单,调用它们的构造函数即可。
有没有想过为什么像true
或者1
这样的基本数据类型也可以调用一个方法,其实这是V8
内部将基本数据类型转成了对象,这个过程叫做包装。比如,true.toString()
这行代码等价于以下代码:
const tmp = new Boolean(true)
tmp.toString()
tmp = null
与包装相对的是拆封,拆封一般发生需要用到封装对象的基本类型值的时候,比如在隐式类型转化的过程中,如下列代码所示:
const newStr = new String('abc') + 'd' // newStr = 'abcd'
上面的代码等价于new String('abc').valueof() + 'd'
。
需要注意的是我们在开发的过程中,我们尽量少用包装类,尽量让JS
引擎自动处理。比如以下情况使用包装类就会出问题:
const flag = new Boolean(false)
if (flag) {
// 为真,执行逻辑
} else {
// 为假,执行逻辑
}
上述代码走不到else
分支……
隐式类型转换
JavaScript
的隐式类型转换发生在很多情况下,下面讲下我平时开发过程中常见的几种会发生隐式类型转换的场景。
&&
和 ||
&&
先对第一个操作数进行判断,如果不是Boolean
类型,就调用ToBoolean()
进行类型转换,如果为false
就返回第一个操作数的值,如果为true
就返回第二个操作数的值。
||
也是先对第一个操作数进行判断,如果不是Boolean
类型,就调用ToBoolean()
进行类型转换,如果为true
就返回第一个操作数的值,如果为false
就返回第二个操作数的值。
可以看到&&
和||
的返回值不是Boolean
类型,比如'hello' && 123 === 123
。平时我们可以用它进行判断,是因为if()
的时候发生了隐式类型转换。
if
if
的布尔判断比较简单,和Boolean()
是一样的逻辑,底层都是调用的ToBoolean()
。
!
和!!
!
是对操作数进行取反操作:调用ToBoolean()
对操作数进行强制转换然后取反。
!!
是将操作数转成Boolean
类型,!!value
等价于Boolean(value)
。在开发的时候,推荐使用Boolean(value)
显示转换,语义明显,可读性更强。
+
一元操作符
当+
运算符作为一元操作符的时候,表示将操作数转成一个数字,所以会调用ToNumber()
处理该值。
来个例子+[]
等于什么?
- 先调用
valueOf()
,返回的是一个[]
valueOf()
的返回值不是一个原始值,继续调用toString()
toString()
的返回值是空串''
- 调用
ToNumber('')
,返回0
二元操作符
当+
作为二元操作符计算value1 + value2
的时候,处理逻辑如下:
lprim = ToPrimitive(value1)
rprim = ToPrimitive(value2)
- 如果
lprim
和rprim
中有一个是字符串,那么返回ToString(lprim)
和ToString(rprim)
的拼接结果 - 否则返回
ToNumber(lprim)
和ToNumber(rprim)
的运算结果
来几个例子:
1 + true
/**
1、左右操作数都是原始值
2、两者都不是字符串,执行ToNumber()操作
3、ToNumber(true) = 1 ==> 1 + 1 = 2
*/
[] + {}
/**
1. 执行ToPrimitive([]),[].valueOf()返回的是[]本身,所以调用toString()方法,[].toString()为空串,所以返回''
2. 执行ToPrimitive({}),{}.valueOf()返回的是{}本身,所以调用toString()方法,{}.toString()为'[Object Object]',所以返回'[Object Object]'
3. lprim和rprim返回的都是字符串,执行拼接操作,所以最后的结果是'[Object Object]'
*/
{} + []
/**
分析过程同上,结果和上面的一样,但是chrome的console中执行,发现结果是0
这是因为在chrome的console中,{}放在前面,被当成了一个代码块,所以此时的{} + []等价于+[],所以结果是0
如果加个括号({} + [])就可以得到和[] + {}一样的执行结果了
*/
{} + {}
/**
踩了上面的坑,我们知道第一个{}被当成了代码块,所以{} + {}等价于+ {}
对{}执行ToNumber()操作,得到NaN,所以结果是NaN。
但是chrome的console中输出结果却是'[object Object][object Object]',和({} + {})是一样的结果
这又是怎么回事?感觉{}什么时候被当成代码块什么时候被当成对象有点混乱
个人的建议就是在浏览器的console中测试这些类似的代码的时候,带上()
*/
==
一般我们区分==
和===
的时候是说:==
是比较值是否相等,而===
除了值相等,还需要类型相等,如果这样区分,怎么解释0 == []
呢?准确的区分这两者应该是:
非严格相等==
(loose equals): 允许在比较两个值是否相等的时候进行强制类型转换
严格相等===
(strict equals):不允许在比较两个值是否相等的时候进行强制类型转换
当执行a == b
的时候,a
和b
数据类型不一样的时候,会发生隐式类型转换将两个被比较的值转换为相同类型,然后执行严格相等判断。转换规则可以参考MDN
上的一张表格,如下图:
总结下表格中的转化规则,如果左右操作数数据类型相同,执行严格相等判断,如果类型不同,隐式转换规则如下:
NaN
不等于任何值null == undefined
- 一般而言,根据
ECMAScript
规范,所有的对象都与undefined
和null
都不相等。但是大部分浏览器中的document.all
对象除外,在chrome
等浏览器中typeof document.all === 'undefined'
- 操作数是对象类型,执行
ToPrimitive()
操作 - 操作数是基本数据类型,是非
Number
类型的操作数执行ToNumber()
操作 来几个例子:
console.log(false == undefined)
/**
1、false和undefined都是基本数据类型,执行ToNumber()操作
2、lprim = ToNumber(false) = 0
rprim = ToNumber(undefined) = NaN
3、0 === NaN ? false
*/
console.log(false == [])
/**
1、ToNumber(false); ToPrimitive([])
2、ToNumber(false) = 0
ToPrimitive([]) ==> 调用valueOf() ==> 返回数组本身[]
==> 继续调用toString(),返回空串'',转换成功,结果是'' ==> ToNumber('') = 0
3、0 === 0 ? true
*/
console.log(1 == ['1'])
/**
1、1不需要转,ToPrimitive(['1'])
2、ToPrimitive(['1']) ==> 调用valueOf() ==> 返回数组本身['1']
==> 继续调用toString(),返回字符串'1',转换成功,结果是'1' ==> ToNumber('1') = 1
3、1 === 1 ? true
*/
console.log([] == ![])
/**
1、ToPrimitive([]) 返回0
2、右操作数先取反 ToBoolean([]) = true ==> 取反等于false ==> ToNumber(false) = 0
3、0 === 0 ? true
*/
参考
《你不知道的JavaScript
》
《JavaScript
的语言精髓与编程实践(第三版)》