用 Dinero.js 构建 Expense Splitter:账单分摊、余额追踪与最优结算的完整实战

发布时间:2026/10/9 1:42:58
用 Dinero.js 构建 Expense Splitter:账单分摊、余额追踪与最优结算的完整实战 金融科技【免费下载链接】dinero.jsCreate, calculate, and format money in JavaScript and TypeScript项目地址https://gitcode.com/gh_mirrors/di/dinero.js点击查看免费下载Expense Splitter 是 Dinero.js 官方仓库中一个基于 React TypeScript 的示例应用用于在朋友之间分摊开支并计算最优结算方案。本文以该示例为骨架完整讲解如何用 Dinero.js 的allocate()、add()/subtract()、compare()、toDecimal()、toSnapshot()等 API 解决真实世界的金额问题并结合仓库源码剖析分摊余数分配、净余额计算与贪心结算算法的底层原理。读完本文你将能够独立搭建一个输入账单 → 自动分摊 → 展示余额 → 生成最少笔数结算的完整资金应用并理解 Dinero.js 整数化金额模型的设计哲学。一、示例应用概览用五个 API 解决五个真实问题示例应用源码入口 README.md、App.tsx默认预置了 4 位成员Alice、Bob、Charlie、Diana和 3 笔示例账单展示 Dinero.js 解决真实资金问题的五个核心能力能力使用的 API解决的问题账单分摊allocate()按等额或百分比拆分账单余数不丢一分钱余额追踪add()/subtract()计算每个人应收/应付的净余额债务简化compare()/greaterThan()/isZero()用贪心算法最小化转账笔数货币格式化toDecimal()Intl.NumberFormat把内部整数金额渲染成$120.00样式本地持久化toSnapshot()dinero()把 Dinero 对象序列化进 LocalStorage应用的领域模型定义在 types/index.tsexport type SplitType equal | percentage; export interface Person { id: string; name: string; } export interface ExpenseShare { personId: string; value: number; // equal 模式下为 1percentage 模式下为百分比数值 } export interface Expense { id: string; description: string; amount: Dineronumber; // 金额本身就是一个 Dinero 对象 paidBy: string; splitType: SplitType; shares: ExpenseShare[]; createdAt: Date; } export interface Settlement { from: string; to: string; amount: Dineronumber; }注意Expense.amount的类型直接就是Dineronumber——金额不是普通数字而是带货币与刻度的类型化对象这是理解整个示例的关键。二、环境准备与运行按 README.md 中的步骤从仓库根目录安装依赖因为示例依赖与主包在同一个 workspace 中npm install进入示例目录并启动 Vite 开发服务器cd examples/expense-splitter npm run dev示例的 package.json 还提供了完整脚本{ scripts: { build:clean: rimraf ./dist, dev: vite, build: tsc -b vite build, serve: vite preview }, dependencies: { dinero.js: 2.0.2, lucide-react: ^0.475.0, react: ^19.0.0, react-dom: ^19.0.0 } }技术栈为 React 19 Vite 6 Tailwind CSS 4 TypeScript 5.7Dinero.js 版本为 2.0.2。三、金额的构造模型整数化最小单位所有 Dinero.js 金额逻辑集中在 lib/money.ts这是全应用的资金中枢。import type { Dinero } from dinero.js; import { dinero, add, subtract, multiply, allocate, toDecimal, toSnapshot, isZero, isPositive, isNegative, greaterThan, compare } from dinero.js; import { USD } from dinero.js/currencies; export const currency USD; export function zero(): Dineronumber { return dinero({ amount: 0, currency: USD }); } export function fromAmount(amount: number): Dineronumber { return dinero({ amount, currency: USD }); } export function toMinorUnits(value: string): number { return Math.round(parseFloat(value) * 100); } export function snapshot(amount: Dineronumber): number { return toSnapshot(amount).amount; }三个关键约定金额永远以最小单位分存储。用户输入19.99通过toMinorUnits变成整数1999再交给dinero()。这正是 Dinero.js 的核心设计——amount 文档 指出金额必须用最小货币单位表示避免浮点误差。构造时只传amount和currencyscale由货币自动推导USD 的 base 为 100因此默认 scale 为 2amount: 12000即 $120.00。零元也有专门的工厂函数zero()用于初始化所有人的余额避免到处手写dinero({ amount: 0, currency: USD })。货币从哪里来USD从dinero.js/currencies导入对应仓库源码 packages/dinero.js/src/currencies/iso4217.ts该文件包含 ISO 4217 全套货币定义含 base、exponent、decimal digits 等元数据并统一在 packages/dinero.js/src/currencies/index.ts 导出。四、账单分摊allocate()与不丢一分钱的余数算法分摊是应用的核心功能实现在calculateShareslib/money.tsexport function calculateShares( expense: Expense, people: Person[] ): Mapstring, Dineronumber { const shares new Mapstring, Dineronumber(); switch (expense.splitType) { case equal: { const participantIds expense.shares.length 0 ? expense.shares.map(({ personId }) personId) : people.map(({ id }) id); const ratios participantIds.map(() 1); const allocated allocate(expense.amount, ratios); participantIds.forEach((personId, index) { shares.set(personId, allocated[index]); }); break; } case percentage: { const ratios expense.shares.map(({ value }) value); const allocated allocate(expense.amount, ratios); expense.shares.forEach((share, index) { shares.set(share.personId, allocated[index]); }); break; } } for (const person of people) { if (!shares.has(person.id)) { shares.set(person.id, zero()); } } return shares; }两种分摊模式统一走allocate(dineroObject, ratios)equal均摊参与者每人 ratio 为1例如 4 人聚餐allocate(amount, [1, 1, 1, 1])percentage按比例ratio 直接使用百分比数值例如allocate(amount, [40, 30, 30])。底层原理distribute()如何消化余数allocate()的公共 API 定义在 packages/dinero.js/src/api/allocate.ts真正实现是 core/api/allocate.ts 中的safeAllocate它会先校验 ratios 必须非空、非负且至少一个非零否则抛出INVALID_RATIOS_MESSAGE并把所有 ratio 归一化到统一 scale 后再调用distribute。余数分配的关键在 core/utils/distribute.tsconst shares ratios.map((ratio) { const share calculator.integerDivide(calculator.multiply(value, ratio), total) || zero; remainder calculator.subtract(remainder, share); return share; }); // 按 ratio 降序排序余数优先分给占比大的成员 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); while (compare(remainder, zero)) { const index sortedIndices[i % sortedIndices.length]; shares[index] calculator.add(shares[index], amount); ... }算法分两步先做整数除法integerDivide(value * ratio / total)得到整数份额把余数收集起来再把余数按 1 个单位逐一分给占比最大的成员即剩余几分钱优先补给比例更高的人保证所有份额之和恰好等于原金额。以示例中 Uber 账单3500分给 3 人ratio 34/33/33为例1190 1155 1155 3500一分不差。表单侧的校验分摊输入在 add-expense.tsx金额输入step0.01 min0百分比模式实时显示Total: {totalPercentage}%非 100% 时提示(should be 100%)提交按钮在totalPercentage ! 100时被禁用从源头保证 ratio 合法。五、余额追踪add()与subtract()构建净余额calculateNetBalanceslib/money.ts遍历所有账单维护每个人的净余额export function calculateNetBalances( expenses: Expense[], people: Person[] ): Mapstring, Dineronumber { const balances new Mapstring, Dineronumber(); for (const person of people) { balances.set(person.id, zero()); } for (const expense of expenses) { const shares calculateShares(expense, people); balances.set( expense.paidBy, add(balances.get(expense.paidBy)!, expense.amount) // 付款人 全额 ); for (const [personId, share] of shares) { balances.set(personId, subtract(balances.get(personId)!, share)); // 参与者 - 份额 } } return balances; }逻辑非常直观付款人余额加全额每个参与者余额减去自己的份额。最终余额为正的人是债权人isPositive为负的是债务人isNegative为零表示已结清。界面渲染在 balances.tsxisPositive(balance)显示绿色上箭头与金额isNegative(balance)用negate(balance)即multiply(amount, -1)定义见 lib/money.ts取绝对值后以红色下箭头显示isZero则显示 Settled。六、最优结算贪心算法匹配最大债权人与最大债务人calculateSettlementslib/money.ts是应用的技术亮点目标是用最少的转账笔数清账。注释明确指出它采用贪心策略match the largest creditor with the largest debtor。const creditors: { id: string; amount: Dineronumber }[] []; const debtors: { id: string; amount: Dineronumber }[] []; for (const [personId, balance] of netBalances) { if (isPositive(balance)) { creditors.push({ id: personId, amount: balance }); } else if (isNegative(balance)) { debtors.push({ id: personId, amount: negate(balance) }); } } creditors.sort((a, b) compare(b.amount, a.amount)); debtors.sort((a, b) compare(b.amount, a.amount)); let i 0; let j 0; while (i creditors.length j debtors.length) { // ... if (greaterThan(creditor.amount, debtor.amount)) { settlementAmount debtor.amount; creditor.amount subtract(creditor.amount, debtor.amount); debtor.amount zero(); j; } else if (greaterThan(debtor.amount, creditor.amount)) { settlementAmount creditor.amount; debtor.amount subtract(debtor.amount, creditor.amount); creditor.amount zero(); i; } else { settlementAmount creditor.amount; creditor.amount zero(); debtor.amount zero(); i; j; } settlements.push({ from: debtor.id, to: creditor.id, amount: settlementAmount }); }算法要点compare(b.amount, a.amount)降序排序债权人和债务人比较函数定义见 packages/dinero.js/src/api/compare.ts每次取出欠款最多的人和应收最多的人配对greaterThan判断谁先清空一方清零即指针前进另一方带着剩余额进入下一轮每次配对产生一笔{ from, to, amount }结算转账笔数最多为min(债权人数量, 债务人数量)。结算结果由 settlements.tsx 渲染为 Alice → Bob $40.00 样式的列表当settlements.length 0时显示 All settled up!即所有人净余额为零。七、货币格式化toDecimal()Intl.NumberFormat格式化函数lib/money.ts把 Dinero 对象转换为本地化货币字符串const formatter new Intl.NumberFormat(en-US, { style: currency, currency: USD, }); export function formatMoney(amount: Dineronumber): string { return toDecimal(amount, ({ value }) { return formatter.format(Number(value)); }); }toDecimal的公开 API 在 packages/dinero.js/src/api/toDecimal.ts内部实现见 core/api/toDecimal.ts它先校验货币必须为十进制base 是 10 的幂否则抛出NON_DECIMAL_CURRENCY_MESSAGE这也解释了 FAQ 中为何不能用大数货币直接格式化 的场景再按 scale 切分整数与小数部分默认返回120.00这样的字符串传入 transformer 回调后可对{ value, currency }做任意自定义处理——这里正是把字符串120.00交给Intl.NumberFormat渲染成$120.00。整个应用所有金额展示账单、余额、结算都复用formatMoney保证了显示格式全局一致。八、本地持久化toSnapshot()与dinero()的序列化往返应用把账单存进 LocalStorage但 Dinero 对象是类实例不能直接 JSON 序列化。解决方案定义在 App.tsxfunction serializeExpenses(expenses: Expense[]): StoredExpense[] { return expenses.map((expense) ({ id: expense.id, description: expense.description, amount: snapshot(expense.amount), // toSnapshot(...).amount整数分 paidBy: expense.paidBy, splitType: expense.splitType, shares: expense.shares, createdAt: expense.createdAt.toISOString(), })); } function deserializeExpenses(stored: StoredExpense[]): Expense[] { return stored.map((expense) ({ id: expense.id, description: expense.description, amount: fromAmount(expense.amount), // 用整数分重新构造 dinero({ amount, currency: USD }) paidBy: expense.paidBy, splitType: expense.splitType, shares: expense.shares, createdAt: new Date(expense.createdAt), })); }序列化toSnapshot(amount)返回{ amount, currency, scale }纯数据对象packages/dinero.js/src/api/toSnapshot.ts 的实现直接就是dineroObject.toJSON()snapshot()再取出其中的amount整数分存入StoredExpense反序列化fromAmount用整数分重新调用dinero({ amount, currency: USD })恢复成完整的 Dinero 对象。之所以只存整数分而非字符串正是利用 Dinero.js 金额即最小单位整数的模型让序列化/反序列化零损失、零浮点误差。这种toSnapshot()往返模式同样适用于 transporting-and-restoring 文档所描述的跨端传输场景。九、状态联动与删除级联App.tsx 展示了如何让状态保持自洽默认数据DEFAULT_EXPENSES用整数分硬编码Dinner 12000、Uber 3500、Concert tickets 20000对应三种分摊形态4 人均摊、3 人 34/33/33 均摊、3 人 40/30/30 百分比setExpenses统一经过 serialize/deserialize 往返保证 React state 中始终是普通 JSON而派生计算中始终是 Dinero 对象删除成员时级联处理过滤掉该成员付款的账单、从所有账单的 shares 中移除该成员并丢弃已无参与者的账单App.tsx金额合法性由 add-expense.tsx 的isNaN(amountInCents) || amountInCents 0校验兜底。十、延伸阅读API 参考allocate、toDecimal、toSnapshot、compare、add、subtract、multiply核心概念金额模型、scale 与刻度、格式化、变更操作实战指南从浮点数创建金额、非十进制货币格式化、数据库存储、传输与恢复同仓库其他示例cart-react、pricing-react、invoice-builder、portfolio-tracker可对比不同场景下的 Dinero.js 用法结语Expense Splitter 虽小却完整覆盖了 Dinero.js 在真实应用中的全链路dinero()构造 →allocate()分摊余数零丢失→add/subtract记账 →compare/greaterThan/isZero贪心结算 →toDecimal格式化展示 →toSnapshot持久化。它验证了 Dinero.js 最核心的设计哲学——金额始终以最小单位整数存储所有运算都经由可注入的 calculator 完成number 与 bigint 两种实现见 packages/dinero.js/src/calculator从而在浏览器、Node 与任意精度场景下都能得到确定性的计算结果。以此示例为起点你可以把同一套模式移植到订单结算、AA 记账、报销系统等任何需要算钱的前端应用中。赞分享金融科技【免费下载链接】dinero.jsCreate, calculate, and format money in JavaScript and TypeScript项目地址https://gitcode.com/gh_mirrors/di/dinero.js点击查看免费下载相关推荐用 Go 实现 Splitwise 低层设计LLD账单分摊与余额结算的完整实践用 Go 实现 Splitwise 低层设计LLD账单分摊与余额结算的完整实践 导读 本文以 awesome low level design 仓库中的示例工程Splitwise 低层设计实战Java 实现费用分摊、余额记账与债务简化Splitwise 低层设计实战Java 实现费用分摊、余额记账与债务简化 Splitwise 是经典的低层设计LLD面试题其核心是「把一笔费用按规则摊示例工程TREK 旅行预算与费用分摊完全指南多币种记账、成员分摊与自动结算Costs / BudgetTREK 旅行预算与费用分摊完全指南多币种记账、成员分摊与自动结算Costs / Budget 本文基于 TREK 开源仓库的 wiki/Budget T后端前端MCP 服务AI 应用上一篇Intel I225/I226网卡驱动适配群晖NAS 2.5G网络性能解锁方案下一篇如何安装Silent-Hill-2-Enhancements新手必备的增强版《寂静岭2》Setup指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询