现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(五)篇,旨在系统地、逻辑性地把原生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 | Number.MAX_VALUE // 1.7976931348623157e+308 |
乍一看,已经挺大了,用正常的计数法已经读不出来,一旦超过这个值,就会被转换为科学计数法,形如:1.79e+308,且精度上会有误差。
问题来了,什么时候会用这么大的数?比如,前后端进行Long型数据传递的时候,就可能出现这种情况,而且这种情况是需要处理的,在之前,一种常见的方法是把它转换为字符串,逐位进行计算,之后再组合。一些专门用于大数处理的库也是这么做的,比如:big.js。
有了BigInt类型之后,就可辅助处理这种情况。比如,可以像下面这样定义:
1 | 10n,或者调用函数BigInt() |
它类似Number,但也有一些不同,它不能用 Math 对象中的方法;也不能和任何 Number 实例混合运算,两者必须转换成同一种类型。当然,还有一件自然的事情,就是带小数的运算会丢掉小数部分取整。
说了这么多,看看它能够发挥的作用吧。
1 | let testInt = 9007199254740991; |
可以看出,在进行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 | let a = 0.1 + 0.2; |
虽然不是真正解决,但这也提供了一种变相成立的情况是吧。
词法“误会”
已经聊了两个问题,索性继续聊下去,前面的文章里,提到过JavaScript中的一个常见方法,toString(),用于将一切具备这个方法的类型的值转为字符串类型的值。通常情况下,都能得到预期的结果。比如:
1 | let a = 1; |
但是,如果写成:
1 | 1.toString() |
这就有点纠结了,按照书写上的意愿,我们是想把1转换成字符串,但是按照数字的看法,“1.”也能理解为小数的前半部分,这怎么弄。
这就涉及到了词法规则,计算机对于程序,或者说字符的读取,并不会完全按照人的“真正意图”来进行,而是它读到的内容只要符合规则,就按规则来。
JavaScript当中的最小语义单元叫“词”,只要符合词的规则,就构成词。
我们知道,代码当中有空白、换行、注释,然后就是我们写的有具体含义的代码了。
JavaScript词的规则中,十进制的 Number 可以带小数,小数点前后部分都可以省略,但是不能同时省略
1 | .01 |
以上几种写法都是合法的。
那么“1.toString()”中的“1.”就会被当做省略了小数点后面部分的数字来处理,自然就不会得到正确的结果,而是报错。
那正确结果的写法是什么呢?以下两种均可:
1 | 1 .toString() //中间隔了个空格 |
回正轨
在文章开头,为了引入 BigInt,讲了个精度的问题,就直接铺开讲了几个常见问题,从这段开始正常聊聊Number中最常见的属性和方法。
toString()
上面刚看过toString,咱就趁热打铁接着聊。
toString是很多对象的原型上都会有的通用方法,但在通用的前提下,每种类型可能有自己独特的作用,比如,Number类型的toString支持传入一个参数(radix),表示以“几进制”来转换数字。
1 | let a = 10; |
上面代码已经能够简单看到效果了,但进制的总体要比我们常用的更多。比如,我们所熟知的,CSS当中的色值,就有十六进制表示。#FFF 表示白色。所以,如果转换的基数大于10,则会使用字母来表示大于9的数字。
1 | a.toString(16) //"a" 嗯,这真的是个巧合~ |
parseInt()/parseFloat()
这两个方法就很好理解了,Number上的这两个方法和全局对象的方法没有不同,通常用于将字符串转换为数字。
看一下效果:
1 | let a = "1.5"; |
但它的处理机制不止这么简单,再看
1 | let a = "1.5b"; |
当处理的字符串是“数字+其他字符”的时候,它们会把后面的值给砍掉,返回前面的数字。
这在一些需要输入数字的场景下很有用,比如,有个表单输入框的价格,限制只能输入数字,而不能输入其他字符,就派上了用场。
凡事有个但是,如果字符加在了前面呢?1
2
3let a = "c1.5b";
parseInt(a); // NaN
parseFloat(a); // NaN
这就真的无能为力了…
慢着,前面好像说到限制表单输入,你说,我何必用这些方法,我用input的 number 类型不就完了么。理想很丰满,现实很骨感,input的 number 交互体验不完美不说,它绑定的值也是被处理成字符串的,而不是 Number。
除了限制输入只能是数字之外,有时还会限制输入几位小数,怎么办?不用怕,也是有方法的。
toFixed()
toFixed()就是用来保留小数位数的,称为“定点表示法”,直接看:
1 | let a = 1.5; |
嚯,这一看不打紧,发现两个问题。
不传值默认不保留小数位,这很明显就不说了。重点在于另外两项:
- 返回值是字符串类型,而不是数字
- 当小数位为5(或者大于5),结果会进行四舍五入。
可以再验证一下。
1 | let a = 1.4; |
第一点还没什么,稍加注意就好,第二点显然就会出问题,如果你限制输入两位小数,在输入框里输入“14.56”,然后多输了一个数字“14.565”,就会被处理成“14.57”,看起来一个数字之差,也是不该被允许的。所以,在便利之余,这是它的一点小瑕疵。
如何处理呢,还记得前面我们处理“0.1+0.2”问题的方法吗?用在这个地方更合适,即,保留几位小数,就先乘以“10的n倍”,再除以“10的n倍”,就可以得到原本的数字了。
toPrecision()
跟toFixed()效果类似的一个方法是保留值的精度,或者换个说法,指定数字的有效位数。
1 | let a = 1.4; |
它也有个同样的问题,就是四舍五入,会把“1.6”保留为“2”,但作为保留几位数的常规定义来说,好像又是合理的,所以,只需要在使用的时候注意一下就好。
至此,常用的数字处理方法介绍差不多了,再看两个数字判断的方法。
数字判定
isNaN()
做程序处理的时候,总会遇到异常情况,预期是数字,或者能转换成数字的,如果真的不能,就可能是 NaN,这时候,用其他的值或者方法来判断NaN是不凑效的,isNaN()方法就派上用场了。
1 | Number.isNaN(Number('a')) // true |
isInteger()
某些数值只适合用整数表示,需要有个整数判定方法,如果用常规方法,可以先看是不是数字,然后看是不是小数,而用isInteger()只需一步。1
2let 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 | Math.round(1.4) // 1 |
“四舍五入”本身不是问题,只是某些场景的默认处理不合适罢了,这个算法是很经典的算法,在很多方面都能发挥作用,虽然如此,还有一种它不适合的场景,比如下一位。
Math.ceil(x)/Math.floor(x)
有时候,我们需要对计算的结果做取整处理,比如返回的小数有“1.4、1.6”,如果大于1的都按2处理,或者小于2的都按1处理,就要用到取整算法。
1 | //向上 |
初记可能总记不住,只需要记得floor有“地面、地板”的意思,自然就是向下了~
Math.max()/Math.min()
如果对Math对象不熟悉,碰到一组数据需要取到它们当中的最大或者最小值,你可能会写一个逐项比较的算法取结果,但这样复杂度是最高的,显得不太划算,有这么个好方法就要善用。
1 | let a = [1,2,3,4,5]; |
PS:此方法只能直接处理数字,此处用展开运算符将数组中的数值进行了展开处理。
Math.pow()
这个方法前面已经见过面了,求数值的多少次方。而且我们也提过,现在有了新的运算符来做这件事,即’**‘。
比如2的3次方可写成“2**3”,不再赘述。
Math.random()
Math中最后一个常用方法就是random(),也就是“随机数”。
很多时候,有规律会显得整齐、美观,但有些时候无规律更显自然和多样。
random()方法本身只会返回从0到1中间的一个数字,但如果发挥想象,它就能做很多有用的事。
比如,0到1可理解为比例,百分之几,那么就可以输出任意两个数中间的数,先算出差值,再用随机数乘以差值,加上其中一方。
1 | w = m - n; |
或者,有时我们需要进行无规律的重复,球弹的高度,雪花飘落的速度和距离等,就可以先定一个值,再用随机数与之相乘,就能控制从0%到100%不同范围的值随机出现。
至此,Math对象的介绍也告一段落,Math中还有很多其他方法,比如三角函数等,鉴于使用场景比较特殊,这里就不详述,如有需要,可自行深入研究。
总结
编程中数字的话题,说小也小,小到我们使用它的时候就顺手一写的事儿,但说大也大,拿之前说的输入框限制输入几位小数,就需要把每种输入的可能性都考虑到才能做到没有bug,终归,编程是个细活儿,不论工具箱有多丰富和强大,还是要使用它的人思路清晰、思维缜密,才能写出bug更少的代码。
这是第五篇了,行程将将过半,后面也会逐渐进入深水区,为大家揭开一些看似复杂概念的神秘面纱,我们继续加油!~