十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3+TypeScript+Pinia 资金信息Store从设计到落地实践

Vue3+TypeScript+Pinia 资金信息Store从设计到落地实践 去年接手一个带资金流水模块的后台管理系统页面从 Vue2 迁到 Vue3状态管理也从 Vuex JS 换成了 Pinia TypeScript。做完这个改造之后陆续有同事和朋友来问资金这类数据到底该怎么放 Store为什么不能用 Vuex 直接接着用TypeScript 在这里到底解决了什么问题所以这篇就用我实际跑过两个迭代的“资金信息 Store”为例子完整复盘一下从设计到落地的全过程包括为什么用 Vue3TypeScript、为什么选 Pinia、资金数据在状态管理里有哪些特别容易踩的坑以及最终代码长什么样、值不值得参考。这套方案不是给简单 Demo 用的而是给真实业务里那种多页面共享余额、有提现和支付流程、需要持久化、需要多标签同步的项目准备的。如果你正在做电商后台、财务系统、会员钱包这类前端或者你刚入手 Vue3 TypeScript 但不太确定 Store 层怎么组织这篇应该能帮你省不少试错时间。1. 资金信息类状态管理到底难在哪1.1 资金数据与普通业务数据的差异我接手这个系统的时候交互体验还是小事真正让人头疼的是数据一致性。普通业务数据比如商品列表、通知消息丢了可以从后端拉一次延迟几秒也无所谓。资金数据不行余额多了一个零、支付状态错了、提现记录被覆盖用户直接投诉甚至可能涉及赔付。资金数据有三个显著特征这也是它必须被“特殊对待”的原因。第一是强一致性。同一时刻一个账户的余额只能是一个值不能因为用户开了两个标签页就出现“A 页面显示余额 100B 页面显示余额 90”的情况。普通列表数据没这么严格资金数据稍有不一致前端体验立刻崩坏。第二是可追溯性。余额的每一次变化都应该对应一条流水记录哪怕用户不查后台也要能审计。前端虽然不负责最终持久化但状态流转必须守住底线不能把 UI 的临时状态塞进真正的资金字段。第三是全局共享。钱包余额可能同时出现在导航栏、个人中心、下单页、充值页而且这些页面必须保持同步。你在充值页充完钱回到首页余额还是旧值那就是状态管理没做到位。维度普通业务数据资金数据一致性要求较弱允许短暂不一致强同一时刻必须唯一错误成本低刷新可修复高可能直接影响用户资产共享范围局部页面全局多页面审计要求一般没有必须有变更记录这决定了资金信息不能简单用“每个页面各自请求”的方式而是需要有一个统一的状态管理层把数据的读写入口收敛起来。1.2 “资金信息Store”的核心需求拆解围绕资金信息这个领域我梳理出四类核心状态。第一是用户钱包账户余额、冻结金额、可提现余额、币种这些基础信息。第二是资金流水每一条收支明细包括时间、类型、金额、余额快照、关联订单号。第三是订单支付状态pending、paid、refunded 等状态这些状态会直接驱动页面 UI 变化。第四是资产汇总视图总资产、今日收入、本月支出等派生数据。对应到操作层主要是这些查询余额、刷新流水、创建充值单、创建提现单、订单支付、支付结果回调、退款。这些操作基本都是异步请求然后把结果写回 Store再同步到所有消费页面。我在设计之前先列了一个矩阵哪些状态是只读展示哪些会被修改修改来源是用户操作还是后端回调。这样就能明确区分哪些用 getters 派生、哪些用 actions 修改避免一股脑把所有字段都塞进 state结果越写越乱。1.3 为什么选 Vue3 TypeScript这次重构最核心的原因是旧代码里资金字段到处用 any从后端拿到 price 直接渲染类型完全失控。切到 Vue3 TypeScript 后最大的收益不是“写起来多爽”而是把约束前置到了编译期。Vue3 的组合式 API 很适合把资金逻辑封装成独立的 composable 或 store不再像 Options API 那样把数据和方法散落在各个组件里。TypeScript 则可以把钱包状态、流记录这些结构用接口定义好业务上不允许出现的字段、不允许出现的类型在编译阶段就拦下来。比如 amount 必须定义为以“分”为单位的整数如果有人往 store 里塞一个字符串或者一个小数编辑器会直接标红。还有一个隐藏好处重构时特别安全。我改过一次 FinancialRecord 结构所有用到这个类型的地方一起报错能顺着错误列表把影响面全部清理干净不会出现“改完 A 页面B 页面悄悄崩了”的情况。2. Store 方案选型与整体设计思路2.1 为什么我最终选了 Pinia 而不是 Vuex项目从 Vuex 4 开始评估翻了半天文档说实话体验一般。Vuex 4 虽然能在 Vue3 里跑但 TypeScript 支持还是要靠 Module 包一层或者自己写一堆辅助类型模块化带来的嵌套也很重。对一个以资金模块为核心的中小型项目来说心智负担不划算。Pinia 在几个点上正好打中需求。结构扁平不需要像 Vuex 那样嵌套 Module天然支持多个独立 store支持 setup store 写法本质上就是一个 useXxx 函数和组合式 API 无缝配合TypeScript 类型推导几乎零配置state、getters、actions 都有完整类型自带 HMR开发时热更新不会清空状态devtools 直接支持时间旅行调试没有问题我选型时用了一个很土但有效的判断标准把两个方案都写一遍同样的钱包 store看哪个代码让新人更容易读懂。结果 Pinia 的代码量少了一半类型还更清晰所以很快定了 Pinia。2.2 资金信息Store的模块划分接下来是模块划分。我按业务领域而不是按页面维度来拆最终分成三个独立 store 文件。walletStore钱包账户相关的余额、冻结、可提现额度transactionStore资金流水、收支明细列表orderStore支付订单状态与订单结果这样划分是经过考量的。如果按页面划分把“首页的余额卡片”和“充值页的操作”塞进同一个 store页面一多必然出现重复状态。按领域划分后页面只是这些 store 的消费者新增页面不会导致结构膨胀而且每个 store 的职责边界非常清楚。模块之间允许存在依赖但尽量保持单向。例如在 orderStore 里支付成功后需要更新 walletStore 的余额我会在 action 内部调用 useWalletStore 的 action形成一条明确的调用链而不是把钱包余额在 orderStore 里再复制一份。复制数据意味着两个来源两个来源就会产生不一致。2.3 状态结构设计从“能用”到“好维护”资金相关的状态结构我坚持三条原则扁平化、不存重复数据、派生数据用 getters 计算。以钱包 store 为例state 里只保存后端返回的原始数据balance、frozenAmount、currency、updatedAt。而“可提现余额 balance - frozenAmount”“总资产 余额 冻结 其他账户”这类都不应该存进 state而是在 getters 里实时计算。为什么这样设计因为一旦把派生数据存进 state就制造了一个冗余副本。每次余额更新你都得记得同步那个副本一旦漏掉页面上就会出现“余额显示 100可提现却显示 80”的奇怪 bug。用 computed 派生则永远不会有这个问题因为它是从原始数据实时算出来的。我在 store 里还加了一个约定所有通过 action 修改 state 的地方必须同时生成一条流水记录。哪怕是内部调整了冻结金额也要有对应的审计痕迹。这个约定让排查问题非常舒服线上反馈余额不对时顺着流水一查就能精确定位到是哪个操作导致的。3. 核心实现细节与实操要点3.1 资金数据的TypeScript类型建模资金数据建模是整个 Store 的地基类型定义决定了后续所有代码的上限。先定义基础金额类型。实际开发中我用“分”作为存储单位所以金额字段基本都是 number 整数。虽然也可以用 string 来避免精度问题但前端做计算时string 反而容易踩到隐式转换的坑。我的选择是 number 整数分一劳永逸。// 基础金额类型以“分”为单位存储避免浮点误差 export type Money number export enum Currency { CNY CNY, USD USD } export interface BalanceSnapshot { before: Money after: Money }这里 Money 是 number 的别名本质上是提供语义约束看到 Money 就知道这是以分为单位的整数不应该直接当小数处理。如果同事不小心传了小数Review 时一眼就能发现问题。再定义资金记录和订单状态。订单状态我建议使用联合类型而不是枚举因为后端返回的就是字符串联合类型可以保证类型和运行时值一致不会出现枚举映射不一致的情况。export type OrderStatus pending | paid | refunded | failed export interface FinancialRecord { id: string type: recharge | withdraw | pay | refund amount: Money balanceAfter: Money status: OrderStatus createdAt: string orderId?: string remark?: string }好的类型设计能让业务代码很舒服。比如 order 的状态只允许固定四个字符串其他地方想写 ‘success’ 或 ‘successed’ 会直接报错。FinancialRecord 里 orderId 是可选字段因为充值流水不一定有订单这样语义就体现在类型里了。3.2 Store的基本实现State 与 Getter我用 Pinia 的 setup store 写法定义钱包 store。相比 options storesetup 写法更像写 composable逻辑组织更自由也可以复用子函数。import { defineStore } from pinia import { ref, computed } from vue import type { FinancialRecord, Money } from ../types export const useWalletStore defineStore(wallet, () { // state const balance refMoney(0) const frozenAmount refMoney(0) const currency refCNY | USD(CNY) const records refFinancialRecord[]([]) const lastUpdatedAt refstring() // getters const availableAmount computed(() balance.value - frozenAmount.value) const totalAmount computed(() balance.value frozenAmount.value) // actions function setWalletInfo(data: { balance: Money frozenAmount: Money currency: CNY | USD updatedAt: string }) { balance.value data.balance frozenAmount.value data.frozenAmount currency.value data.currency lastUpdatedAt.value data.updatedAt } function addRecord(record: FinancialRecord) { records.value.unshift(record) } return { balance, frozenAmount, currency, records, lastUpdatedAt, availableAmount, totalAmount, setWalletInfo, addRecord } })这里要强调一点getters 在 setup store 里用 computed 实现它们和 state 一样具有响应性。在组件模板里直接写 store.availableAmount 没问题但如果想解构使用必须用 storeToRefs直接解构会丢失响应式。这个坑我在第 4 节会详细展开。3.3 Actions 中的金额计算与边界处理Actions 是资金 Store 最容易出 bug 的地方因为金额计算本身有精度、边界、审计三个层面的问题。先说精度。前端金额计算我坚持一条硬性规范所有状态存储都使用整数分禁止在 store 中出现浮点数运算。比如要计算“余额减扣款”不要写 100.5 - 0.1而是统一成 10050 - 10。如果后端接口返回的是“元”前端拿到后先转成“分”再存进 store。再说边界。一个常见的资金操作是“创建提现申请”提现金额不能大于可用余额。这个校验不能只放在表单页store action 内部也要做双保险。async function createWithdraw(amount: Money) { if (amount 0) { throw new Error(提现金额必须大于0) } if (amount availableAmount.value) { throw new Error(提现金额不能超过可用余额) } const before balance.value // 调用后端接口创建提现单 await api.createWithdrawOrder(amount) // 成功后本地先行扣减并冻结 balance.value - amount frozenAmount.value amount addRecord({ id: generateId(), type: withdraw, amount, balanceAfter: balance.value, status: pending, createdAt: new Date().toISOString(), remark: 提现单创建变更前余额 ${before} }) }注意这里先把可用余额算出来再校验操作结束后还写了一条流水。如果后端失败必须在 catch 里回滚本地状态或者干脆不让前端先改。我见过很多组件直接去调 store 的 state 改字段接口一失败本地状态就跟后端不一致了。另外为了操作可审计我在流水里带上了前置余额 before。这个信息不是给用户看的而是给后续排查用的非常关键。3.4 异步资金操作请求、竞态与幂等性资金 Store 里最麻烦的不是同步计算而是异步请求。常见的三个场景每个我都踩过。第一个是竞态。用户快速点击“创建订单”按钮前端发了两次请求后端创建了两张订单用户被扣两次款。核心是幂等性前端在 actions 里可以加一个 pending 开关。let submitting false async function createRechargeOrder(amount: Money) { if (submitting) return submitting true try { const order await api.createRechargeOrder(amount) // 处理订单 } finally { submitting false } }第二个是轮询支付结果。创建完支付单后前端需要轮询后端确认支付是否成功。如果用 setInterval 在组件里写组件一销毁就乱了。我把轮询逻辑放进 store action并暴露 stop 方法组件 onUnmounted 时调用避免泄漏。let pollTimer: number | undefined function startPollingPayment(orderId: string) { stopPolling() pollTimer window.setInterval(async () { const status await api.getOrderStatus(orderId) if (status paid) { // 更新订单状态和余额 stopPolling() } }, 2000) } function stopPolling() { if (pollTimer) { clearInterval(pollTimer) pollTimer undefined } }第三个是接口请求乱序。比如刷新余额和提交提现两个请求并发返回顺序不一致可能导致旧数据覆盖新数据。一个简单的方案是在 store 里维护 lastRequestId每次请求前自增返回时只接受最新响应。3.5 持久化与多标签页同步资金信息 Store 如果刷新就丢失体验会很差。我把钱包余额和基础信息做了一层 localStorage 持久化刷新后先展示缓存再请求后端更新。这里有个很容易踩的坑金额字段在 JSON.parse 之后可能变成字符串。如果后端返回的余额是 “1000”存进 localStorage 再读出来就是字符串直接做运算会变成字符串拼接。所以我封装了一个 safeParseWallet 函数解析后对字段类型做一次校验非 number 就重置为 0。function safeParseWallet(raw: string | null): WalletCache | null { if (!raw) return null try { const data JSON.parse(raw) if (typeof data.balance number typeof data.frozenAmount number) { return data } return null } catch { return null } }多标签页同步我也踩过坑。用户同时开着两个后台页面在 A 页面充值成功B 页面如果不刷新仍显示旧余额。解决方法是使用 Web Storage 的 storage 事件在一个 tab 修改 localStorage 后其他 tab 会收到事件此时重新读取缓存并更新 store。这个方案改动小、纯前端能搞定我实测下来比轮询更轻量。4. 开发中高频踩坑实录与排查技巧4.1 TypeScript 类型报错Property ‘balance’ does not exist刚开始很多人会在组件里直接 import 一个 store 文件然后写 store.balance结果报错 “Property ‘balance’ does not exist on type”。原因多半是 store 实例化前类型推导失败。还有一个隐蔽原因是循环引用A store 里 import B storeB store 又 import A store导致 Pinia 在解析类型时拿到一个不完整的类型。解决办法是先用 useWalletStore() 拿到实例再访问属性检查 store 文件之间是否成环如果实在避免不了相互依赖就在 action 内部再懒调用另一个 store不要在模块顶层互相引用。4.2 金额精度0.1 0.2 的问题经典问题但在资金场景里特别致命。我有一个真实案例优惠券支付时前端用元直接计算0.1 0.2 0.30000000000000004导致支付金额校验不过。后来全局改造把所有前端展示前的计算都换成整数分。具体做法是请求到元以后乘以 100 取整存 store展示时除以 100 并调用 toFixed(2)再加千分位格式化。这要求后端接口的金额字段语义必须明确最好后端也返回分前端就不用二次转换了。如果后端坚持返回元那就让后端文档里写清楚前端统一封装 parseMoney 和 formatMoney 两个工具函数。4.3 响应式丢失解构后状态不更新组合式 API 时代最经典的坑const { balance, availableAmount } store然后模板里直接用结果操作完 UI 不刷新。原因很简单store 实际上是一个 reactive 对象直接解构会把原始值复制出来切断响应式连接。正确姿势是使用 storeToRefs。import { storeToRefs } from pinia const { balance, availableAmount } storeToRefs(walletStore)action 不需要解构直接调用 walletStore.createWithdraw(1000) 就行。我建议在团队代码规范里直接禁止对 store 使用普通解构只允许 storeToRefs因为这是个隐蔽性很高的坑。4.4 从 localStorage 恢复后类型变形前面提过字符串问题这里补充一个更深层的坑JSON.parse 返回的时间字段是字符串订单状态也可能是任意字符串。如果直接把缓存对象覆盖到 store stateTypeScript 类型检查不会帮你做运行时校验因为 JSON.parse 返回 any。所以持久化恢复必须走一次“字段白名单校验”而不是无脑 Object.assign。校验失败就当作没有缓存重新请求后端。宁可闪一下 loading也不能展示脏数据。4.5 异步更新后 getter 不刷新这是一个伪问题但排查起来很浪费时间。用 computed 写的 getter 本身是响应式的不刷新的原因通常是 getter 依赖了非响应性数据比如在 getter 里读了一个组件局部变量或者直接访问了另一个非响应式模块的引用。解决方法是回看 getter 依赖的来源确保所有值都来自 ref、reactive 或其他 getter。如果依赖的是另一个 store 的 state也要使用响应式引用方式不要在 getter 里缓存非响应式值。问题现象核心解法TS 报错store.balance 不存在先实例化检查循环引用金额精度0.10.2 不等于 0.3统一用整数分存储响应式丢失解构后 UI 不刷新使用 storeToRefs持久化类型变形数字变字符串白名单字段校验getter 不更新依赖源不是响应式检查 computed 依赖来源4.6 轮询重复启动轮询支付结果时如果进入页面多次就会启动多个轮询。后端一回调多个轮询同时更新状态轻则闪烁重则重复写入记录。解决方式是 store 内部用一个实例级变量记录 pollTimer启动前先 clearInterval。这个我在 3.4 节已经给了代码实际用下来稳定很多。5. 项目整体评价与后续演进5.1 这套方案给我带来的实际改变切到 Vue3 TypeScript Pinia 之后最直观的变化是资金相关的 bug 数量明显下降尤其是因为字段误用、类型不明导致的低级错误几乎消失了。以前在 Vuex 里随便定义 state组件里直接 this.$store.state.wallet.balance根本没有约束。现在所有字段都有明确的类型和归属代码 Review 也比以前轻松很多。页面侧变得更“傻”组件几乎只剩 UI 和事件分发资金逻辑都在 store 里可测试性也提高了。我甚至把 walletStore 的 action 单独写了一轮单测模拟余额不足、精度异常、接口失败等场景这在纯 Vuex JS 时代几乎是不可能的。5.2 哪些地方还有改进空间如果说这套方案还有什么遗憾我觉得有三点。第一错误处理还没有完全收敛。部分操作失败后只 throw Error组件层 catch 后弹出 message如果页面已经卸载弹窗可能会出现。后续应该增加统一的 action 错误回调机制或者在 store 里维护一个 errorMessage 状态。第二资金操作缺少操作日志埋点。目前只在 store 内生成 FinancialRecord但用户行为轨迹比如点击了哪个按钮、停留了多久没有接入埋点系统。虽然这不是 store 的职责但资金系统出事时前端能提供的上下文越多越好。第三pinia-plugin-persistedstate 这类插件虽然好用但行为比较黑盒。如果团队对持久化要求较高我更推荐自己写一个轻量插件这样能完全掌控字段校验的时机。5.3 可以迁移到其他业务域这套“类型驱动 领域拆分 审计流水的 store 设计”并不只适用于资金。类似的敏感状态域还有很多库存管理库存量、预占量、优惠券/积分余额、过期时间、会员等级当前等级、成长值、甚至角色权限权限位、开关。它们的共性是有强一致性要求、有频繁的全局共享、有明确的派生计算在 localStorage 持久化、多标签同步这些需求上也完全一样。如果你已经理解了资金 Store 的设计思路迁移到这些场景基本就是换套类型定义和接口调用。我个人做资金类前端模块时最大的体会是先把数据模型和边界条件定义清楚再去写页面和接口。TypeScript 不是用来折磨人的而是把一个容易出错的约束变成编译器帮你检查的规则。如果你正在用 Vue3 TypeScript想把资金模块管好建议从今天开始给自己的余额字段加上“分”这个单位给每个修改余额的 action 加上一条流水把解构响应式那几个坑记在团队规范里。这套组合我跑了两个版本迭代整体非常稳定后续如果你们要动资金模块可以直接照着这个思路搭。
返回列表