理解编程的设计原则(前端角度)

编程中的设计原则是指导开发者编写 "高内聚、低耦合、可复用、易维护" 代码的核心思想,贯穿需求分析、架构设计、编码实现全流程。

这里说说最核心、最常用的设计原则,按 "通用原则(跨范式)+ OOP 核心原则" 分类,结合前端(如 JavaScript/React)示例和实际应用场景来理解

graph LR A[设计原则:高内聚·低耦合·可维护] --> B[跨范式通用原则
(所有编程场景适用)] A --> C[OOP核心原则
(面向对象设计专属)] %% 跨范式通用原则(蓝色系) B --> B1[KISS原则
保持简单,避免过度设计] B --> B2[DRY原则
拒绝重复,单一数据源] B --> B3[YAGNI原则
活在当下,拒绝推测性开发] B --> B4[单一职责原则SRP
一个单元只做一件事] B --> B5[最小知识原则LoD
只与直接朋友通信] %% KISS原则 内涵+落地 B1 --> B1a[核心内涵
- 逻辑直截了当
- 拒绝冗余抽象
- 命名直观易懂] B1 --> B1b[落地方式
- 优先原生API/简单语法
- 拆分嵌套逻辑
- 避免为设计模式而用模式] %% DRY原则 内涵+落地 B2 --> B2a[核心内涵
- 逻辑不重复
- 结构不重复
- 数据不重复] B2 --> B2b[落地方式
- 提取通用工具函数
- 封装复用组件
- 集中管理常量配置] %% YAGNI原则 内涵+落地 B3 --> B3a[核心内涵
- 只开发当前必需功能
- 不提前实现未来需求
- 保持最小可用] B3 --> B3b[落地方式
- 基于明确需求开发
- 定期删除未使用代码
- 需求明确后再扩展] %% SRP原则 内涵+落地 B4 --> B4a[核心内涵
- 一个变化原因
- 职责边界清晰
- 高内聚低耦合] B4 --> B4b[落地方式
- 一句话描述职责
- 按功能拆分单元
- 控制代码规模(函数<50行)] %% LoD原则 内涵+落地 B5 --> B5a[核心内涵
- 不与陌生人说话
- 避免链式访问
- 隐藏内部细节] B5 --> B5b[落地方式
- 封装高层接口
- 减少props穿透
- 只传递必要参数] %% OOP核心原则(橙色系) C --> C1[开闭原则OCP
对扩展开放,对修改关闭] C --> C2[里氏替换原则LSP
子类可无缝替换父类] C --> C3[依赖倒置原则DIP
依赖抽象,不依赖具体] C --> C4[接口隔离原则ISP
小而专的接口,拒绝臃肿] C --> C5[组合优于继承
积木式组合,替代层级继承] %% OCP原则 内涵+落地 C1 --> C1a[核心内涵
- 扩展新增代码
- 修改关闭核心
- 抽象隔离变化] C1 --> C1b[落地方式
- 定义稳定抽象接口
- 用策略模式封装变化
- 依赖抽象而非实现] %% LSP原则 内涵+落地 C2 --> C2a[核心内涵
- 方法签名兼容
- 行为语义兼容
- 异常行为兼容] C2 --> C2b[落地方式
- 先复用父类逻辑再扩展
- 单元测试验证替换性
- 伪继承改用组合] %% DIP原则 内涵+落地 C3 --> C3a[核心内涵
- 高层不依赖低层
- 两者依赖抽象
- 抽象不依赖细节] C3 --> C3b[落地方式
- 定义抽象接口契约
- 依赖注入传递实现
- 面向接口编程] %% ISP原则 内涵+落地 C4 --> C4a[核心内涵
- 接口专一化
- 按需依赖
- 拆分臃肿接口] C4 --> C4b[落地方式
- 按客户端需求拆分
- 接口方法最小化
- Props用可选+组合类型] %% 组合优于继承 内涵+落地 C5 --> C5a[核心内涵
- has-a关系替代is-a
- 低耦合高灵活
- 避免继承爆炸] C5 --> C5b[落地方式
- 拆分独立功能模块
- 通过注入组合功能
- 用Hook/高阶函数复用逻辑] %% 样式优化 style A fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px style B fill:#e1f5ff,stroke:#0277bd,stroke-width:2px style C fill:#fff4e1,stroke:#ef6c00,stroke-width:2px %% 通用原则子节点样式 style B1 fill:#b3e5fc,stroke:#01579b,stroke-width:1px style B2 fill:#b3e5fc,stroke:#01579b,stroke-width:1px style B3 fill:#b3e5fc,stroke:#01579b,stroke-width:1px style B4 fill:#b3e5fc,stroke:#01579b,stroke-width:1px style B5 fill:#b3e5fc,stroke:#01579b,stroke-width:1px style B1a fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B1b fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B2a fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B2b fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B3a fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B3b fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B4a fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B4b fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B5a fill:#e0f7fa,stroke:#00695c,stroke-width:1px style B5b fill:#e0f7fa,stroke:#00695c,stroke-width:1px %% OOP原则子节点样式 style C1 fill:#ffe0b2,stroke:#e65100,stroke-width:1px style C2 fill:#ffe0b2,stroke:#e65100,stroke-width:1px style C3 fill:#ffe0b2,stroke:#e65100,stroke-width:1px style C4 fill:#ffe0b2,stroke:#e65100,stroke-width:1px style C5 fill:#ffe0b2,stroke:#e65100,stroke-width:1px style C1a fill:#fff8e1,stroke:#827717,stroke-width:1px style C1b fill:#fff8e1,stroke:#827717,stroke-width:1px style C2a fill:#fff8e1,stroke:#827717,stroke-width:1px style C2b fill:#fff8e1,stroke:#827717,stroke-width:1px style C3a fill:#fff8e1,stroke:#827717,stroke-width:1px style C3b fill:#fff8e1,stroke:#827717,stroke-width:1px style C4a fill:#fff8e1,stroke:#827717,stroke-width:1px style C4b fill:#fff8e1,stroke:#827717,stroke-width:1px style C5a fill:#fff8e1,stroke:#827717,stroke-width:1px style C5b fill:#fff8e1,stroke:#827717,stroke-width:1px

