- 金融科技
【免费下载链接】dinero.js
Create, calculate, and format money in JavaScript and TypeScript
导读
本文聚焦 Dinero.js 中用于操作(变更)货币金额的一组核心 API——add、subtract、multiply与allocate,它们共同构成了"mutation(变更)"这一核心概念。文章将带你完整复刻一个典型结算页(购物车小计、折扣分摊、运费合计)的金额计算流程,并深入讲解"Dinero 对象不可变"这一关键设计;同时结合本仓库源码(packages/dinero.js/src/core/api),揭示每一步运算背后的同币种校验、scale 归一化与"最安全精度"转换机制。读完本文,你将能够在真实项目中安全地组合这些函数,理解它们何时返回新对象、如何处理不同精度、如何分配余数。
Mutations:操纵金钱的核心 API 集合
在 Dinero.js 中,操纵金额的核心手段就是 mutation 函数。它们大多基于算术运算:加法、乘法、减法,以及更复杂的按比例分配。与直觉相反,这些函数虽然被归类为"mutations(变更)",却不会修改传入的对象——它们总是返回一个全新的 Dinero 对象。
一个最基础的例子(对应 docs/core-concepts/mutations.md 开头):
import { dinero, add } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const d1 = dinero({ amount: 500, currency: USD }); const d2 = dinero({ amount: 800, currency: USD }); add(d1, d2); // 返回 amount 为 1300 的新 Dinero 对象除了add,完整的 mutation API 还包括subtract、multiply、allocate。你可以在 docs/api/mutations/add.md、docs/api/mutations/subtract.md、docs/api/mutations/multiply.md 与 docs/api/mutations/allocate.md 中查看每个函数的完整参数表与独立示例。
计算新金额:一个完整的结算页案例
任何处理金钱的应用都必然需要操纵金额。最经典的场景是结算页:你需要计算商品小计、加上运费、减去折扣等。原文档给出了一个非常完整的例子,这里完整复刻并补充说明每一步的含义:
import { dinero, add, allocate, subtract } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const products = [ { name: 'Apple iPhone 12', price: dinero({ amount: 89900, currency: USD }), }, { name: 'Apple AirPods Pro', price: dinero({ amount: 17495, currency: USD }), }, ]; // 用 reduce 把购物车中所有商品价格累加得到小计 const subtotal = products.reduce( (acc, { price }) => add(acc, price), dinero({ amount: 0, currency: USD }) ); // 按 20% / 80% 的比例分配小计,取第一份作为折扣 const [discount] = allocate(subtotal, [20, 80]); const discounted = subtract(subtotal, discount); const shipping = dinero({ amount: 1000, currency: USD }); const total = add(discounted, shipping);这个例子串联了 mutation API 的四种典型用法:
add:以dinero({ amount: 0, currency: USD })为零元累加器,逐步累加商品价格;allocate:把subtotal按[20, 80]比例拆成两份(这里只取第一份作为折扣金额);subtract:从小计中扣掉折扣;add:再加上运费得到最终total。
注意:金额的单位是"最小货币单位"(cents),例如 89900 表示 $899.00。关于 amount 与 scale 的详细约定,可参阅 docs/core-concepts/amount.md 与 docs/core-concepts/scale.md。
Dinero 对象是不可变的
虽然这类函数被归类为 "mutations",但 Dinero 对象是不可变的(immutable)。使用任何 mutation 函数时,传入的既有对象始终保持原样,函数返回的是全新对象。
原文档用toSnapshot直观地证明了这一点:
import { dinero, add, toSnapshot } from 'dinero.js'; // 假设 d1 = dinero({ amount: 500, currency: USD }) // 假设 d2 = dinero({ amount: 800, currency: USD }) toSnapshot(add(d1, d2)); // { // amount: 1300, // currency: { // code: 'USD', // base: 10, // exponent: 2, // }, // scale: 2, // } toSnapshot(d1); // { // amount: 500, // currency: { // code: 'USD', // base: 10, // exponent: 2, // }, // scale: 2, // } toSnapshot(d2); // { // amount: 800, // currency: { // code: 'USD', // base: 10, // exponent: 2, // }, // scale: 2, // }执行add(d1, d2)之后,d1仍是 500、d2仍是 800,只有返回的新对象携带相加后的 1300。这一不变性对构建可预测的状态管理(如 React 中的 reducer、函数式流水线)至关重要——你可以在任何时刻放心保留对旧对象的引用,而不用担心被"改掉"。
从源码层面看,这种不变性体现在每个 mutation 函数都通过create返回新对象。例如 core/api/add.ts 中:
const { amount: augendAmount, currency, scale } = augend.toJSON(); const { amount: addendAmount } = addend.toJSON(); const amount = calculator.add(augendAmount, addendAmount); return augend.create({ amount, currency, scale, });它读取原对象的快照数据,用 calculator 算出新金额,再通过augend.create(...)构造并返回新Dinero 对象,全程没有对原对象做任何写入。
深入add与subtract:同币种校验与 scale 归一化
只允许相同币种相加/相减
add与subtract都要求参与运算的对象必须使用同一种货币,否则会抛出错误。其实现逻辑在 core/api/add.ts 与 core/api/subtract.ts 中如出一辙:
const condition = haveSameCurrency([augend, addend]); assert(condition, UNEQUAL_CURRENCIES_MESSAGE); const [newAugend, newAddend] = normalizeFn([augend, addend]); return addFn(newAugend, newAddend);即:先通过haveSameCurrency校验币种,不满足则抛出断言错误。错误消息定义在 core/checks/messages.ts:
export const UNEQUAL_CURRENCIES_MESSAGE = 'Objects must have the same currency.';在 TypeScript 中,若配合类型化货币(typed currencies)使用,这一约束还会在编译期被强制检查,详见 guides/currency-type-safety.md。
自动归一化到最高 scale
币种相同还不够:两个对象的scale(小数位数)可能不同。add/subtract会自动把两个对象归一化到最高的 scale后再运算。normalizeScale的实现位于 core/api/normalizeScale.ts:它先求出所有对象 scale 的最大值,然后对 scale 较低的对象调用transformScale提升精度。
因此,以下两个不同 scale 的对象相加,结果是 scale 为 4 的新对象(对应 docs/api/mutations/add.md 的示例):
import { dinero, add } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const d1 = dinero({ amount: 400, currency: USD }); const d2 = dinero({ amount: 104545, currency: USD, scale: 4 }); add(d1, d2); // amount 144545,scale 4减法同理(对应 docs/api/mutations/subtract.md 的示例):
import { dinero, subtract } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const d1 = dinero({ amount: 500, currency: USD }); const d2 = dinero({ amount: 1000, currency: USD, scale: 3 }); subtract(d1, d2); // amount 4000,scale 3这里500(scale 2)先被归一化为5000(scale 3),再减去1000得到4000。
批量求和/求差
add与subtract都是二元运算(接受两个参数,对应源码中的AddParams/SubtractParams元组类型)。要处理多个对象,可以多次调用或使用reduce组合:
const d1 = dinero({ amount: 300, currency: USD }); const d2 = dinero({ amount: 200, currency: USD }); const d3 = dinero({ amount: 100, currency: USD }); const addMany = (addends) => addends.reduce(add); addMany([d1, d2, d3]); // amount 600const s1 = dinero({ amount: 400, currency: USD }); const s2 = dinero({ amount: 200, currency: USD }); const s3 = dinero({ amount: 100, currency: USD }); const subtractMany = (subtrahends) => subtrahends.reduce(subtract); subtractMany([s1, s2, s3]); // amount 100从源码类型可以看出add的第一个参数augend与第二个参数addend都必须是 Dinero 对象(core/api/add.ts);subtract对应minuend(被减数)与subtrahend(减数),见 core/api/subtract.ts。
深入multiply:乘法器与"最安全 scale"
multiply用于把一个 Dinero 对象乘以某个数值。它的参数multiplier有两种形式(见 docs/api/mutations/multiply.md 的参数表):
- 整数:如
4; - 带刻度的数量(scaled amount):如
{ amount: 21, scale: 1 },表示 2.1。
为什么小数乘法要用 scaled amount
原文档明确警告:如果需要乘以小数(fractional multiplier),不要使用浮点数,而要使用 scaled amounts。例如要乘以 2.1,应传{ amount: 21, scale: 1 },而不是2.1。原因与 Dinero.js 一贯的精度策略一致:浮点数(如 0.1 + 0.2)在二进制表示下存在精度误差,而整数运算可以避免这类误差。
整数乘法示例
import { dinero, multiply } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const d = dinero({ amount: 400, currency: USD }); multiply(d, 4); // amount 1600scaled multiplier 与 scale 相加规则
import { dinero, multiply } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const d = dinero({ amount: 401, currency: USD }); multiply(d, { amount: 2001, scale: 3 }); // amount 802401,scale 5注意结果 scale 变为 5:因为对象的 scale(2)与 multiplier 的 scale(3)相加得到 5。这一逻辑直接体现在源码 core/api/multiply.ts 中:
const { amount: multiplierAmount, scale: multiplierScale } = getAmountAndScale(multiplier, zero); const newScale = calculator.add(scale, multiplierScale); return convertScaleFn( multiplicand.create({ amount: calculator.multiply(amount, multiplierAmount), currency, scale: newScale, }), newScale );其中getAmountAndScale负责把整数或 scaled amount 统一抽取为"金额 + scale";若传入整数,其 scale 视为 0(工具函数见 core/utils/index.ts 下的getAmountAndScale)。随后convertScaleFn(即transformScale)会把结果转换到最安全的 scale,避免出现无法整除造成精度损失的情况。关于安全 scale 的算法细节,可参考 docs/api/conversions/transform-scale.md。
深入allocate:按比例分配与余数摊派
allocate把一个 Dinero 对象的金额按一组比例(ratios)拆分到多个新对象上。货币的最小单位不可再分,因此金额不一定能被精确均分——allocate的职责就是拆分后把余数"尽可能公平地"分配出去。
百分比与比值两种写法等价
你可以用百分比风格,也可以用比值风格,两者等价:[25, 75]与[1, 3]效果相同。
import { dinero, allocate } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const d = dinero({ amount: 500, currency: USD }); const [d1, d2] = allocate(d, [50, 50]); // d1: amount 250,d2: amount 250const d = dinero({ amount: 100, currency: USD }); const [d1, d2] = allocate(d, [1, 3]); // d1: amount 25,d2: amount 75余数如何被"尽可能公平"地摊派
当金额无法被比例整除时,余数会按比例大小降序依次分配给各份。原文档的示例:
const d = dinero({ amount: 1003, currency: USD }); const [d1, d2] = allocate(d, [50, 50]); // d1: amount 502,d2: amount 5011003 无法被平分,余数 1 被分配给第一份,得到 502 与 501。这一行为由distribute工具函数实现,见 core/utils/distribute.ts:
let remainder = value; const shares = ratios.map((ratio) => { const share = calculator.integerDivide(calculator.multiply(value, ratio), total) || zero; remainder = calculator.subtract(remainder, share); return share; }); // ... // 按比例降序排序索引,余数依次 +1 分配给比例较大的份额 const sortedIndices = ratios .map((ratio, index) => ({ ratio, index })) .filter(({ ratio }) => !equalFn(ratio, zero)) .sort((a, b) => (greaterThanFn(a.ratio, b.ratio) ? -1 : 1)) .map(({ index }) => index);也就是说:先按value × ratio / total做整数除法得到基础份额,余数则按"比例大的优先"逐一分发(每次 +1)。源码中还包含一个针对浮点精度损失的防死循环保护(if (equalFn(newRemainder, remainder)) break;),这在使用 number calculator 且金额超过Number.MAX_SAFE_INTEGER时尤为重要。
支持零比例
你可以传入零比例,例如[0, 50, 50]。如果存在需要分配的余数,零比例会被跳过,返回 amount 为 0 的对象:
const d = dinero({ amount: 1003, currency: USD }); const [d1, d2, d3] = allocate(d, [0, 50, 50]); // d1: amount 0 // d2: amount 502 // d3: amount 501合法比例的两个硬性约束
原文档强调两条规则:所有比例必须为正,且不能只传零比例。这两条约束在源码 core/api/allocate.ts 中被编码为断言条件:
const hasOnlyPositiveRatios = normalizedRatios.every(({ amount }) => greaterThanOrEqualFn(amount, zero) ); const hasOneNonZeroRatio = normalizedRatios.some(({ amount }) => greaterThanFn(amount, zero) ); const condition = hasRatios && hasOnlyPositiveRatios && hasOneNonZeroRatio; assert(condition, INVALID_RATIOS_MESSAGE);不满足时抛出'Ratios are invalid.'(见 core/checks/messages.ts)。注意hasOnlyPositiveRatios使用greaterThanOrEqual,即允许 0,但至少需要一个严格大于 0 的比例。
小数比例同样使用 scaled amounts
与multiply一致,allocate也要求小数比例使用 scaled amounts,而不是浮点数。例如 50.5% 与 49.5% 应写成:
import { dinero, allocate } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const ratios = [ { amount: 505, scale: 1 }, { amount: 495, scale: 1 }, ]; // 等价于比例 50.5 和 49.5 const d = dinero({ amount: 100, currency: USD }); const [d1, d2] = allocate(d, ratios); // d1: amount 505,scale 3 // d2: amount 495,scale 3这里返回对象的 scale 变为 3,来自对象的 scale(2)与比例最高 scale(1)相加(newScale = scale + highestRatioScale,见 core/api/allocate.ts),随后同样会转换到最安全 scale。内部实现会先把所有比例归一化到同一 scale(按最高比例 scale 对齐,用power(ten, factor)补足倍数),再交给distribute计算份额。
组合使用与相关资源
一个综合示例
把add、subtract、allocate串起来,就是一个完整的"打折 + 均摊"流水线:
import { dinero, add, allocate, subtract } from 'dinero.js'; import { USD } from 'dinero.js/currencies'; const base = dinero({ amount: 1003, currency: USD }); const [partA, partB] = allocate(base, [50, 50]); // 502 / 501 const fee = dinero({ amount: 99, currency: USD }); const totalForA = add(partA, fee); // 601 const finalForB = subtract(partB, fee); // 402由于所有函数都返回新对象且不修改入参,你可以放心地把每一步结果作为下一步的输入,形成清晰的声明式计算链。
深入阅读指引
- API 参考:每个 mutation 函数的参数表与更多示例见 docs/api/mutations/add.md、docs/api/mutations/subtract.md、docs/api/mutations/multiply.md、docs/api/mutations/allocate.md;
- 核心概念:金额与精度约定见 docs/core-concepts/amount.md、docs/core-concepts/scale.md;比较类运算见 docs/core-concepts/comparisons.md;
- 源码实现:各函数核心逻辑位于 packages/dinero.js/src/core/api,余数摊派算法见 packages/dinero.js/src/core/utils/distribute.ts,错误消息见 packages/dinero.js/src/core/checks/messages.ts;
- 测试用例:
add、subtract、multiply、allocate的单元测试分别位于 packages/dinero.js/src/api/tests/add.test.ts、packages/dinero.js/src/api/tests/subtract.test.ts、packages/dinero.js/src/api/tests/multiply.test.ts、packages/dinero.js/src/api/tests/allocate.test.ts,可用于验证本文描述的各种边界行为; - 实战参考:仓库中的 examples/cart-react 与 examples/cart-vue 示例项目,展示了如何在真实购物车场景中组合这些 mutation 函数。
小结
- mutation 函数虽名为"变更",实则返回新对象:
add、subtract、multiply、allocate都不会修改入参,这是 Dinero.js 不可变设计(immutability)的核心; - 同币种是加减法的硬性前提:
add/subtract在运行时断言币种一致,TypeScript 类型化货币下还能在编译期拦截; - 不同 scale 自动归一化:加减法会统一到最高 scale 后再计算,乘法与分配则采用"scale 相加 + 转换到最安全 scale"的策略;
- 避免浮点,使用 scaled amounts:无论是小数乘法器还是小数比例,都应写成
{ amount, scale }形式; allocate会把余数公平摊派:按比例降序分配余数、支持零比例,但要求全为正且至少一个非零。
掌握这些规则后,你就能在结算、分摊、折扣、报表等场景中安全地组合 Dinero.js 的 mutation API,写出既精确又易于维护的金额计算代码。
- 金融科技
【免费下载链接】dinero.js
Create, calculate, and format money in JavaScript and TypeScript
相关推荐
Dinero.js源码解析:深入理解不可变货币对象的实现原理
Dinero.js源码解析:深入理解不可变货币对象的实现原理 Dinero.js是一个用于在JavaScript和TypeScript中创建、计算和格式化货币的
金融科技dinero.js 金额比较:greaterThanOrEqual 函数使用指南与实现原理
dinero.js 金额比较:greaterThanOrEqual 函数使用指南与实现原理 greaterThanOrEqual 是 dinero.js 提供的
金融科技OpenCloud 中的环境变量加载利器:深入解析 gotenv 的变更历史与源码实现
OpenCloud 中的环境变量加载利器:深入解析 gotenv 的变更历史与源码实现 导读 gotenv 是 OpenCloud 项目中用于从 .env 文件
后端微服务存储认证鉴权
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考