前端工程师大概都听过同一套建议:把相似界面抽象成组件,抽完代码就能少删几百行。但真实项目里,抽象并不总是划算。有一类“看起来几乎一样”的流程,硬抽象之后,维护成本会从写代码转移到沟通成本上。
一个组件,三套流程
先看一个具体场景。早期项目里有三个流程需要对同一条“条目”做操作:
- 审批:确认这条条目确实需要(“你真的需要它吗?”)
- 修改:调整条目的配置
- 移动:把条目移入或移出受管区域
三者界面确实高度雷同:列表、选择、确认、展示结果,同样的表格、弹窗和提示条。于是很自然的想法就是——做成一个组件,一个入口。
但当时忽略了一点:相似只是表面上的。这三个流程对“部分失败”的处理方式不同,各自的校验要求不同,需要留存的审计信息也不同。
审批是对未来的断言(“我将来会需要这个条目”),修改是即时生效的,而移动需要外部系统接收请求,那个系统完全可能拒绝。
抽象之后:flag、mode 与不断增长的例外
最初的版本只有一个核心对象,几周后演化成这样(示意,非真实代码):
type Item = { id: string; name: string };
type ItemActionProps = {
item: Item;
mode: "approve" | "change" | "move";
variant?: "inline" | "dialog" | "bulk";
showDetails?: boolean;
requireReason?: boolean;
onSpecialCase?: (item: Item) => boolean;
};
function ItemAction({ item, mode, variant, showDetails, requireReason, onSpecialCase }: ItemActionProps) {
if (mode === "move" && onSpecialCase?.(item)) {
return <SpecialCaseNotice />;
}
const needsReason = mode === "change" || requireReason;
}
这些字段每一个看起来都有正当理由。规律是:每多一个界面,就多一条“为什么这个组件不能改”的理由;移动流程每多一条新要求,就要再加一个 flag。如果这个 flag 用起来别扭,另一种选择是改动共享行为,然后还得验证审批和修改流程没被带坏。
结果就是:**流程的所有者失去了单独调整自己流程的能力**。改一下确认步骤,得三个流程的所有者都同意,还得由那个熟悉所有 mode 的评审人把关,并为 mode × variant 的所有组合补齐测试用例。改一处,就要跟其余所有地方争论。
为什么反直觉地会变成这样
抽象的好处当然是真的:代码更干净,删掉两百行的 diff 值得庆祝,一致性也是合理的设计追求。 问题在于,这类抽象只依据当前状态下的相似性。很多相似案例会告诉你“它们有共同的属性”,但不会告诉你它们之间的差异——因为差异往往要到未来才显现:新政策、新的审计发现、某个新对接系统表现得不符预期。于是差异只能以 flag 的形式被塞进去。 重复代码很容易删掉,错误的抽象却可能很贵。
判断规则:等到第三次,再检查理由
规则本身:在同一个模式以同一个原因重复三次之前,先别抽象。在此期间,让代码先各自独立演化。 后半句同样重要。两处代码“变更原因相同”,指的是同一个人的同一个决策会同时改变它们。比如:
formatExpiry(item.expiresAt);
await api.post("/approve", body); toast.success("Done");
await api.post("/change", body);
toast.success("Done");
日期格式化函数可以抽出来——格式一变,所有屏幕都得跟着变。但提交按钮看着重复,实际归属不同的负责人;把它们合并,等于把两个互不相关的决策绑在了一起。
我的判断标准是一句话:**“如果这个需求发生变化,我需要去问谁?所有副本会同时变吗?”** 如果不同副本答案不同,那你面对的是“巧合”,不是“重复”。
这个思路并不新鲜。Martin Fowler 在《Refactoring》中把“Rule of three”归于 Don Roberts:第一次重复就直接写,第二次会皱眉但还是写,第三次再重构。Sandi Metz 在《The Wrong Abstraction》里表达了同样的观点——重复比错误的抽象便宜得多。Kent C. Dodds 的 AHA 编程原则同样建议不要过早抽象。
复用确实值得的地方
这不等于“永远不要抽象”。有些东西从第一天就值得共享:
- 稳定的原子组件:按钮、文本输入框、布局容器。小而无聊,几乎不含业务逻辑。
- 设计 token:颜色、间距、字号阶梯,天生就是为跨页面复用而存在。
- 无障碍行为:弹窗的焦点陷阱、键盘导航、标签标注。做好一次,好过在五个地方各做错一点。
- 由标准驱动的东西:日期时间、货币格式化、协议客户端。形态由外部定义,下一个界面不容易把它设计歪。
我用三条标准区分这类抽象:它的形态是否由外部决定,而不是我对未来的猜测?它是否只有一个变更理由?它是一个组件,还是一个工作流? (本段完,后续部分将涉及这条原则在具体项目中的落地步骤与团队协作取舍。)
判断抽象是否过早:读 props
最靠谱的检验方法永远是先读一遍 props 列表。
- 如果 props 描述的是「应该渲染成什么」——比如子元素、尺寸、色调——那这层抽象大概率是合理的。
- 如果 props 描述的是「你现在处于什么情境」——比如 mode、variant、isSpecial——那多半说明你把几个相似但不相同的东西硬塞进了一个抽象里。
换句话说,props 里出现「状态语义」而不是「渲染语义」,就是过早抽象的信号。
如何从过早抽象中退回来
第一步,把公共模块重新内联回每个调用点。把模块的代码复制到各个工作流里,并删掉那些该工作流永远不会走到的分支。
这个做法是退行式的,但现在所有副本都很短、很容易看懂,而且每个工作流都能独立修改,不必再等别人点头。
第二步,按工作流而不是按布局来拆分代码。等几份代码各自稳定下来之后,再去发现「因为同样原因而重复」的部分——比如表格格式化器、日期格式化器——然后只把这一小块抽出来。Metz 在她的文章里也建议这么做。
规则重申
重复到三次、且是出于同一个原因时,再去提取;但如果你手里已经有 primitives、tokens、可访问性和标准化基础,那就要先把真正的模式挖出来。
所以我最后想问的是:那段大家都在绕着走的代码是什么?你为某个特定调用方准备的那个特殊标志位,又是什么?


评论