跨范式通用设计原则(所有编程场景适用)

这些原则不依赖特定范式(命令式、声明式、函数式、面向对象等),核心是提升代码的可读性、可维护性和灵活性,是所有开发者的 "基本功"。

KISS 原则(Keep It Simple, Stupid) - 一看就懂,一改就对

KISS 核心思想是 "保持简单,避免过度设计"。

能用简单逻辑实现的功能,绝不引入复杂的框架、设计模式或冗余代码(不要自作聪明地搞复杂),让代码直观、易懂、易维护。

大白话来说,一看就懂,一改就对!

KISS 原则不是 "不用复杂技术",而是 "按需选择复杂度"。

关键判断标准:这个复杂度是否为当前需求所必需?是否能显著提升代码质量(性能、复用性、可维护性)? 若答案是否定,则遵循 KISS 原则简化。比如以下类似场景就需要考虑复杂度:

  • 性能敏感场景:如大数据渲染(万级列表),需要用虚拟滚动(复杂但必要);
  • 高复用场景:如通用组件库(如 Button、Input),需要封装抽象(复杂但提升复用性);
  • 可扩展性要求高的场景:如支付模块(需要支持多种支付方式),需要用策略模式(复杂但便于扩展)。

DRY 原则(Don't Repeat Yourself)- 一处修改,处处生效

DRY 核心思想是 "不要重复自己"。

避免在代码中出现重复的逻辑、结构或数据,通过抽象、复用等方式将重复部分统一管理,从而减少冗余、降低维护成本。

大白话来说,一处修改,处处生效!

DRY 原则的核心可以概括为 3 点:

  • 逻辑不重复:相同的业务逻辑(如表单验证、数据转换)只写一次,避免在多个地方重复实现;
  • 结构不重复:相同的代码结构(如组件布局、循环逻辑)通过抽象复用,不重复编写;
  • 数据不重复:相同的常量、配置(如 API 地址、状态码)集中管理,不散落各处。

DRY落地技巧

落地 DRY 原则的关键是:先识别重复模式(逻辑、UI、数据),再选择合适的复用方式(函数、组件、配置),同时避免过度抽象。记住:DRY 的目标是 "让代码更简单、更易维护",而非为了 "复用而复用",实际开发中可通过以下方式落地:

  1. 提取通用工具函数(函数级复用) 将重复的逻辑(如验证、格式化、计算)抽象为纯函数,放在utils目录中,例如: 日期格式化:formatDate.js 表单验证:validator.js 数据转换:transform.js

  2. 封装通用组件(UI 级复用) 将重复的 UI 结构(如按钮、卡片、表单)抽象为可复用组件,通过props接收差异化参数,例如: React:components/Button/index.jsx Vue:components/Input.vue

  3. 集中管理常量与配置(数据级复用) 将 API 地址、状态码、枚举值、样式变量等集中存放,例如: 接口配置:config/api.js 常量定义:config/constants.js CSS 变量:styles/variables.css

  4. 使用继承 / 组合(OOP 级复用) 在类组件或工具类中,通过继承(少用)或组合(推荐)复用逻辑,例如: React 自定义 Hook:将组件间重复的状态逻辑抽象为 Hook(如useAuth、usePagination); 类的组合:UserService 组合 HttpService 复用请求逻辑,而非继承。

  5. 抽象公共逻辑(流程级复用) 将重复的业务流程(如 "登录校验→接口请求→错误处理")抽象为高阶函数或中间件,例如: 高阶函数:withAuth 包装需要登录的组件,复用登录校验逻辑; Axios 拦截器:统一处理所有请求的 token 添加、错误提示。

  6. 警惕 "过度 DRY":重复≠冗余 DRY 原则的核心是 "避免无意义的重复",但不是所有相似代码都必须抽象。以下场景可接受 "有限重复": 逻辑相似但细节差异大(抽象后会导致参数过多、逻辑复杂); 复用成本高于重复成本(如两个页面各有一处简单逻辑,抽象为组件反而增加复杂度)。 判断标准:抽象后的代码是否比重复代码更简洁、更易维护?若否,则保持有限重复。

YAGNI 原则(You Aren't Gonna Need It) - 活在当下

YAGNI 核心思想是 "不要为未来可能的需求做过度设计"。

YAGNI 原则的核心可以概括为 3 点:

  • 只做 "现在需要" 的事:基于当前明确的需求开发,不考虑 "可能下个月会加这个功能""以后用户说不定需要这个按钮";
  • 拒绝 "推测性开发":不提前编写未被验证的代码(如为一个简单表单提前设计 "国际化""权限控制""数据导出" 等未明确的功能);
  • 保持代码 "最小可用":满足当前需求即可,不追求 "一步到位" 的完美设计,后续需求明确后再扩展。

大白话来说,活在当下!

YAGNI落地技巧

YAGNI 原则的核心是 "克制预测,聚焦当前"。

  1. 基于 "明确需求" 开发,拒绝 "我觉得可能需要"
  2. 用 "最小可用" 验证需求,而非 "一步到位"
  3. 定期 "修剪" 冗余代码,删除未使用的逻辑
  4. 用 "重构" 应对需求变化,而非 "提前设计"
  5. 警惕 "过度工程化" 的借口

注意,YAGNI 不反对 "可扩展性",而是反对 "为未明确需求预留的扩展性"。合理的做法是:在当前需求基础上保持代码简洁(便于未来重构),而非提前设计复杂的扩展点。当前需求确实需要(如多个支付方式需要策略模式),就引入设计模式,否则保持简单实现。

克制 "提前优化" 的冲动,接受 "当前不完美",相信 "未来需求明确后,重构比提前设计更高效"。好的代码是 "逐步生长" 出来的,而非 "一次性设计" 出来的。

单一职责原则(Single Responsibility Principle, SRP) - 专注一件事

SRP 核心思想是:一个模块、函数、类或组件,应该只负责一件事(只有一个引起它变化的原因)。,SRP 要求代码单元(模块 / 函数 / 组件)的 "职责边界清晰"------ 不做 "万能工具",只聚焦一个核心功能。其本质是 "高内聚、低耦合":一个代码单元内部逻辑紧密相关(内聚),与其他代码单元的依赖尽可能少(耦合)。

大白话来说,专注一件事!

SRP 的核心可以概括为 3 点:

  • "一件事"= 一个核心职责:代码单元的所有逻辑都应围绕同一个核心目标,不包含无关功能(如 "用户信息展示组件" 不应同时负责 "用户数据修改");
  • "一个变化原因"= 职责唯一:如果一个代码单元需要修改,只应该是因为 "它负责的那件事" 的需求变了(如 "按钮组件" 只因 "样式 / 交互变化" 修改,不因 "数据请求逻辑变化" 修改);
  • 职责分离 = 解耦:若一个代码单元承担了多个职责,应将其拆分为多个独立的代码单元,每个负责一个职责。

SRP落地技巧

SRP 的核心是 "识别职责,拆分耦合",实际开发中可通过以下技巧落地:

  1. 明确 "职责边界":用一句话描述代码单元的用途。为每个函数、组件、类写一句 "职责描述"(如 "formatDate:格式化日期为'yyyy-MM-dd'格式"); 若一句话无法描述(如 "这个组件既展示列表又处理表单"),说明它承担了多个职责,需要拆分。

  2. 拆分 "混合职责":按 "功能类型" 拆分。数据层:请求(fetchUser)、格式化(formatUser)、存储(saveToLocalStorage);UI 层:展示组件(UserDisplay)、编辑组件(UserEditor)、布局组件(PageLayout);业务层:权限校验(checkPermission)、日志打印(log)、事件处理(handleClick)。

  3. 避免 "组件 / 函数过大":控制代码规模 函数:建议代码行数不超过 50 行 ,超过则考虑拆分(如拆分辅助函数); 组件:建议代码行数不超过 200 行,超过则考虑拆分为子组件(如列表组件拆分为 "列表项组件 + 分页组件")。

  4. 用 "组合" 替代 "混合":通过依赖注入 / 回调复用逻辑。组件:通过props传递回调(如UserEditor接收onSubmit回调,不自己处理提交);类 / 函数:通过参数注入依赖(如UserOperation注入UserService和Logger,不自己实现)。

  5. 警惕 "假单一职责":避免 "表面拆分,实质耦合"。反例:将 "用户展示 + 编辑" 拆分为两个组件,但编辑组件直接修改展示组件的状态(通过ref或全局状态);正例:拆分后的组件通过 "状态提升" 或 "发布订阅" 通信,不直接依赖对方的内部逻辑。

SRP 的 "边界":单一≠过度拆分

SRP 不是 "拆分得越细越好",过度拆分会导致代码碎片化(如一个简单的 "加法" 拆分为 "获取第一个数 + 获取第二个数 + 计算和 + 返回结果" 四个函数),反而降低可读性。

判断标准:

  • 拆分后的代码是否更易维护?(如修改逻辑时无需跨多个文件);
  • 拆分后的代码是否更易复用?(如某个拆分后的单元能在其他场景使用);
  • 拆分后的代码是否更易理解?(如每个单元的职责清晰,无需记忆复杂的依赖关系)。

若答案是否定,则无需过度拆分 ------ 比如一个简单的 "日期格式化函数",无需拆分为 "获取年 + 获取月 + 获取日 + 拼接字符串" 四个函数。

最小知识原则(Law of Demeter, LoD) - 只与直接朋友通信

最小知识原则(又称 "迪米特法则")核心思想是:一个对象应该对其他对象有最少的了解(只与直接朋友通信,不与陌生人说话)。

简单说,LoD 要求代码单元(对象、函数、组件)之间的交互应保持 "最小信息量"------ 只依赖 "直接相关" 的对象,不深入访问其他对象的内部细节(如属性的属性、方法的返回值的方法)。其本质是降低耦合度,让模块间的依赖关系清晰可控,避免 "牵一发而动全身"。

用一句话总结:"只问直接相关的对象要结果,不关心它内部怎么实现,更不深入它的'朋友的朋友'"。

LoD 的核心可以概括为 3 点:

  1. "直接朋友"= 有限的交互对象:一个对象的 "直接朋友" 包括:自身(方法内的this);方法的参数(如function doSomething(obj) { ... }中的obj); 方法内部创建的对象(如function fn() { const helper = new Helper(); ... }中的helper);自身的属性对象(如this.user、this.config)。 除此之外的对象均为 "陌生人",应避免直接交互。

  2. "不与陌生人说话"= 禁止链式访问:不允许通过 "对象。属性。属性。方法" 的链式调用访问深层对象(如user.address.city.zipCode),这会导致对 "陌生人"(address、city)的过度依赖。

  3. "最小知识"= 暴露必要接口,隐藏内部细节:对象应通过自身方法提供服务,而非让外部直接操作其内部属性(如user.getCity()而非user.address.city)。

LoD落地技巧

LoD 的核心是 "控制依赖范围,隐藏内部细节",实际开发中可通过以下技巧落地:

  1. 避免链式访问:用 "一层调用" 替代 "多层链式调用"。反例:user.address.city.getZipCode();正例:user.getZipCode()(让user内部处理地址逻辑)。

  2. 封装内部逻辑:对象提供高层接口,隐藏属性和子对象。对复杂对象(如User、Order),用方法(getXxx、doXxx)暴露功能,而非直接暴露属性;例:user.getFullName() 优于直接访问 user.firstName + user.lastName。

  3. 减少 props 穿透:用 Context / 状态管理传递深层数据。前端组件树中,避免 "爷爷→父→子" 的 props 链传递,改用 Context 或 Redux 让子组件直接获取数据;中间组件应专注自身 UI / 逻辑,不承担 "数据传递者" 角色。

  4. 控制函数参数:避免传递 "大对象",只传必要字段。反例:function renderUser(user) { ... }(函数可能依赖user的多个内部属性); 正例:function renderUser(name, age) { ... }(只依赖必要字段,不关心user整体结构)。

  5. 警惕 "假封装":避免暴露内部对象的引用。反例:对象的方法返回内部属性的引用(如getAddress() { return this.address; },外部可直接修改address);正例:返回副本或只读视图(如getAddress() { return { ...this.address }; }),或提供修改接口(setCity(city) { this.address.city = city; })。

LoD 的 "边界":最小≠零知识

LoD 不是 "完全禁止对象间交互",而是 "只进行必要的、直接的交互"。过度遵循 LoD 可能导致 "接口爆炸"(每个细节都需要一个接口),反而增加复杂度。

判断标准:

  • 交互是否必要?(如获取用户的城市是业务必需,无法避免);
  • 交互是否直接?(是否通过 "直接朋友" 完成,而非多层穿透);
  • 修改成本是否可控?(若依赖的对象变化,修改范围是否最小)。

例如,一个简单的工具函数getUserName(user) { return user.name; },虽然访问了user的属性,但name是user的核心属性(属于 "直接朋友" 的合理暴露),且修改成本低(若name改为username,只需改这一个函数),这种情况无需过度封装。

面向对象编程(OOP)核心设计原则

开闭原则(Open/Closed Principle, OCP) - 尽量不改已有代码,而是通过扩展来实现功能

开闭原则是面向对象设计(SOLID 原则)中最核心的原则之一,由 "软件设计之父" Bertrand Meyer 提出,核心思想是:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。【S是Single Responsibility Principle,单一职责原则,上面说过了,因为适用范围广,所以在上面列出了】

简单说,当需要新增功能时,应通过 "扩展已有代码" 实现(如新增类、方法),而非 "修改已有代码"(如改动现有类的逻辑)。其本质是通过抽象稳定的接口,隔离易变的实现,让系统在变化中保持稳定。

核心内涵:"开放" 与 "关闭" 的准确含义

对扩展开放(Open for Extension):当需求变化时,能够通过新增代码(如派生新类、实现新接口)为系统添加新功能,无需修改原有逻辑。

对修改关闭(Closed for Modification):一旦系统的核心逻辑稳定,就不应再修改其源代码(除非修复 bug),避免因修改引发新的问题。

OCP 的核心不是 "完全禁止修改",而是将 "修改" 限制在 "扩展层",保护 "核心层" 的稳定性。例如:一个支付系统支持微信支付,当需要新增支付宝支付时,只需新增支付宝支付类,而非修改原有的微信支付逻辑或支付核心流程。

开闭原则的实现方式:依赖抽象,隔离变化

OCP 的实现核心是 "依赖抽象,而非具体实现",通过抽象定义稳定的接口,让具体实现可以灵活替换。常见手段包括:

  1. 抽象基类 / 接口(定义稳定契约) 定义抽象类或接口,声明核心功能的 "契约"(方法签名),具体实现由子类或实现类完成。核心逻辑依赖抽象接口,而非具体子类。

  2. 多态(实现动态扩展) 通过多态特性,核心逻辑可以调用抽象接口的方法,实际执行的是子类的具体实现。新增功能时,只需新增子类并实现接口,核心逻辑无需修改。

  3. 策略模式(封装变化点) 将易变的逻辑(如不同支付方式、不同排序算法)封装为 "策略类",核心类通过依赖策略接口实现功能,新增策略时只需新增类并注入核心类。

反例:

js 复制代码
// 违反OCP:新增支付方式需修改Payment类
class Payment {
  pay(method, money) {
    if (method === 'wechat') {
      // 原有微信支付
      console.log(`微信支付${money}元`);
    } else if (method === 'alipay') {
      // 新增支付宝支付,修改此处
      console.log(`支付宝支付${money}元`);
    }
    // 未来新增银联支付,还需再添加else if...
  }
}

// 使用
const payment = new Payment();
payment.pay('wechat', 100);
payment.pay('alipay', 200);

正例:

js 复制代码
// 1. 定义抽象接口(稳定契约)
class PaymentMethod {
  pay(money) {
    throw new Error('子类必须实现pay方法'); // 抽象方法
  }
}

// 2. 具体实现类(可扩展)
class WechatPay extends PaymentMethod {
  pay(money) {
    console.log(`微信支付${money}元`);
  }
}

class Alipay extends PaymentMethod {
  pay(money) {
    console.log(`支付宝支付${money}元`);
  }
}

// 3. 核心支付类(依赖抽象,对修改关闭)
class Payment {
  constructor(method) {
    this.method = method; // 注入具体支付方式(多态)
  }

  pay(money) {
    this.method.pay(money); // 调用抽象接口,不关心具体实现
  }
}

// 使用:新增支付方式只需新增类,无需修改Payment
const wechatPayment = new Payment(new WechatPay());
wechatPayment.pay(100);

const alipayPayment = new Payment(new Alipay());
alipayPayment.pay(200);

// 新增银联支付:只需扩展,不修改原有代码
class UnionPay extends PaymentMethod {
  pay(money) {
    console.log(`银联支付${money}元`);
  }
}
const unionPayment = new Payment(new UnionPay());
unionPayment.pay(300);

OCP的落地技巧

  1. 识别 "变化点" 与 "稳定点"。稳定点:系统中长期不变的核心逻辑(如支付流程的 "创建订单→调用支付→回调通知" 框架);变化点:可能随需求变化的部分(如支付方式、验证规则、UI 样式)。OCP 要求:用抽象固定 "稳定点",用扩展应对 "变化点"。

  2. 依赖抽象,而非具体实现。函数 / 类的参数、返回值尽量使用抽象类型(如接口、基类),而非具体类;前端中,可通过 "约定接口"(如{ pay: (money) => void })替代显式抽象类,确保扩展性。

  3. 封装变化:用 "策略模式""工厂模式" 隔离变化。策略模式:将变化的逻辑封装为策略类(如支付方式、排序算法),核心类依赖策略接口;工厂模式:用工厂类创建具体对象(如PaymentFactory.create('wechat')),新增类型时只需扩展工厂,无需修改调用方。

  4. 避免 "大而全" 的类,拆分职责(结合单一职责原则)。一个类承担的职责越多,越容易因需求变化被修改(违反 OCP);按职责拆分后,每个类只负责一个稳定的功能,扩展时只需新增类,不修改旧类。

  5. 前端框架中的 OCP 实践。React/Vue 组件:通过props传递可变配置(如style、onClick),通过 "组件组合" 扩展功能(而非修改组件源码); 插件化设计:如webpack的loader、babel的plugin,核心框架稳定,通过插件扩展功能。

OCP 的 "边界":平衡抽象与复杂度

OCP 的核心是 "抽象",但过度抽象会导致系统复杂度飙升(如为简单功能定义多层接口)。判断是否需要抽象的标准是:

  • 变化频率:若某部分功能几乎不会变化(如基础工具函数),无需抽象;
  • 扩展成本:抽象的成本(设计接口、维护子类)是否低于 "修改的成本"(频繁改核心代码的风险)。例如:一个内部使用的简单计算器,无需为 "加减乘除" 设计抽象接口;但一个面向多行业的财务系统,需为 "税费计算" 设计可扩展的接口(不同行业税率规则不同)。

里氏替换原则(Liskov Substitution Principle, LSP) - 领导(父类)说往东,你(子类)不能往西,方向保持一致性(契约精神)

里氏替换原则是面向对象设计(SOLID 原则L)中的重要原则,核心思想是:如果S是T的子类型,那么程序中所有使用T的地方,都可以用S替换,且不会改变程序的正确性(无需修改任何代码)。

简单说,子类必须能 "无缝替代" 父类 ------ 子类可以扩展父类的功能,但不能改变父类原有的行为逻辑。其本质是维护继承关系的一致性,确保父类和子类在契约(方法签名、行为语义)上的兼容,避免继承关系引入隐藏的 bug。

核心内涵:子类型的 "契约兼容"

里氏替换原则的核心不是 "子类可以继承父类",而是 "子类必须能安全地替代父类"。这种 "安全性" 体现在三个层面:

  • 方法签名兼容:子类实现父类的方法时,参数类型、返回值类型必须与父类保持一致(或更宽松):
    • 参数:子类方法的参数类型可以是父类参数类型的超类(逆变);
    • 返回值:子类方法的返回值类型可以是父类返回值类型的子类(协变)。
  • 行为语义兼容:子类不能修改父类方法的 "前置条件" 和 "后置条件":
    • 前置条件:子类方法的输入限制不能比父类更严格(如父类允许amount>0,子类不能要求amount>100);
    • 后置条件:子类方法的输出承诺不能比父类更弱(如父类保证返回number,子类不能返回string)。
  • 异常行为兼容:子类方法不能抛出父类方法未声明的异常(如父类方法不抛异常,子类不能抛Error)。

经典反例:

js 复制代码
// 父类:长方形,宽和高可独立设置
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

// 子类:正方形,宽高必须相等(修改父类行为)
class Square extends Rectangle {
  setWidth(width) {
    this.width = width;
    this.height = width; // 父类允许宽高不同,子类强制相等
  }

  setHeight(height) {
    this.width = height; // 同样修改宽
    this.height = height;
  }
}

// 问题:使用子类替换父类时,依赖"宽高独立"的逻辑会出错
function stretch(rectangle) {
  rectangle.setWidth(2); // 期望宽=2,高不变
  rectangle.setHeight(3); // 期望高=3,宽不变
  console.log('面积应为:2*3=6,实际为:', rectangle.getArea());
}

stretch(new Rectangle(1, 1)); // 正确输出6
stretch(new Square(1, 1)); // 实际输出9(宽高均为3),违反LSP

正例,子类先复用父类的验证逻辑(不改变前置条件),再添加额外校验(后置条件更严格),替换父类时原有场景不受影响:

js 复制代码
// 父类:基础验证器,验证非空
class Validator {
  validate(value) {
    if (value === '' || value === undefined) {
      return '不能为空';
    }
    return null; // 验证通过返回null
  }
}

// 子类:邮箱验证器,扩展父类(不改变原有行为)
class EmailValidator extends Validator {
  validate(value) {
    // 1. 先调用父类验证(保留非空校验,不改变前置条件)
    const baseError = super.validate(value);
    if (baseError) return baseError;

    // 2. 扩展邮箱格式校验(强化后置条件,更严格)
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      return '邮箱格式错误';
    }
    return null;
  }
}

// 使用:子类可安全替换父类
function checkValue(validator, value) {
  const error = validator.validate(value);
  console.log(error || '验证通过');
}

checkValue(new Validator(), ''); // 输出"不能为空"
checkValue(new EmailValidator(), ''); // 同样输出"不能为空"(兼容父类行为)
checkValue(new EmailValidator(), 'test'); // 输出"邮箱格式错误"(扩展功能)

LSP的落地技巧

  1. 子类需 "尊重父类契约":先复用,再扩展。重写父类方法时,先通过super调用父类逻辑(确保保留核心行为),再添加子类特有功能;禁止修改父类已明确的前置条件(如参数校验)和后置条件(如返回值结构)。

  2. 用 "行为测试" 验证替换性。为父类编写的单元测试,子类也应能通过(无需修改测试用例);例如:父类add(1, -2)返回-1,子类也必须返回-1(可额外打印日志,但结果不能变)。

  3. 避免 "伪继承":若不能替换,改用组合。若子类无法安全替代父类(如 "正方形 vs 长方形"),说明两者不是真正的is-a关系,应改用组合(如Square包含Rectangle对象,而非继承);

js 复制代码
// 正确:组合替代继承
class Square {
  constructor(side) {
    this.rectangle = new Rectangle(side, side); // 组合长方形
  }

  setSide(side) {
    this.rectangle.setWidth(side);
    this.rectangle.setHeight(side);
  }

  getArea() {
    return this.rectangle.getArea();
  }
}
  1. 依赖抽象而非具体类(结合依赖倒置原则)。代码中尽量使用父类 / 接口类型声明变量(如const payment: Payment = new DiscountPayment()),而非直接使用子类类型。这种 "面向抽象编程" 的方式,天然要求子类必须兼容父类,间接促进 LSP 的遵守。

  2. 限制子类的 "权力":父类方法用final修饰(部分语言)。在 Java 等语言中,可用final关键字禁止子类重写核心方法(如public final void pay() { ... });前端 JavaScript 虽无final,但可通过 "不鼓励重写" 的代码规范(如文档标注 "请勿重写此方法")避免违反 LSP。

依赖倒置原则(Dependency Inversion Principle, DIP) - 领导(高层模块)只负责告诉你做什么,不关心你(低层模块)怎么完成

依赖倒置原则是面向对象设计(SOLID 原则的D)中的关键原则,核心思想是:高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。

简单说,系统中的依赖关系应 "倒置" 过来 ------ 不是高层功能依赖具体的低层实现,而是双方都依赖稳定的抽象接口。其本质是通过抽象隔离变化,让高层模块(业务逻辑)不被低层模块(具体实现)的变动所影响,从而提升系统的灵活性和可维护性。

核心内涵:依赖关系的 "倒置" 与 "抽象"

依赖倒置原则的 "倒置" 是相对传统开发模式而言的:

  • 传统依赖:高层模块直接依赖低层模块(如 "订单服务" 直接调用 "微信支付" 的具体实现),低层变化会直接影响高层;
  • 倒置依赖:高层和低层都依赖抽象接口(如 "订单服务" 依赖 "支付接口","微信支付""支付宝支付" 也实现该接口),高层与低层通过抽象间接交互,互不直接依赖。

核心内涵可概括为两点:

  • 依赖抽象,而非具体:所有变量、参数、返回值都应声明为抽象类型(接口 / 抽象类),而非具体类;
  • 抽象稳定,细节可变:抽象接口定义稳定的契约,具体实现(细节)可以灵活替换,不影响依赖抽象的模块。

比如日志系统的输出方式扩展(不修改日志核心逻辑):

js 复制代码
// 1. 定义抽象日志接口
class Logger {
  log(message) {
    throw new Error('子类必须实现log方法');
  }
}

// 2. 低层实现:控制台日志
class ConsoleLogger extends Logger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
}

