你真理解JavaScript数据类型了?

1,786 阅读16分钟

前言

看文章前,首先问自己两个问题。

  1. 为什么有时候数字会失真?比如0.1 + 0.2 !== 0.3
  2. 能够说清楚JavaScript的强制类型转换吗?

如果无法回答这两个问题,那大家可以仔细往下看,本文将仔细讲解数据类型中比较疑难的这两个问题

精度丢失

Number类型,大家最关心的一个问题之一就是精度丢失,因为在开发中经常会遇到计算不精确的问题,比如 0.1 + 0.2 !== 0.3 。这是什么原因?我们来研究一下。

原因

查阅资料知道,在JavaScriptNumber 类型使用 IEEE754 标准来表示整数和浮点数值。所谓 IEEE754 标准,全称 IEEE 二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容。

IEEE754 中,规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度、与延伸双精确度。像 ECMAScript 采用的就是双精确度,即会用 64 位来储存一个浮点数。

所以说,0.10.2会以64位二进制来储存,需要将0.10.2先转成二进制:

  • 0.1 转二进制为 0.000110011001100....
  • 0.2 转二进制为 0.0011001100110011....

可以看出0.10.2转成二进制都是无限循环的。

那在JavaScript如何存储无限循环的值?这又要提到IEEE754了,这个标准认为一个浮点数可以表示为:

Value = sign * exponent * fraction。

简单来讲就是科学计数法,也就是说0.1的二进制0.000110011001100....会以科学计数法的方式存储。表示为

1 * 2^-4 * 1.1001100110011……

该标准只做二进制科学计数法的表示时,这个 Value 的会被更具体的变成公式:

V = (-1)^S * (1 + Fraction) * 2^E

来理解一下这个公式:

  • (-1)^S 表示符号位,当 S = 0,V 为正数;当 S = 1,V 为负数。

  •  (1 + Fraction) 因为所有的浮点数都可以表示为 1.xxxx * 2^xxx 的形式,而且 1.XXX 前面部分一定是1,所以只存后面的 xxxxx 。

  • 2^E0.1的二进制的科学计数法为例,E 的值就是 -4,因为 E 既可能是负数,又可能是正数,所以储存这个 E 需要 + bias,储存E + bias

为什么E存储需要+bias ?

因为要存正负数。假如用 8 位来存储 E 这个数,只有正数的话,储存的值的范围是 0 ~ 254,

而如果要储存正负数的话,值的范围就是 -127~127,

在存储的时候,把要存储的数字加上 127,当存 -127 的时候表示存 0,当存 127 的时候表示存 254。

这样就解决了存负数的问题。

对应的,当取值的时候,我们再减去 127。

所以呢,真到实际存储的时候,我们并不会直接存储 E,而是会存储 E + bias,

当用 8 位的时候,这个 bias 就是 127

综上所述,如果要存储一个浮点数,只要存 SFractionE + bias,那具体要分配多少位来存储这些数呢?IEEE754 给出了浮点数值双精度的标准:

  • sign(正负): 会用 1 位存储 S,0 表示正数,1 表示负数。

  • exponent(E + bias): 用 11 位存储 E + bias,对于 11 位来说,bias 的值是 2^(11-1) - 1,也就是 1023。

  • Fraction: 用 52 位存储 Fraction。

按这样标准,以0.1的二进制为例可以表示成:

1 * 1.1001100110011…… * 2^-4

Sign0E + bias-4 + 1023 = 10191019 用二进制表示是 1111111011Fraction1001100110011……

按照这个标准,0.1的64位的完整表示就是

0 01111111011 1001100110011001100110011001100110011001100110011010

同样的,0.2表示为:

0 01111111100 1001100110011001100110011001100110011001100110011010

所以可以看到在这一步精度就开始丢失了,0.10.2的尾部都被截取掉了,只存到了64为止。

接下来将这两个数相加。运算步骤分为对阶尾数运算规格化舍入处理溢出判断

  • 对阶:阶码调整为相同

0.1: 1.1001100110011... * 2^-4,阶码是 -4

0.2: 1.10011001100110...* 2^-3,阶码是 -3

两个阶码不同,需要调成一样的,调整原则是小阶对大阶,也就是0.1-4 调成 -3,对应变成 0.11001100110011... * 2^-3

  • 尾数计算
0.1100110011001100110011001100110011001100110011001101 
1.1001100110011001100110011001100110011001100110011010 
———————————————————————————————————————————————————————— 
10.0110011001100110011001100110011001100110011001100111

得到的结果为

10.0110011001100110011001100110011001100110011001100111 * 2^-3

  • 规格化

需要把得到的结果规划化,也就是将上面的结果转成

1.0011001100110011001100110011001100110011001100110011(1) * 2^-2

