【轻聊前端】JavaScript中的数字游戏

现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(五)篇,旨在系统地、逻辑性地把原生JavaScript知识分享给大家,帮助各位较为轻松地理清知识体系,更好地理解和记忆,我尽力而为,望不负期待。

数字,即本篇的主角Number,在程序的世界里能代表很多东西——状态、年龄、价格、计数器等,但由于存储机制的原因,Number并不能很健壮地适应所有场景,就会有一些细节问题,此篇文章,我们沿着Number的属性和方法一起讨论一下数字的应用场景、问题及解决方案。

再谈基本类型

在聊变量的那篇文章里,我们说JavaScript中原本有5种基本类型,加上ES6之后引入的Symbol(符号),是6种,但是,还有一种“预备役”的类型——BigInt,目前应该是在“建议推荐标准阶段”,可以按其字面意思理解叫“大整数”。

说“大整数”,得先看看Number,JavaScript中是没有其他语言中的“int、float、double”这些类型的,统一为定义一个Number类型的数字,那么,是因为它不够大?还真是。

精度的“陷阱”

大数

Number能够表示的最大数字是 2的53次方 - 1。咱先看看这个数是多少:

9007199254740991

使用 Number.MAX_SAFE_INTEGER 即最大的安全整数也可获得这个值。

类似地,Number中有这么几个值:

1
2
3
4
Number.MAX_VALUE // 1.7976931348623157e+308
Number.MAX_SAFE_INTEGER // 9007199254740991
Number.MIN_VALUE // 5e-324
Number.MIN_SAFE_INTEGER // -9007199254740991

乍一看,已经挺大了,用正常的计数法已经读不出来,一旦超过这个值,就会被转换为科学计数法,形如:1.79e+308,且精度上会有误差。

问题来了,什么时候会用这么大的数?比如,前后端进行Long型数据传递的时候,就可能出现这种情况,而且这种情况是需要处理的,在之前,一种常见的方法是把它转换为字符串,逐位进行计算,之后再组合。一些专门用于大数处理的库也是这么做的,比如:big.js

有了BigInt类型之后,就可辅助处理这种情况。比如,可以像下面这样定义:

1
10n,或者调用函数BigInt()

它类似Number,但也有一些不同,它不能用 Math 对象中的方法;也不能和任何 Number 实例混合运算,两者必须转换成同一种类型。当然,还有一件自然的事情,就是带小数的运算会丢掉小数部分取整。

说了这么多,看看它能够发挥的作用吧。

1
2
3
let testInt = 9007199254740991;
testInt * 1000000 // 9.007199254740991e+21
BigInt(testInt * 1000000) // 9007199254740990951424n

可以看出,在进行BigInt处理后,数字不再会被“科学化”。但这个类型不能乱用,只有当确定数字会超过 2的53次方时才用,且不宜进行 Number 装换。

小数

既然会有精度问题,涉及大数就很可能涉及小数。比如大家喜闻乐道的“0.1 + 0.2”的问题。

我们在控制台输入这个表达式,会发现:

1
0.1 + 0.2 //    0.30000000000000004

结果并不是0.3,而是很长的一串,末尾还有个4。

不熟的可能想不到,对人来说很容易的运算,计算机居然出错了,什么道理?简要解释一下:

计算机有能够表示的最大值,也有能表示的最小值(大于零的),即上文提到的 Number.MIN_VALUE(5e-324) 。

计算机中的数字都是以二进制存储的,所以要先将 0.1 和 0.2 转化成二进制,对于十进制转二进制,整数部分除二取余,倒序排列,小数部分乘二取整,顺序排列。

0.1 转化为二进制
0.0 0011 0011 0011 0011 0011 0011 … (0011循环)

0.2 转化为二进制
0.0011 0011 0011 0011 0011 0011 0011 … (0011循环)

接下来就是两个二进制数的计算。

二进制数之间的计算规则是:进位“逢二进一”,借位“借一当二”。

最终得到的二进制数是0.010011001100110011001100110011001100110011001100110100

然后又要转成十进制。

二进制转十进制的方法是:小数点后第一位 2 ^ -1,第二位 2 ^ -2,以此类推。

转换后结果是0.30000000000000004,即我们之前看到的值。

既然只要数字够小就会出问题,同理的,“0.3 - 0.2”也有问题,会得到 0.09999999999999998。

光知道问题不行,要解决。

  • 方法一:

既然数字小的情况下出的问题,变大再缩小不就行了?这和CSS中单像素之类效果的实现如出一辙。

1
(0.1*1000+0.2*1000)/1000   // 0.3
  • 方法二:

ES6后,Number新增了一个属性——Number.EPSILON,两个“可表示数”之间的最小间隔。为数字之间的误差提供了一个范围,我们打印出来看看:

1
Number.EPSILON   // 2.220446049250313e-16

这个值刚好等于

1
2**-52    // 2.220446049250313e-16

