简介: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 抽离逻辑非常有效。比如把账户操作封装成 useBankAccount:
function 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 vite@latest react-bank -- --template react-ts这里我选择了 TypeScript 模板,虽然纯 JS 也能写,但 TypeScript 带来的类型约束能把账户模型、交易记录这些数据结构定义得清清楚楚,写代码时的智能提示和重构安全性都会有一个大台阶的提升。项目创建好之后,目录结构我建议按照功能模块组织:
- src/components:通用 UI 组件
- src/hooks:业务逻辑 Hooks
- src/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、filter |
| useEffect 无限循环 | 依赖数组引用了内联对象/函数 | 用 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,不能在条件、循环或嵌套函数中调用。
我在实现注册过的账户列表时,一开始想在条件渲染里调用自定义 Hook:
if (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 的组件都会重新渲染。优化的方式是用 useMemo:
const contextValue = useMemo(() => ({ state, dispatch }), [state, dispatch]);这个小改动在高频操作(比如连续转账)时,对页面流畅度的提升非常明显。
5.3 最后的细节价值
在我复现的过程中,最大的收获是认识到前端项目不一定要依赖复杂架构才能做出完整产品。react-bank 用纯 React 技术栈,从数据建模、状态管理、持久化到交互反馈,完整体验了一遍生产级项目的思考路径。它没有后端接口、没有数据库,但对于学习 React 来说,覆盖的知识点密度远高于通常的 demo 项目。
如果继续扩展,可以加的东西还有很多:用 React Router 做多页面路由、接入 ECharts 做收支趋势图表、甚至模拟定期存款和利息计算。技术栈换到 Taro 之后,同样的代码结构也能迁移成小程序版本。想深入理解 React 核心机制的朋友,非常推荐从复现 react-bank 开始,先把状态管理和数据流跑通,再加自己的业务逻辑。这个过程本身比任何零散的知识点学习都更有价值。
本文还有配套的精品资源,点击获取