前端向架构突围系列 - 框架设计(五):契约继承原则
写在前面
原名叫《里氏替换原则》, 但感觉这个名字不是很好理解, 便转译成为《契约继承原则》,很多前端同学看到, 第一反应通常是:“这是 Java/C++ 那帮后端搞继承时用的吧?我写 React/Vue 都是组合优于继承,这玩意儿跟我有啥关系?”
这是一个巨大的误区。
如果你曾经遇到过 “封装了一个组件,别人想加个
style却死活不生效” ,或者 “换了一个数据源 SDK,结果整个页面直接白屏” ,那么恭喜你,你正好撞在了违反 LSP 的枪口上。今天我们要聊的就两个字:契约。
![]()
一、 什么是里氏替换?(别背公式,看人话)
教科书上说: “若对每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编写的程序 P 中,用 o1 替换 o2 后,程序 P 行为功能不变,则 S 是 T 的子类型。”
🤯 是不是想关网页了?来,翻译成人话:
“老爸能去的地方,儿子必须也能去,而且不能搞破坏。”
在前端语境下,这个“父子关系”不一定是 class extends,更多时候体现为 接口(Interface)与实现,或者 基础组件与业务组件 的关系。
如果不遵守 LSP,代码就会变成:“看着像个鸭子,走路像个鸭子,但你喂它吃饭时,它突然爆炸了。”
插播一条星爷语录
![]()
二、 场景一:UI 组件的“阉割”惨案
这是前端违反 LSP 最重灾区的现场。
假设你为了统一 UI 风格,基于原生的 <button> 封装了一个 PrimaryButton。
错误示范:自以为是的封装
interface PrimaryButtonProps {
label: string;
onClick: () => void;
}
// 看起来很清爽,对吧?
export const PrimaryButton = ({ label, onClick }: PrimaryButtonProps) => {
return (
<button className="bg-blue-500 text-white px-4 py-2" onClick={onClick}>
{label}
</button>
);
};
使用者崩溃现场: 同事 A 拿去用,想给按钮加个 disabled 状态: <PrimaryButton label="提交" disabled={true} /> 👉 报错:类型 'PrimaryButtonProps' 上不存在属性 'disabled'。
同事 B 强行用 any 传了进去,结果界面上按钮依然可以点击。 👉 Bug 原因:你在内部根本没把 ...rest 传给 button。
深度解析: 这里的 PrimaryButton 既然在语义上是一个“按钮”,它就应该能替换原生 <button> 的绝大部分场景。你为了省事, “阉割” 了父类(原生 button)的能力,这就是典型的违反 LSP。
✅ 正确示范:透传与契约
符合 LSP 的组件设计,应该遵循“开闭原则”的同时,保证“子类”能完整履行“父类”的职责。
// 1. 继承原生属性,确立契约
interface PrimaryButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string; // 只有 label 是特有的
// 其他的 className, style, onClick, disabled 全部继承
}
export const PrimaryButton = ({ label, className, ...rest }: PrimaryButtonProps) => {
return (
<button
// 2. 允许样式叠加(而不是覆盖)
className={`bg-blue-500 text-white px-4 py-2 ${className || ''}`}
// 3. 核心:能力透传
{...rest}
>
{label}
</button>
);
};
现在的关系是:PrimaryButton 是一个更具体的 <button>,它满足了使用 <button> 的所有预期。
三、 场景二:那些“我也想但我做不到”的接口
在做架构设计时,我们常使用 DIP(依赖倒置)注入接口。但实现类如果不守规矩,系统一样会崩。
假设我们要设计一个缓存系统:
// 定义契约
interface ICache {
set(key: string, value: string): void;
get(key: string): string | null;
}
错误示范:抛出异常的子类
这时候来个需求:我们需要一个“只读缓存”适配器(可能数据源是配置中心,不允许客户端修改)。
class ReadOnlyCache implements ICache {
get(key: string) {
return localStorage.getItem(key);
}
set(key: string, value: string) {
// 违反 LSP!父类承诺能 set,你却抛错?
throw new Error("这是只读缓存,别写!");
}
}
业务代码猝死现场:
function updateUserData(cache: ICache, user: any) {
// 这里的代码以为所有 cache 都能写
cache.set('user', JSON.stringify(user));
}
// 某天如果不小心注入了 ReadOnlyCache,整个 update 流程直接炸穿
updateUserData(new ReadOnlyCache(), user);
深度思考:如何修正?
违反 LSP 通常意味着抽象层级出了问题。如果不具备 set 的能力,它就不应该实现 ICache 接口。
这里需要结合 接口隔离原则 (ISP) 进行拆分:
interface IReadable {
get(key: string): string | null;
}
interface IWritable extends IReadable {
set(key: string, value: string): void;
}
// ReadOnlyCache 只实现 IReadable
class ReadOnlyCache implements IReadable { ... }
// 业务函数明确要求:我需要可写的缓存
function updateUserData(cache: IWritable, user: any) { ... }
这样,TypeScript 静态检查会直接阻止你把 ReadOnlyCache 传给 updateUserData,在编译期就扼杀了 Bug。
四、 进阶深度:TypeScript 中的协变与逆变
如果你想在面试中或是架构评审里秀一把深度,类型系统的 LSP 是绕不开的。
在 TS 中,LSP 具体表现为:
- 返回类型必须协变(Covariant) :子类返回的必须比父类更具体(或相同)。
- 参数类型必须逆变(Contravariant) :子类接收的必须比父类更宽泛(或相同)。
听晕了?看个例子:
假设父类定义了一个处理事件的方法: handleEvent(e: MouseEvent): void
子类实现 1(安全): handleEvent(e: Event): void ✅ 符合 LSP。父类只能处理鼠标事件,子类说“我也能处理鼠标事件,甚至所有 Event 我都能处理”。参数更宽泛,这是逆变。
子类实现 2(危险): handleEvent(e: ClickEvent): void ❌ 违反 LSP。父类承诺能处理所有 MouseEvent(比如 MouseMove),但子类说“我只能处理 Click”。如果你传个 MouseMove 给子类,它就处理不了了。
这在 React 的 Props 回调设计中非常重要: 如果你设计一个组件 List,它的 onItemClick 期望接收 (item: BaseItem) => void,那么使用者传入的函数最好能处理 BaseItem,而不是只处理 VideoItem,否则可能会在运行时访问不存在的属性。
五、 总结:架构设计的“信任链”
里氏替换原则的本质,是建立信任。
-
组件信任:使用者相信你的
CustomInput真的能像input一样工作,支持value和onChange。 -
对象信任:业务逻辑相信你注入的
Service真的实现了接口承诺的所有方法,而不是会在某个方法里偷偷抛出NotImplementedError。
前端架构突围的路上,不要只顾着“复用代码”(继承/封装),更要顾着“遵守契约”。 一个随时可能“罢工”的子类,比没有子类更可怕。
举例 + 简短文章篇幅,防止内容过于“干巴”
互动环节: 你在使用第三方组件库(如 Antd/MUI)二次封装时,遇到过最坑的“属性丢失”是什么?欢迎在评论区吐槽!