你可能想到了,仅仅一个范围并不能使得“0.1 + 0.2 == 0.3”真正成立,而是让“忽略误差”之后的布尔成立。

1
2
3
let a = 0.1 + 0.2;
let b = 0.3;
Math.abs(a-b)<Number.EPSILON // true

虽然不是真正解决,但这也提供了一种变相成立的情况是吧。

词法“误会”

已经聊了两个问题,索性继续聊下去,前面的文章里,提到过JavaScript中的一个常见方法,toString(),用于将一切具备这个方法的类型的值转为字符串类型的值。通常情况下,都能得到预期的结果。比如:

1
2
let a = 1;
a.toString() // “1

但是,如果写成:

1
1.toString()

这就有点纠结了,按照书写上的意愿,我们是想把1转换成字符串,但是按照数字的看法,“1.”也能理解为小数的前半部分,这怎么弄。

这就涉及到了词法规则,计算机对于程序,或者说字符的读取,并不会完全按照人的“真正意图”来进行,而是它读到的内容只要符合规则,就按规则来。

JavaScript当中的最小语义单元叫“词”,只要符合词的规则,就构成词。

我们知道,代码当中有空白、换行、注释,然后就是我们写的有具体含义的代码了。

JavaScript词的规则中,十进制的 Number 可以带小数,小数点前后部分都可以省略,但是不能同时省略

1
2
3
.01
1.
1.01

以上几种写法都是合法的。

那么“1.toString()”中的“1.”就会被当做省略了小数点后面部分的数字来处理,自然就不会得到正确的结果,而是报错。

那正确结果的写法是什么呢?以下两种均可:

1
2
1 .toString()  //中间隔了个空格
1..toString() //中间多加一个点符号

回正轨

在文章开头,为了引入 BigInt,讲了个精度的问题,就直接铺开讲了几个常见问题,从这段开始正常聊聊Number中最常见的属性和方法。

toString()

上面刚看过toString,咱就趁热打铁接着聊。

toString是很多对象的原型上都会有的通用方法,但在通用的前提下,每种类型可能有自己独特的作用,比如,Number类型的toString支持传入一个参数(radix),表示以“几进制”来转换数字。

1
2
3
4
let a = 10;
a.toString() // "10" 不传值的时候默认“十进制”
a.toString(2) // "1010" 传了2,就会转换成“二进制”
a.toString(8) // "12" 传了8,就会转换成“八进制”

上面代码已经能够简单看到效果了,但进制的总体要比我们常用的更多。比如,我们所熟知的,CSS当中的色值,就有十六进制表示。#FFF 表示白色。所以,如果转换的基数大于10,则会使用字母来表示大于9的数字。

1
a.toString(16)  //"a"   嗯,这真的是个巧合~

parseInt()/parseFloat()

这两个方法就很好理解了,Number上的这两个方法和全局对象的方法没有不同,通常用于将字符串转换为数字。

看一下效果:

1
2
3
let a = "1.5";
parseInt(a); // 1
parseFloat(a); // 1.5

但它的处理机制不止这么简单,再看

1
2
3
let a = "1.5b";
parseInt(a); // 1
parseFloat(a); // 1.5

当处理的字符串是“数字+其他字符”的时候,它们会把后面的值给砍掉,返回前面的数字。

这在一些需要输入数字的场景下很有用,比如,有个表单输入框的价格,限制只能输入数字,而不能输入其他字符,就派上了用场。

凡事有个但是,如果字符加在了前面呢?

1
2
3
let a = "c1.5b";
parseInt(a); // NaN
parseFloat(a); // NaN

这就真的无能为力了…

慢着,前面好像说到限制表单输入,你说,我何必用这些方法,我用input的 number 类型不就完了么。理想很丰满,现实很骨感,input的 number 交互体验不完美不说,它绑定的值也是被处理成字符串的,而不是 Number。

除了限制输入只能是数字之外,有时还会限制输入几位小数,怎么办?不用怕,也是有方法的。

toFixed()

toFixed()就是用来保留小数位数的,称为“定点表示法”,直接看:

1
2
let a = 1.5;
a.toFixed() // "2"

嚯,这一看不打紧,发现两个问题。

不传值默认不保留小数位,这很明显就不说了。重点在于另外两项:

  • 返回值是字符串类型,而不是数字
  • 当小数位为5(或者大于5),结果会进行四舍五入。

可以再验证一下。

1
2
let a = 1.4;
a.toFixed() // "1"

第一点还没什么,稍加注意就好,第二点显然就会出问题,如果你限制输入两位小数,在输入框里输入“14.56”,然后多输了一个数字“14.565”,就会被处理成“14.57”,看起来一个数字之差,也是不该被允许的。所以,在便利之余,这是它的一点小瑕疵。