// 低层实现:本地存储日志(新增实现)
class LocalStorageLogger extends Logger {
  log(message) {
    const logs = JSON.parse(localStorage.getItem('logs') || '[]');
    logs.push({ time: new Date(), message });
    localStorage.setItem('logs', JSON.stringify(logs));
  }
}

// 3. 高层模块:日志服务(依赖抽象)
class LogService {
  constructor(logger) {
    this.logger = logger; // 注入抽象日志接口
  }

  recordOperation(operation) {
    // 核心逻辑:记录操作,不关心日志如何存储
    this.logger.log(`操作:${operation}`);
  }
}

// 使用:切换日志输出方式,不影响LogService
const consoleLogService = new LogService(new ConsoleLogger());
consoleLogService.recordOperation('用户登录'); // 输出到控制台

const storageLogService = new LogService(new LocalStorageLogger());
storageLogService.recordOperation('订单提交'); // 存储到localStorage

DIP的落地技巧

  1. 定义稳定的抽象接口(核心)。抽象接口应聚焦 "做什么"(功能契约),而非 "怎么做"(实现细节);前端中,可通过 "类继承""TypeScript 接口" 或 "约定对象结构"(如{ log: (msg) => void })定义抽象。
  2. 依赖注入(Dependence Injection, DI):传递抽象实现。高层模块不自己创建低层实现,而是通过 "构造函数参数""函数参数" 或 "配置对象" 接收低层实现(如new OrderService(paymentMethod));前端框架(如 React)中,可通过props实现组件的依赖注入。
  3. 面向接口编程,而非面向实现编程。声明变量、参数时,使用抽象类型(如const logger: Logger),而非具体类型(如const logger: ConsoleLogger);调用方法时,只依赖抽象接口定义的方法,不调用具体实现特有的方法(避免 "向下转型")。
  4. 拆分 "稳定层" 与 "变化层"。稳定层:抽象接口和高层业务逻辑(如PaymentMethod接口、OrderService);变化层:抽象接口的具体实现(如WechatPayment、AlipayPayment);确保稳定层不依赖变化层,变化层依赖稳定层。
  5. 结合工厂模式创建实例(隐藏实现细节)。用工厂类 / 函数创建具体实现实例,高层模块通过工厂获取抽象接口,无需知道具体类名;
