# 「重学 JavaScript」变量和类型
前端工程师吃饭的家伙,深度、广度一样都不能差。
# 数据类型
7 种基本数据类型:BigInt、Symbol、Undefined、Null、Boolean、Number 和 String
1 种复杂数据类型:Object
# 底层数据结构
通过 V8 的源码尝试分析 Object 的实现:V8 里面所有的数据类型的根父类都是 Object,Object 派生 HeapObject,提供存储基本功能,往下的 JSReceiver 用于原型查找,再往下的 JSObject 就是 JS 里面的 Object,Array/Function/Date 等继承于 JSObject。左边的 FixedArray 是实际存储数据的地方。 在创建一个 JSObject 之前,会先把读到的 Object 的文本属性序列化成 constant_properties ,如下的 data:
var data = {
name: "yin",
age: 18,
"-school-": "high school",
};
会被序列成:
../../v8/src/runtime/runtime-literals.cc 72 constant_properties:
0xdf9ed2aed19: [FixedArray]
– length: 6
[0]: 0x1b5ec69833d1 <String[4]: name>
[1]: 0xdf9ed2aec51 <String[3]: yin>
[2]: 0xdf9ed2aec71 <String[3]: age>
[3]: 18
[4]: 0xdf9ed2aec91 <String[8]: -school->
[5]: 0xdf9ed2aecb1 <String[11]: high school>
它是一个 FixedArray,一共有 6 个元素,由于 data 总共是有 3 个属性,每个属性有一个 key 和一个 value,所以 Array 就有 6 个。第一个元素是第一个 key,第二个元素是第一个 value,第三个元素是第二个 key,第四个元素是第二个 key,依次类推。
Object 提供了一个 Print()的函数,把它用来打印对象的信息非常有帮助。上面的输出有两种类型的数据,一种是 String 类型,第二种是整型类型的。 FixedArray 是 V8 实现的一个类似于数组的类,它表示一段连续的内存。
参考自:https://www.rrfed.com/2017/04/04/chrome-object/ (opens new window)
# Symbol
Symbol 类型在实际开发中的应用、可手动实现一个简单的 Symbol
# 3.1 使用 Symbol 来作为对象属性名(key)
在这之前,我们通常定义或访问对象的属性时都是使用字符串,比如下面的代码:
let obj = {
abc: 123,
hello: "world",
};
obj["abc"]; // 123
obj["hello"]; // 'world'
而现在,Symbol 可同样用于对象属性的定义和访问:
const PROP_NAME = Symbol();
const PROP_AGE = Symbol();
let obj = {
[PROP_NAME]: "一斤代码",
};
obj[PROP_AGE] = 18;
obj[PROP_NAME]; // '一斤代码'
obj[PROP_AGE]; // 18
随之而来的是另一个非常值得注意的问题:就是当使用了 Symbol 作为对象的属性 key 后,在对该对象进行 key 的枚举时,会有什么不同?在实际应用中,我们经常会需要使用 Object.keys() 或者 for...in 来枚举对象的属性名,那在这方面,Symbol 类型的 key 表现的会有什么不同之处呢?来看以下示例代码:
let obj = {
[Symbol("name")]: "一斤代码",
age: 18,
title: "Engineer",
};
Object.keys(obj); // ['age', 'title']
for (let p in obj) {
console.log(p); // 分别会输出:'age' 和 'title'
}
Object.getOwnPropertyNames(obj); // ['age', 'title']
由上代码可知,Symbol 类型的 key 是不能通过 Object.keys()或者 for...in 来枚举的,它未被包含在对象自身的属性名集合 (property names) 之中。所以,利用该特性,我们可以把一些不需要对外操作和访问的属性使用 Symbol 来定义。
也正因为这样一个特性,当使用 JSON.stringify() 将对象转换成 JSON 字符串的时候,Symbol 属性也会被排除在输出内容之外:
JSON.stringify(obj); // {"age":18,"title":"Engineer"}
我们可以利用这一特点来更好的设计我们的数据对象,让“对内操作”和“对外选择性输出”变得更加优雅。
然而,这样的话,我们就没办法获取以 Symbol 方式定义的对象属性了么?非也。还是会有一些专门针对 Symbol 的 API,比如:
// 使用Object的API
Object.getOwnPropertySymbols(obj); // [Symbol(name)]
// 使用新增的反射API
Reflect.ownKeys(obj); // [Symbol(name), 'age', 'title']
# 3.2 使用 Symbol 来替代常量
先来看一下下面的代码,是不是在你的代码里经常会出现?
const TYPE_AUDIO = "AUDIO";
const TYPE_VIDEO = "VIDEO";
const TYPE_IMAGE = "IMAGE";
function handleFileResource(resource) {
switch (resource.type) {
case TYPE_AUDIO:
playAudio(resource);
break;
case TYPE_VIDEO:
playVideo(resource);
break;
case TYPE_IMAGE:
previewImage(resource);
break;
default:
throw new Error("Unknown type of resource");
}
}
如上面的代码中那样,我们经常定义一组常量来代表一种业务逻辑下的几个不同类型,我们通常希望这几个常量之间是唯一的关系,为了保证这一点,我们需要为常量赋一个唯一的值(比如这里的'AUDIO'、'VIDEO'、 'IMAGE'),常量少的时候还算好,但是常量一多,你可能还得花点脑子好好为他们取个好点的名字。
现在有了 Symbol,我们大可不必这么麻烦了:
const TYPE_AUDIO = Symbol();
const TYPE_VIDEO = Symbol();
const TYPE_IMAGE = Symbol();
这样定义,直接就保证了三个常量的值是唯一的了!是不是挺方便的呢。
# 3.3 使用 Symbol 定义类的私有属性/方法
我们知道在 JavaScript 中,是没有如 Java 等面向对象语言的访问控制关键字 private 的,类上所有定义的属性或方法都是可公开访问的。因此这对我们进行 API 的设计时造成了一些困扰。
而有了 Symbol 以及模块化机制,类的私有属性和方法才变成可能。例如:
在文件 a.js 中
const PASSWORD = Symbol();
class Login {
constructor(username, password) {
this.username = username;
this[PASSWORD] = password;
}
checkPassword(pwd) {
return this[PASSWORD] === pwd;
}
}
export default Login;
在文件 b.js 中
import Login from "./a";
const login = new Login("admin", "123456");
login.checkPassword("123456"); // true
login.PASSWORD; // oh!no!
login[PASSWORD]; // oh!no!
login["PASSWORD"]; // oh!no!
由于 Symbol 常量 PASSWORD 被定义在 a.js 所在的模块中,外面的模块获取不到这个 Symbol,也不可能再创建一个一模一样的 Symbol 出来(因为 Symbol 是唯一的),因此这个 PASSWORD 的 Symbol 只能被限制在 a.js 内部使用,所以使用它来定义的类属性是没有办法被模块外访问到的,达到了一个私有化的效果。
# 3.4 手动实现 Symbol
(function() {
var root = this;
var generateName = (function() {
var postfix = 0;
return function(descString) {
postfix++;
return "@@" + descString + "_" + postfix;
};
})();
var SymbolPolyfill = function Symbol(description) {
if (this instanceof SymbolPolyfill)
throw new TypeError("Symbol is not a constructor");
var descString =
description === undefined ? undefined : String(description);
var symbol = Object.create({
toString: function() {
return this.__Name__;
},
valueOf: function() {
return this;
},
});
Object.defineProperties(symbol, {
__Description__: {
value: descString,
writable: false,
enumerable: false,
configurable: false,
},
__Name__: {
value: generateName(descString),
writable: false,
enumerable: false,
configurable: false,
},
});
return symbol;
};
var forMap = {};
Object.defineProperties(SymbolPolyfill, {
for: {
value: function(description) {
var descString =
description === undefined ? undefined : String(description);
return forMap[descString]
? forMap[descString]
: (forMap[descString] = SymbolPolyfill(descString));
},
writable: true,
enumerable: false,
configurable: true,
},
keyFor: {
value: function(symbol) {
for (var key in forMap) {
if (forMap[key] === symbol) return key;
}
},
writable: true,
enumerable: false,
configurable: true,
},
});
root.SymbolPolyfill = SymbolPolyfill;
})();
# 变量在内存中的具体存储形式
# 4.1 栈内存和堆内存
JavaScript 中的变量分为基本类型和引用类型:
- 基本类型是保存在栈内存中的简单数据段,它们的值都有固定的大小,保存在栈空间,通过按值访问
- 引用类型是保存在堆内存中的对象,值大小不固定,栈内存中存放的该对象的访问地址指向堆内存中的对象,JavaScript 不允许直接访问堆内存中的位置,因此操作对象时,实际操作对象的引用
# 4.2 结合代码与图来理解
let a1 = 0; // 栈内存
let a2 = "this is string"; // 栈内存
let a3 = null; // 栈内存
let b = { x: 10 }; // 变量b存在于栈中,{ x: 10 }作为对象存在于堆中
let c = [1, 2, 3]; // 变量c存在于栈中,[1, 2, 3]作为对象存在于堆中
当我们要访问堆内存中的引用数据类型时:
- 从栈中获取该对象的地址引用
- 再从堆内存中取得我们需要的数据
# 4.3 基本类型发生复制行为
let a = 20;
let b = a;
b = 30;
console.log(a); // 20
结合下面图进行理解:
在栈内存中的数据发生复制行为时,系统会自动为新的变量分配一个新值,最后这些变量都是相互独立互不影响的。
# 4.4 引用类型发生复制行为
let a = { x: 10, y: 20 };
let b = a;
b.x = 5;
console.log(a.x); // 5
- 引用类型的复制,同样为新的变量 b 分配一个新的值,保存在栈内存中,不同的是,这个值仅仅是引用类型的一个地址指针
- 他们两个指向同一个值,也就是地址指针相同,在堆内存中访问到的具体对象实际上是同一个
- 因此改变 b.x 时,a.x 也发生了变化,这就是引用类型的特性
结合下图理解:
总结:
# 内置对象和装箱拆箱操作
# 5.1 JS 中的内置函数(对象)
String()、Number()、Boolean()、RegExp()、Date()、Error()、Array()、Function()、Object()、symbol();类似于对象的构造函数
- 这些内置函数构造的变量都是封装了基本类型值的对象如:
var a = new String("abb"); // typeof(a)=object
除了利用 Function() 构造的变量通过 typeof 输出为 function 外其他均为 object
- 为了知道构造的变量的真实类型可以利用:
Object.prototype.toString.call([1, 2, 3]); // "[object,array]"
后面的一个值即为传入参数的类型
- 如果有常量形式(即利用基本数据类型)赋值给变量就不要用该方式来定义变量
# 5.2 装箱
就是把基本类型转变为对应的对象。装箱分为隐式和显示
- 隐式装箱: 每当读取一个基本类型的值时,后台会创建一个该基本类型所对应的对象。在这个基本类型上调用方法,其实是在这个基本类型对象上调用方法。这个基本类型的对象是临时的,它只存在于方法调用那一行代码执行的瞬间,执行方法后立刻被销毁。
let num = 123;
num.toFixed(2); // '123.00'//上方代码在后台的真正步骤为
var c = new Number(123);
c.toFixed(2);
c = null;
创建一个 Number 类型的实例。
在实例上调用方法。
销毁实例。
- 显式装箱: 通过内置对象 Boolean、Object、String 等可以对基本类型进行显示装箱。
var obj = new String("123");
# 5.3 拆箱
拆箱与装箱相反,把对象转变为基本类型的值。拆箱过程内部调用了抽象操作 ToPrimitive 。该操作接受两个参数,第一个参数是要转变的对象,第二个参数 PreferredType 是对象被期待转成的类型。第二个参数不是必须的,默认该参数为 number,即对象被期待转为数字类型
Number 转化为对象
先调用对象自身的 valueOf 方法。如果返回原始类型的值,则直接对该值使用 Number 函数,返回结果。
如果 valueOf 返回的还是对象,继续调用对象自身的 toString 方法。如果 toString 返回原始类型的值,则对该值使用 Number 函数,返回结果。
如果 toString 返回的还是对象,报错。
Number([1]); //1
转换演变:
[1].valueOf(); // [1];
[1].toString(); // '1';Number('1'); //1
String 转化为对象
先调用对象自身的 toString 方法。如果返回原始类型的值,则对该值使用 String 函数,返回结果。
如果 toString 返回的是对象,继续调用 valueOf 方法。如果 valueOf 返回原始类型的值,则对该值使用 String 函数,返回结果。
如果 valueOf 返回的还是对象,报错。
String([1,2]) //"1,2"
转化演变:
[1,2].toString(); //"1,2"
String("1,2"); //"1,2"
Boolean 转化对象
Boolean 转换对象很特别,除了以下六个值转换为 false,其他都为 true
undefined null false 0(包括+0和-0) NaN 空字符串('')
Boolean(undefined) //false
Boolean(null) //false
Boolean(false) //false
Boolean(0) //false
Boolean(NaN) //false
Boolean('') //false
Boolean([]) //true
Boolean({}) //true
Boolean(new Date()) //true
# 值类型和引用类型
# 6.1 声明变量时不同的内存分配
- 原始值:存储在栈(stack)中的简单数据段,也就是说,它们的值直接存储在变量访问的位置。
这是因为这些原始类型占据的空间是固定的,所以可将他们存储在较小的内存区域 – 栈中。这样存储便于迅速查寻变量的值。
- 引用值:存储在堆(heap)中的对象,也就是说,存储在变量处的值是一个指针(point),指向存储对象的内存地址。
这是因为:引用值的大小会改变,所以不能把它放在栈中,否则会降低变量查寻的速度。相反,放在变量的栈空间中的值是该对象存储在堆中的地址。 地址的大小是固定的,所以把它存储在栈中对变量性能无任何负面影响。
# 6.2 不同的内存分配机制也带来了不同的访问机制
在 JS 中是不允许直接访问保存在堆内存中的对象的,所以在访问一个对象时,首先得到的是这个对象在堆内存中的地址,然后再按照这个地址去获得这个对象中的值,这就是传说中的按引用访问。
而原始类型的值则是可以直接访问到的。
# 6.3 复制变量时的不同
原始值:在将一个保存着原始值的变量复制给另一个变量时,会将原始值的副本赋值给新变量,此后这两个变量是完全独立的,他们只是拥有相同的值而已,彼此都是独立的。
引用值:在将一个保存着对象内存地址的变量复制给另一个变量时,会把这个内存地址赋值给新变量,也就是说这两个变量都指向了堆内存中的同一个对象,他们中任何一个作出的改变都会反映在另一个身上。(复制对象时并不会在堆内存中新生成一个一模一样的对象,只是多了一个保存指向这个对象指针的变量罢了)
# 6.4 参数传递的不同(把实参复制给形参的过程)
首先我们应该明确一点:ECMAScript 中所有函数的参数都是按值来传递的。 但是为什么涉及到原始类型与引用类型的值时仍然有区别呢?还不就是因为内存分配时的差别。
原始值:只是把变量里的值传递给参数,之后参数和这个变量互不影响。
引用值:对象变量它里面的值是这个对象在堆内存中的内存地址,这一点你要时刻铭记在心!
因此它传递的值也就是这个内存地址,这也就是为什么函数内部对这个参数的修改会体现在外部的原因了,因为它们都指向同一个对象。
# null 和 undefined 的区别
# 7.1 定义
Null 类型:Null 类型也只有一个特殊的值——null。从逻辑角度来看,null 值表示一个空对象指针。
Undefined 类型:Undefined 类型只有一个值,即特殊的 undefined。在使用 var 声明变量但未对其加以初始化时,这个变量的值就是 undefined。
# 7.2 null 和 undefined 的应用场景
null 表示"没有对象",即该处不应该有值。典型用法是:
- 作为函数的参数,表示该函数的参数不是对象。
- 作为对象原型链的终点。
console.log(null instanceof Object); // false
undefined 表示"缺少值",就是此处应该有一个值,但是还没有定义。典型用法是:
- 变量被声明了,但没有赋值时,就等于 undefined。
- 调用函数时,应该提供的参数没有提供,该参数等于 undefined。
- 对象没有赋值的属性,该属性的值为 undefined。
- 函数没有返回值时,默认返回 undefined。
# 7.3 Number 转换的值
Number(null) 输出为 0, Number(undefined) 输出为 NaN
# 判断数组类型
至少可以说出三种判断 JavaScript 数据类型的方式,以及他们的优缺点,如何准确的判断数组类型
# 8.1 typeof
- 适用场景
typeof
操作符可以准确判断一个变量是否为下面几个原始类型:
typeof "ConardLi"; // string
typeof 123; // number
typeof true; // boolean
typeof Symbol(); // symbol
typeof undefined; // undefined
你还可以用它来判断函数类型:
typeof function() {}; // function
不适用场景
当你用
typeof
来判断引用类型时似乎显得有些乏力了:
typeof []; // object
typeof {}; // object
typeof new Date(); // object
typeof /^\d*$/; // object
除函数外所有的引用类型都会被判定为 object
。
另外 typeof null === 'object'
也会让人感到头痛,这是在 JavaScript
初版就流传下来的 bug
,后面由于修改会造成大量的兼容问题就一直没有被修复...
# 8.2 instanceof
instanceof
操作符可以帮助我们判断引用类型具体是什么类型的对象:
[] instanceof Array; // true
new Date() instanceof Date; // true
new RegExp() instanceof RegExp; // true
我们先来回顾下原型链的几条规则:
- 所有引用类型都具有对象特性,即可以自由扩展属性
- 所有引用类型都具有一个
__proto__
(隐式原型)属性,是一个普通对象 - 所有的函数都具有
prototype
(显式原型)属性,也是一个普通对象 - 所有引用类型
__proto__
值指向它构造函数的prototype
- 当试图得到一个对象的属性时,如果变量本身没有这个属性,则会去他的
__proto__
中去找
[] instanceof Array
实际上是判断 Array.prototype
是否在 []
的原型链上。
所以,使用 instanceof
来检测数据类型,不会很准确,这不是它设计的初衷:
[] instanceof Object // true
function(){} instanceof Object // true
另外,使用 instanceof
也不能检测基本数据类型,所以 instanceof
并不是一个很好的选择。
# 8.3 toString
上面我们在拆箱操作中提到了 toString
函数,我们可以调用它实现从引用类型的转换。
每一个引用类型都有
toString
方法,默认情况下,toString()
方法被每个Object
对象继承。如果此方法在自定义对象中未被覆盖,toString()
返回"[object type]"
,其中type
是对象的类型。
const obj = {};
obj.toString(); // [object Object]
注意,上面提到了 如果此方法在自定义对象中未被覆盖
, toString
才会达到预想的效果,事实上,大部分引用类型比如 Array、Date、RegExp
等都重写了 toString
方法。
我们可以直接调用 Object
原型上未被覆盖的 toString()
方法,使用 call
来改变 this
指向来达到我们想要的效果。
# 8.4 jquery
我们来看看 jquery
源码中如何进行类型判断:
var class2type = {};
jQuery.each(
"Boolean Number String Function Array Date RegExp Object Error Symbol".split(
" "
),
function(i, name) {
class2type["[object " + name + "]"] = name.toLowerCase();
}
);
type: (obj) => {
if (obj == null) {
return obj + "";
}
return typeof obj === "object" || typeof obj === "function"
? class2type[Object.prototype.toString.call(obj)] || "object"
: typeof obj;
};
isFunction: (obj) => {
return jQuery.type(obj) === "function";
};
原始类型直接使用 typeof
,引用类型使用 Object.prototype.toString.call
取得类型。
判断数组类型可以用 Array.isArray(value)
或者 Object.prototype.toString.call(value)
。
# 隐式类型转换
可能发生隐式类型转换的场景以及转换原则,应如何避免或巧妙应用
因为 JavaScript
是弱类型的语言,所以类型转换发生非常频繁,上面我们说的装箱和拆箱其实就是一种类型转换。
类型转换分为两种,隐式转换即程序自动进行的类型转换,强制转换即我们手动进行的类型转换。
强制转换这里就不再多提及了,下面我们来看看让人头疼的可能发生隐式类型转换的几个场景,以及如何转换:
# 9.1 类型转换规则
如果发生了隐式转换,那么各种类型互转符合下面的规则:
# 9.2 if 语句和逻辑语句
在 if
语句和逻辑语句中,如果只有单个变量,会先将变量转换为 Boolean
值,只有下面几种情况会转换成 false
,其余被转换成 true
:
null;
undefined;
("");
NaN;
0;
false;
# 9.3 各种运数学算符
我们在对各种非 Number
类型运用数学运算符(- * /
)时,会先将非 Number
类型转换为 Number
类型;
1 - true; // 0
1 - null; // 1
1 * undefined; // NaN
2 * ["5"]; // 10
注意 +
是个例外,执行 +
操作符时:
- 当一侧为
String
类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型。 - 当一侧为
Number
类型,另一侧为原始类型,则将原始类型转换为Number
类型。 - 当一侧为
Number
类型,另一侧为引用类型,将引用类型和Number
类型转换成字符串后拼接。
123 + "123"; // 123123 (规则1)
123 + null; // 123 (规则2)
123 + true; // 124 (规则2)
123 + {}; // 123[object Object] (规则3)
# 9.4 ==
使用 ==
时,若两侧类型相同,则比较结果和 ===
相同,否则会发生隐式转换,使用 ==
时发生的转换可以分为几种不同的情况(只考虑两侧类型不同):
NaN
NaN
和其他任何类型比较永远返回false
(包括和他自己)。
NaN == NaN; // false
Boolean
Boolean
和其他任何类型比较,Boolean
首先被转换为Number
类型。
true == 1; // true
true == "2"; // false
true == ["1"]; // true
true == ["2"]; // false
这里注意一个可能会弄混的点:
undefined、null
和Boolean
比较,虽然undefined、null
和false
都很容易被想象成假值,但是他们比较结果是false
,原因是false
首先被转换成0
:
undefined == false; // false
null == false; // false
String 和 Number
String
和Number
比较,先将String
转换为Number
类型。
123 == "123"; // true
"" == 0; // true
null 和 undefined
null == undefined
比较结果是true
,除此之外,null、undefined
和其他任何结果的比较值都为false
。
null == undefined; // true
null == ""; // false
null == 0; // false
null == false; // false
undefined == ""; // false
undefined == 0; // false
undefined == false; // false
原始类型和引用类型
当原始类型和引用类型做比较时,对象类型会依照
ToPrimitive
规则转换为原始类型:
"[object Object]" == {}; // true
"1,2,3" == [1, 2, 3]; // true
来看看下面这个比较:
[] == ![]; // true
!
的优先级高于 ==
, ![]
首先会被转换为 false
,然后根据上面第三点, false
转换成 Number
类型 0
,左侧 []
转换为 0
,两侧比较相等。
([null] == false[undefined]) == // true
false; // true
根据数组的 ToPrimitive
规则,数组元素为 null
或 undefined
时,该元素被当做空字符串处理,所以 [null]、[undefined]
都会被转换为 0
。
所以,说了这么多,推荐使用 ===
来判断两个值是否相等...
# 9.5 一道有意思的面试题
一道经典的面试题,如何让: a == 1 && a == 2 && a == 3
。
根据上面的拆箱转换,以及 ==
的隐式转换,我们可以轻松写出答案:
const a = {
value: [3, 2, 1],
valueOf: function() {
return this.value.pop();
},
};
# 小数精度
出现小数精度丢失的原因,JavaScript 可以存储的最大数字、最大安全数字,JavaScript 处理大数字的方法、避免精度丢失的方法
# 10.1 出现小数精度丢失的原因
计算机的二进制实现和位数限制有些数无法有限表示。就像一些无理数不能有限表示,如 圆周率 3.1415926...,1.3333... 等。JS 遵循 IEEE 754 (opens new window) 规范,采用双精度存储(double precision),占用 64 bit。如图
1 位用来表示符号位
11 位用来表示指数
52 位表示尾数
浮点数,比如
1
2
0.1 >> 0.0001 1001 1001 1001…(1001 无限循环)
0.2 >> 0.0011 0011 0011 0011…(0011 无限循环)
此时只能模仿十进制进行四舍五入了,但是二进制只有 0 和 1 两个,于是变为 0 舍 1 入。这即是计算机中部分浮点数运算时出现误差,丢失精度的根本原因。 JS 的最大和最小安全值可以这样获得:
console.log(Number.MAX_SAFE_INTEGER); //9007199254740991
console.log(Number.MIN_SAFE_INTEGER); //-9007199254740991
对于整数,前端出现问题的几率可能比较低,毕竟很少有业务需要需要用到超大整数,只要运算结果不超过 Math.pow(2, 53) 就不会丢失精度。如果实在是超过最大安全数字了,那就用 BigInt(Number)计算。
对于小数,前端出现问题的几率还是很多的,尤其在一些电商网站涉及到金额等数据。解决方式:把小数放到位整数(乘倍数),再缩小回原来倍数(除倍数),也就是说,尽量在业务中避免处理小数。
# 感谢
- 图片来源网络。
- 文中如有错误,欢迎在评论区批评指正。
- 如果本文对你有帮助 😘,就点个Star 👍 (opens new window) 支持下吧!感谢阅读。