如何处理呢,还记得前面我们处理“0.1+0.2”问题的方法吗?用在这个地方更合适,即,保留几位小数,就先乘以“10的n倍”,再除以“10的n倍”,就可以得到原本的数字了。

toPrecision()

跟toFixed()效果类似的一个方法是保留值的精度,或者换个说法,指定数字的有效位数

1
2
3
let a = 1.4;
a.toPrecision(2) // "1.4"
a.toPrecision(1) // "1"

它也有个同样的问题,就是四舍五入,会把“1.6”保留为“2”,但作为保留几位数的常规定义来说,好像又是合理的,所以,只需要在使用的时候注意一下就好。

至此,常用的数字处理方法介绍差不多了,再看两个数字判断的方法。

数字判定

isNaN()

做程序处理的时候,总会遇到异常情况,预期是数字,或者能转换成数字的,如果真的不能,就可能是 NaN,这时候,用其他的值或者方法来判断NaN是不凑效的,isNaN()方法就派上用场了。

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

isInteger()

某些数值只适合用整数表示,需要有个整数判定方法,如果用常规方法,可以先看是不是数字,然后看是不是小数,而用isInteger()只需一步。

1
2
let a = 1.4;
Number.isInteger(a) // false

Math

说完Number数字本身的属性和方法,该来看一下另一个重要角色了,即内置的Math对象。

Math的属性和方法多且强大,我们挑几个常用的说说。

Math.PI

大家知道,PI是数学中的圆周率,读书的时候,我们使用的都是圆周率的近似值“3.14”,而Math方法给我们提供了现成的属性可直接调用Math.PI。

既然是圆周率,当然是会用在圆或者圆弧的场景,比如画一个圆。

也经常会涉及弧度和角度的转换,弧度除以 (Math.PI / 180) 可转换为角度,同理,角度乘以这个数则能转换为弧度。

Math.abs(x)

这个方法在上面已经见到过,我们在比较两个数值的时候,如果只需知道它们的差值,而不在乎谁大谁小,就可用此法。

Math.round(x)

前面聊保留位数的时候,多次提到“四舍五入”,本尊终于出现了。

1
2
Math.round(1.4)    // 1
Math.round(1.6) // 2

“四舍五入”本身不是问题,只是某些场景的默认处理不合适罢了,这个算法是很经典的算法,在很多方面都能发挥作用,虽然如此,还有一种它不适合的场景,比如下一位。

Math.ceil(x)/Math.floor(x)

有时候,我们需要对计算的结果做取整处理,比如返回的小数有“1.4、1.6”,如果大于1的都按2处理,或者小于2的都按1处理,就要用到取整算法。

1
2
3
4
5
6
7
//向上
Math.ceil(1.4) // 2,下同
Math.ceil(1.6)

//向下
Math.floor(1.4) // 1,下同
Math.floor(1.6)

初记可能总记不住,只需要记得floor有“地面、地板”的意思,自然就是向下了~

Math.max()/Math.min()

如果对Math对象不熟悉,碰到一组数据需要取到它们当中的最大或者最小值,你可能会写一个逐项比较的算法取结果,但这样复杂度是最高的,显得不太划算,有这么个好方法就要善用。

1
2
3
let a = [1,2,3,4,5];
Math.max(...a) // 5
Math.min(...a) // 1

PS:此方法只能直接处理数字,此处用展开运算符将数组中的数值进行了展开处理。

Math.pow()

这个方法前面已经见过面了,求数值的多少次方。而且我们也提过,现在有了新的运算符来做这件事,即’**‘。

比如2的3次方可写成“2**3”,不再赘述。

Math.random()

Math中最后一个常用方法就是random(),也就是“随机数”。

很多时候,有规律会显得整齐、美观,但有些时候无规律更显自然和多样。

random()方法本身只会返回从0到1中间的一个数字,但如果发挥想象,它就能做很多有用的事。

比如,0到1可理解为比例,百分之几,那么就可以输出任意两个数中间的数,先算出差值,再用随机数乘以差值,加上其中一方。

1
2
w = m - n;
Math.random()*w + n

或者,有时我们需要进行无规律的重复,球弹的高度,雪花飘落的速度和距离等,就可以先定一个值,再用随机数与之相乘,就能控制从0%到100%不同范围的值随机出现。

至此,Math对象的介绍也告一段落,Math中还有很多其他方法,比如三角函数等,鉴于使用场景比较特殊,这里就不详述,如有需要,可自行深入研究。

总结

编程中数字的话题,说小也小,小到我们使用它的时候就顺手一写的事儿,但说大也大,拿之前说的输入框限制输入几位小数,就需要把每种输入的可能性都考虑到才能做到没有bug,终归,编程是个细活儿,不论工具箱有多丰富和强大,还是要使用它的人思路清晰、思维缜密,才能写出bug更少的代码。

这是第五篇了,行程将将过半,后面也会逐渐进入深水区,为大家揭开一些看似复杂概念的神秘面纱,我们继续加油!~