1. 项目概述一个纯粹、高效的订阅费用追踪器在数字订阅服务泛滥的今天你是否也常常感到困惑每个月到底有多少笔自动扣款Netflix、Spotify、各种云服务、会员费……这些零散的费用加起来一年可能是一笔不小的开销。更麻烦的是这些订阅往往分散在不同的货币、不同的扣款周期里手动记账不仅繁琐而且容易遗忘。市面上的解决方案要么功能臃肿要么需要将你的财务数据上传到第三方服务器隐私问题让人担忧。这就是我决定动手搭建Subs的初衷。它不是一个复杂的个人财务管理软件而是一个专注、快速、隐私至上的订阅追踪工具。它的核心目标只有一个让你用最少的操作清晰地掌握所有周期性支出的全貌。你可以把它看作一个为你量身定制的、本地优先的“订阅仪表盘”。整个项目基于现代 Web 技术栈Remix React Tailwind CSS构建界面清爽响应迅速并且提供了浏览器本地存储和服务器端文件存储两种数据持久化方案完全由你掌控数据。接下来我将从设计思路、技术实现、部署实践到深度使用技巧为你完整拆解这个项目。2. 核心设计思路与技术选型解析2.1 为什么是“单一职责”与“隐私优先”在设计 Subs 之初我明确拒绝了两个方向一是做成大而全的记账应用二是依赖任何第三方云同步服务。我的核心思路是“单一职责”和“隐私优先”。单一职责意味着功能聚焦。Subs 只解决“订阅追踪”这一个问题并且解决得足够好。它不需要连接你的银行账户不需要管理投资甚至不需要记录一次性消费。这种聚焦带来了极致的用户体验打开应用映入眼帘的就是一个清晰的列表告诉你“谁”在“何时”会扣“多少钱”。这种简洁性降低了用户的使用和决策成本。隐私优先则体现在数据存储策略上。项目默认使用服务器端的 JSON 文件存储data/config.json这意味着你的所有订阅数据都静静地躺在你自己部署的服务器硬盘上。作为备选也提供了完全的浏览器本地存储方案通过设置USE_LOCAL_STORAGEtrue。这两种方式都确保了数据不出你的可控环境。我刻意没有集成任何需要 API Key 的云同步服务如 Dropbox, Google Drive因为一旦引入代码复杂度和隐私边界就会变得模糊。对于这类工具我认为数据的“可控性”比“便捷的跨设备同步”更重要后者可以通过导出/导入 JSON 文件这种“笨办法”手动解决虽然麻烦一点但换来的是绝对的心安。2.2 技术栈选型的深层考量选型不是堆砌热门技术而是为产品理念寻找最合适的基石。Remix React全栈能力与现代 UX 的平衡我选择了 Remix 而非 Next.js。虽然两者都是优秀的 React 全栈框架但 Remix 在表单处理、数据加载和渐进式增强方面的设计哲学更贴合 Subs 的需求。Subs 中有大量的交互是“增删改查”订阅信息Remix 的Action和Loader函数让服务器端逻辑和前端状态同步变得异常清晰和高效。例如当用户删除一条订阅时前端发起一个表单提交或 fetch 请求Remix 在服务端处理删除逻辑并返回新的数据状态前端无需复杂的客户端状态管理就能更新视图这对保持应用逻辑简洁至关重要。React 则提供了构建动态、响应式 UI 组件的最佳体验。Tailwind CSS shadcn/ui效率与一致性的组合拳样式方面Tailwind CSS 的实用性优先原则让我能快速构建出想要的界面而无需在 CSS 文件和组件间来回切换。更重要的是我引入了shadcn/ui。这不是一个传统的 npm 组件库而是一套可以拷贝到你项目中的、基于 Tailwind 和 Radix UI 的高质量组件源代码。这意味着零运行时依赖组件代码完全属于你的项目没有额外的 bundle 体积。完全可定制你可以修改任何组件的源代码使其完美符合你的设计系统。出色的可访问性底层基于 Radix UI组件默认支持键盘导航、屏幕阅读器等。 对于 Subs 这样一个注重细节和体验的工具使用 shadcn/ui 构建的对话框、下拉菜单、表格等组件在获得开箱即用的优秀交互的同时也保持了项目的轻量和自主权。状态管理Zustand 的轻量之道对于客户端状态比如模态框的开关状态、筛选条件、排序规则我选择了 Zustand。相比 Redux 的繁琐Zustand 的 API 极其简洁一个create函数就能定义一个 Store并且在 TypeScript 下类型推断非常完美。它的体积很小约 1KB完美契合 Subs “轻量”的理念。对于从服务器加载的订阅数据我主要利用 Remix 的loader来管理Zustand 只负责那些纯粹的前端交互状态职责划分清晰。质量保障Biome 与 PlaywrightBiome这是一个新兴的、速度极快的 JavaScript 工具链集成了格式化、linting 等功能。用它替代了传统的 ESLint Prettier 组合简化了配置并大幅提升了代码检查和格式化的速度。Playwright用于端到端E2E测试。我编写了测试用例来模拟用户的核心操作流添加订阅、编辑金额、删除条目、导入导出数据等。Playwright 能真实地在浏览器中运行这些测试确保应用的关键功能在任何代码改动后依然正常工作。这对于一个没有专职测试人员的个人项目来说是保证长期稳定性的安全网。3. 核心功能实现与实操要点3.1 数据模型与存储逻辑剖析Subs 的核心数据模型非常简单但设计时考虑了几个易用性细节。一个订阅Subscription对象通常包含以下字段{ id: string, // 唯一标识通常使用 crypto.randomUUID() 生成 name: string, // 服务名称如 “Netflix” price: number, // 价格如 15.99 currency: string, // 货币代码遵循 ISO 4217如 “USD”, “EUR” cycle: daily | weekly | monthly | yearly, // 计费周期 cycleAnchor: string, // ISO 8601 格式的日期字符串如 “2024-01-15”代表上一个或下一个扣款日 category?: string, // 可选分类如 “娱乐”, “生产力” notes?: string // 可选备注 }存储逻辑的实现是项目的关键之一。代码中抽象了一个统一的存储层在app/store/subscriptionStore.ts中它根据环境变量USE_LOCAL_STORAGE的值动态决定使用哪种持久化方式。服务器端文件存储默认当USE_LOCAL_STORAGEfalse时所有对订阅数据的增删改查操作都会通过 Remix 的loader和action函数映射到对服务器上data/config.json文件的读写。Remix 处理了 HTTP 请求与服务器逻辑的对接前端组件只需像调用普通函数一样发起请求即可。注意在生产环境中部署时务必确保运行应用的进程如 Node.js 进程或 Docker 容器对./data目录有读写权限否则会导致数据保存失败。浏览器本地存储当USE_LOCAL_STORAGEtrue时前端会直接调用localStorageAPI 来存取数据。此时Remix 的服务端路由仅用于提供静态资源和初始页面所有数据操作都在浏览器内完成。这种方式非常适合在静态托管平台如 Vercel, Netlify上以纯前端模式运行或者用于临时的、单设备使用的场景。实操心得在开发时我经常在两种模式间切换测试。一个有用的技巧是在root.tsx或入口组件中可以添加一个简单的环境检测提示例如在页面角落显示“当前使用本地存储模式”避免在测试时混淆数据源。3.2 核心日期计算下一个付款日“自动计算下一个付款日”是提升体验的核心功能。逻辑封装在app/utils/nextPaymentDate.ts中。其核心算法并不复杂但需要考虑边界情况。基本逻辑给定一个cycleAnchor周期锚点日和cycle周期计算从“今天”开始下一个即将发生的付款日期。将cycleAnchor解析为 JavaScript 的Date对象。根据cycle进行循环按月monthly在锚点日期的“月”分量上依次加 1。按年yearly在锚点日期的“年”分量上依次加 1。按周weekly和按日daily分别增加对应的天数。每次增加后判断得到的日期是否大于或等于“今天”。如果是则该日期即为“下一个付款日”。难点与处理月末日期如果锚点日是某月的 31 日但计算出的目标月份没有 31 日如 2 月则需要处理为当月的最后一天如 2 月 28 日或 29 日。JavaScript 的Date对象会自动处理setMonth时传入的日期超出范围会滚动到下个月但需要显式地纠正回当月的最后一天。时区所有日期计算都应基于 UTC 或用户的本地时区进行避免因服务器和客户端时区不同导致日期显示错误。在 Subs 中我选择使用UTC 日期字符串YYYY-MM-DD进行存储和传输在前端显示时再根据用户时区格式化为本地日期。这样可以保证在任何地方看到的“日期”都是一致的。踩坑记录早期版本曾直接使用new Date().toISOString().split(‘T’)[0]来获取本地日期但在 UTC 时间凌晨零点之前获取的会是前一天的日期。后来统一改为先构造本地日期的年、月、日再用Date.UTC生成一个无时区影响的日期字符串问题得以解决。3.3 多货币与总额统计为了让用户对总支出有直观感受支持多货币并显示总计是必要的。前端界面会显示每条订阅的原始货币金额同时在顶部汇总栏会计算一个“总计”。实现方式实时汇率获取通过一个公开的汇率 API例如exchangerate-api.com或api.frankfurter.app在客户端获取实时汇率。这个请求通常在应用初始化或用户手动刷新时进行。统一换算将所有非基准货币比如我设定 USD 为基准的订阅金额通过汇率换算成基准货币。显示总计将换算后的所有金额相加得到总支出。同时也可以选择显示各货币的原始小计。注意事项汇率是波动的因此这个“总计”是一个估算值主要用于趋势参考和月度预算规划不能作为精确的会计数据。在代码中我会缓存汇率数据例如 1 小时避免过于频繁的 API 调用同时也要处理 API 调用失败时的降级方案比如显示“汇率暂不可用总计仅供参考”。3.4 键盘快捷键提升操作效率对于需要频繁操作的工具键盘快捷键能极大提升效率。Subs 通过监听keydown事件实现了全局快捷键。实现要点事件监听在 React 组件中使用useEffect在document上添加keydown事件监听器。条件判断在事件处理函数中首先判断event.target是否是输入框input,textarea等避免在用户输入时触发全局快捷键。修饰键处理正确识别CtrlWindows/Linux和CmdMac键。执行操作调用对应的状态管理函数或 Remix 导航函数。防止默认行为调用event.preventDefault()防止浏览器处理这些按键例如/键通常会触发搜索框聚焦。为了方便用户还实现了一个快捷键帮助对话框按?键触发清晰列出所有可用快捷键。4. 从零开始的完整部署与使用指南4.1 本地开发环境搭建假设你希望基于 Subs 的代码进行二次开发或只是想本地运行以下是详细步骤环境准备确保你的系统已安装 Node.js 20 或更高版本。我个人推荐使用nvmNode Version Manager来管理多个 Node.js 版本。同时安装git。获取代码git clone https://github.com/ajnart/subs.git cd subs安装依赖项目支持 npm 和 Bun。Bun 的安装速度通常更快。# 使用 npm npm install # 或使用 Bun bun install启动开发服务器npm run dev # 或 bun run dev访问http://localhost:3000你应该能看到 Subs 的界面。默认使用服务器端文件存储数据会保存在项目根目录的./data/config.json文件中首次运行会自动创建。切换为本地存储模式如果你想测试纯前端模式需要创建一个.env文件参考项目中的.env.example并设置USE_LOCAL_STORAGEtrue重启开发服务器后数据将保存在浏览器的 LocalStorage 中。4.2 使用 Docker 进行生产环境部署Docker 部署是最简单、最一致的方式能完美解决环境依赖问题。单容器运行# 创建数据目录用于持久化存储 mkdir -p /path/to/your/subs_data # 运行容器将主机的 7574 端口映射到容器的 7574 端口并挂载数据卷 docker run -d \ -p 7574:7574 \ -v /path/to/your/subs_data:/app/data \ --name subs \ --restart unless-stopped \ ghcr.io/ajnart/subs:latest运行后通过http://你的服务器IP:7574即可访问。所有订阅数据将安全地保存在你主机上的/path/to/your/subs_data目录中。使用 Docker Compose推荐 对于长期运行的服务使用docker-compose.yml管理更为方便。创建一个docker-compose.yml文件version: 3.8 services: subs: image: ghcr.io/ajnart/subs:latest container_name: subs ports: - 7574:7574 # 你可以将前面的 7574 改为任何未被占用的端口如 8080:7574 restart: unless-stopped volumes: - ./data:/app/data # 使用相对路径数据会保存在同目录的 data 文件夹下 # 环境变量可选 # environment: # - USE_LOCAL_STORAGEfalse # 默认即为 false使用服务器存储然后在同一目录下执行docker-compose up -d使用docker-compose logs -f查看日志docker-compose down停止服务。部署经验如果你在云服务器如 AWS EC2, DigitalOcean Droplet上部署强烈建议使用nginx或Caddy作为反向代理将域名如subs.yourdomain.com指向localhost:7574并配置 SSL 证书使用 Let‘s Encrypt启用 HTTPS。定期备份./data目录。你可以写一个简单的 cron 任务将目录打包压缩并上传到另一个存储位置。4.3 核心使用流程与数据管理添加订阅点击“Add Subscription”按钮或按n键。填写服务名称、价格、货币、计费周期和起始日。分类和备注是选填项但建议填写分类便于后续筛选。查看与管理主列表会展示所有订阅默认按下一个付款日排序。你可以使用顶部的搜索框按/键快速聚焦过滤订阅也可以点击表头进行排序如按价格、名称。理解视图“Next Payment”列自动计算的下次扣款日距离今天较近的会用高亮颜色提醒。顶部汇总栏显示所有订阅换算后的月度估算总花费和年度估算总花费。这是一个非常直观的“消费健康度”指标。数据导入/导出这是在不同设备间迁移数据或备份的关键。导出点击设置菜单中的“Export JSON”或按Ctrl/Cmd e浏览器会下载一个subscriptions.json文件。导入点击“Import JSON”或按Ctrl/Cmd i选择之前导出的 JSON 文件。系统会进行验证并提示你是“覆盖”现有数据还是“合并”。重要警告导入操作会直接修改当前数据存储。在进行覆盖导入前强烈建议先执行一次导出操作作为备份。5. 常见问题排查与进阶技巧5.1 部署与运行问题问题现象可能原因解决方案访问http://localhost:3000显示空白或错误Node.js 版本过低或依赖安装失败确认 Node.js 20。删除node_modules和package-lock.json/bun.lockb重新运行npm install或bun install。Docker 容器启动后无法访问端口映射错误或防火墙限制检查docker ps确认容器状态为Up。检查-p参数映射的端口如7574:7574主机端口是否被占用。检查服务器防火墙/安全组是否放行了该端口。添加订阅后刷新页面数据丢失Docker部署数据卷挂载失败容器使用临时存储检查docker run或docker-compose.yml中的-v卷挂载路径是否正确且主机目录有写权限。进入容器检查/app/data目录下是否有config.json文件。汇率总计显示为 0 或 “N/A”汇率 API 请求失败或被浏览器 CORS 策略阻止打开浏览器开发者工具的“网络(Network)”选项卡查看获取汇率的请求是否失败。如果是 CORS 问题可能需要配置反向代理或更换汇率 API 源需修改前端代码。在开发环境下这可能是因为使用了 HTTP 而非 HTTPS 访问某些 API。生产环境构建失败内存不足或构建脚本错误对于 Vercel/Netlify 等平台尝试在项目设置中增加构建内存。本地构建可尝试npm run build --max-old-space-size4096。检查package.json中的build脚本是否正确。5.2 使用技巧与最佳实践利用分类进行预算管理在添加订阅时积极使用category字段。例如分为娱乐、软件工具、云服务、学习等。这样你不仅可以看到总支出还可以通过筛选功能快速了解在某个特定类别上的花费便于进行预算控制和优化。“周期锚点日”的设置技巧这个日期通常设置为你最近一次已发生的扣款日或者你已知的下一次扣款日。系统会根据这个日期和周期自动向前推算所有未来的日期。设置准确是自动计算功能正确的基础。处理年度订阅对于年付订阅虽然周期是yearly但你可能更关心月度现金流影响。Subs 顶部的汇总栏会同时显示月度估算和年度估算。月度估算值是将年费除以 12 得出的这能帮助你更直观地理解它对你每月预算的实际影响。数据备份策略即使数据存储在你自己服务器上定期备份也至关重要。除了使用应用内的导出功能你还可以Docker部署写一个 cron 定时任务定期执行docker cp subs:/app/data/config.json /backup/path/如果数据在容器内。服务器文件存储直接备份主机上的./data目录。将备份的 JSON 文件存储在另一个物理位置或云存储中。在静态托管平台使用如果你想在 Vercel 或 Netlify 上零成本部署且不关心数据持久化或愿意每次手动导入可以将USE_LOCAL_STORAGE设置为true。这样构建出的就是一个纯静态前端应用数据保存在浏览器本地。但请注意清除浏览器数据会导致数据丢失跨设备也无法同步。这个项目从构思到实现贯穿了我对“工具应该为人服务”的理解它应该简单、专注、尊重隐私并且把核心体验做到极致。Subs 的代码结构清晰注释完善无论是想直接部署使用还是作为学习 Remix 全栈开发、现代前端状态管理、以及如何构建一个完整可用的 Web 应用的参考项目都具有很高的价值。如果你在使用的过程中有任何问题或者有改进的想法项目的 GitHub 仓库始终开放着 Issues 和 Pull Requests。