括号里的 1 意思是超出位数了,所以需要做舍入处理了

  • 舍入处理

在二进制中,舍入的规则是0舍1入。因为括号中是1,所以要进一位,也就是变成

1.0011001100110011001100110011001100110011001100110100 * 2^-2

  • 溢出判断

因为这里没溢出就不做处理

所以0.1 + 0,2最终的结果会被存成

0 01111111101 0011001100110011001100110011001100110011001100110100

将它转换为十进制数会变成 0.30000000000000004440892098500626

所以两次(0.10.2)存储时的精度丢失加上一次运算时的精度丢失,最终导致了 0.1 + 0.2 !== 0.3

我们来总结一下:因为JavaScriptNumber类型使用的是IEEE754标准,所以在储存时可能会导致精度丢失,并且在运算时可能会进行舍入处理导致第二次精度丢失,最终可能得不到我们预料的结果。所以在开发过程中进行数字运算包括(+、-、*、/)时要十分注意精度丢失的问题导致BUG

解决方案

建议采用一些成熟的库来处理js的精度丢失问题。

为什么这样建议?
因为在实践中证明团队不统一用一种正确地方式处理精度丢失问题,而是各自处理会经常出问题。

比如:decimal.jsbig-numberbig.js,关于这三者的区别,可以参考这个链接

类型转换

强制类型转换在JavaScript是最令人头疼的问题之一,它经常被人认为是JavaScript设计上有问题,应该避而远之。但是当你真正理解它之后,就会改变看法。接下来我们来深入理解一下JavaScript类型转换。

将值从一种类型显示的转成另一种类型称为类型转换,这是显示的情况;一些内置的或者隐蔽的情况被称为强制类型转换

通常我们会称类型转换显示类型转换,而强制类型转换我们称之为隐式类型转换

显示类型转换

下面来看一下类型之间的显示转换实例,这些情况是根据JavaScript内置的抽象操作来进行的。

ToString

基本类型的值转成字符串:

String(null) // null
String(undefined) // undefined
String(false) // false
String(true) // true 
String(0) // 0 
String(-0) // 0 
String(NaN) // NaN
String(Infinity) // Infinity
String(-Infinity) // -Infinity 
String(1) // 1

对普通对象来说,除非自行定义,否则toString()Object.prototype.toString())返回内部属性[[Class]]的值,如"[object Object]"。

如果对象有自己的toString()方法,字符串化时就会调用该方法并使用其返回值。

ToNumber

基本类型的值转成数字类型:

Number(null) // +0 
Number(undefined) // NaN 
Number(false) // +0 
Number(true) // 1 
Number("1") // 1
Number("-1") // -1 
Number("0001") // 1
Number("") // 0
Number(" ") // 0
Number("1 1") // NaN 
Number("南墨") // NaN 

ToBoolean

基本类型的值转成布尔值类型:

如下例:

Boolean(false) // false
Boolean(undefined) // false
Boolean(null) // false
Boolean(+0) // false
Boolean(-0) // false
Boolean(NaN) // false
Boolean("") // false

除了这些,其他都会被转为true

虽然Boolean(..)是显式的,但并不常用,显式强制类型转换为布尔值最常用的方法是!!

const a = [];
const b = {};

const c = false;
const d = undefined;
const e = null;
const f = 0;
const g = NaN;
consg h = "";

!!a // true
!!b // true

!!c // false
!!d // false
!!e // false
!!f // false
!!g // false
!!g // false

建议使用Boolean(..)!!来进行显式转换以便让代码更清晰易读

ToPrimitive

对象转数字

对象转成Number类型,需要进行抽象操作ToPrimitive,步骤如下:

  • ValueOf: 看看有没valueOf方法,如果有并且返回的是非数字的基本类型值,那就把它转换为Number类型再返回,否则就直接使用该值进行类型转换。

  • toString:如果第一步无法转换,就会看toString()是否存在值,如果存在值把它转换为Number类型然后用该值进行类型转换。

  • TypeError: 如果不能返回基本类型的值,那么就会报TypeError

来看一些例子

console.log(Number({})) // NaN
console.log(Number([])) // 0
console.log(Number({name : '南墨'})) //  NaN
console.log(Number([0])) // 0
console.log(Number([1,1])) // NaN
console.log(Number(new Date(2023, 0, 1))) // 1672502400000
  1. Number({}),为什么会输出0

根据ToPrimitive方法,先查看valueOf返回{},不是基本类型,转而查toStrong()返回'[object Object]',是基本类型,将'[object Object]'ToNumber转成 NaN,所以得到了NaN

  1. Number([]),为什么会输出0

根据ToPrimitive方法,先查看valueOf返回空数组,不是基本类型,转而查toStrong()返回'',再将''ToNumber,返回了 0,所以得到了0