js 复制代码
// 支付方式工厂
class PaymentFactory {
  static create(method) {
    switch (method) {
      case 'wechat':
        return new WechatPayment();
      case 'alipay':
        return new AlipayPayment();
      default:
        throw new Error('不支持的支付方式');
    }
  }
}

// 高层模块通过工厂获取抽象,无需直接依赖具体类
const payment = PaymentFactory.create('wechat');
const orderService = new OrderService(payment);

DIP的 "边界":避免过度抽象

DIP 的核心是 "依赖抽象",但过度抽象会导致系统复杂度飙升(如为简单功能定义多层接口)。判断是否需要抽象的标准是:

  • 变化频率:若某部分功能几乎不会变化(如基础工具函数),无需抽象;
  • 扩展成本:抽象的成本(设计接口、维护子类)是否低于 "修改的成本"(频繁改核心代码的风险)。例如:一个内部使用的简单计算器,无需为 "加减乘除" 设计抽象接口;但一个面向多行业的财务系统,需为 "税费计算" 设计可扩展的接口(不同行业税率规则不同)。

DIP 的核心是 "通过抽象解耦",但过度抽象会导致系统复杂度上升(如为简单功能定义多层接口)。判断是否需要抽象的标准是:

  • 变化频率:若低层实现几乎不变(如基础工具函数),无需抽象;
  • 扩展需求:若未来可能替换实现(如支付方式、数据源),则必须抽象;
  • 成本平衡:抽象的设计成本应低于 "未来修改的成本"。 例如:一个内部工具的日志功能,若永远只需要输出到控制台,无需抽象;但一个面向多环境的应用(开发 / 测试 / 生产),日志可能需要输出到控制台、文件或服务端,则必须抽象。

