SOLID你都不懂,还谈代码质量?

一个健壮的软件系统,整洁的代码是这项工作的根基,如何编写整洁的代码,在诸如《整洁代码之道》《重构》等书籍已经给出建议;光有好的砖头是无法建造健壮的大楼,我们还要设计各部件的关系,这就要了解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失效的状态时,这段代码会根据不同平台执行不同的退出逻辑。这种写法违反了开闭原则中提到的“给系统添加新功能时不应该更改现有代码”的原则。

此外,由于网络层是应用的核心部分,属于高层级代码,而退出登录操作则属于低层级代码。代码中高层级依赖低层级,也违反了依赖倒置原则。稍后我们会详细说明依赖倒置原则。

image.png

正确的做法

可以进一步抽象异常,例如将其分为权限问题、请求超时、token失效等。同时,提供异常处理钩子函数对外暴露。不同页面可以根据业务需求调用钩子并注入逻辑,这样的好处是高层级模块不再关注低层级模块的实现,当低层级模块业务变更页不会影响应用稳定性。

image.png

开源库实现开闭原则例子

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);
  });

依赖倒置原则

依赖倒置原则的核心思想是:高层次模块不应该依赖低层次模块,两者都应该依赖于抽象接口;抽象接口不应该依赖于具体实现,具体实现应该依赖于抽象接口。

如果你从这句定义听得云里雾里,暂时不用紧张,下面会通过两个例子逐一讲解。

反面例子

例子包含accountrequest两个方法,request方法提供post用于网络请求,在发送请求前调用account的getToken方法获取用户token。如果token不存在,调用logout方法退出登录。

image.png

这段代码违反依赖倒置原则的高层次代码依赖低层次代码,假如需求要把request.ts迁移到移动端使用,UI框架换为Taro,这些代码将无法使用,因为account.ts耦合了element-plus和vue-router。

正确的做法

由于前端代码不是用面向对象风格编写,就无法用抽象接口之类的实现依赖关系,但是我们可以通过依赖类型实现相同的解耦效果。

image.png

在request暴露IAccount 类型定义,只要实现相同协议,即可被正确调用,这样无论是迁移到任何平台,都可以在index.ts 传入不同account实现无需改动网络层代码。

此外,我们可能会对代码的**“高层级”“低层级”**划分存在疑问。简单来说,高层级代码一般是变更频率较低、与框架无关的核心代码。它不会因为你使用的视图框架、数据库或其他第三方设施的改变而发生变化。而低层级代码的变更频率通常较高,例如前端的视图代码。

面向对象中的依赖倒置

依赖倒置原则提到:两者都应该依赖于抽象接口;抽象接口不应该依赖于具体实现,具体实现应该依赖于抽象接口。

这句话理解起来比较拗口,我用一个示例说明,如下图是一个异常日志采集器,最左边的ErrorHandler构造函数声明对ErrorFeedBack 抽象类依赖,而非具体实现,具体实现ElememtErrorFeedBack 通过继承抽象类实现扩展。

image.png

通过这样的依赖关系,可以对ErrorFeedBack 抽象类的继承,实现多平台支持,如下图实现针对React框架的ReactErrorFeedBackImpl 和 针对小程序的 TaroErrorFeedBackImpl

image.png

扩展知识(依赖注入)

在上一个例子中,你会发现依赖关系通过构造函数传入,如果一个类有太多的依赖,会导致构造函数变得臃肿。此外,手动实例化也会很麻烦。为了解决这个问题,可以使用依赖注入的方式来管理依赖关系。

下面是 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 耦合,如果此时有一个特卖商品类型ISaleProductThumbnail组件将不可用。

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和支持两个尺寸,最终效果如下图。

image.png

接着你在组件代码定义了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 原则,一个模块只应该对应一个角色,而这个方法明显违反了单一职责原则。因此,正确的做法是将 dogcat 拆分到两个模块中进行维护。

// cat.ts
function cat() {
  const say = () => {
    console.log('喵~');
  };
  return {
    say,
  };
}

// dog.ts
function dog() {
  const say = () => {
    console.log('汪~');
  };
  return {
    say,
  };
}

我认为单一职责设计是一个动态的设计过程,因为随着系统迭代,一开始的职责划分未必能满足后期的变化。这时,您需要重新划分职责,重新聚合代码。

总结

本文介绍了 SOLID 设计原则在前端开发中的应用。我们介绍了依赖倒置、依赖注入、接口隔离、里氏替换和单一职责原则。这些原则对于代码的可维护性和可扩展性非常重要。在实际开发中,我们应该尽量遵循这些原则,以便更好地管理我们的代码库。

另外,基于这些原则,后面我想聊一下关于整洁架构在前端的应用,如果你喜欢这类型的文章,可以点赞或者评论。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYPRpjXW' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片