12 KiB
编程通用范式:编写与重构规则
第四部分:编写规则
规则 1:值对象先行,实体随后
从需求中先识别值对象,再识别实体。值对象定义了系统的"度量衡",实体定义了系统的"角色"。
规则 2:最简 Workflow 先行
写一个最简编排把核心流程跑通,节点先用空实现。先达成结构共识,再填细节。
规则 3:逐个节点填充,每填一个跑一次测试
规则 4:同层同级,控制认知负荷
编排函数每一行保持同一抽象层级(见第 4 节)。所有长度和密度约束见基础约束(第 0 节),核心数字速查:
- 单函数:编排 10-30 行(上限 50),计算不超过 50 行(上限 100)
- 单文件:一个文件一个角色。编排文件 50-150 行,计算文件 100-300 行,超 400 行必拆
- 逻辑量:单函数操作的独立概念不超过 7 个,分支不超过 5 个
- 密度平衡:理解一段逻辑不应需要同时打开超过 3 个文件
- 终极判断:新人打开这个文件,30 秒内能否理解意图?
规则 5:复制还是复用
判断:给它起个名字
把"相同的部分"假装抽出来,试着给它起个名字:
第一步:能起具体名字吗?
├─ 需要 Generic / Common / Base / Shared 前缀 → 不是真概念 → 复制
└─ 有具体名字(CopyableTip、useAsync、Money)→ 进入第二步
第二步:不看调用点能理解它吗?
├─ 不能,要看谁在用才知道它干什么 → 意义依赖场景 → 复制
└─ 能,它自己就说清了自己是什么 → 是独立概念 → 复用
两步都过了才复用。任何一步没过就复制。
这条原则在不同层级上的表现:
| 层级 | 复制(各自演化) | 复用(同生共死) |
|---|---|---|
| 编排/workflow | 两个业务流程看起来像 → 复制,各走各的 | — |
| 固定结构组合 | — | CopyableTip = HoverTip + CopyButton,没有 if,改一个就该改全部 → 复用 |
| 领域计算 | — | "税怎么算"不因流程不同而不同 → 必须复用,单一真相源 |
判断错了怎么办:识别信号,内联回去
预测不可能总对。关键不是判断准,而是错了能快速纠正。错误的复用会发出四个明确的代码信号:
信号一:参数里出现了 boolean / enum 开关。 某个调用点说"我要不一样的行为",你加了 useIcon=true、feedbackMode="color"。开关越多,说明调用点分化越严重——当初的"同一个概念"假设已经不成立。
信号二:内部出现"只为某个调用点服务"的 if。 打开共享模块,发现某个分支只有一个地方在走。它不再是通用模式,而是特殊需求的拼盘。
信号三:改代码时脑子里在想"别影响别的调用点"。 这是最直觉的信号——你已经在为错误的复用买单了。
信号四:新人看不懂这个抽象。 需要看调用点才能理解它是干什么的,说明它不是独立概念。
出现任何一个信号,执行内联:
1. 把共享模块的代码复制回每个调用点(出现重复代码是正确的中间状态)
2. 删掉共享模块
3. 让每个调用点独立演化
4. 观察是否有新的真实共性浮现——演化后的共性才是真共性
默认策略:先复制,等待复用浮现
复制 → 发现是真共性 → 提取复用 容易,两份代码都是完整的
复用 → 发现不该复用 → 内联拆开 痛苦,N 个调用点已经依赖它
错误的复用比错误的复制代价大得多。 所以默认倾向复制——等到命名测试两步都通过、重复出现 3 次以上、而且没有出现上述四个信号时,再提取复用。
规则 6:新功能叠加,骨架不动
新功能以子流程、装饰器、Null Object 替换的方式叠加到现有 workflow 上。主 workflow 骨架尽量保持不变。功能上下 = 子流程的挂载或卸载。
在组件/模块层面同样适用:新功能是组合出新组件,不是往老组件里加 prop/开关。
判断方法:细化还是组合?
遇到"要改老模块还是建新模块"时,问一个问题:
新功能和老模块是"同一个概念的细化",还是"两个概念的组合"?
- 细化 → 改老模块。比如 tooltip 支持不同方向(top/bottom/left/right),方向是 tooltip 本身的属性。
- 组合 → 建编排层,把多个独立能力组装在一起。比如 tooltip + 复制、tooltip + 确认按钮。
实例:HoverTip + 点击复制
需求:已有 HoverTip 组件(鼠标悬停显示提示),现在某个页面需要 tip 内容可以点击复制。
分析:
- HoverTip:鼠标悬停 → 显示文字 → 离开消失。核心是"被动信息展示"。
- 点击复制:用户主动点击 → 写入剪贴板 → 反馈"已复制"。核心是"主动用户动作"。
一个是被动展示,一个是主动交互——不是同一个概念,是两个独立的能力。
❌ 在原组件里加 prop(瑞士军刀式膨胀)
<HoverTip content="xxx" :copyable="true" />
今天加 copyable,明天加 closable,后天加 hasAction,
半年后 15 个 prop、8 个 slot、300 行模板。
本质错误:把编排逻辑(哪个页面需要什么组合)塞进了计算层(组件本身)。
正确方向:HoverTip 提供 slot,CopyButton 作为独立能力,组合发生在调用点。
// CopyButton:纯"复制"能力,不知道自己会被用在哪里
component CopyButton(text):
copied = false
fn copy():
clipboard.write(text)
copied = true
after 1.5s: copied = false
render: button(onClick=copy, label=copied ? "已复制" : "复制")
最简方案——调用点直接用 slot 组合,不建新组件:
// 调用点就是编排层,决定 tip 里放什么
HoverTip:
slot content:
text("可复制内容")
CopyButton(text="可复制内容")
<span>悬停我</span>
HoverTip 和 CopyButton 各自不知道对方的存在,组合关系只在调用点体现。
判断链:何时提取组合组件?
1. 老模块有 slot / 扩展点吗?
├─ 没有 → 先加 slot(这是"细化",支持自定义内容是它本身的能力)
└─ 有 → 在调用点直接用 slot 组合新能力
2. 这个组合模式重复出现了几次?
├─ 1-2 次 → 保持调用点组合,不建新模块
└─ 3+ 次 → 提取组合组件固化模式,消除重复
只有当"tip + copy"的组合出现 3 次以上,才值得提取 CopyableTip:
// CopyableTip:固化重复的组合模式
component CopyableTip(content):
render:
HoverTip:
slot content:
text(content)
CopyButton(text=content)
slot default:
<children/>
先用最简单的方式(slot 组合),等重复逼你封装的时候再封装。 这是规则 2(最简方案先行)在组件层面的体现。
通用原则:出现 1 次是用法,出现 3 次是模式,模式才值得封装。
第五部分:重构规则
规则 7:安全手法蚕食,不重写
操作顺序:重命名 → 抽取函数 → 移到新模块 → 清理新模块内部。
核心纪律:
- 抽取函数和移到新模块是一个完整动作,只做抽取不做搬移 = 做了一半
- 搬和改永远不在同一次提交——搬的 commit 只移动代码不改逻辑,改的 commit 只优化不移动
- 每步之间运行测试,确保行为不变
安全手法按风险从低到高:
- 重命名:零风险,工具一键完成
- 抽取函数:极低风险,工具一键完成
- 引入参数对象:低风险
- 移到新模块:中低风险
- 内联后重新抽取:中风险,用于修正之前抽错的结构
规则 8:让结构浮现,再让结构裸露
从大函数中抽取小函数 → 结构浮现(编排逻辑从细节中露出来)。
把小函数搬到独立模块 → 结构裸露(编排模块只剩编排,打开就是流程图)。
最终目标:编排模块 20-30 行,一眼可读,每一行是一个动作名。所有实现细节在各自的独立模块里。
浮现了但没裸露,等于没做完。
第六部分:重构实操节奏
绞杀者模式
新结构从老代码外围长出来,逐步替换。整个过程老代码始终能跑、老功能始终不坏。
渐进阶段
| 阶段 | 动作 | 产出 |
|---|---|---|
| 0 | 画现状流程图(不动代码) | 节点清单 + 每个节点的读写依赖 |
| 1 | 抽取函数 | 主函数变成 workflow 雏形 |
| 2 | 识别隐式状态(全局变量、线程本地、闭包捕获) | 需要显式化的状态清单 |
| 3 | 引入 Context 结构体 | 所有状态传递显式化 |
| 4 | 分离加载/计算/持久化 | 纯计算函数可独立测试 |
| 5 | 搬到独立模块 | 老模块只剩编排 |
| 6 | 拆出 Loader / Workflow / Persister | 完整分层结构 |
每个阶段都可停下来,代码始终可运行。
关键技巧
- 特征测试做安全网:录制真实输入输出,每次改完对比行为一致性
- 开关切流:新老流程并存,小比例流量先走新流程,逐步放量
- 提交粒度越细越好:每搬一个函数就提交一次
- 先别追求完美 Context:重构中期先用最简单的结构,稳定后再优化封装
- 不值得重构的代码别动:又烂又稳定、无人修改的代码没有重构 ROI
附录:范式速查
| 原则 | 一句话 |
|---|---|
| 基础约束 | 函数一屏读完,文件一眼知主题,逻辑不用记就能跟上;理解一段逻辑不超过 3 个文件 |
| 三段式 | 加载、计算、持久化不混在一个函数里 |
| 显式容器 | 状态放在显式容器里,不藏在全局/闭包/魔法中;框架的响应式容器是合法 Context |
| 编排-计算分离 | 编排说"做什么",计算说"怎么做",分处不同模块 |
| 编排是职责不是架构层 | 调用点组合→提取组合模块→独立编排类,按复杂度和重复度选择重量级 |
| 计算层不碰容器 | 计算层接收值、返回值;编排层负责从容器取值和写回;只读派生不算"碰" |
| Context at the Edge | Context 只出现在编排层,计算层只接收真实依赖 |
| 同层同级 | 一个函数体内每一行同一抽象层级 |
| 注册发现 | 功能增减靠注册表,不靠 if/else |
| 缺省行为 | 契约提供有意义的缺省,缺席用空实现顶替 |
| 契约稳定性 | 新方法给缺省、新字段走扩展区、大版本用适配器 |
| 复制还是复用 | 能起具体名字且脱离调用点能理解→复用;否则复制。默认先复制 |
| 错误复用的纠正 | 出现 boolean 开关 / 专属 if / 怕影响别人 / 新人看不懂 → 内联回去 |
| 细化改原模块,组合建新模块 | 同一概念的细化→改;两个概念的组合→slot 组合;重复 3 次→提取组合模块 |
| 领域计算必须复用 | 业务规则单一真相源,复制了就是定时炸弹 |
| 值对象先行 | 先识别度量衡,再识别角色 |
| 浮现再裸露 | 抽取函数 + 搬到新模块是一个完整动作 |
| 搬和改分离 | 移动代码和修改逻辑永远不在同一次提交 |