接口隔离原则(Interface Segregation Principle, ISP) - 小而专,够用就行

接口隔离原则是面向对象设计(SOLID 原则的I)中的重要原则,,核心思想是:客户端不应该被迫依赖它不需要的接口。即,一个接口应该只包含客户端需要的方法,避免创建 "大而全" 的臃肿接口,应将其拆分为多个小而专的接口,让客户端只依赖自己需要的接口。

在 OOP 中,"接口" 不仅指显式声明的接口(如 Java 的interface、TypeScript 的interface),也包括类的公共方法集合(隐式接口)。接口隔离原则的本质是通过拆分接口降低依赖冗余,减少客户端与无关方法的耦合,从而提升系统的灵活性和可维护性。

核心内涵:"小而专" 的接口设计

接口隔离原则的核心可概括为三点:

  • 接口应 "专一":每个接口只服务于一个特定的客户端或功能模块,避免包含无关方法(如 "支付接口" 不应包含 "用户注册" 方法);
  • 客户端 "按需依赖":客户端只依赖自己实际使用的接口,不依赖包含多余方法的 "大接口";
  • 拆分 "臃肿接口":若一个接口被多个客户端使用,且每个客户端只需要其中一部分方法,应将其拆分为多个接口,每个接口对应一个客户端的需求。 简单说,ISP 要求接口设计遵循 "最小知识" 原则 ------ 接口只暴露客户端必需的方法,隐藏所有无关细节。

