距离上次更新已经过了一个多月。这段时间发生了一件大事——整个系统推倒重写了。
上篇文章结尾我说过:“项目规模已经到了 Vanilla JS 的舒适边界”。当时觉得还能再撑一阵,结果只过了两周,代码就彻底失控了。
重写的导火索
v1.13 之后又迭代了几个版本(v1.14 到 v1.16),每次都在原有架构上加功能:
- 合同终止与续约:销售订单和库存资源都需要支持提前终止、到期续约、逐项操作
- 批次容量管理:Base + Batch 模式的成本分摊
- CSV/Excel 导入:三步向导式批量数据导入
- 移动端卡片视图:客户、供应商列表的响应式适配
每加一个功能,都要在多个 IIFE 模块间穿针引线。salesForm.js 拆了又拆、拆出了十几个子模块,inventory.js 也开始走上同样的路。代码量突破了 21000 行,但真正让我下决心的不是行数,而是两个具体的痛点:
1. 状态管理的噩梦
续约弹窗需要同时操作多个成本项,每个成本项有独立的日期、金额、选中状态。用 DOM 操作来维护这些状态,代码写出来是这样的:
// 每次点击复选框,手动同步所有关联 DOM
const checkbox = card.querySelector('.renew-checkbox');
const dateInput = card.querySelector('.renew-start-date');
const termInput = card.querySelector('.renew-term');
if (checkbox.checked) {
dateInput.disabled = false;
termInput.disabled = false;
recalcEndDate(card);
updateSubmitButton();
} else {
dateInput.disabled = true;
// ... 还要清理 N 个联动状态
}
同样的逻辑用 React 写:
const [items, setItems] = useState(initialItems);
// 选中状态变了,UI 自动更新
<Checkbox checked={item.selected}
onCheckedChange={(v) => updateItem(i, { selected: v })} />
2. 测试的死角
v1.x 加了测试框架,但能测的只有纯函数(日期计算、状态判断)。涉及 DOM 的业务逻辑根本没法测,因为每个模块都依赖全局的 App 对象和一堆 DOM 节点。
重写,不是重构
2 月 23 日,v2.0.0 发布。技术栈完全更换:
| 组件 | v1.x | v2.0 |
|---|---|---|
| 框架 | Vanilla JS (ES6 Modules) | React 18 + TypeScript |
| 构建 | 无(直接引入) | Vite |
| 样式 | 手写 CSS(12 个模块文件) | Tailwind CSS |
| 认证 | Supabase Auth(手写集成) | Supabase Auth(Context) |
| 部署 | GitHub Pages | GitHub Pages |
| 代码规模 | 21,000+ 行 JS | ~8,000 行 TSX |
v1.x 的 96 个文件全部删除,一行旧代码都没留。
架构设计
新版采用了 Feature-based 的目录结构:
src/
├── features/
│ ├── inventory/ # 库存管理
│ │ ├── api/ # Supabase 查询(拆分为 resources, lifecycle, sales-links...)
│ │ ├── components/
│ │ └── hooks/ # useInventoryPageController, useInventoryFormActions...
│ ├── sales/ # 销售管理
│ │ ├── api/
│ │ ├── components/
│ │ └── hooks/
│ ├── dashboard/
│ └── crm/
├── components/ # 共享 UI 组件
├── contexts/ # AuthContext, ThemeContext
└── types/
核心模式是 Controller-Hook 架构:每个页面有一个 Controller Hook 负责编排数据流和操作,UI 组件只负责渲染。
Page → usePageController → usePageData + usePageActions → Components
这样做的好处是,可以对 Hook 层单独测试业务逻辑,UI 组件保持"纯展示"。
数据模型的升级
趁着重写,把数据模型也做了清理:
- 电路级追踪:从"容量数字"升级为每条电路独立管理,带接口类型和状态指示
- Base+Batch 容量:支持分批点亮的容量管理,自动计算成本分摊
- 原子 ID 生成:用数据库 Sequence + Trigger 生成
RES-00001/SO-00001,消除客户端竞态
两天迭代出的 v2.x
重写完成后,因为架构清晰了,新功能的开发速度快了很多。两天内从 v2.0 推进到了 v2.9:
📊 Dashboard(v2.1)
从"施工中"占位图变成真正的仪表盘:KPI 卡片、资源利用率进度条、销售管道堆叠图、到期预警、最近活动流。
⚡ 生命周期管理(v2.2 - v2.3)
这个在 v1.x 时写得很痛苦的模块,在新架构下实现得非常顺畅:
- 按订单项终止:复选框选择要终止的 Line Item,输入 ETF,未选中的保持活跃
- 到期资源释放:Expired 订单专属的 🔓 释放按钮,简化流程
- 选择性续约:续约弹窗里 IRU 项自动标记为不可续约,混合订单只列出可续约部分
- 库存联动:终止/取消时自动释放电路、清零已用容量
🎨 主题切换(v2.6)
Light / Dark / System 三档切换,CSS 变量系统自动继承,Light 模式专门调过 WCAG AA 对比度:
/* Light */ --text-dim: #64748B; /* 4.6:1 */
/* Dark */ --text-dim: 保持原有 OLED 黑底配色
📐 Page-Level Decomposition(v2.5)
这一步是"重构后再重构"——v2.0 虽然用了 React,但页面组件还是偏胖。v2.5 做了系统性的拆分:
InventoryDetailPage.tsx: 1292 → 262 行 (−80%)
InventoryFormPage.tsx: 861 → 166 行 (−81%)
SalesDetailPage.tsx: 916 → 186 行 (−80%)
净减少 4,419 行,新增 40 个聚焦模块。纯结构重构,行为零变化。
🔌 最新功能(v2.7 - v2.9)
- Sales Revenue Summary:订单列表和详情页都能看到汇总的 MRC / OTC / NRC / Annual O&M
- O&M Adjustments:针对 IRU 资源的运维费减免、预付、折扣记录
- 电路接口变更:从销售表单直接修改电路接口类型,自动记录变更日志
- 移动端卡片优化:更大间距、更多信息密度、圆角卡片风格
一些数字
| 指标 | v1.16(重写前) | v2.9(当前) |
|---|---|---|
| 代码行数 | ~21,000 JS | ~8,000 TSX |
| 文件数 | 96 | ~80(但每个都更聚焦) |
| 构建工具 | 无 | Vite(<1s HMR) |
| 类型安全 | 零 | TypeScript strict |
| 测试覆盖 | 纯函数 only | Hook + 纯函数 |
| 页面组件最大行数 | 1,800+ | 262 |
感想
重写 vs 重构的边界
v1.x 从 v1.2 开始就在持续重构:IIFE → ES6 Modules → 子模块拆分 → 状态引擎模块化。每一步都有收益,但每一步也都受限于"没有框架"这个天花板。
回头看,触发重写的不是代码质量,而是交互复杂度。当表单开始需要"多层嵌套的条件联动状态"时(比如续约弹窗里每个成本项独立控制日期和金额),React 的声明式模型就不再是"锦上添花",而是"刚需"。
为什么选 React 而不是 Vue / Svelte?
纯粹是团队熟悉度。另一个项目已经在用 Next.js + React,同一套心智模型可以复用。
“推倒重写"没那么可怕
Joel Spolsky 的名言"永远不要从头重写"在很多场景下是对的。但这次重写之所以顺利,是因为:
- 需求完全明确:v1.x 已经跑了两个月,所有功能和边界情况都在 CHANGELOG 里
- 数据模型不变:Supabase 里的表结构和 RLS 规则完全复用
- 只有一个开发者:不存在"新旧系统并行"的协调成本
如果这是一个多人协作、需求还在变的项目,结论可能完全不同。
相关链接
代码仍然开源 👉 GitHub: cable-inventory
上一篇 👉 v1.13 更新记录:打磨细节,优化体验