其他同理

对象转字符串

对象转成String类型,需要进行抽象操作ToPrimitive,步骤如下:

  • toString

  • 看看有没toString方法,如果有并且返回的是非字符串的基本类型值,那就把它转换为String类型再返回,否则就直接使用该值进行类型转换。

  • ValueOf: 如果第一步无法转换,就会看ValueOf()是否存在值,如果存在值把它转换为String类型,然后用该值进行类型转换。

  • TypeError: 如果不能返回基本类型的值,那么就会报TypeError

基本与转成Number类似,不再赘述。

隐式类型转换

前面提到过比较隐蔽的类型转换是隐式类型转换。大家可能会觉得隐式类型转换晦涩难懂,就选择退而求其次,只使用显示类型转换。我们理解完隐式类型转换后,就会明白他们不仅相辅相成,而且有助于提升代码可读性。

接下来,讲解一下所有隐式转换具体的例子。

一元操作符 +

根据ES规范1.4.6,当 + 单独放在一个类型的前面的时候,是一元操作符,相当于调用了ToNumber

来看几个例子

consle.log(+[]) // 0 
console.log(+{}) // NaN

因为+一元操作符相等于调用了ToNumber。所以例子相当于变成了

consle.log(Number([])) // 0 
console.log(Number({})) // NaN

ToPrimitive那一节清楚的解释该例子的转换过程。

二元操作符 +

关于二元操作符,我们直接来看两个例子。

  • 例子1:
var a = "123"
var b = "0"

var c = 123
var d = 0

a + b // "1230"
c + d // 123

字符串相加得到"1230",数字相加得到预想的结果。 正常来说我们会认为:操作值如果是字符就拼接,如果是数字那就进行运算。

其实没这么简单,比如说

var a = [1,1]
var b = [2,2]
a + b // 1,12,2

这就解释不通 + 的规律。那这到底是为什么?

根据ES5规范11.6.1节,以下两种情况操作值会进行拼接

  1. 操作值是字符串就拼接
  2. 操作值能够通过ToPrimitive抽象操作转成字符串(ToPrimitive那一节讲过)就拼接。

上述例子中的[1,1]、[2,2]是数组并且进行+运算,说明操作值要转成数字,所以说会先看数组[1,1]、[2,2]有没有ValueOf,因为数组的ValueOf()得不到简单的基本类型。于是转而调用toString(),发现可以返回值,因此上述的两个值变成了'1,1''2,2',所以将它们拼接起来返回了1,12,2

  • 例子2:

来看个奇怪的例子

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

是不是有点摸不着头脑。

  • 我们来捋捋,先来看看[] + {}:

    • []: 操作符是+ 说明是想进行运算,根据要ToToPrimitive转成数字,先看valueOf(), 返回[]不是基本类型转而看toString()返回 ''
    • {}: 同理,先看valueOf() 返回{},不是基本类型转而看toString()返回 [object object]
    • '' + [object object] 得到[object object]

这一个符合我们的逻辑。

  • {} + [],同样的分析一下,最后会是[object object] + '' 得出的结果也是[object object]。实际输出0,这到底是为什么? 原因是 {} + []中,{}被当做一个独立的代码块(不执行任何操作)。而在console.log中真正输出的是+[]的值,+[]前面讲过会得到0,所以才变成了0

看完了这两个例子,来总结一下,如果+运算其中一个操作值是字符串(或能转成字符串),前后就会被拼接起来;否则就进行数字运算。

隐式转为布尔值

在开发过程中,我们经常会使用到这种隐式转换,我们来看一些例子

if(..) 语句的隐式转换

var a = 10
if (!!a) { ... }

三目运算

var b = '1'
var c = '南墨';
var d = 'wzx';
var e;

e = b ? c : d;

逻辑运算符 &&||

var a = 'a'
var b = 'b'
var c = 'c'

if ((a && b) || c) {
    console.log('ok,输出成功')
}

上面的情况都会被隐式的转为boolean值方便判断。我认为这是十分方便的。

宽松相等 == 与 严格相等===

相信很多人在刚刚开始区分 =====时,会认为 == 只比较值是否相等,而 === 比较值与类型是否同时相等。

其实不然,应该是==允许在相等比较中进行隐式类型转换,而 === 不允许。

null和undefined的 == 比较

