纯前端实现银行模拟器:React状态管理与数据持久化实战

📅 发布时间:2026/9/3 4:12:39
纯前端实现银行模拟器:React状态管理与数据持久化实战 简介react-bank是一个采用React与Next.js开发的虚拟银行模拟器面向初中级前端开发者适合用于学习组件化开发和业务逻辑组织。项目用纯前端技术模拟了余额查询、存入余额、Pix转账、朋友列表等银行功能并实现了银、金、铂三档账户计划——不同级别对应不同余额门槛及信用额度能直观展示前端如何处理真实业务规则。压缩包共39个文件以15个TSX组件和10个SCSS样式表为主另有JSON配置、CSS和JPG图片素材等整体仅1.88MB目录结构清晰易读。已有482人学习源码中可同时掌握TypeScript类型约束、React组件划分、Sass嵌套写法以及Next.js页面组织技巧。从模拟银行场景切入既锻炼了前端交互设计能力也提供了贴近业务的状态管理与权限控制思路是一份紧凑实用的前端进阶参考。 先聊点实在的我在前端技术社区逛的时候刷到一个很有意思的项目——react-bank全程只用 React 做了一整套虚拟银行模拟器。初次看觉得就是个玩具项目核心理念其实很有价值不依赖任何后端服务把账户管理、存取款、转账这些银行基础业务全在前端跑通了。特别适合正在学 React、准备面试或者想独立搞一个完整项目的朋友拿来参考。整个项目最打动我的点在于它用纯前端方案打赢了一场本应该属于后端的仗。对前后端边界探索、React 状态管理实战、表单交互细节打磨来说这几乎是最完整的练手素材之一。下面我直接展开聊聊这个项目的技术内幕、实现思路和我在复现过程中踩过的坑。1. 项目核心思路拆解为什么纯前端做得了银行模拟1.1 从需求倒推技术选型先说结论这个项目表面上是模拟银行内里是一套完整的前端状态管理考题。账户开户、余额变动、交易流水、转账收款这些在传统业务里都是典型的数据库事务操作但在纯前端环境下这些动作全部转化成了一层更本质的问题——如何在浏览器里可靠地管理会变的数据。选型逻辑很清晰React 的组件树天然适合表达账户、账单、操作面板这些 UI 模块而状态管理则用 React 自带的 Hooks 体系解决。没有引入 Redux 或 MobX这个决策我认为非常聪明。原因很简单对于一个中大体型的教学项目useReducer Context 组合已经能覆盖绝大多数场景硬上 Redux 只会让代码多一层繁琐的样板逻辑。1.2 模块边界设计前端也要有领域模型这个项目在模块划分上做得比较规范。常规 React 项目容易把所有逻辑堆在组件里react-bank 的做法是把账户数据模型、操作逻辑和 UI 层分开即便没有后端领域逻辑仍然独立存在。账户数据模型定义账户的基本结构、余额、流水记录业务操作逻辑存款、取款、转账等操作的处理函数UI 展示组件账户卡片、交易列表、操作表单这个分层思路直接映射到印象中的传统后端三层架构Controller-Service-DAO 的变体。前端虽然不需要处理并发和持久化事务但把数据、逻辑、视图分开的思路对后期扩展和调试都有巨大帮助。1.3 适合谁去复现这个项目如果你是React 入门但写腻了 Todo List 的人这个项目价值最大。它覆盖了 React 学习路线中多个核心阶段useState 到 useReducer 的状态进阶、Context 跨组件通信、自定义 Hooks 抽离公共逻辑、表单受控组件处理以及 React 18 新版自动批处理机制下的一些行为差异。如果是准备面试前端面试题里关于组件通信、状态提升、Hooks 闭包陷阱等常见考点都能在这个项目里找到活生生的例子比背八股文强太多了。2. 核心实现解析状态管理与数据持久化2.1 状态管理useReducer 比 useState 更适合业务场景项目里有多个账户、多笔流水账户之间还会发生转账关联如果所有状态都用 useState 分散管理组件一多就乱。react-bank 选择了 useReducer 来集中管理业务数据这个决策非常贴近真实生产环境的需求。useReducer 的价值在于把操作意图和数据变更收敛到唯一的 reducer 函数里调试时只需要看 dispatch 的动作类型就能知道发生了什么。比如function bankReducer(state, action) { switch (action.type) { case deposit: { return { ...state, accounts: state.accounts.map(acc acc.id action.payload.accountId ? { ...acc, balance: acc.balance action.payload.amount } : acc ), transactions: [ { type: deposit, accountId: action.payload.accountId, amount: action.payload.amount, time: Date.now() }, ...state.transactions ] }; } case withdraw: { if (!canWithdraw(state.accounts, action.payload.accountId, action.payload.amount)) { return state; } return { /* ...省略类似逻辑 */ }; } default: return state; } }这种写法的核心收益体现在三个方面状态变化路径单一、业务规则集中校验、React 18 下并发特性友好。尤其是 React 18 引入了更激进的自动批处理机制如果你还在用一堆分散的 setState同一事件里多次更新可能因为批处理而出现意想不到的中间态而 reducer 天然解决了这个问题。2.2 那些容易被忽略的 Hooks 细节用 Hooks 写业务逻辑时细节很重要。这次实现里我印象最深的是 useEffect 的依赖数组问题。比如监听账户余额变化来更新页面标题useEffect(() { document.title 当前余额: ${formatAmount(totalBalance)} 元; }, [totalBalance]);如果依赖数组里少写了 totalBalance效果就是页面标题永远停留在第一次渲染时的值这是 React 初学者最容易踩的坑。另一种情况是依赖数组里放了引用类型导致每次渲染都触发 effect造成死循环。实际开发中建议用 ESLint 的 exhaustive-deps 规则来检查但这个项目因为是手动构建所以我用了最笨也最有效的方式——每次运行后在浏览器里观察 Console 是否有连续打印。业务逻辑复杂后用自定义 Hooks 抽离逻辑非常有效。比如把账户操作封装成 useBankAccountfunction useBankAccount(accountId) { const { state, dispatch } useContext(BankContext); const deposit useCallback((amount) { dispatch({ type: deposit, payload: { accountId, amount } }); }, [accountId, dispatch]); const withdraw useCallback((amount) { dispatch({ type: withdraw, payload: { accountId, amount } }); }, [accountId, dispatch]); return { balance: state.accounts.find(a a.id accountId)?.balance || 0, deposit, withdraw }; }用 useCallback 包裹函数避免子组件无谓重渲染用 useContext 访问全局状态整个代码立刻有了生产级项目的影子。2.3 localStorage 持久化底层原理与封装方案纯前端模拟器有个绕不开的需求刷新页面之后数据不能丢。react-bank 的做法是把账户数据同步到 localStorage这个方案实现成本低浏览器原生支持数据量对当前的场景完全够用。自己手写封装的时候有两个关键细节。第一localStorage 只能存字符串对象需要 JSON.stringify读取时再 JSON.parse。第二读写操作如果频繁执行会影响性能需要做一层节流。我参考了社区版本的实现做了一个轻量封装function usePersistedReducer(reducer, initialState, key) { const [state, dispatch] useReducer(reducer, initialState, (initial) { const persisted localStorage.getItem(key); return persisted ? JSON.parse(persisted) : initial; }); useEffect(() { const timer setTimeout(() { localStorage.setItem(key, JSON.stringify(state)); }, 300); return () clearTimeout(timer); }, [state, key]); return [state, dispatch]; }用这一层 Hook 替换普通 useReducer 后数据持久化自动化完成。不过这里必须提醒一个坑localStorage 的数据是明文存储的任何能打开 DevTools 的人都能直接改余额。真实项目里千万不要用这种方式存储敏感信息虚拟模拟器只是为了演示方便。2.4 金额计算精度避免浮点数缺陷前端做金额计算时最容易踩的坑就是浮点数精度问题。举个例子在 JavaScript 里直接跑 0.1 0.2结果大概率是 0.30000000000000004。银行模拟器如果出现这种数字交互体验直接崩塌。react-bank 的稳妥处理方式是把所有金额统一换算成最小单位分进行整数运算展示时再格式化为元。整数的加减乘除在 JavaScript 里是精确的只有涉及小数运算时才会出现精度问题。这是所有涉及钱的前端项目必须遵守的底线规则。// 内部存储和计算全部使用分 const depositAmountInCents Math.round(inputAmount * 100); // 展示时再格式化成元 const formatAmount (cents) (cents / 100).toFixed(2);做转账功能时尤其要注意在检测余额是否充足时必须使用整数比较。用浮点数比较的话因为精度误差可能会出现余额显示为 100.00实际计算时却判定不足 100 元的诡异情况。3. 实操过程与核心环节实现3.1 搭建项目骨架创建 react-bank 项目我用的是 Vite对比 Webpack 方案Vite 启动速度快开发体验现代对 React 18 的支持也更原生。初始化命令很简单npm create vitelatest react-bank -- --template react-ts这里我选择了 TypeScript 模板虽然纯 JS 也能写但 TypeScript 带来的类型约束能把账户模型、交易记录这些数据结构定义得清清楚楚写代码时的智能提示和重构安全性都会有一个大台阶的提升。项目创建好之后目录结构我建议按照功能模块组织src/components通用 UI 组件src/hooks业务逻辑 Hookssrc/reducers状态管理逻辑src/types类型定义3.2 交易数据模型定义模拟银行系统中最核心的数据结构是账户和交易记录。账户相对简单交易记录则需要考虑多账户关联的索引问题。参考 react-bank 的设计我定义的数据结构如下interface Account { id: string; name: string; balance: number; // 单位分 createdAt: number; } interface Transaction { id: string; type: deposit | withdraw | transfer; amount: number; // 单位分 fromAccountId?: string; toAccountId?: string; description?: string; timestamp: number; }这样定义的核心逻辑是让每笔交易自带完整的业务上下文。渲染交易列表时不需要回溯账户表去拼接信息同时为后续扩展交易分类、时间筛选等功能预留了空间。3.3 组件拆分与通信方案React 组件通信是面试高频题在 react-bank 这个项目里我推荐分三层通信策略顶层用户上下文如语言环境、用户信息用 Context 跨层级传递。React 18 中 Context 的消费方在 Provider value 变化时会自动重新渲染甚至不需要中间组件手动传递 props因此在全局数据上使用非常合适。const BankContext createContext(null); export function BankProvider({ children }) { const [state, dispatch] usePersistedReducer(bankReducer, initialState, react-bank-state); // ... }页面内组件通信用 props 回调。比如账户卡片组件向父组件发起转账请求时直接调用父组件传进来的 onTransfer 函数父组件内部再 dispatch 对应的 action这种数据流单向且清晰。跨页面/跨路由通信如果做多页面靠全局 Context 兜底或者对于需要长期保留的数据继续持久化到 localStorage。react-bank 的核心场景是单页面应用所以 Context 方案完全够用了。3.4 表单处理与交互细节银行模拟器中有大量表单存款金额、取款金额、转账目标账户、备注信息等。React 中处理表单有两个选择受控组件和非受控组件。react-bank 的实际做法是受控组件。原因有二一是校验实时反馈方便比如取款金额不能超过余额输入时就能给出提示而不用等提交时才校验二是与 React 18 的自动批处理特性配合更好避免表单频繁更新状态下出现异常闪烁。const [amount, setAmount] useState(); const [error, setError] useState(); const handleSubmit (e) { e.preventDefault(); const parsed Number(amount); if (isNaN(parsed) || parsed 0) { setError(请输入合法的金额); return; } if (type withdraw parsed * 100 balance) { setError(余额不足); return; } // 提交操作 };一个常见误区是提交后直接手动清理表单。建议统一把表单的初始化值放到 key 属性控制中例如提交成功后将表单组件的 key 递增这样 React 会重新挂载组件表单自动回到初始状态比手动 setState 更干净。3.5 UI 反馈与视觉细节银行模拟器这类项目最怕的是做完之后看起来很廉价其实几个简单的细节就能让质感完全不同。首先金额变化时加一个微小的过渡动画比如余额数字在变化时有一个渐变动画或淡入效果视觉上会明显高级。其次操作成功或失败后的 Toast 提示必不可少这个项目里我封装了一个极简的事件提示机制const [toast, setToast] useState(null); // 在 reducer 执行成功后的回调中调用 const notify (message, type success) { setToast({ message, type }); setTimeout(() setToast(null), 3000); };交互反馈一旦到位整个项目的完成度和体验评价会有质的提升。4. 常见问题与排查心得实录和速查表4.1 React 18 严格模式导致 effect 执行两次开发时我遇到第一个奇怪的现象所有的初始化和请求类 useEffect 都执行了两次。查了资料才知道 React 18 引入的 StrictMode 在开发模式下会故意挂载-卸载-再挂载组件用于帮助开发者发现潜在副作用问题。排查思路很简单由于 StrictMode 只在开发模式启用生产构建后不会出现所以不影响最终功能。但如果是自己写的数据初始化逻辑比如往 localStorage 里写初始值重复执行可能会导致重复插入。解决办法是在 useEffect 里加一个 ref 标记或者在初始化函数里做幂等判断。4.2 useReducer 中不小心改变了原 state这是我在做转账功能时踩的一个大坑。在 reducer 里直接做 push 操作case transfer: { state.accounts.find(...).balance - amount; // 错误直接修改了原 state return state; }表面上看数据变化正常但 React 的浅比较机制下因为 state 的引用没有变组件可能不会重新渲染或者在不同时间点的并发场景里出现数据错乱。正确做法是使用展开运算符或结构拷贝方式生成全新对象case transfer: { return { ...state, accounts: state.accounts.map(acc { if (acc.id fromId) { return { ...acc, balance: acc.balance - amount }; } if (acc.id toId) { return { ...acc, balance: acc.balance amount }; } return acc; }) }; }这个坑非常经典几乎所有 React 实战项目里都会遇到面试时也经常被追问。4.3 常见问题速查表问题现象可能原因解决方案页面刷新后状态丢失未接入 localStorage 或 key 不一致统一使用 usePersistedReducer 封装检查 key 是否稳定金额显示很长的小数未使用整数分存储直接用浮点数运算所有内部计算换算为分展示时再格式化转账后 UI 未刷新在 reducer 中直接修改了原 state使用不可变更新模式展开运算符、map、filteruseEffect 无限循环依赖数组引用了内联对象/函数用 useMemo/useCallback 包装或调整依赖项表单验证不生效受控组件的 value 与 state 未绑定确保 input 的 value 和 onChange 都受控React 18 开发模式报 findDOMNode 警告不推荐直接操作 DOM用 ref 回调或 createRef 替代4.4 一个容易忽略的隐性 Bug类型转换在接 localStorage 时如果读取出来的数据是旧版本的格式比如之前存的balance是字符串后来改成 number就会出现界面显示异常但控制台完全没有报错的情况。这是纯前端模拟器最容易踩的隐性坑。建议在读取持久化数据后做一层结构校验和类型转换const persisted JSON.parse(localStorage.getItem(key)); const normalized { accounts: persisted.accounts.map(a ({ ...a, balance: Number(a.balance), // 强制转为数字 })), transactions: persisted.transactions || [] };这也是为什么生产级项目都会引入 schema 校验库比如 Zod。如果项目规模再大一点直接用这个方案会更省心。5. 本地调试与避坑技巧5.1 自定义 Hooks 的规则不能破React 的 Hooks 规则是只能在函数组件或自定义 Hooks 的顶层调用 Hooks不能在条件、循环或嵌套函数中调用。我在实现注册过的账户列表时一开始想在条件渲染里调用自定义 Hookif (accounts.length 0) { const persisted usePersistedReducer(...); // 错误不能在条件中调用 }这会导致 React 在多次渲染时 Hooks 调用顺序不一致抛出 Rendered fewer hooks than expected 之类的错误。排查方法其实很直接——把所有 Hooks 调用放到顶层条件判断放在 Hooks 的结果之后再做。5.2 使用 DevTools 检查组件重渲染React 开发者工具是调试这个项目的重要工具之一。开启 Highlight updates 选项后完成一次转账操作你能直观看到哪些组件发生了重新渲染。如果发现父组件一更新所有子组件全部闪烁就需要检查是否有组件没有经过 memo 包裹或者 Context 的 value 是否每次都创建了新对象。react-bank 这个项目因为引入了 Context如果 Context 的 value 是内联创建的对象BankContext.Provider value{{ state, dispatch }}这里有一个细节React 18 下 value 每次渲染都是新引用所有消费这个 Context 的组件都会重新渲染。优化的方式是用 useMemoconst contextValue useMemo(() ({ state, dispatch }), [state, dispatch]);这个小改动在高频操作比如连续转账时对页面流畅度的提升非常明显。5.3 最后的细节价值在我复现的过程中最大的收获是认识到前端项目不一定要依赖复杂架构才能做出完整产品。react-bank 用纯 React 技术栈从数据建模、状态管理、持久化到交互反馈完整体验了一遍生产级项目的思考路径。它没有后端接口、没有数据库但对于学习 React 来说覆盖的知识点密度远高于通常的 demo 项目。如果继续扩展可以加的东西还有很多用 React Router 做多页面路由、接入 ECharts 做收支趋势图表、甚至模拟定期存款和利息计算。技术栈换到 Taro 之后同样的代码结构也能迁移成小程序版本。想深入理解 React 核心机制的朋友非常推荐从复现 react-bank 开始先把状态管理和数据流跑通再加自己的业务逻辑。这个过程本身比任何零散的知识点学习都更有价值。本文还有配套的精品资源点击获取