反例,多功能设备接口的设计(一个接口包含所有功能):

js 复制代码
// 违反ISP:臃肿接口,包含打印、扫描、复印功能
interface MultifunctionalDevice {
  print(document: string): void; // 打印
  scan(): string; // 扫描
  copy(document: string): void; // 复印
}

// 客户端1:只需要打印功能的打印机
class Printer implements MultifunctionalDevice {
  print(document: string) {
    console.log(`打印:${document}`);
  }

  // 被迫实现不需要的方法(冗余)
  scan() {
    throw new Error("打印机不支持扫描"); // 不合理:实现了不需要的方法
  }

  copy(document: string) {
    throw new Error("打印机不支持复印");
  }
}

// 客户端2:只需要扫描功能的扫描仪
class Scanner implements MultifunctionalDevice {
  scan() {
    return "扫描的内容";
  }

  // 被迫实现不需要的方法
  print(document: string) {
    throw new Error("扫描仪不支持打印");
  }

  copy(document: string) {
    throw new Error("扫描仪不支持复印");
  }
}

// 问题:
// 1. 接口修改(如新增fax()方法),所有实现类都必须修改;
// 2. 客户端调用时可能误调用不需要的方法(如Printer调用scan()会报错);
// 3. 实现类包含大量冗余的"未实现"方法,逻辑混乱。