ES5规范11.9.3.2-3规定,nullundefined== 比较:

  • 如果xnull, yundefined,则结果为true
  • 如果xundefined, y null `,则结果为true。

也就是说在==中null和undefined相等。

null == undefined // true

字符串和数字的 == 比较

ES5规范11.9.3.4-5这样定义,字符串和数字的 == 比较:

  • 如果Type(x)是数字,Type(y)是字符串,则返回x == ToNumber(y)的结果。
  • 如果Type(x)是字符串,Type(y)是数字,则返回ToNumber(x) == y的结果。

简单来说就是,字符串和数字的 == 比较,都是把字符串转成数字再进行比较。

来看个简单的例子:

var a = '123'
var b = 123

a == b // true
a === b // false

a == b 返回true 是因为a被隐式转成了数字123 所以就等于123,而a=== b 没有进行隐式转换,所以返回false, ab 不相等。

其他类型和布尔类型的 == 比较

规范11.9.3.6-7是这样说的:

  • 如果Type(x)是布尔类型,则返回ToNumber(x) == y的结果;

  • 如果Type(y)是布尔类型,则返回x == ToNumber(y)的结果。

总之就是,将布尔值转成数字再进行比较。来看两个例子。

例子1:

var a = true;
var b = '123'

a === b // false

来看下怎么返回false的:

  • 根据上述规则,先将a转成数字
  • 再根据ToNumber的规则,true会被转成 1
  • 1'123'比较,变成数字与字符串的比较,所以根据数字与字符串的比较规则将'123'转成123
  • 1123不相等,返回 false

例子2:

var a = false
var b = '123'
a === b // false

从这个例子可以看出,字符串"123"既不等于true,也不等于false。一个值怎么可以既非真值也非假值,这是为什么?

因为"123" == true中并没有发生布尔值的比较和隐式类型转换,即"123"没转换为true,而是true转换为1,所以"123"是真值还是假值与==本身没有关系。

这里很容易误解,所以说建议无论什么情况下都不要使用== true== false

如果你需要判断a是一个真假值,我建议这样写

// 不错

if (Boolean(a)) {
    ...
}
// 最佳
if (!!a) {
    ...
}

这样可以避免上述的 == true和== false 这些坑了。

对象和非对象之间的相等比较

ES5规范11.9.3.8-9做如下规定:

  • 如果Type(x)是字符串或数字,Type(y)是对象,则返回x == ToPrimitive(y)的结果;
  • 如果Type(x)是对象,Type(y)是字符串或数字,则返回ToPrimitive(x) == y的结果。

也就是说会把对象通过ToPrimitive进行转换再进行对比。

例如:

var num = 18
var arr = [18]
num == arr // true

根据上述规则,[18] 会先通过ToPrimitive,转成"18""18"在与18相比,"18"又转成了18,所以最终返回true

特殊情况

下面两个例子都是更改内置原生原型了

例子1:

Number.prototype.valueOf = function() {
    return 3;
}

new Number(2) == 3 // true

2 == 3不会有这种问题,因为23都是数字基本类型值, 不会调用Number.prototype.valueOf()方法。 而Number(2)涉及ToPrimitive强制类型转换,因此会调用valueOf()

这看起来让人觉得JavaScript设计有问题,其实有问题的是写出该代码的人。 我们应该避免这样的写法。

例子2:

if (a == 2 && a == 3) {
    // .. 
}

第一反应:a怎么可能既等于2又等于3

如果让a.valueOf()每次调用都 +1,比如设置一个变量一开始是2,调用a后该变量+1,返回3

var i = 2;
Number.prototype.valueOf = function() {
    return i++; 
}; 
var a = new Number( 42 ); 
if (a == 2 && a == 3) { 
    console.log( "居然可以!" ); 
}

看完这两个例子,我们应该明白千万不要去更改内置原生原型,避免这些奇奇怪怪的情况。

假值的==比较

下面分别列出了常规和非常规的情况:

"" == null // false
"" == undefined // false
"" == NaN // false
"" == 0 // true (1)
"" == [] // true (2)
"" =={} // false

"0" == null // false
"0" == undefined // false
"0" == false // true (3)
"0" == NaN // false
"0" == 0 // true
"0" == "" // false

0 == null // false
0 == undefined // false
0 == NaN // false
0 == [] // true(4)
0 == {} // false

false == null // false
false == undefined // false
false == NaN false
false == 0 // true (5)
false == "" // true (6)
false == [] // true (7)
false == {} // false

以上被我标记的七种情况不好理解,我们来看一下

"" == 0 // true (1)
"" == [] // true (2)

"0" == false // true (3)

0 == [] // true(4)

false == 0 // true (5)
false == "" // true (6)
false == [] // true (7)

(3)(5)(6)(7)都涉及到其他值与布尔值的转换,其实不难理解,根据其他值与布尔值的转换规则就能得出结果。我们应该避免使用布尔值与其他值的 ==比较,所以我们重点看其他三种情况

"" == 0 // true (1)
"" == [] // true (2)
0 == [] // true(4)

这些情况比较特殊,我们一般不会这样写代码,所以要用心记一下这几个情况,以免遇到了感到诧异。

参考文献