在 JavaScript 中实现 BigDecimal

2026-02-06 14:55:37  阅读 22 次 评论 0 条

最近项目要求,在前端计算单价,由于 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(比较数值)



微信扫码查看本文
本文地址:https://www.yangguangdream.com/?id=2270
版权声明:本文为原创文章,版权归 编辑君 所有,欢迎分享本文,转载请保留出处!
NEXT:已经是最新一篇了

发表评论


表情

还没有留言,还不快点抢沙发?