1. 项目概述与核心价值如果你在Web3领域摸爬滚打过一段时间尤其是在和团队一起管理链上资产时大概率会听说过或者用过Gnosis Safe现在叫Safe。它本质上是一个多签智能合约钱包是管理DAO金库、项目资金甚至个人家庭资产的核心工具。最近我花了些时间深入研究其官方钱包应用——Safe{Wallet}的代码仓库发现它是一个非常典型的、现代化的大型前端工程实践范本。这个仓库采用Monorepo单体仓库架构统一管理着WebNext.js和移动端Expo/React Native两套应用以及一系列共享的内部包。对于想要学习如何构建复杂、安全且跨平台一致的Web3应用的前端工程师来说这个仓库简直就是一座金矿。为什么说它值得深挖首先它直面了Web3应用开发中最核心的挑战状态管理、链上交互安全、多链支持以及复杂的交易构建流程。其次它没有选择走“快速拼凑”的捷径而是采用了严谨的工程化方案比如强类型TypeScript、统一的代码规范ESLint/Prettier、完善的测试体系单元测试、E2E测试以及组件文档化Storybook。通过拆解这个项目你不仅能学会如何用Next.js和React Native构建功能更能理解一套工业级Web3应用是如何被组织、开发和维护的。接下来我将带你从零开始深入这个Monorepo的每一个关键部分分享我的配置心得、踩过的坑以及从代码中学到的实战技巧。2. 环境准备与Monorepo初探2.1 工具链与核心依赖解析上手这个项目的第一步是配好环境。官方要求Node.js和Yarn这里有些细节需要注意。项目锁定了Yarn 4.5.3及以上版本并推荐使用corepack来管理。corepack是Node.js内置的包管理器管理器它能确保每个项目使用其package.json中定义的特定Yarn版本避免全局版本冲突。我实测下来这是避免“在我机器上能跑”问题的关键一步。# 启用corepackNode.js 16 默认包含 corepack enable # 进入项目目录后运行yarn会自动安装package.json中定义的版本 yarn运行yarn后你会看到依赖安装过程比普通项目稍长因为它需要为apps/web、apps/mobile以及packages/下的多个内部包分别解析和安装依赖。这就是Yarn Workspaces的威力它会在根目录的node_modules里安装所有共享的依赖而各个子工作空间workspace独有的依赖则会安装在其自身的node_modules中但通过符号链接关联极大地节省了磁盘空间并保证了依赖版本的一致性。注意如果你之前全局安装过旧版Yarn如1.x建议先运行yarn set version stable更新全局版本或直接依赖corepack避免因全局版本不兼容导致奇怪的安装或运行错误。2.2 项目结构深度解读安装完依赖后我们来看看这个Monorepo的骨架。理解这个结构是后续高效开发和调试的基础。safe-wallet-monorepo/ ├── apps/ │ ├── web/ # Next.js 13 前端应用 │ │ ├── src/ │ │ │ ├── components/ # 可复用UI组件 │ │ │ ├── features/ # 按功能模块组织的页面逻辑Redux slice、组件、服务等 │ │ │ ├── hooks/ # 自定义React Hooks │ │ │ ├── services/ # 与后端、链上交互的服务层 │ │ │ └── utils/ # 应用特定工具函数 │ │ ├── public/ │ │ ├── next.config.js │ │ └── package.json # 声明依赖name为 safe-global/web │ │ │ └── mobile/ # Expo (React Native) 移动端应用 │ ├── src/ # 结构类似web但使用Tamagui作为UI库 │ └── package.json # name为 safe-global/mobile │ ├── packages/ # 共享包内部库 │ ├── store/ # 共享的Redux状态管理 │ │ ├── slices/ # 各个功能模块的Redux切片auth, transactions, settings等 │ │ └── index.ts # 导出store实例、hooks、types │ │ │ └── utils/ # 共享工具函数与类型定义 │ ├── chains/ # 多链配置信息RPC URL, 链ID, 区块浏览器等 │ ├── constants/ # 通用常量 │ ├── types/ # 共享的TypeScript类型定义 │ └── index.ts │ ├── config/ # 共享配置文件 │ ├── eslint/ # ESLint配置 │ ├── jest/ # Jest测试配置 │ └── typescript/ # TypeScript基础配置 │ ├── package.json # 根package.json定义workspaces和全局脚本 ├── yarn.lock # 由Yarn Workspaces管理的统一依赖锁文件 └── .yarnrc.yml # Yarn 4 配置文件这种结构清晰地将“应用”和“共享库”分离。packages/store的设计尤其精妙它包含了整个钱包最核心的状态逻辑比如当前连接的钱包地址、Safe账户列表、待处理的交易、网络设置等。Web和移动端应用都导入并使用同一个store这保证了用户在网页和手机App上看到的数据状态是完全同步的体验一致。packages/utils则提供了像链配置、安全地址校验、交易编码解码等纯函数工具避免了代码重复。3. 核心开发流程与工程化实践3.1 启动与运行跨工作空间操作环境就绪后启动项目非常直观。得益于Yarn Workspaces你可以从根目录直接运行特定工作空间的脚本。# 启动Web开发服务器Next.js开发模式 yarn workspace safe-global/web dev # 启动移动端开发服务器Expo开发模式 yarn workspace safe-global/mobile start这里有个实用技巧因为Web应用使用了Next.js其开发服务器支持热重载HMR修改代码后几乎立刻能在浏览器看到变化。移动端使用Expo你可以在电脑上启动开发服务器然后在手机Expo Go App中扫描二维码预览同样支持热重载。对于依赖管理Monorepo的优势凸显。假设你需要给Web应用添加一个UI库或者给共享工具包添加一个工具函数你不需要跳出项目根目录。# 为Web应用添加一个依赖 yarn workspace safe-global/web add lucide-react # 为共享utils包添加一个依赖会被其他workspace共用 yarn workspace safe-global/utils add lodash-es # 从移动端移除一个依赖 yarn workspace safe-global/mobile remove some-unused-package所有依赖的添加和移除都会同步更新根目录的yarn.lock确保整个Monorepo的依赖树始终保持一致。这比分别在两个独立仓库管理依赖要可靠和高效得多。3.2 代码质量保障Lint、格式化与类型检查大型项目尤其是涉及资产安全的钱包应用代码质量是生命线。这个项目配置了一套严格但高效的自动化检查流程。# 运行ESLint检查所有工作空间的代码规范 yarn lint # 使用Prettier检查代码格式--check模式 yarn prettier # 自动修复ESLint问题和格式化代码非常实用 yarn fixyarn fix这个命令是我强烈推荐的它实际上是组合了eslint --fix和prettier --write一键修复大多数代码风格和格式问题。这些检查通过Husky配置的pre-commit钩子在每次提交代码前自动运行确保有问题的代码不会被提交到仓库。类型安全是TypeScript的核心价值。项目配置了严格的tsconfig.json例如strict: true。你可以针对特定工作空间进行类型检查yarn workspace safe-global/web type-check yarn workspace safe-global/mobile type-check实操心得在开发新功能时我习惯在提交前手动跑一遍yarn fix和对应工作空间的type-check。虽然钩子会做但提前本地解决可以避免提交时因检查失败而中断流程。另外VS Code配合项目根目录的ESLint和Prettier配置可以做到保存时自动格式化体验极佳。3.3 测试策略从单元到端到端测试覆盖全面是这个项目的另一个亮点。它采用了分层测试策略。单元测试Jest针对工具函数、Redux reducer、纯逻辑组件进行测试。你可以运行所有测试也可以针对某个工作空间。# 运行所有工作空间的单元测试 yarn test # 仅运行Web应用的单元测试 yarn workspace safe-global/web test组件开发与文档StorybookWeb应用集成了Storybook。这对于构建和维护大型UI组件库至关重要。你可以独立于主应用开发和测试组件并生成可视化文档。yarn workspace safe-global/web storybook运行后Storybook会在本地启动一个服务器你可以浏览所有已编写的组件故事Story查看它们在不同状态Props下的表现进行交互测试。这对于团队协作和保证UI一致性帮助巨大。端到端E2E测试Cypress Maestro这是保障核心用户流程不出错的关键。Web端使用Cypress移动端使用Maestro。# Web端打开Cypress交互测试运行器 yarn workspace safe-global/web cypress:open # Web端在CI环境中无头运行所有E2E测试 yarn workspace safe-global/web cypress:runCypress的测试用例通常放在apps/web/cypress/e2e/下模拟用户从创建/加载Safe、发起交易、到签名确认的全流程。这些测试会启动一个真实的开发服务器并在浏览器中自动执行操作是最接近真实用户场景的测试。注意事项E2E测试虽然强大但运行较慢且相对脆弱容易受网络、UI微小变动影响。项目通常会在持续集成CI流程中运行cypress:run而在本地开发时更推荐使用cypress:open进行针对性调试。确保在运行前你的Web开发服务器yarn workspace safe-global/web dev已经启动。4. 核心功能模块与实现原理剖析4.1 状态管理架构Redux Toolkit的现代化应用状态管理是任何复杂应用的核心对于钱包应用更是如此。Safe{Wallet}没有使用过于新颖或复杂的状态库而是选择了经过充分验证的Redux ToolkitRTK并将其提升到了Monorepo共享的级别。packages/store是这个架构的心脏。我们来看看它的典型结构// packages/store/slices/safeSlice.ts import { createSlice, createSelector, PayloadAction } from reduxjs/toolkit; import { SafeInfo } from safe-global/utils/types; interface SafeState { safes: Recordstring, SafeInfo; // 按地址索引的Safe信息 currentSafeAddress: string | null; loading: boolean; error: string | null; } const initialState: SafeState { safes: {}, currentSafeAddress: null, loading: false, error: null, }; const safeSlice createSlice({ name: safe, initialState, reducers: { setSafes: (state, action: PayloadActionRecordstring, SafeInfo) { state.safes { ...state.safes, ...action.payload }; }, setCurrentSafe: (state, action: PayloadActionstring) { state.currentSafeAddress action.payload; }, setLoading: (state, action: PayloadActionboolean) { state.loading action.payload; }, // ... 其他reducer }, }); // 异步Thunk从链上加载Safe信息 export const fetchSafeInfo createAsyncThunk( safe/fetchInfo, async (safeAddress: string, { dispatch }) { dispatch(setLoading(true)); try { const info await safeService.getSafeInfo(safeAddress); // 调用服务层 dispatch(setSafes({ [safeAddress]: info })); return info; } catch (error) { dispatch(setError(error.message)); throw error; } finally { dispatch(setLoading(false)); } } ); export const { setSafes, setCurrentSafe } safeSlice.actions; export default safeSlice.reducer;设计精妙之处标准化切片Slice每个功能域如safe, transactions, settings, notifications都有自己的slice文件逻辑清晰便于维护。createAsyncThunk处理副作用所有涉及网络请求如读取链上数据、提交交易的异步逻辑都通过createAsyncThunk封装。它自动生成pending、fulfilled、rejected的action简化了加载和错误状态的处理。RTK Query集成项目还大量使用了RTK QueryRedux Toolkit的一部分来处理数据获取和缓存。例如获取链上交易历史、代币余额等。RTK Query自动管理缓存、重复请求、数据失效和重新获取极大地减少了手动管理服务器状态的样板代码。类型安全贯穿始终从PayloadAction到selector全程TypeScript支持在dispatch action或访问state时都能获得完整的类型提示减少了运行时错误。在应用层Web或Mobile通过useAppSelector和useAppDispatch这两个自定义Hook来访问和操作状态体验非常流畅。// 在React组件中 import { useAppSelector, useAppDispatch } from safe-global/store; import { fetchSafeInfo } from safe-global/store/slices/safeSlice; function SafeDashboard() { const dispatch useAppDispatch(); const { currentSafeAddress, safes, loading } useAppSelector((state) state.safe); const currentSafe currentSafeAddress ? safes[currentSafeAddress] : null; useEffect(() { if (currentSafeAddress) { dispatch(fetchSafeInfo(currentSafeAddress)); } }, [currentSafeAddress, dispatch]); // ... 渲染组件 }4.2 多链与网络层抽象Web3应用必须处理多条区块链。Safe{Wallet}支持数十条EVM兼容链。其网络层抽象做得非常干净主要逻辑封装在packages/utils/chains中。它通常会定义一个ChainInfo类型包含链的基本信息// packages/utils/types/chains.ts export interface ChainInfo { chainId: string; // 16进制字符串如 0x1 (Ethereum Mainnet) chainName: string; nativeCurrency: { name: string; symbol: string; // ETH, MATIC, etc. decimals: 18; }; rpcUrls: string[]; blockExplorerUrls: string[]; isTestnet?: boolean; }然后在一个文件如chains.ts或chainsConfig.ts中导出一个所有支持链的配置对象或数组。// packages/utils/chains/index.ts import { ChainInfo } from ../types; export const MAINNET_CHAINS: Recordstring, ChainInfo { 0x1: { chainId: 0x1, chainName: Ethereum Mainnet, nativeCurrency: { name: Ether, symbol: ETH, decimals: 18 }, rpcUrls: [https://cloudflare-eth.com], blockExplorerUrls: [https://etherscan.io], }, 0x89: { chainId: 0x89, chainName: Polygon Mainnet, nativeCurrency: { name: MATIC, symbol: MATIC, decimals: 18 }, rpcUrls: [https://polygon-rpc.com], blockExplorerUrls: [https://polygonscan.com], }, // ... 更多链 }; export const TESTNET_CHAINS: Recordstring, ChainInfo { /* ... */ }; export const ALL_CHAINS { ...MAINNET_CHAINS, ...TESTNET_CHAINS }; // 一个根据chainId获取配置的辅助函数 export function getChainById(chainId: string): ChainInfo | undefined { return ALL_CHAINS[chainId]; }服务层Service Layer与链交互的复杂逻辑被抽象成服务。例如一个TransactionService会负责构建交易数据、估算Gas、提交交易到Safe合约、轮询交易状态等。它内部会使用ethers.js或web3.js库并根据当前选择的链配置RPC URL来初始化Provider。// apps/web/src/services/TransactionService.ts import { ethers } from ethers; import { getChainById } from safe-global/utils/chains; import { SafeTransactionData } from safe-global/utils/types; export class TransactionService { private provider: ethers.providers.JsonRpcProvider; constructor(chainId: string) { const chainConfig getChainById(chainId); if (!chainConfig) throw new Error(Unsupported chain: ${chainId}); // 使用链配置中的RPC URL this.provider new ethers.providers.JsonRpcProvider(chainConfig.rpcUrls[0]); } async proposeTransaction( safeAddress: string, txData: SafeTransactionData, sender: string ): Promisestring { // 1. 使用ethers与Safe合约交互构建交易对象 // 2. 调用Safe合约的submitTransaction或类似方法 // 3. 返回交易哈希或Safe内部交易ID // ... 具体实现 } async getTransactionStatus(safeTxHash: string): PromisePENDING | SUCCESS | FAILED { // 轮询链上或Safe后端API获取交易状态 // ... 具体实现 } }这种设计的好处是高内聚、低耦合。UI组件不需要知道如何连接区块链、如何编码交易数据它只需要调用transactionService.proposeTransaction(...)并传入必要的参数。当需要支持一条新链时你只需要在chains/index.ts中添加配置并确保相关服务能处理该链的RPC即可。4.3 交易构建与签名流程这是Safe钱包最核心、最复杂的功能。一个典型的多签交易流程如下发起提案一名所有者Owner在Web或App界面填写接收地址、金额、数据如果是合约调用并提交。前端会调用TransactionService构建一个“待签名”的交易对象包含nonce, to, value, data, operation, safeTxGas等字段。链下签名发起后这笔交易会出现在所有所有者的交易队列中。其他所有者连接钱包后可以看到待确认的交易详情并点击“签名”。此时签名发生在链下前端使用ethers的signer.signMessage或类似方法对交易哈希进行签名不会消耗Gas。签名结果一个ECDSA签名字符串会被发送到Safe的后端服务或由前端临时存储。收集签名当签名数量达到预设的阈值例如2/3时界面会提示“可以执行”。执行交易任何一名所有者可以是已签名者也可以是另一个可以支付Gas费来执行这笔交易。前端会调用Safe合约的execTransaction方法并附上所有收集到的签名。合约会验证签名是否来自有效的所有者以及是否达到阈值验证通过后才执行最终的转账或合约调用。前端的关键实现点交易哈希计算必须严格按照Safe合约的规范来计算待签名的交易哈希getTransactionHash。这个计算过程在packages/utils中会有对应的函数确保Web和Mobile端计算结果一致。签名编码多个签名需要被正确编码并拼接然后传递给合约。Safe合约通常期望签名是bytes类型包含v, r, s分量。Gas估算执行交易前需要准确估算Gas。这里需要估算两部分一是执行交易本身所需的Gas二是Safe合约内部验证和执行所需的额外GassafeTxGas。项目中的服务层会处理这些复杂的估算。踩坑实录在早期测试中最容易出错的就是交易哈希的计算。如果前端计算的哈希和合约计算的不一致签名验证就会失败。务必使用官方Safe合约库如safe-global/safe-core-sdk提供的工具函数来计算哈希不要自己手动拼接编码。另外注意不同版本的Safe合约可能在哈希计算细节上有差异项目代码通常会通过合约版本号来做兼容处理。5. 跨平台一致性挑战与解决方案5.1 UI组件库与设计系统维护Web和Mobile两套UI并保持体验一致是个巨大挑战。Safe{Wallet}的解决方案是Web端可能使用自研组件库或流行的如MUI、Chakra UI结合Tailwind CSS进行样式管理。通过Storybook来保证组件质量和文档。移动端项目选择了Tamagui。这是一个高性能的React Native UI库支持在Web和Native之间共享大部分样式代码并且编译时优化做得很好能生成高效的样式代码。虽然组件实现因平台而异Web用divMobile用View但通过共享在packages/utils或packages/store中的业务逻辑、状态和类型定义它们背后的数据流和交互逻辑是统一的。例如交易列表的获取、筛选、排序逻辑可以抽成共享的Hook或SelectorWeb和Mobile的UI层只需负责渲染不同的组件树。5.2 导航与路由状态同步导航状态是平台差异最大的部分之一。Web使用Next.js的App Router或Pages Router基于URLMobile使用React Navigation的堆栈导航。 然而一些核心状态需要同步。例如当前选中的Safe地址、当前所在的链ID。这些状态被放在共享的Redux store中。无论用户在Web端切换了Safe还是在Mobile端切换了网络这个变化都会通过store同步到另一个平台如果用户同时登录。虽然不能做到实时同步需要后端或推送但至少保证了下次打开任一平台时状态是最新的用户操作结果。5.3 平台特定代码的处理有些功能是平台特有的。比如Web浏览器扩展钱包如MetaMask的连接、剪贴板操作、更复杂的文件上传。Mobile生物识别认证Face ID/Touch ID、推送通知、深色模式跟随系统。项目通常通过两种方式处理条件编译/导入在共享工具函数或Hook中使用动态导入或平台检测。// packages/utils/clipboard.ts export const copyToClipboard async (text: string): Promiseboolean { if (typeof navigator ! undefined navigator.clipboard) { // Web 环境 await navigator.clipboard.writeText(text); return true; } else { // Mobile 环境 (通过React Native的Clipboard API) // 这里可能需要导入一个mobile-specific的模块 // 或者这个函数本身就不在shared utils中而是分别实现 console.warn(Clipboard not available in this environment); return false; } };分别实现统一接口在apps/web/src/services和apps/mobile/src/services下分别实现WalletConnectService但暴露相同的函数接口如connect(),signMessage()。上层业务组件通过依赖注入或Context来使用平台对应的服务实现。6. 部署、发布与持续集成考量6.1 构建与部署流程对于Monorepo构建和部署需要精细化控制。Web应用Next.js# 在Web工作空间下运行构建 yarn workspace safe-global/web build这会生成一个优化的生产版本在.next目录。部署时只需要部署apps/web这个目录连同.next和public等到你的静态站点托管或Node.js服务器。Next.js支持静态导出或服务器端渲染项目会根据配置选择。移动端应用Expo# 构建Android APK/AAB yarn workspace safe-global/mobile eas build --platform android # 构建iOS IPA yarn workspace safe-global/mobile eas build --platform iosExpo EASExpo Application Services极大地简化了React Native应用的构建流程。它会在云端处理原生代码的编译和打包。开发者在本地只需要配置好eas.json和提交代码即可。6.2 版本管理与发布Monorepo的版本管理是个学问。Safe{Wallet}可能采用两种策略之一锁定版本所有apps和packages共享一个版本号在根package.json中定义。每次发布新版本整个Monorepo一起升版本。这适用于紧密耦合的项目。独立版本每个package特别是packages/store和packages/utils有自己的版本号使用如changesets或lerna这样的工具来管理依赖更新和发布日志。当只修改了共享工具库时可以只发布该库的新版本然后更新应用中对它的依赖。从仓库的Release process文档链接来看它很可能有一套自动化的发布流程可能涉及生成变更日志、更新版本号、创建Git tag、触发CI/CD构建和部署。6.3 CI/CD流水线设计一个健壮的CI/CD流水线对于这样的项目至关重要。通常会在.github/workflows/下配置多个GitHub Actions工作流PR检查流水线当有Pull Request时自动运行。安装依赖 (yarn install)代码 lint 和格式化检查 (yarn lint,yarn prettier)类型检查 (yarn workspace ... type-check)运行单元测试 (yarn test)可能还会构建所有工作空间以确保没有编译错误 (yarn workspace ... build)发布流水线当代码合并到主分支如main并打上版本tag时触发。执行所有PR检查的步骤。运行端到端测试可能需要启动测试服务器。构建生产版本Web的.nextMobile的二进制文件。将Web应用部署到生产环境如Vercel, AWS。将移动端构建产物提交到应用商店TestFlight/Google Play Internal Test或分发平台。个人经验在配置CI时充分利用Yarn Workspaces的--filter选项可以优化构建速度。例如如果只修改了packages/utils那么CI可以只运行与该包相关的lint、test和type-check以及依赖于此包的应用的测试而不是全量运行。这需要精心设计CI脚本但能显著缩短反馈时间。7. 为项目贡献代码与最佳实践7.1 开发工作流建议如果你想为这个开源项目贡献代码遵循既定的工作流能让你的贡献更容易被接受。Fork与分支Fork仓库到你的账户克隆到本地。为每个新功能或修复创建一个清晰命名的分支如feat/add-dark-mode,fix/tx-history-pagination。安装与启动按照前文所述在根目录运行yarn安装所有依赖。关联包由于是Monorepo如果你修改了packages/utils里的代码并且想在apps/web中立即测试效果不需要发布。Yarn Workspaces的符号链接已经帮你搞定了。直接启动Web应用它就会使用本地修改后的utils包。遵循代码规范在提交前务必运行yarn fix来格式化代码和修复简单的lint问题。确保没有TypeScript错误。编写测试如果你添加了新功能请尽量补充单元测试。如果修改了用户流程考虑是否需要更新或添加E2E测试。提交信息使用 约定式提交 规范如feat(web): add support for WalletConnect v2fix(store): correct balance polling interval。这有助于自动生成变更日志。创建PR在PR描述中清晰说明修改内容、动机和测试情况。7.2 常见陷阱与调试技巧幽灵依赖Phantom Dependencies这是Monorepo常见问题。指某个工作空间使用了没有在其自身package.json的dependencies中声明但被根目录或其他工作空间间接安装的包。这会导致构建在本地成功但在干净的CI环境或部署时失败。解决方法始终在每个工作空间的package.json中显式声明其直接依赖。可以使用yarn workspace safe-global/web why package-name来检查一个包为什么被安装。类型解析错误有时TypeScript可能找不到来自其他工作空间的类型定义。确保每个包的tsconfig.json正确设置了paths或references并且根目录的tsconfig.json配置了composite: true和references。通常项目已经配置好如果遇到问题检查相关配置。Husky钩子不生效确保你已经运行过yarn install它应该会自动设置Husky。如果没生效可以尝试yarn husky install。移动端原生模块问题如果移动端添加了新的原生依赖需要pod install或gradle配置记得在apps/mobile目录下运行yarn prebuild或expo prebuild来同步原生代码。7.3 性能优化点观察通过阅读代码可以发现项目已经实施了一些性能优化代码分割Code SplittingNext.js和React Native Expo默认支持代码分割按需加载。图片优化Next.js的Image组件自动处理图片优化。状态选择性订阅在React组件中使用Redux时通过精细化的useSelector选择器避免不必要的重渲染。列表虚拟化对于长列表如交易历史使用虚拟化列表组件如react-virtualized或React Native的FlatList来提升滚动性能。缓存策略RTK Query为数据请求提供了强大的缓存避免重复请求链上数据。深入研究这个Monorepo就像参加一个由顶尖Web3工程师主讲的实战大师课。它不仅仅是一个钱包应用更是一套关于如何构建可维护、可扩展、高质量现代Web应用的标准答案。从架构设计、工具链选型到具体的编码实践每一个细节都值得推敲和学习。无论你是想贡献代码还是仅仅想学习其设计思想这个仓库都是一个宝贵的资源。
深入Safe{Wallet} Monorepo:Web3多签钱包的现代化前端工程实践
1. 项目概述与核心价值如果你在Web3领域摸爬滚打过一段时间尤其是在和团队一起管理链上资产时大概率会听说过或者用过Gnosis Safe现在叫Safe。它本质上是一个多签智能合约钱包是管理DAO金库、项目资金甚至个人家庭资产的核心工具。最近我花了些时间深入研究其官方钱包应用——Safe{Wallet}的代码仓库发现它是一个非常典型的、现代化的大型前端工程实践范本。这个仓库采用Monorepo单体仓库架构统一管理着WebNext.js和移动端Expo/React Native两套应用以及一系列共享的内部包。对于想要学习如何构建复杂、安全且跨平台一致的Web3应用的前端工程师来说这个仓库简直就是一座金矿。为什么说它值得深挖首先它直面了Web3应用开发中最核心的挑战状态管理、链上交互安全、多链支持以及复杂的交易构建流程。其次它没有选择走“快速拼凑”的捷径而是采用了严谨的工程化方案比如强类型TypeScript、统一的代码规范ESLint/Prettier、完善的测试体系单元测试、E2E测试以及组件文档化Storybook。通过拆解这个项目你不仅能学会如何用Next.js和React Native构建功能更能理解一套工业级Web3应用是如何被组织、开发和维护的。接下来我将带你从零开始深入这个Monorepo的每一个关键部分分享我的配置心得、踩过的坑以及从代码中学到的实战技巧。2. 环境准备与Monorepo初探2.1 工具链与核心依赖解析上手这个项目的第一步是配好环境。官方要求Node.js和Yarn这里有些细节需要注意。项目锁定了Yarn 4.5.3及以上版本并推荐使用corepack来管理。corepack是Node.js内置的包管理器管理器它能确保每个项目使用其package.json中定义的特定Yarn版本避免全局版本冲突。我实测下来这是避免“在我机器上能跑”问题的关键一步。# 启用corepackNode.js 16 默认包含 corepack enable # 进入项目目录后运行yarn会自动安装package.json中定义的版本 yarn运行yarn后你会看到依赖安装过程比普通项目稍长因为它需要为apps/web、apps/mobile以及packages/下的多个内部包分别解析和安装依赖。这就是Yarn Workspaces的威力它会在根目录的node_modules里安装所有共享的依赖而各个子工作空间workspace独有的依赖则会安装在其自身的node_modules中但通过符号链接关联极大地节省了磁盘空间并保证了依赖版本的一致性。注意如果你之前全局安装过旧版Yarn如1.x建议先运行yarn set version stable更新全局版本或直接依赖corepack避免因全局版本不兼容导致奇怪的安装或运行错误。2.2 项目结构深度解读安装完依赖后我们来看看这个Monorepo的骨架。理解这个结构是后续高效开发和调试的基础。safe-wallet-monorepo/ ├── apps/ │ ├── web/ # Next.js 13 前端应用 │ │ ├── src/ │ │ │ ├── components/ # 可复用UI组件 │ │ │ ├── features/ # 按功能模块组织的页面逻辑Redux slice、组件、服务等 │ │ │ ├── hooks/ # 自定义React Hooks │ │ │ ├── services/ # 与后端、链上交互的服务层 │ │ │ └── utils/ # 应用特定工具函数 │ │ ├── public/ │ │ ├── next.config.js │ │ └── package.json # 声明依赖name为 safe-global/web │ │ │ └── mobile/ # Expo (React Native) 移动端应用 │ ├── src/ # 结构类似web但使用Tamagui作为UI库 │ └── package.json # name为 safe-global/mobile │ ├── packages/ # 共享包内部库 │ ├── store/ # 共享的Redux状态管理 │ │ ├── slices/ # 各个功能模块的Redux切片auth, transactions, settings等 │ │ └── index.ts # 导出store实例、hooks、types │ │ │ └── utils/ # 共享工具函数与类型定义 │ ├── chains/ # 多链配置信息RPC URL, 链ID, 区块浏览器等 │ ├── constants/ # 通用常量 │ ├── types/ # 共享的TypeScript类型定义 │ └── index.ts │ ├── config/ # 共享配置文件 │ ├── eslint/ # ESLint配置 │ ├── jest/ # Jest测试配置 │ └── typescript/ # TypeScript基础配置 │ ├── package.json # 根package.json定义workspaces和全局脚本 ├── yarn.lock # 由Yarn Workspaces管理的统一依赖锁文件 └── .yarnrc.yml # Yarn 4 配置文件这种结构清晰地将“应用”和“共享库”分离。packages/store的设计尤其精妙它包含了整个钱包最核心的状态逻辑比如当前连接的钱包地址、Safe账户列表、待处理的交易、网络设置等。Web和移动端应用都导入并使用同一个store这保证了用户在网页和手机App上看到的数据状态是完全同步的体验一致。packages/utils则提供了像链配置、安全地址校验、交易编码解码等纯函数工具避免了代码重复。3. 核心开发流程与工程化实践3.1 启动与运行跨工作空间操作环境就绪后启动项目非常直观。得益于Yarn Workspaces你可以从根目录直接运行特定工作空间的脚本。# 启动Web开发服务器Next.js开发模式 yarn workspace safe-global/web dev # 启动移动端开发服务器Expo开发模式 yarn workspace safe-global/mobile start这里有个实用技巧因为Web应用使用了Next.js其开发服务器支持热重载HMR修改代码后几乎立刻能在浏览器看到变化。移动端使用Expo你可以在电脑上启动开发服务器然后在手机Expo Go App中扫描二维码预览同样支持热重载。对于依赖管理Monorepo的优势凸显。假设你需要给Web应用添加一个UI库或者给共享工具包添加一个工具函数你不需要跳出项目根目录。# 为Web应用添加一个依赖 yarn workspace safe-global/web add lucide-react # 为共享utils包添加一个依赖会被其他workspace共用 yarn workspace safe-global/utils add lodash-es # 从移动端移除一个依赖 yarn workspace safe-global/mobile remove some-unused-package所有依赖的添加和移除都会同步更新根目录的yarn.lock确保整个Monorepo的依赖树始终保持一致。这比分别在两个独立仓库管理依赖要可靠和高效得多。3.2 代码质量保障Lint、格式化与类型检查大型项目尤其是涉及资产安全的钱包应用代码质量是生命线。这个项目配置了一套严格但高效的自动化检查流程。# 运行ESLint检查所有工作空间的代码规范 yarn lint # 使用Prettier检查代码格式--check模式 yarn prettier # 自动修复ESLint问题和格式化代码非常实用 yarn fixyarn fix这个命令是我强烈推荐的它实际上是组合了eslint --fix和prettier --write一键修复大多数代码风格和格式问题。这些检查通过Husky配置的pre-commit钩子在每次提交代码前自动运行确保有问题的代码不会被提交到仓库。类型安全是TypeScript的核心价值。项目配置了严格的tsconfig.json例如strict: true。你可以针对特定工作空间进行类型检查yarn workspace safe-global/web type-check yarn workspace safe-global/mobile type-check实操心得在开发新功能时我习惯在提交前手动跑一遍yarn fix和对应工作空间的type-check。虽然钩子会做但提前本地解决可以避免提交时因检查失败而中断流程。另外VS Code配合项目根目录的ESLint和Prettier配置可以做到保存时自动格式化体验极佳。3.3 测试策略从单元到端到端测试覆盖全面是这个项目的另一个亮点。它采用了分层测试策略。单元测试Jest针对工具函数、Redux reducer、纯逻辑组件进行测试。你可以运行所有测试也可以针对某个工作空间。# 运行所有工作空间的单元测试 yarn test # 仅运行Web应用的单元测试 yarn workspace safe-global/web test组件开发与文档StorybookWeb应用集成了Storybook。这对于构建和维护大型UI组件库至关重要。你可以独立于主应用开发和测试组件并生成可视化文档。yarn workspace safe-global/web storybook运行后Storybook会在本地启动一个服务器你可以浏览所有已编写的组件故事Story查看它们在不同状态Props下的表现进行交互测试。这对于团队协作和保证UI一致性帮助巨大。端到端E2E测试Cypress Maestro这是保障核心用户流程不出错的关键。Web端使用Cypress移动端使用Maestro。# Web端打开Cypress交互测试运行器 yarn workspace safe-global/web cypress:open # Web端在CI环境中无头运行所有E2E测试 yarn workspace safe-global/web cypress:runCypress的测试用例通常放在apps/web/cypress/e2e/下模拟用户从创建/加载Safe、发起交易、到签名确认的全流程。这些测试会启动一个真实的开发服务器并在浏览器中自动执行操作是最接近真实用户场景的测试。注意事项E2E测试虽然强大但运行较慢且相对脆弱容易受网络、UI微小变动影响。项目通常会在持续集成CI流程中运行cypress:run而在本地开发时更推荐使用cypress:open进行针对性调试。确保在运行前你的Web开发服务器yarn workspace safe-global/web dev已经启动。4. 核心功能模块与实现原理剖析4.1 状态管理架构Redux Toolkit的现代化应用状态管理是任何复杂应用的核心对于钱包应用更是如此。Safe{Wallet}没有使用过于新颖或复杂的状态库而是选择了经过充分验证的Redux ToolkitRTK并将其提升到了Monorepo共享的级别。packages/store是这个架构的心脏。我们来看看它的典型结构// packages/store/slices/safeSlice.ts import { createSlice, createSelector, PayloadAction } from reduxjs/toolkit; import { SafeInfo } from safe-global/utils/types; interface SafeState { safes: Recordstring, SafeInfo; // 按地址索引的Safe信息 currentSafeAddress: string | null; loading: boolean; error: string | null; } const initialState: SafeState { safes: {}, currentSafeAddress: null, loading: false, error: null, }; const safeSlice createSlice({ name: safe, initialState, reducers: { setSafes: (state, action: PayloadActionRecordstring, SafeInfo) { state.safes { ...state.safes, ...action.payload }; }, setCurrentSafe: (state, action: PayloadActionstring) { state.currentSafeAddress action.payload; }, setLoading: (state, action: PayloadActionboolean) { state.loading action.payload; }, // ... 其他reducer }, }); // 异步Thunk从链上加载Safe信息 export const fetchSafeInfo createAsyncThunk( safe/fetchInfo, async (safeAddress: string, { dispatch }) { dispatch(setLoading(true)); try { const info await safeService.getSafeInfo(safeAddress); // 调用服务层 dispatch(setSafes({ [safeAddress]: info })); return info; } catch (error) { dispatch(setError(error.message)); throw error; } finally { dispatch(setLoading(false)); } } ); export const { setSafes, setCurrentSafe } safeSlice.actions; export default safeSlice.reducer;设计精妙之处标准化切片Slice每个功能域如safe, transactions, settings, notifications都有自己的slice文件逻辑清晰便于维护。createAsyncThunk处理副作用所有涉及网络请求如读取链上数据、提交交易的异步逻辑都通过createAsyncThunk封装。它自动生成pending、fulfilled、rejected的action简化了加载和错误状态的处理。RTK Query集成项目还大量使用了RTK QueryRedux Toolkit的一部分来处理数据获取和缓存。例如获取链上交易历史、代币余额等。RTK Query自动管理缓存、重复请求、数据失效和重新获取极大地减少了手动管理服务器状态的样板代码。类型安全贯穿始终从PayloadAction到selector全程TypeScript支持在dispatch action或访问state时都能获得完整的类型提示减少了运行时错误。在应用层Web或Mobile通过useAppSelector和useAppDispatch这两个自定义Hook来访问和操作状态体验非常流畅。// 在React组件中 import { useAppSelector, useAppDispatch } from safe-global/store; import { fetchSafeInfo } from safe-global/store/slices/safeSlice; function SafeDashboard() { const dispatch useAppDispatch(); const { currentSafeAddress, safes, loading } useAppSelector((state) state.safe); const currentSafe currentSafeAddress ? safes[currentSafeAddress] : null; useEffect(() { if (currentSafeAddress) { dispatch(fetchSafeInfo(currentSafeAddress)); } }, [currentSafeAddress, dispatch]); // ... 渲染组件 }4.2 多链与网络层抽象Web3应用必须处理多条区块链。Safe{Wallet}支持数十条EVM兼容链。其网络层抽象做得非常干净主要逻辑封装在packages/utils/chains中。它通常会定义一个ChainInfo类型包含链的基本信息// packages/utils/types/chains.ts export interface ChainInfo { chainId: string; // 16进制字符串如 0x1 (Ethereum Mainnet) chainName: string; nativeCurrency: { name: string; symbol: string; // ETH, MATIC, etc. decimals: 18; }; rpcUrls: string[]; blockExplorerUrls: string[]; isTestnet?: boolean; }然后在一个文件如chains.ts或chainsConfig.ts中导出一个所有支持链的配置对象或数组。// packages/utils/chains/index.ts import { ChainInfo } from ../types; export const MAINNET_CHAINS: Recordstring, ChainInfo { 0x1: { chainId: 0x1, chainName: Ethereum Mainnet, nativeCurrency: { name: Ether, symbol: ETH, decimals: 18 }, rpcUrls: [https://cloudflare-eth.com], blockExplorerUrls: [https://etherscan.io], }, 0x89: { chainId: 0x89, chainName: Polygon Mainnet, nativeCurrency: { name: MATIC, symbol: MATIC, decimals: 18 }, rpcUrls: [https://polygon-rpc.com], blockExplorerUrls: [https://polygonscan.com], }, // ... 更多链 }; export const TESTNET_CHAINS: Recordstring, ChainInfo { /* ... */ }; export const ALL_CHAINS { ...MAINNET_CHAINS, ...TESTNET_CHAINS }; // 一个根据chainId获取配置的辅助函数 export function getChainById(chainId: string): ChainInfo | undefined { return ALL_CHAINS[chainId]; }服务层Service Layer与链交互的复杂逻辑被抽象成服务。例如一个TransactionService会负责构建交易数据、估算Gas、提交交易到Safe合约、轮询交易状态等。它内部会使用ethers.js或web3.js库并根据当前选择的链配置RPC URL来初始化Provider。// apps/web/src/services/TransactionService.ts import { ethers } from ethers; import { getChainById } from safe-global/utils/chains; import { SafeTransactionData } from safe-global/utils/types; export class TransactionService { private provider: ethers.providers.JsonRpcProvider; constructor(chainId: string) { const chainConfig getChainById(chainId); if (!chainConfig) throw new Error(Unsupported chain: ${chainId}); // 使用链配置中的RPC URL this.provider new ethers.providers.JsonRpcProvider(chainConfig.rpcUrls[0]); } async proposeTransaction( safeAddress: string, txData: SafeTransactionData, sender: string ): Promisestring { // 1. 使用ethers与Safe合约交互构建交易对象 // 2. 调用Safe合约的submitTransaction或类似方法 // 3. 返回交易哈希或Safe内部交易ID // ... 具体实现 } async getTransactionStatus(safeTxHash: string): PromisePENDING | SUCCESS | FAILED { // 轮询链上或Safe后端API获取交易状态 // ... 具体实现 } }这种设计的好处是高内聚、低耦合。UI组件不需要知道如何连接区块链、如何编码交易数据它只需要调用transactionService.proposeTransaction(...)并传入必要的参数。当需要支持一条新链时你只需要在chains/index.ts中添加配置并确保相关服务能处理该链的RPC即可。4.3 交易构建与签名流程这是Safe钱包最核心、最复杂的功能。一个典型的多签交易流程如下发起提案一名所有者Owner在Web或App界面填写接收地址、金额、数据如果是合约调用并提交。前端会调用TransactionService构建一个“待签名”的交易对象包含nonce, to, value, data, operation, safeTxGas等字段。链下签名发起后这笔交易会出现在所有所有者的交易队列中。其他所有者连接钱包后可以看到待确认的交易详情并点击“签名”。此时签名发生在链下前端使用ethers的signer.signMessage或类似方法对交易哈希进行签名不会消耗Gas。签名结果一个ECDSA签名字符串会被发送到Safe的后端服务或由前端临时存储。收集签名当签名数量达到预设的阈值例如2/3时界面会提示“可以执行”。执行交易任何一名所有者可以是已签名者也可以是另一个可以支付Gas费来执行这笔交易。前端会调用Safe合约的execTransaction方法并附上所有收集到的签名。合约会验证签名是否来自有效的所有者以及是否达到阈值验证通过后才执行最终的转账或合约调用。前端的关键实现点交易哈希计算必须严格按照Safe合约的规范来计算待签名的交易哈希getTransactionHash。这个计算过程在packages/utils中会有对应的函数确保Web和Mobile端计算结果一致。签名编码多个签名需要被正确编码并拼接然后传递给合约。Safe合约通常期望签名是bytes类型包含v, r, s分量。Gas估算执行交易前需要准确估算Gas。这里需要估算两部分一是执行交易本身所需的Gas二是Safe合约内部验证和执行所需的额外GassafeTxGas。项目中的服务层会处理这些复杂的估算。踩坑实录在早期测试中最容易出错的就是交易哈希的计算。如果前端计算的哈希和合约计算的不一致签名验证就会失败。务必使用官方Safe合约库如safe-global/safe-core-sdk提供的工具函数来计算哈希不要自己手动拼接编码。另外注意不同版本的Safe合约可能在哈希计算细节上有差异项目代码通常会通过合约版本号来做兼容处理。5. 跨平台一致性挑战与解决方案5.1 UI组件库与设计系统维护Web和Mobile两套UI并保持体验一致是个巨大挑战。Safe{Wallet}的解决方案是Web端可能使用自研组件库或流行的如MUI、Chakra UI结合Tailwind CSS进行样式管理。通过Storybook来保证组件质量和文档。移动端项目选择了Tamagui。这是一个高性能的React Native UI库支持在Web和Native之间共享大部分样式代码并且编译时优化做得很好能生成高效的样式代码。虽然组件实现因平台而异Web用divMobile用View但通过共享在packages/utils或packages/store中的业务逻辑、状态和类型定义它们背后的数据流和交互逻辑是统一的。例如交易列表的获取、筛选、排序逻辑可以抽成共享的Hook或SelectorWeb和Mobile的UI层只需负责渲染不同的组件树。5.2 导航与路由状态同步导航状态是平台差异最大的部分之一。Web使用Next.js的App Router或Pages Router基于URLMobile使用React Navigation的堆栈导航。 然而一些核心状态需要同步。例如当前选中的Safe地址、当前所在的链ID。这些状态被放在共享的Redux store中。无论用户在Web端切换了Safe还是在Mobile端切换了网络这个变化都会通过store同步到另一个平台如果用户同时登录。虽然不能做到实时同步需要后端或推送但至少保证了下次打开任一平台时状态是最新的用户操作结果。5.3 平台特定代码的处理有些功能是平台特有的。比如Web浏览器扩展钱包如MetaMask的连接、剪贴板操作、更复杂的文件上传。Mobile生物识别认证Face ID/Touch ID、推送通知、深色模式跟随系统。项目通常通过两种方式处理条件编译/导入在共享工具函数或Hook中使用动态导入或平台检测。// packages/utils/clipboard.ts export const copyToClipboard async (text: string): Promiseboolean { if (typeof navigator ! undefined navigator.clipboard) { // Web 环境 await navigator.clipboard.writeText(text); return true; } else { // Mobile 环境 (通过React Native的Clipboard API) // 这里可能需要导入一个mobile-specific的模块 // 或者这个函数本身就不在shared utils中而是分别实现 console.warn(Clipboard not available in this environment); return false; } };分别实现统一接口在apps/web/src/services和apps/mobile/src/services下分别实现WalletConnectService但暴露相同的函数接口如connect(),signMessage()。上层业务组件通过依赖注入或Context来使用平台对应的服务实现。6. 部署、发布与持续集成考量6.1 构建与部署流程对于Monorepo构建和部署需要精细化控制。Web应用Next.js# 在Web工作空间下运行构建 yarn workspace safe-global/web build这会生成一个优化的生产版本在.next目录。部署时只需要部署apps/web这个目录连同.next和public等到你的静态站点托管或Node.js服务器。Next.js支持静态导出或服务器端渲染项目会根据配置选择。移动端应用Expo# 构建Android APK/AAB yarn workspace safe-global/mobile eas build --platform android # 构建iOS IPA yarn workspace safe-global/mobile eas build --platform iosExpo EASExpo Application Services极大地简化了React Native应用的构建流程。它会在云端处理原生代码的编译和打包。开发者在本地只需要配置好eas.json和提交代码即可。6.2 版本管理与发布Monorepo的版本管理是个学问。Safe{Wallet}可能采用两种策略之一锁定版本所有apps和packages共享一个版本号在根package.json中定义。每次发布新版本整个Monorepo一起升版本。这适用于紧密耦合的项目。独立版本每个package特别是packages/store和packages/utils有自己的版本号使用如changesets或lerna这样的工具来管理依赖更新和发布日志。当只修改了共享工具库时可以只发布该库的新版本然后更新应用中对它的依赖。从仓库的Release process文档链接来看它很可能有一套自动化的发布流程可能涉及生成变更日志、更新版本号、创建Git tag、触发CI/CD构建和部署。6.3 CI/CD流水线设计一个健壮的CI/CD流水线对于这样的项目至关重要。通常会在.github/workflows/下配置多个GitHub Actions工作流PR检查流水线当有Pull Request时自动运行。安装依赖 (yarn install)代码 lint 和格式化检查 (yarn lint,yarn prettier)类型检查 (yarn workspace ... type-check)运行单元测试 (yarn test)可能还会构建所有工作空间以确保没有编译错误 (yarn workspace ... build)发布流水线当代码合并到主分支如main并打上版本tag时触发。执行所有PR检查的步骤。运行端到端测试可能需要启动测试服务器。构建生产版本Web的.nextMobile的二进制文件。将Web应用部署到生产环境如Vercel, AWS。将移动端构建产物提交到应用商店TestFlight/Google Play Internal Test或分发平台。个人经验在配置CI时充分利用Yarn Workspaces的--filter选项可以优化构建速度。例如如果只修改了packages/utils那么CI可以只运行与该包相关的lint、test和type-check以及依赖于此包的应用的测试而不是全量运行。这需要精心设计CI脚本但能显著缩短反馈时间。7. 为项目贡献代码与最佳实践7.1 开发工作流建议如果你想为这个开源项目贡献代码遵循既定的工作流能让你的贡献更容易被接受。Fork与分支Fork仓库到你的账户克隆到本地。为每个新功能或修复创建一个清晰命名的分支如feat/add-dark-mode,fix/tx-history-pagination。安装与启动按照前文所述在根目录运行yarn安装所有依赖。关联包由于是Monorepo如果你修改了packages/utils里的代码并且想在apps/web中立即测试效果不需要发布。Yarn Workspaces的符号链接已经帮你搞定了。直接启动Web应用它就会使用本地修改后的utils包。遵循代码规范在提交前务必运行yarn fix来格式化代码和修复简单的lint问题。确保没有TypeScript错误。编写测试如果你添加了新功能请尽量补充单元测试。如果修改了用户流程考虑是否需要更新或添加E2E测试。提交信息使用 约定式提交 规范如feat(web): add support for WalletConnect v2fix(store): correct balance polling interval。这有助于自动生成变更日志。创建PR在PR描述中清晰说明修改内容、动机和测试情况。7.2 常见陷阱与调试技巧幽灵依赖Phantom Dependencies这是Monorepo常见问题。指某个工作空间使用了没有在其自身package.json的dependencies中声明但被根目录或其他工作空间间接安装的包。这会导致构建在本地成功但在干净的CI环境或部署时失败。解决方法始终在每个工作空间的package.json中显式声明其直接依赖。可以使用yarn workspace safe-global/web why package-name来检查一个包为什么被安装。类型解析错误有时TypeScript可能找不到来自其他工作空间的类型定义。确保每个包的tsconfig.json正确设置了paths或references并且根目录的tsconfig.json配置了composite: true和references。通常项目已经配置好如果遇到问题检查相关配置。Husky钩子不生效确保你已经运行过yarn install它应该会自动设置Husky。如果没生效可以尝试yarn husky install。移动端原生模块问题如果移动端添加了新的原生依赖需要pod install或gradle配置记得在apps/mobile目录下运行yarn prebuild或expo prebuild来同步原生代码。7.3 性能优化点观察通过阅读代码可以发现项目已经实施了一些性能优化代码分割Code SplittingNext.js和React Native Expo默认支持代码分割按需加载。图片优化Next.js的Image组件自动处理图片优化。状态选择性订阅在React组件中使用Redux时通过精细化的useSelector选择器避免不必要的重渲染。列表虚拟化对于长列表如交易历史使用虚拟化列表组件如react-virtualized或React Native的FlatList来提升滚动性能。缓存策略RTK Query为数据请求提供了强大的缓存避免重复请求链上数据。深入研究这个Monorepo就像参加一个由顶尖Web3工程师主讲的实战大师课。它不仅仅是一个钱包应用更是一套关于如何构建可维护、可扩展、高质量现代Web应用的标准答案。从架构设计、工具链选型到具体的编码实践每一个细节都值得推敲和学习。无论你是想贡献代码还是仅仅想学习其设计思想这个仓库都是一个宝贵的资源。