核心步骤:拆分臃肿接口 → 客户端依赖专用接口

js 复制代码
// 遵循ISP:拆分接口为专用接口
interface Printable {
  print(document: string): void; // 仅打印功能
}

interface Scannable {
  scan(): string; // 仅扫描功能
}

interface Copyable {
  copy(document: string): void; // 仅复印功能
}

// 客户端1:打印机只实现Printable接口
class Printer implements Printable {
  print(document: string) {
    console.log(`打印:${document}`);
  }
  // 无需实现无关方法,代码简洁
}

// 客户端2:扫描仪只实现Scannable接口
class Scanner implements Scannable {
  scan() {
    return "扫描的内容";
  }
}

// 客户端3:多功能设备实现多个接口(按需组合)
class MultifunctionalMachine implements Printable, Scannable, Copyable {
  print(document: string) {
    console.log(`打印:${document}`);
  }

  scan() {
    return "扫描的内容";
  }

  copy(document: string) {
    console.log(`复印:${document}`);
  }
}

// 使用:客户端只依赖自己需要的接口
function usePrinter(printer: Printable) {
  printer.print("文档内容"); // 只调用打印方法,无需关心其他功能
}

function useScanner(scanner: Scannable) {
  const content = scanner.scan(); // 只调用扫描方法
  console.log("扫描结果:", content);
}

// 调用时类型安全,不会误调用无关方法
usePrinter(new Printer());
useScanner(new Scanner());
usePrinter(new MultifunctionalMachine()); // 多功能设备也可作为打印机使用

再举个反例,React 组件的 Props 设计(避免 "万能 Props"):

js 复制代码
// 违反ISP:一个组件接收所有可能的Props,包含大量无关属性
function UserComponent({
  name,
  age, // 基础信息
  onEdit,
  onDelete, // 编辑/删除回调
  isAdmin,
  showPermission, // 权限相关
  isLoading,
  errorMessage, // 加载状态
}) {
  return (
    <div>
      <h3>{name}</h3>
      <p>年龄:{age}</p>
      {isAdmin && <button onClick={onEdit}>编辑</button>}
      {isLoading && <div>加载中...</div>}
    </div>
  );
}

// 客户端使用:被迫传递不需要的Props
function SimpleUserView() {
  // 只需要展示name和age,但必须传递其他Props(或显式传undefined)
  return <UserComponent name="张三" age={20} isAdmin={false} isLoading={false} />;
}

正确的做法是,拆分接口为专用接口:

js 复制代码
// 遵循ISP:拆分Props为专用接口(用TypeScript接口或文档约定)
interface UserInfoProps {
  name: string;
  age: number;
}

interface AdminActionsProps {
  isAdmin: boolean;
  onEdit: () => void;
  onDelete?: () => void; // 可选方法,避免强制依赖
}

interface LoadingStateProps {
  isLoading: boolean;
  errorMessage?: string;
}

// 组件通过组合Props接口,客户端按需传递
function UserComponent({
  name,
  age,
  isAdmin = false,
  onEdit = () => {},
  onDelete,
  isLoading = false,
  errorMessage
}: UserInfoProps & Partial<AdminActionsProps> & Partial<LoadingStateProps>) {
  return (
    <div>
      {isLoading ? (
        <div>加载中...</div>
      ) : errorMessage ? (
        <div>错误:{errorMessage}</div>
      ) : (
        <>
          <h3>{name}</h3>
          <p>年龄:{age}</p>
          {isAdmin && <button onClick={onEdit}>编辑</button>}
        </>
      )}
    </div>
  );
}

