前端向架构突围系列 - 框架设计(五):契约继承原则

写在前面

原名叫《里氏替换原则》, 但感觉这个名字不是很好理解, 便转译成为《契约继承原则》,很多前端同学看到, 第一反应通常是:"这是 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

错误示范:自以为是的封装

typescript 复制代码
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 的组件设计,应该遵循"开闭原则"的同时,保证"子类"能完整履行"父类"的职责。

typescript 复制代码
// 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(依赖倒置)注入接口。但实现类如果不守规矩,系统一样会崩。

假设我们要设计一个缓存系统:

php 复制代码
// 定义契约
interface ICache {
  set(key: string, value: string): void;
  get(key: string): string | null;
}

错误示范:抛出异常的子类

这时候来个需求:我们需要一个"只读缓存"适配器(可能数据源是配置中心,不允许客户端修改)。

typescript 复制代码
class ReadOnlyCache implements ICache {
  get(key: string) {
    return localStorage.getItem(key);
  }

  set(key: string, value: string) {
    //  违反 LSP!父类承诺能 set,你却抛错?
    throw new Error("这是只读缓存,别写!"); 
  }
}

业务代码猝死现场:

sql 复制代码
function updateUserData(cache: ICache, user: any) {
  // 这里的代码以为所有 cache 都能写
  cache.set('user', JSON.stringify(user)); 
}

// 某天如果不小心注入了 ReadOnlyCache,整个 update 流程直接炸穿
updateUserData(new ReadOnlyCache(), user);

深度思考:如何修正?

违反 LSP 通常意味着抽象层级出了问题 。如果不具备 set 的能力,它就不应该实现 ICache 接口。

这里需要结合 接口隔离原则 (ISP) 进行拆分:

php 复制代码
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 具体表现为:

  1. 返回类型必须协变(Covariant) :子类返回的必须比父类更具体(或相同)。
  2. 参数类型必须逆变(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,否则可能会在运行时访问不存在的属性。


五、 总结:架构设计的"信任链"

里氏替换原则的本质,是建立信任

  1. 组件信任 :使用者相信你的 CustomInput 真的能像 input 一样工作,支持 valueonChange
  2. 对象信任 :业务逻辑相信你注入的 Service 真的实现了接口承诺的所有方法,而不是会在某个方法里偷偷抛出 NotImplementedError

前端架构突围的路上,不要只顾着"复用代码"(继承/封装),更要顾着"遵守契约"。 一个随时可能"罢工"的子类,比没有子类更可怕。

举例 + 简短文章篇幅,防止内容过于"干巴"


互动环节: 你在使用第三方组件库(如 Antd/MUI)二次封装时,遇到过最坑的"属性丢失"是什么?欢迎在评论区吐槽!

相关推荐
ashcn20012 小时前
水滴按钮解析
前端·javascript·css
豆苗学前端2 小时前
你所不知道的前端知识,html篇(更新中)
前端·javascript·面试
一 乐2 小时前
绿色农产品销售|基于springboot + vue绿色农产品销售系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端·宠物
zzjyr2 小时前
Webpack 生命周期原理深度解析
前端
xiaohe06012 小时前
💘 霸道女总裁爱上前端开发的我
前端·游戏开发·trae
sophie旭2 小时前
内存泄露排查之我的微感受
前端·javascript·性能优化
k***1953 小时前
Spring 核心技术解析【纯干货版】- Ⅶ:Spring 切面编程模块 Spring-Instrument 模块精讲
前端·数据库·spring
rgeshfgreh3 小时前
Spring事务传播机制深度解析
java·前端·数据库