一个健壮的软件系统,整洁的代码是这项工作的根基,如何编写整洁的代码,在诸如《整洁代码之道》《重构》等书籍已经给出建议;光有好的砖头是无法建造健壮的大楼,我们还要设计各部件的关系,这就要了解SOLID原则。
SOLID原则指导我们设计模块、数据结构、类之间的耦合关系,以满足应用易于变更、易于维护和实现基础组件跨平台的特性。
SOLID原则最早是为了指导面向对象编程而提出的,但是其设计思想在非面向对象编程中也可以得到应用。本文将从前端的角度讲述SOLID原则的应用。
SOLID原则组成
SOLID 是由五个设计原则首字母组成:
- Single Responsibility Principle (SRP) 单一职责原则
- Open/Closed Principle (OCP) 开闭原则
- Liskov Substitution Principle (LSP) 里氏替换原则
- Interface Segregation Principle (ISP) 接口隔离原则
- Dependency Inversion Principle (DIP) 依赖倒置原则
开闭原则
1988年,Bertrand Meyer提出了一项原则:为了使软件系统易于更改,必须将其设计成允许通过添加新代码来更改这些系统的行为,而不是更改现有的代码。
反面例子
以下是一个网络层异常处理函数。当服务端返回token失效的状态时,这段代码会根据不同平台执行不同的退出逻辑。这种写法违反了开闭原则中提到的“给系统添加新功能时不应该更改现有代码”的原则。
此外,由于网络层是应用的核心部分,属于高层级代码,而退出登录操作则属于低层级代码。代码中高层级依赖低层级,也违反了依赖倒置原则。稍后我们会详细说明依赖倒置原则。
正确的做法
可以进一步抽象异常,例如将其分为权限问题、请求超时、token失效等。同时,提供异常处理钩子函数对外暴露。不同页面可以根据业务需求调用钩子并注入逻辑,这样的好处是高层级模块不再关注低层级模块的实现,当低层级模块业务变更页不会影响应用稳定性。
开源库实现开闭原则例子
axios是一个网络请求库,提供基础的网络请求服务。我们往往需要在请求前后加入自定义逻辑,axios提供了拦截器(interceptors)。只需要通过调用use(fn)即可加入自定义逻辑。
axios.interceptors.request.use(function (config) {
// Do something before request is sent
return config;
}, function (error) {
// Do something with request error
return Promise.reject(error);
});
axios.interceptors.response.use(function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
}, function (error) {
return Promise.reject(error);
});
依赖倒置原则
依赖倒置原则的核心思想是:高层次模块不应该依赖低层次模块,两者都应该依赖于抽象接口;抽象接口不应该依赖于具体实现,具体实现应该依赖于抽象接口。
如果你从这句定义听得云里雾里,暂时不用紧张,下面会通过两个例子逐一讲解。
反面例子
例子包含account
和request
两个方法,request方法提供post
用于网络请求,在发送请求前调用account的getToken
方法获取用户token。如果token不存在,调用logout
方法退出登录。
这段代码违反依赖倒置原则的高层次代码依赖低层次代码,假如需求要把request.ts
迁移到移动端使用,UI框架换为Taro,这些代码将无法使用,因为account.ts
耦合了element-plus和vue-router。
正确的做法
由于前端代码不是用面向对象风格编写,就无法用抽象接口之类的实现依赖关系,但是我们可以通过依赖类型实现相同的解耦效果。
在request暴露IAccount
类型定义,只要实现相同协议,即可被正确调用,这样无论是迁移到任何平台,都可以在index.ts
传入不同account实现无需改动网络层代码。
此外,我们可能会对代码的**“高层级”和“低层级”**划分存在疑问。简单来说,高层级代码一般是变更频率较低、与框架无关的核心代码。它不会因为你使用的视图框架、数据库或其他第三方设施的改变而发生变化。而低层级代码的变更频率通常较高,例如前端的视图代码。
面向对象中的依赖倒置
依赖倒置原则提到:两者都应该依赖于抽象接口;抽象接口不应该依赖于具体实现,具体实现应该依赖于抽象接口。
这句话理解起来比较拗口,我用一个示例说明,如下图是一个异常日志采集器,最左边的ErrorHandler
构造函数声明对ErrorFeedBack
抽象类依赖,而非具体实现,具体实现ElememtErrorFeedBack
通过继承抽象类实现扩展。
通过这样的依赖关系,可以对ErrorFeedBack
抽象类的继承,实现多平台支持,如下图实现针对React框架的ReactErrorFeedBackImpl
和 针对小程序的 TaroErrorFeedBackImpl
扩展知识(依赖注入)
在上一个例子中,你会发现依赖关系通过构造函数传入,如果一个类有太多的依赖,会导致构造函数变得臃肿。此外,手动实例化也会很麻烦。为了解决这个问题,可以使用依赖注入的方式来管理依赖关系。
下面是 Angular 依赖注入的写法。首先,使用 Injectable
注解声明这个类可以被注入。
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class Logger {
logs: string[] = []; // capture logs for testing
log(message: string) {
this.logs.push(message);
console.log(message);
}
}
接着在构造器声明使用,实例将会被自动注入
import { Injectable } from '@angular/core';
import { HEROES } from './mock-heroes';
import { Logger } from '../logger.service';
@Injectable({
providedIn: 'root',
})
export class HeroService {
constructor(private logger: Logger) { }
getHeroes() {
this.logger.log('Getting heroes ...');
return HEROES;
}
}
依赖注入在后端开发框架Spring中广泛使用。但在前端用的比较少,因为前端更多关注UI视图的交互,没有复杂的分层。如果你的应用足够复杂,想在Vue、React中实现依赖注入,可以参考开源框架inversify,他提供了容器管理,依赖采集等工具。
接口隔离原则
接口隔离原则:任何层次的软件设计如果依赖于不需要的东西,都会有害的。
如果在前端组件中应用这个原则,更贴切的说法是,“组件的Props不应该包含未使用的字段”。
反面例子
下面例子假设我们开发一个商品缩略图组件Thumbnail
,假如直接依赖商品对象的类型定义IProduct
,将违反接口隔离原则,因为该组件只需依赖imageUrl
,而其他属性对于它都是多余的。
export interface IProduct {
id: string;
title: string;
price: number;
rating: { rate: number };
image: string;
}
import { IProduct } from "./product";
interface IThumbnailProps {
product: IProduct;
}
export function Thumbnail(props: IThumbnailProps) {
const { product } = props;
return (
<img
className="p-8 rounded-t-lg h-48"
src={ product.image }
alt="product image"
/>
);
}
这种写法会导致组件和IProduct
耦合,如果此时有一个特卖商品类型ISaleProduct
,Thumbnail
组件将不可用。
export interface ISaleProduct {
id: string;
title: string;
salePrice: number;
imageUrl: string;
}
正确的做法
基于接口隔离原则,只定义组件需要的属性即可
interface IThumbnailProps {
imageUrl: string;
}
export function Thumbnail(props: IThumbnailProps) {
const { imageUrl } = props;
return (
<img
className="p-8 rounded-t-lg h-48"
src={imageUrl}
alt="product image"
/>
);
}
里氏替换原则
里氏替换原则:要求子类能够完全替代父类,并且在使用父类的地方保持行为的一致性。这意味着子类应该遵守父类所定义的接口约定、拥有相同的行为预期,而不引入新的异常、破坏性质或者违反父类的约束。
在前端更易理解的说法是:当你对组件进行扩展时,应该继承父组件的所有属性并在此基础上进行扩展,而不是自定义一套属性,违反父组件的约束。
反面例子
我们的需求是要封装一个搜索输入框SearchInput
组件,组件对比原生input
输入框有一个放大镜icon和支持两个尺寸,最终效果如下图。
接着你在组件代码定义了inputValue
属性和onValueChange
方法用于接收初始值和输入框变更事件,虽然功能都被实现了,但是这种写法违反了里氏替换原则,因为SearchInput
组件实际是继承于Input
组件,但是破坏了父组件约束,把value
改为了inputValue
,把onChange
改为onValueChange
,这导致原来代码使用input组件的地方无法直接替换为新组件。
// SearchInput组件入参
interface ISearchInputProps {
inputValue: string; // 输入框内容
onValueChange: (e: React.ChangeEvent<HTMLInputElement>) => void; // 输入框内容变化回调
isLarge?: boolean; // 是否显示大尺寸
}
// 调用代码
<SearchInput inputValue={value} onValueChange={handleChange} isLarge={false} />
// 原生input调用方式
<Input value={value} onChange={handleChange} />
正确的做法
新的组件属性应该继承父组件,这在替换新组件时将无需改变任何传值
// 继承原生input元素属性
interface ISearchInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
isLarge?: boolean;
}
<Input value={value} onChange={handleChange} />
// 直接替换新组件
<SearchInput value={value} onChange={handleChange} isLarge={false} />
这种做法在tiangong组件库扩展element-plus组件库时常被用到,基于里氏替换原则,组件库的替换将会变得简单。
单一职责原则
在整洁代码中的单一职责更多是关注函数维度,建议一个函数只做一件事,保持函数功能的单一性。
但在SOLID原则中的单一职责,更多关注模块维度,它的定义可以描述为“一个模块有且只能对一个*角色/行为者(actor)*负责”。
下面代码定义animal
方法,其中say
方法实现猫和狗两种动物的行为
function animal(type: 'cat' | 'dog') {
const say = () => {
if (type === 'cat') {
console.log('喵~');
}
if (type === 'dog') {
console.log('汪~');
}
};
return {
say,
};
}
根据整洁代码原则,一个函数只应该做一件事情,因此 say
方法看起来并没有问题。然而,根据 SOLID 原则,一个模块只应该对应一个角色,而这个方法明显违反了单一职责原则。因此,正确的做法是将 dog
和 cat
拆分到两个模块中进行维护。
// cat.ts
function cat() {
const say = () => {
console.log('喵~');
};
return {
say,
};
}
// dog.ts
function dog() {
const say = () => {
console.log('汪~');
};
return {
say,
};
}
我认为单一职责设计是一个动态的设计过程,因为随着系统迭代,一开始的职责划分未必能满足后期的变化。这时,您需要重新划分职责,重新聚合代码。
总结
本文介绍了 SOLID 设计原则在前端开发中的应用。我们介绍了依赖倒置、依赖注入、接口隔离、里氏替换和单一职责原则。这些原则对于代码的可维护性和可扩展性非常重要。在实际开发中,我们应该尽量遵循这些原则,以便更好地管理我们的代码库。
另外,基于这些原则,后面我想聊一下关于整洁架构在前端的应用,如果你喜欢这类型的文章,可以点赞或者评论。