// 客户端使用:只传递需要的Props,不依赖无关属性
function SimpleUserView() {
  return <UserComponent name="张三" age={20} />; // 无需传递isAdmin、isLoading等
}

function AdminUserView() {
  return (
    <UserComponent
      name="张三"
      age={20}
      isAdmin={true}
      onEdit={() => console.log("编辑")}
    />
  );
}

function LoadingUserView() {
  return <UserComponent name="张三" age={20} isLoading={true} />;
}

ISP的落地技巧

  1. 按 "客户端需求" 拆分接口,而非 "实现类功能"。接口设计的出发点是 "客户端需要什么",而非 "实现类能提供什么";例如:即使一个设备同时支持打印和扫描,也应拆分为Printable和Scannable接口,因为有的客户端只需要其中一种功能。
  2. 避免 "接口合并":不将多个小接口合并为大接口。反例:为了 "方便",将Printable、Scannable合并为MultifunctionalDevice,强制所有客户端依赖; 正例:允许实现类同时实现多个小接口(如class Machine implements Printable, Scannable),但客户端只依赖自己需要的接口。
  3. 接口方法应 "最小化":只包含必需的方法。每个接口的方法数量应尽可能少,遵循 "一个接口一个职责"(结合单一职责原则);例如:UserInfoService只包含与用户基础信息相关的方法,不包含订单、权限等无关功能。
  4. 前端 Props 设计:使用 "可选属性" 和 "组合类型"。对组件 Props,用Partial(TypeScript)或默认值标记非必需属性,避免客户端传递无关值;用类型组合(如Props = A & B)替代 "大而全" 的 Props 类型,让客户端按需组合。
  5. 警惕 "过度拆分":平衡接口数量与复杂度。ISP 不要求 "一个方法一个接口",过度拆分会导致接口数量爆炸(如Printable1、Printable2),反而降低可读性;判断标准:拆分后的接口是否被不同的客户端使用,且每个客户端只依赖其中一个接口。

组合优于继承(Composition Over Inheritance)- 积木搭建

"组合优于继承" 核心思想是:优先通过 "对象组合"(将多个独立对象组装成新功能)实现代码复用,而非通过 "类继承"(子类继承父类的属性和方法)。

简单说,继承是 "is-a" 关系(如 "狗是动物"),组合是 "has-a" 关系(如 "汽车有发动机、有轮胎")。

继承 vs 组合的本质区别

继承(Inheritance)

  • 机制:子类通过extends关键字继承父类,获得父类的属性和方法,可重写父类方法实现定制。
  • 关系:"is-a"(是一个)------ 子类是父类的特殊化(如Dog extends Animal,狗是一种动物)。
  • 复用方式:纵向复用(复用父类的代码,形成继承树)。

组合(Composition)

  • 机制:一个对象通过 "包含" 其他对象的引用(如属性),调用其方法实现功能,各对象保持独立。
  • 关系:"has-a"(有一个)------ 对象由多个子对象组成(如Car包含Engine、Tire,汽车有发动机、有轮胎)。
  • 复用方式:横向复用(将不同对象的功能组合,形成 "功能模块")。

原则核心:当需要复用代码时,优先选择 "将功能拆分成独立对象,再组合它们",而非 "创建父类让子类继承"。组合支持 "任意功能搭配",像搭积木一样组装新功能,无需修改已有代码。代码结构扁平,易于理解。

组合优于继承的落地技巧

  1. 拆分 "功能模块":将复杂功能拆分为独立的小对象 / 函数。原则:每个模块只做一件事(符合单一职责原则),如 "日志""缓存""验证""加载状态";示例:将 "用户服务" 拆分为 "基础 CRUD""日志记录""数据缓存" 三个独立模块。

  2. 通过 "注入" 组合功能:将子模块作为参数传入父对象。组件:通过props传递子组件(如IconButton接收icon组件);类 / 函数:通过构造函数或参数注入依赖(如LoggedUserService在构造函数中接收userService和logger)。

  3. 使用 "高阶函数 / 组件" 包装功能:实现横切关注点复用。高阶函数:如withLog(fn)包装函数,添加日志功能;高阶组件(HOC):如withLoading(Component)包装组件,添加加载状态(React 中虽推荐 Hook 替代 HOC,但思想一致)。

  4. 优先用 "自定义 Hook" 复用状态逻辑(React)。Hook 本质是 "功能组合的语法糖",如useForm+useValidation组合表单逻辑,比继承更灵活。

  5. 继承的合理使用场景:当 "is-a" 关系明确且稳定时。组合优于继承,不代表 "完全禁止继承"。以下场景可使用继承:关系明确且稳定(如Array是Object的特殊类型,Button是Component的特殊类型);父类是 "抽象基类"(只定义接口,不包含具体实现,如Shape类定义getArea方法,子类Circle、Rectangle实现)。

相关推荐
Wild_Pointer.5 小时前
设计模式实战精讲:全景目录
设计模式·设计规范
一叶飘零_sweeeet14 小时前
深度拆解汽车制造系统设计:用 Java + 设计模式打造高扩展性品牌 - 车型动态生成架构
java·设计模式·工厂设计模式
阿波罗尼亚15 小时前
设计原则(一)Head First设计模式
设计模式
ZHE|张恒1 天前
设计模式实战篇(五):责任链模式 — 把复杂审批/过滤流程变成可组合的“传递链”
设计模式·责任链模式
CodeAmaz1 天前
使用责任链模式设计电商下单流程(Java 实战)
java·后端·设计模式·责任链模式·下单
大G的笔记本2 天前
Java常见设计模式面试题(高频)
java·开发语言·设计模式
老鼠只爱大米2 天前
Java设计模式之建造者模式(Builder)详解
java·设计模式·建造者模式·builder·23种设计模式
guangzan2 天前
常用设计模式:职责链模式
设计模式
ZHE|张恒2 天前
设计模式实战篇(二):业务逻辑“随时切换招式”——策略模式(Strategy Pattern)解析
设计模式·策略模式