最近项目要求,在前端计算单价,由于 JavaScript 是二进制浮点,十进制小数(比如:0.1、0.01、19.99)在二进制表示不精确,直接运算会得到一个很接近但是不等于的结果。而价格这个东西对于精度又极其敏感。
看到 Java 中是用BigDecimal进行计算的,所以,我这在 JavaScript 中实现了相近的功能
实现代码:
/* BigDecimal.ts
*
* https://www.yangguangdream.com/?id=2270
*
* 精確十進位運算(BigInt + scale 定點小數)
* Precise decimal arithmetic using BigInt + scale (fixed-point).
*
* 建議 TS target / lib: ES2020 以上(需要 BigInt)
* Recommended TS target/lib: ES2020+ (BigInt required).
*/
export type RoundingMode =
| "DOWN" // toward zero
| "UP" // away from zero
| "FLOOR" // toward -∞
| "CEILING" // toward +∞
| "HALF_UP" // ties away from zero
| "HALF_DOWN" // ties toward zero
| "HALF_EVEN"; // banker's rounding
export interface MathContext {
/** 有效位數(precision),必須 >= 1。Significant digits (precision), must be >= 1. */
precision: number;
/** 捨入模式。Rounding mode. */
roundingMode: RoundingMode;
}
export class BigDecimal {
/** 去掉小數點後的整數值(可能為負)。Unscaled integer value (may be negative). */
private readonly intVal: bigint;
/** 小數位數(>= 0)。Scale (digits to the right of the decimal point, >= 0). */
private readonly scale: number;
/**
* 建立 BigDecimal。
* - 建議使用字串輸入以確保「完全精確」。
* - number 會先轉字串,可能已經有二進位浮點誤差。
*
* Create a BigDecimal.
* - Prefer string input for exactness.
* - number is converted to string and may already contain IEEE-754 rounding artifacts.
*/
constructor(value: string | number | bigint | BigDecimal, scale?: number) {
if (value instanceof BigDecimal) {
this.intVal = value.intVal;
this.scale = value.scale;
return;
}
if (typeof value === "bigint") {
const s = scale ?? 0;
if (!Number.isInteger(s) || s < 0) throw new Error("scale must be a non-negative integer");
this.intVal = value === 0n ? 0n : value;
this.scale = s;
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error("Invalid number");
value = String(value);
}
const parsed = BigDecimal.parseDecimalString(value);
this.intVal = parsed.intVal === 0n ? 0n : parsed.intVal; // avoid -0
this.scale = parsed.scale;
}
/**
* 快速建立實例(同 constructor)。
* Convenience factory (same as constructor).
*/
static from(value: string | number | bigint | BigDecimal, scale?: number): BigDecimal {
return new BigDecimal(value as any, scale);
}
/**
* 取得 scale(小數位數)。
* Get scale (digits after decimal point).
*/
getScale(): number {
return this.scale;
}
/**
* 取得未縮放整數值(unscaled value)。
* Get unscaled value (BigInt).
*/
unscaledValue(): bigint {
return this.intVal;
}
/**
* 回傳數值的正負號(-1, 0, 1)。
* Returns signum of this value (-1, 0, 1).
*/
signum(): -1 | 0 | 1 {
if (this.intVal === 0n) return 0;
return this.intVal < 0n ? -1 : 1;
}
/**
* 取絕對值。
* Absolute value.
*/
abs(): BigDecimal {
return this.intVal < 0n ? new BigDecimal(-this.intVal, this.scale) : this;
}
/**
* 取相反數(negate)。
* Negation.
*/
negate(): BigDecimal {
return this.intVal === 0n ? this : new BigDecimal(-this.intVal, this.scale);
}
/**
* 加法(結果 scale = max(a.scale, b.scale),與 Java BigDecimal.add 類似)。
* Addition (result scale = max scales, Java-like).
*/
add(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
const [aAdj, bAdj, s] = BigDecimal.alignScales(this, b);
return new BigDecimal(aAdj + bAdj, s);
}
/**
* 減法(結果 scale = max(a.scale, b.scale),與 Java BigDecimal.subtract 類似)。
* Subtraction (result scale = max scales, Java-like).
*/
sub(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
const [aAdj, bAdj, s] = BigDecimal.alignScales(this, b);
return new BigDecimal(aAdj - bAdj, s);
}
/**
* 乘法(結果 scale = a.scale + b.scale,與 Java BigDecimal.multiply 類似)。
* Multiplication (result scale = sum of scales, Java-like).
*/
mul(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
return new BigDecimal(this.intVal * b.intVal, this.scale + b.scale);
}
/**
* 除法(Java 風格 overload):
* 1) divide(b) -> 精確除法;若結果為無限小數,會拋錯(需指定 scale/rounding)。
* 2) divide(b, s, rm) -> 指定結果 scale 與捨入模式。
*
* Division (Java-like overload):
* 1) divide(b) -> exact division; throws if non-terminating.
* 2) divide(b, s, rm) -> with specified result scale and rounding mode.
*/
divide(other: BigDecimal | string | number | bigint): BigDecimal;
divide(other: BigDecimal | string | number | bigint, resultScale: number, rounding: RoundingMode): BigDecimal;
divide(other: BigDecimal | string | number | bigint, resultScale?: number, rounding?: RoundingMode): BigDecimal {
const b = BigDecimal.from(other);
if (b.intVal === 0n) throw new Error("Division by zero");
if (resultScale === undefined) {
return this.divideExact(b);
}
const rm: RoundingMode = rounding ?? "HALF_UP";
return this.divideScaled(b, resultScale, rm);
}
/**
* 指定 scale(小數位)並捨入,等同 Java BigDecimal.setScale。
* Set scale with rounding (like Java BigDecimal.setScale).
*/
setScale(newScale: number, rounding: RoundingMode = "HALF_UP"): BigDecimal {
if (!Number.isInteger(newScale) || newScale < 0) throw new Error("newScale must be a non-negative integer");
if (newScale === this.scale) return this;
if (newScale > this.scale) {
const factor = BigDecimal.pow10(newScale - this.scale);
return new BigDecimal(this.intVal * factor, newScale);
}
const diff = this.scale - newScale;
const divisor = BigDecimal.pow10(diff); // positive
const { q, r } = BigDecimal.divmodPosDenom(this.intVal, divisor);
const roundedQ = BigDecimal.roundQuotient(q, r, divisor, this.intVal, rounding);
return new BigDecimal(roundedQ, newScale);
}
/**
* 移除尾端多餘的 0(會改變 scale),等同 Java BigDecimal.stripTrailingZeros。
* Strip trailing zeros (changes scale), like Java BigDecimal.stripTrailingZeros.
*/
stripTrailingZeros(): BigDecimal {
if (this.intVal === 0n) return this; // keep scale as-is (Java keeps representation)
let v = this.intVal;
let s = this.scale;
while (s > 0 && (v % 10n) === 0n) {
v /= 10n;
s -= 1;
}
return (v === this.intVal && s === this.scale) ? this : new BigDecimal(v, s);
}
/**
* 比較大小(忽略 scale 差異;數值相等則回傳 0),等同 Java compareTo 語義。
* Compare numerically (ignores scale differences), like Java compareTo.
*/
compareTo(other: BigDecimal | string | number | bigint): -1 | 0 | 1 {
const b = BigDecimal.from(other);
const [aAdj, bAdj] = BigDecimal.alignScales(this, b);
if (aAdj < bAdj) return -1;
if (aAdj > bAdj) return 1;
return 0;
}
/**
* equals(Java 語義:數值與 scale 都必須相同)。
* equals (Java semantics: both value AND scale must match).
*/
equals(other: BigDecimal | string | number | bigint): boolean {
const b = BigDecimal.from(other);
return this.scale === b.scale && this.intVal === b.intVal;
}
/**
* 只比「數值是否相等」(忽略 scale),相當於 compareTo(other) === 0。
* Numeric equality ignoring scale (compareTo(other) === 0).
*/
equalsValue(other: BigDecimal | string | number | bigint): boolean {
return this.compareTo(other) === 0;
}
/**
* 回傳有效位數(precision),與 Java BigDecimal.precision 類似。
* Returns precision (count of significant digits), Java-like.
*/
precision(): number {
// Java: precision of 0 is 1
if (this.intVal === 0n) return 1;
const abs = this.intVal < 0n ? -this.intVal : this.intVal;
// NOTE: unscaled value may include trailing zeros; precision counts them too (Java does).
return abs.toString().length;
}
/**
* 以 MathContext(precision + rounding)做四捨五入(有效位數)。
* Round using MathContext (precision + rounding mode).
*/
round(mc: MathContext): BigDecimal {
if (!mc || !Number.isInteger(mc.precision) || mc.precision < 1) {
throw new Error("MathContext.precision must be an integer >= 1");
}
const rm = mc.roundingMode ?? "HALF_UP";
const curPrec = this.precision();
if (curPrec <= mc.precision) return this;
const drop = curPrec - mc.precision; // digits to drop from unscaled value
const divisor = BigDecimal.pow10(drop); // 10^drop
const { q, r } = BigDecimal.divmodPosDenom(this.intVal, divisor);
const roundedQ = BigDecimal.roundQuotient(q, r, divisor, this.intVal, rm);
// scale decreases by drop; if becomes negative, convert to scale=0 by shifting left.
let newScale = this.scale - drop;
let newIntVal = roundedQ;
if (newScale < 0) {
newIntVal = newIntVal * BigDecimal.pow10(-newScale);
newScale = 0;
}
return new BigDecimal(newIntVal, newScale);
}
/**
* 小數點左移 n 位(相當於 / 10^n)。
* Move decimal point left by n (equivalent to / 10^n).
*/
movePointLeft(n: number): BigDecimal {
if (!Number.isInteger(n)) throw new Error("n must be an integer");
if (n === 0) return this;
if (n < 0) return this.movePointRight(-n);
return new BigDecimal(this.intVal, this.scale + n);
}
/**
* 小數點右移 n 位(相當於 * 10^n);若 scale 變成負數會自動轉為 scale=0 並擴大 intVal。
* Move decimal point right by n (equivalent to * 10^n). If scale becomes negative, it is normalized to scale=0 by expanding intVal.
*/
movePointRight(n: number): BigDecimal {
if (!Number.isInteger(n)) throw new Error("n must be an integer");
if (n === 0) return this;
if (n < 0) return this.movePointLeft(-n);
const newScale = this.scale - n;
if (newScale >= 0) return new BigDecimal(this.intVal, newScale);
// scale would be negative -> shift intVal and set scale=0
const factor = BigDecimal.pow10(-newScale);
return new BigDecimal(this.intVal * factor, 0);
}
/**
* 輸出「一般十進位字串」(不使用科學記號)。
* Plain decimal string (no scientific notation).
*/
toPlainString(): string {
const sign = this.intVal < 0n ? "-" : "";
const abs = this.intVal < 0n ? -this.intVal : this.intVal;
if (this.scale === 0) return sign + abs.toString();
const s = abs.toString();
const pointPos = s.length - this.scale;
if (pointPos > 0) {
const intPart = s.slice(0, pointPos);
const fracPart = s.slice(pointPos);
return sign + intPart + "." + fracPart;
} else {
const zeros = "0".repeat(-pointPos);
return sign + "0." + zeros + s;
}
}
/**
* toString(此實作等同 toPlainString,避免出現科學記號)。
* toString (this implementation equals toPlainString to avoid scientific notation).
*/
toString(): string {
return this.toPlainString();
}
/**
* 嘗試轉成 number(可能失去精度,不建議用於財務/精準計算)。
* Convert to number (may lose precision; not recommended for money/precise math).
*/
toNumber(): number {
return Number(this.toPlainString());
}
// -------------------- private core division helpers --------------------
/**
* 精確除法;若除不盡(無限小數),會拋錯。
* Exact division; throws if the decimal expansion is non-terminating.
*/
private divideExact(divisor: BigDecimal): BigDecimal {
// Build fraction: value = (A * 10^(sb-sa)) / B
let num = this.intVal;
let den = divisor.intVal;
// adjust by exponent = sb - sa
const exp = divisor.scale - this.scale;
if (exp >= 0) num = num * BigDecimal.pow10(exp);
else den = den * BigDecimal.pow10(-exp);
// normalize denominator to positive
if (den < 0n) {
den = -den;
num = -num;
}
// reduce fraction
const g = BigDecimal.gcd(BigDecimal.absBigInt(num), den);
num /= g;
den /= g;
// den must be 2^a * 5^b for terminating decimal
let d = den;
let p2 = 0;
let p5 = 0;
while (d % 2n === 0n) { d /= 2n; p2++; }
while (d % 5n === 0n) { d /= 5n; p5++; }
if (d !== 1n) {
throw new Error("Non-terminating decimal expansion; specify scale and rounding mode");
}
const s = Math.max(p2, p5);
// intVal = num * 10^s / den = num * 2^(s-p2) * 5^(s-p5)
const mul2 = BigDecimal.powBigInt(2n, s - p2);
const mul5 = BigDecimal.powBigInt(5n, s - p5);
const intVal = num * mul2 * mul5;
return new BigDecimal(intVal, s);
}
/**
* 指定結果小數位(scale)與捨入模式的除法。
* Division with specified result scale and rounding mode.
*/
private divideScaled(divisor: BigDecimal, resultScale: number, rounding: RoundingMode): BigDecimal {
if (!Number.isInteger(resultScale) || resultScale < 0) throw new Error("resultScale must be a non-negative integer");
// Compute q = round( numerator / denominator ) where result has `resultScale`.
// exp = resultScale + sb - sa
const exp = resultScale + divisor.scale - this.scale;
let numerator: bigint;
let denominator: bigint;
if (exp >= 0) {
numerator = this.intVal * BigDecimal.pow10(exp);
denominator = divisor.intVal;
} else {
numerator = this.intVal;
denominator = divisor.intVal * BigDecimal.pow10(-exp);
}
// normalize denominator to positive
if (denominator < 0n) {
denominator = -denominator;
numerator = -numerator;
}
const { q, r } = BigDecimal.divmodPosDenom(numerator, denominator);
const roundedQ = BigDecimal.roundQuotient(q, r, denominator, numerator, rounding);
return new BigDecimal(roundedQ, resultScale);
}
// -------------------- private parsing & math helpers --------------------
/**
* 解析十進位字串(支援科學記號 e/E)。
* Parse decimal string (supports scientific notation e/E).
*/
private static parseDecimalString(input: string): { intVal: bigint; scale: number } {
const str = input.trim();
if (!str) throw new Error("Empty decimal");
// Allow: [+|-]? (digits? "." digits? | digits ".") ( [eE][+|-]?digits )?
const m = /^([+-])?(?:(\d+)?(?:\.(\d*)?)?|\.(\d+))(?:[eE]([+-]?\d+))?$/.exec(str);
if (!m) throw new Error(`Invalid decimal string: ${input}`);
const sign = m[1] === "-" ? -1n : 1n;
// Two possible captures:
// - m[2] digits before dot (optional)
// - m[3] digits after dot (optional, can be empty when "12.")
// - or m[4] digits after dot when string starts with "."
const intPart = (m[2] ?? "");
const fracPart = (m[4] ?? m[3] ?? "");
const expStr = m[5] ?? "0";
const exp = Number(expStr);
if (!Number.isInteger(exp)) throw new Error(`Invalid exponent: ${expStr}`);
// Digits without dot
let digits = (intPart + fracPart);
// remove leading zeros for BigInt parsing (keep at least one digit if all zeros)
digits = digits.replace(/^0+(?=\d)/, "");
if (digits === "") digits = "0";
// scale = fracLen - exp (moving decimal right by exp reduces scale)
let scale = fracPart.length - exp;
let intVal = BigInt(digits) * sign;
if (intVal === 0n) intVal = 0n; // avoid -0
if (scale < 0) {
// shift left if scale would be negative
intVal = intVal * BigDecimal.pow10(-scale);
scale = 0;
}
if (!Number.isInteger(scale) || scale < 0) throw new Error("Invalid scale after parsing");
return { intVal, scale };
}
/**
* 將兩個 BigDecimal 對齊到相同 scale(用於加減比大小)。
* Align scales of two BigDecimals (used for add/sub/compare).
*/
private static alignScales(a: BigDecimal, b: BigDecimal): [bigint, bigint, number] {
const s = Math.max(a.scale, b.scale);
const aFactor = BigDecimal.pow10(s - a.scale);
const bFactor = BigDecimal.pow10(s - b.scale);
return [a.intVal * aFactor, b.intVal * bFactor, s];
}
/**
* 10 的 n 次方(BigInt)。
* 10^n as BigInt.
*/
private static pow10(n: number): bigint {
if (!Number.isInteger(n) || n < 0) throw new Error("pow10 expects non-negative integer");
return BigDecimal.powBigInt(10n, n);
}
/**
* base 的 exp 次方(BigInt,快速冪)。
* base^exp using fast exponentiation (BigInt).
*/
private static powBigInt(base: bigint, exp: number): bigint {
if (!Number.isInteger(exp) || exp < 0) throw new Error("powBigInt exp must be non-negative integer");
let r = 1n;
let b = base;
let e = exp;
while (e > 0) {
if (e & 1) r *= b;
b *= b;
e >>= 1;
}
return r;
}
/**
* BigInt 絕對值。
* BigInt absolute value.
*/
private static absBigInt(x: bigint): bigint {
return x < 0n ? -x : x;
}
/**
* 最大公因數 gcd(BigInt)。
* Greatest common divisor (BigInt).
*/
private static gcd(a: bigint, b: bigint): bigint {
let x = a;
let y = b;
while (y !== 0n) {
const t = x % y;
x = y;
y = t;
}
return x;
}
/**
* BigInt 除法與餘數(保證除數為正數)。
* divmod with positive denominator.
*/
private static divmodPosDenom(numer: bigint, denomPos: bigint): { q: bigint; r: bigint } {
if (denomPos <= 0n) throw new Error("denominator must be positive");
const q = numer / denomPos; // trunc toward zero
const r = numer % denomPos; // same sign as numer
return { q, r };
}
/**
* 依照捨入模式把 q + r/denom 進行捨入,denom 必須為正。
* Round quotient for q + r/denom using rounding mode; denom must be positive.
*/
private static roundQuotient(
q: bigint,
r: bigint,
denomPos: bigint,
numer: bigint,
mode: RoundingMode
): bigint {
if (r === 0n) return q;
if (denomPos <= 0n) throw new Error("denominator must be positive");
const sign = numer < 0n ? -1n : 1n; // sign of exact value since denom > 0
const awayFromZero = (x: bigint) => (sign >= 0n ? x + 1n : x - 1n);
switch (mode) {
case "DOWN":
// toward zero
return q;
case "UP":
// away from zero
return awayFromZero(q);
case "FLOOR":
// toward -∞
return (numer < 0n) ? (q - 1n) : q;
case "CEILING":
// toward +∞
return (numer > 0n) ? (q + 1n) : q;
case "HALF_UP":
case "HALF_DOWN":
case "HALF_EVEN": {
const rAbs = BigDecimal.absBigInt(r);
const twiceR = rAbs * 2n;
if (twiceR > denomPos) return awayFromZero(q);
if (twiceR < denomPos) return q;
// exactly half
if (mode === "HALF_UP") return awayFromZero(q);
if (mode === "HALF_DOWN") return q;
// HALF_EVEN: round to make result even
const isOdd = (q & 1n) !== 0n;
return isOdd ? awayFromZero(q) : q;
}
default: {
const _never: never = mode;
return q;
}
}
}
}使用方法(由于JavaScript 精度问题,请直接把数字转为字符串):
const a = new BigDecimal("0.1");
const b = new BigDecimal("0.2");
console.log(a.add(b).toString()); // 0.3
console.log(new BigDecimal("1").divide("3", 10, "HALF_UP").toString()); // 0.3333333333
console.log(new BigDecimal("1").divide("8").toString()); // 0.125(精确可除)
try {
console.log(new BigDecimal("1").divide("3").toString()); // 无限小数会报错
} catch (e) {
console.log(String(e));
}
console.log(new BigDecimal("1.2300").stripTrailingZeros().toString()); // 1.23
console.log(new BigDecimal("1.2300").equals(new BigDecimal("1.23"))); // false(Java equals)
console.log(new BigDecimal("1.2300").equalsValue(new BigDecimal("1.23"))); // true(比较数值)
微信扫码查看本文
发表评论