跳至内容
返回博客

重构 MailVault:从单体到模块

7 分钟阅读

MailVault 一直在快速生长。过去几周里我们发布了键盘快捷键、统一收件箱、邮件模板、发件人洞察、自动清理规则、撤销发送、移动到文件夹,以及一整套 Microsoft Graph API 传输层。功能一个接一个落地,用户很满意 — 而代码库开始被压得吱吱作响。

中央状态存储 — 一个叫 mailStore.js 的 Zustand 单文件 — 已经涨到 4300 多行。它什么都管:账户管理、邮件加载、缓存、搜索、会话归并、Graph API 分支、CONDSTORE 增量同步、批量操作等等。Rust 后端的 main.rs 也在往同一个方向走。SettingsPage 和 EmailViewer 这类组件已经膨胀成一整面 JSX 墙。东西还能用,但想加点新功能就得在迷宫里找路。

是时候重构了 — 不是因为哪里坏了,而是因为发布新功能的速度开始慢下来。这篇文章讲我们做了什么、为什么,以及接下来往哪走。

一个巨型 store 的问题

Zustand 很适合做状态管理。但当单个 store 文件超过几千行,实际的麻烦就来了:每个功能都要改同一个文件,合并冲突成了家常便饭,谁依赖谁也说不清楚。我们的 mailStore 里,邮件加载逻辑和 Graph API 传输代码交织在一起,LRU 缓存管理紧挨着搜索状态,会话归并算法又和账户切换混在一处。

Rust 那边是同样的情形。main.rs 里塞了 Maildir 路径辅助函数、钥匙串操作、缓存管理、附件处理、通知逻辑和系统工具 — 全在一个文件里。IMAP 模块里,连接池和协议层操作也缠在一起。

双流邮件加载

v2.2.0 里最大的架构变化,是重新设计了切换账户或文件夹时邮件的加载方式。旧做法有六个互相重叠的阶段,按一个脆弱的顺序执行:查缓存、显示缓存数据、连接 IMAP、拉取邮件头、合并结果、更新界面。如果你快速切换账户,这些阶段可能交错,于是你会短暂看到别的账户的邮件 — 也就是那个「空邮件」bug。

新设计是一个清爽的双流模型。当你激活一个账户时,两条独立的流并行启动:

流 1(本地)从磁盘加载缓存的邮件头和本地保存的邮件。这几乎是瞬时的 — 借助 headers.json 缓存和新增的 local-index.json,界面在几毫秒内就填满,不必再去 MIME 解析磁盘上每一个 .eml 文件。

流 2(服务器)连接 IMAP(Microsoft 账户则走 Graph API),获取当前的服务器状态,并把变化合并进本地视图。新邮件出现,已删除的消失,已读/未读标记更新 — 全程不闪烁,也不会把屏幕上已有的内容整个换掉。

两条流都包在一个 AbortController 里。如果你在任一条流跑完之前切换了账户,进行中的工作会被干净地取消。没有竞态,没有陈旧数据,也没有“空邮件”。

模块抽取

重构的第一阶段,重点是把那些根本不该待在 store 里或 main.rs 里的代码拆出来。

里的代码拽出来。JavaScript 这边,我们抽出了:

Graph 配置 — Microsoft Graph 的文件夹名映射(Inbox → INBOX、Sent Items → Sent 等等)在代码库里重复了三遍。现在它只住在一个 graphConfig.js 模块里。

LRU 缓存 — 账户缓存(最多保留 8 个账户的邮件状态,用于瞬时切换)和正文缓存(邮件内容,内存上限可配)被抽到了一个专门的缓存模块里,共用同一份 LRU 实现。

搜索 — 搜索状态和逻辑本来就基本独立,却缠在主 store 里。把它拆出来之后,store 和搜索代码都更好读了。

性能追踪 — 加载代码里散落着临时加的 console.time 调用。它们被换成了一个结构化的 perfTrace 工具,可以随时开关,性能调试因此干净得多。

组件拆分

有两个 React 组件管得太多了:SettingsPage 和 EmailViewer。

SettingsPage 在一个文件里渲染了每一个设置标签页 — 账户、外观、键盘快捷键、通知、模板、自动清理等等。我们把它拆成了一个个独立的标签页组件,放在 settings/ 子目录下。父组件现在只负责切换标签页并渲染当前那一个。

EmailViewer 一路长到要同时处理单封邮件显示、会话视图、附件渲染、DKIM 验证徽章、发件人洞察,以及回复/转发操作 — 全在一个组件里。我们把 ThreadView、EmailHeader 和 AttachmentBar 抽成了 email/ 子目录下职责单一的组件。

结果是更小的文件:更好找、更好测,也更容易在不误伤无关部分的前提下修改。

五阶段计划

v2.2.0 里发布的只是开头,不是结尾。我们设计了一份五阶段的重构计划,正在逐步执行:

阶段 1:共享基础 — 抽出共享的常量和工具(Graph 配置、Rust 常量、性能追踪)。已完成。

阶段 2:Rust 后端 — 把 main.rs 拆成职责单一的模块:Maildir 操作、钥匙串访问、缓存管理、附件处理、通知和系统工具。IMAP 模块则拆成协议层操作和连接池管理。

阶段 3:Store 拆解 — 建立专门的 useSearchStore 和 useThreadStore,把 Graph API 传输、缓存管理和邮件加载抽成服务模块。目标:mailStore 从 4300 行缩到 2000 行左右。

阶段 4:组件抽取 — 继续拆分大组件。AccountModal 拆成服务器探测和 OAuth 配置两个子组件。其余过大的组件同样处理。

阶段 5:清理 — 删掉死代码,合并重复的工具函数(比如在好几处都存在的 hasValidCredentials),并把数据库工具模块拆成职责单一的文件。

重构而不引入回归

任何重构最难的部分,都是确保什么都别弄坏。MailVault 处理的是人们的邮件 — 如果切换账户开始显示错误的邮件,或者加载竞态又冒出来,那就是实打实的问题。

我们的做法是:一次只重构一个边界,验证一切照常工作,再动下一个边界。横跨四个层次的 600 多个自动化测试给了我们一张安全网,但每个阶段之后我们还会手动测试 — 快速切换账户、加载中途断网、拿那个 17000 封邮件的邮箱试、确认 Graph API 账户和普通 IMAP 账户都表现正常。

在 store 架构上我们也做了一个有意的取舍。与其把所有东西拆成许多个小 store(那本身会带来协调上的麻烦),我们选了混合方案:真正独立的关注点用独立的 store(搜索和会话归并),而账户与邮件状态因为耦合很紧,仍然放在 mailStore 里。目标是清晰,不是纯粹。

用户得到了什么

重构在设计上对用户是不可见的 — 应用看起来、用起来应该一模一样。但效果是实实在在的:

账户切换更快 — 双流架构意味着切换账户时你几乎立刻就能看到邮件,服务器同步在后台悄悄进行。

不再有加载故障 — 快速切换账户时可能出现的“空邮件”问题没有了,旧的顺序加载代码里的其他竞态也一并消失。

功能开发更快 — 这才是最要紧的。代码库更干净,新功能就能更快做出来、更快发布。即将到来的“全部下载”功能、定时自动备份,以及其他被反复要求的改进,都会因为代码更好动而更早落地。

我们是奔着长期在做 MailVault。邮件是一项几十年前的技术,现代工具链对它的关照并不多,而现有的备份方案要么太技术、要么太笨重。让代码库可持续,是我们确保这个应用持续变好的方式 — 不只是这个月,而是长期。