TypeScript 设计模式系列之 SOLID 法则
引言
TypeScript 的威力不仅仅是为函数(参数、返回值)增加类型限制和检查,更大的威力是如何辅助我们构建整洁、合理的代码设计。装饰器、元数据、抽象类、接口等能力的提供更好的辅助前端语言进行面向对象编程的设计。TypeScript 开发的大型项目,基本都是基于OOP编程,比如 vscode 编辑器等等。本文介绍面向对象编程中的理念SOLID法则。
单一职责(Single Responsebility Principle)
一个类应该只有一个引起它变化的原因,换句话说,一个类应该只有一个职责,这样可以使得类更易于维护和理解。强调一个类只负责完成一个特定的功能或任务,避免一个承担过多的责任。
There should never be more than one reason for a class to change.
举个例子:设计一个小游戏,键盘控制人物的移动(w表示向前,s表示后退)。只实现这个移动的功能。
interface GameInterface {
move(): void;
back(): void;
}
class GameControl implements GameInterface {
private localtion: { x: number, y: number } = { x: 0, y: 0 };
move() {
// 向前移动
this.localtion = { x: this.localtion.x + 1, y: this.localtion.y };
}
back() {
// 向后移动
this.localtion = { x: this.localtion.x - 1, y: this.localtion.y };
}
}
此时我们定义这个 GameControl 类用于控制人物的移动,目前支持前进\后退。实现了前进后退移动的能力。
分析一下这个实现:
现在影响这个类变化的因素有两个【不符合单一职责】,第一个是移动的动作类型(如果需要扩展斜上方45°移动、或者需要蹲下来移动)动作类型的变化需要修改该类支持;第二是数据计算的方式(现在前进一次移动1像素,如果需要进入奔跑模式一次移动 10像素,得修改类)计算方式的变化也会触发这个类的修改,如果我们从2D游戏升级成为3D游戏动作类型不变该类还是会被修改。由此说明的是这个类的设计引起变化的原因有两个。不符合单一职责。需要进行拆分设计:
我们将动作类型的变化拆分到 GameActionInterface 上,将数据计算拆分到 LocationCaculateInterface 上。将两个引起变化的原因拆开,每个类的变化只由一个原因引起。
interface GameActionInterface {
forward(): void;
back():void;
// 扩展一下动作
left(): void;
right():void;
forwardAndLeft(): void; // 左45°行进
}
interface LocationCaculateInterface {
calculateX(type: 'increase' | 'decrease'): void; // 计算x
calculateY(type: 'increase' | 'decrease'): void; // 计算Y
calculateWithAngle(angle: number): void; // 带角度的计算
}
从接口上看,我们将两个引起变化的原因互相隔离开了。实现类有几种选择,第一种是分开实现对应的接口然后组合起来;第二是一个类中实现两个接口;第三种分开实现类使用依赖注入来提供具体的类实例。
第一种分开实现了势必会导致类之间的耦合过重,类数量增加。第二种当然也是实现了变化因素隔离(许多人推荐这种方式),每次修改也是修改的局部接口对应的子项。我最喜欢的是第三种,采用 DI 来进行类实例托管,我们只关注接口上约定的范式编程,这爽的多。
单一职责提出的是一个编写程序的标准,用职责和变化原因来衡量接口或类的设计是否合理。难点在于划分职责和变化原因。
核心一句话
引起类变化的原因只有一个,单一职责原则也可以用于函数接口的定义。所以开发中需要思考,这个函数的功能是不是定义的合理【所有功能都在一个函数中实现了?两个不同的处理类型的东西都在一个函数中实现了?】。看函数名就知道这个函数的作用是啥(这是写出可读性代码的最终要义)。
好处
- 类的复杂性降低,实现什么职责都有明确清晰的定义
- 代码可读性提高
- 可维护性提高
对于单一职责原则,我的建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
开闭原则 (Open-Closed Principle)
Software entities like classes,modules and functions should be open for extension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)
开闭原则是面向对象编程的基础法则,【单一职责、里氏替换、接口隔离、依赖导致、迪米特法则】等五大法则都是开闭法则的具体细分应用。
软件实体通过扩展来实现变化,而不是通过修改已有代码来实现变化。但是变化时分类的,有些变化影响范围很小可直接修改代码完事儿,并不需要继承扩展出新的类来承接变化。有的类是基础类,其变化会引起上层模块之间的变化,这种情况下就需要继承进行扩展。
- 抽象约束
-
- 通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许类中出现接口或者抽象类中不存在的 public 方法。
- 参数类型、引用对象类型使用接口或者抽象类而不是实现类。
- 抽象层保持稳定,一旦确定不允许修改。
- 元数据控制模块行为
-
- 以配置文件信息来承担部分扩展能力。
- 封装变化【单一职责】
-
- 将相同的变化封装到一个接口或者抽象类中
- 将不同的变化封装到不用的接口或者抽象类中【不应该存在两个不同的变化出现在同一个接口或者抽象类中】
开闭原则是一个终极目标,任何人包括大师级人物都无法百分之百做到,但朝这个方向努力,可以非常显著地改善一个系统的架构,真正做到“拥抱变化”。
里氏替换(Liskov Subsituation Principle)
里氏替换原则用于规范类继承,如果一个父类出现的地方两个实现存在差异的子类互相替换都能满足功能的实现。使用子类替换父类能正常的实现功能。要求子类完整的实现父类的方法。
注意:如果子类不能完整地实现父类的方法,或者父类中的方法在子类中发生‘畸变’,此时需要断开父子继承关系,采用依赖、组合等关系替代继承。
所以,父类是子类的子集。方法参数、是子类的子集。不然没法使用子类来替换父类(子类的兼容性更好~)
- 子类必须完全实现父类的方法
- 覆盖或实现父类的方法时输入参数可以被放大
- 覆写或实现父类的方法时输出结果可以被缩小
好处
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!
接口隔离(Interface Segregation Principle)
接口隔离的目的是把接口细化,最小暴露接口。,单一职责原则要求我们将一个职责放在一起维护避免多个职责放在一起维护(这是基础的切分原则),接口隔离要求的是非必要不暴露其余能力(如果一个接口拥有20个方法的定义,但是给A项目使用其实只要其中的5个方法,其余15个不需要)这时候需要考虑将大接口拆分成小接口,暴露小接口而不是大接口。
拆分的粒度是根据需求变化的,把我尺度很重要。不拆分则过渡暴露、拆到过于细则不好,要不然一个方法一个接口,那代码蹭蹭蹭往上搞了~ 。 中庸之道~得合适。
如何把握尺度
- 高内聚,符合带一职责的放在一起【这个和业务上的职责可能需要更细一点】
- 一个接口只服务与一个子模块或者业务
依赖倒置(Dependency Inversion Principle)
高层模块不应该依赖低层模块,高层模块与低层模块之间都应该依赖于抽象而不是具体实现。(面向接口编程?)。
原则
- 模块之间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,依赖关系通过接口或抽象类来承接。
- 接口或抽象类不依赖于实现类。【接口或者抽象类上定义的属性如果是其他类类型的,需要使用该类的抽象类型来修饰,禁止使用具体的实现类来修饰】
- 实现类依赖于接口或抽象类【因此需要对实现类都定义抽象(接口或者抽象类),这样的好处是方便扩展】。
面向接口编程~~~get?
高内聚低耦合,依赖倒置就是解决耦合问题,降低类之间的耦合性。
迪米特法则(Law of Demeter)
一个对象应该对其他对象有最少的了解,该知道的知道不该知道的我不想打听。核心观念是类之间解耦弱化耦合关系。
对于类的封装需要该 private 的就别 protected、不该暴露的就别 public 。有的人可能会说 JS 你控制不了别人的访问呀 ~
对于一个对象,我只关注你提供给我的public函数,其余的我不关心,内部实现不需要了解。
- 只对第一层对象进行访问(只和朋友交流)
-
user.car.run()你就坏了规矩
- 只访问提供的能力, 不控制太细力度的执行过程(这个过程由依赖的对象自己内聚)。高内聚,引用的对象暴露的东西尽量聚合,不要什么都暴露了。
总结
SOLID 法则是面向对象编程思想的基础理念,TypeScript 如果只是作为函数编程中参数、返回值等类型的限制以及检查,发挥不出它真正的威力。装饰器、接口、元数据、抽象类等能力的提供,虽然 JavaScript 中的类继承与C++比起来显得怪异,前端领域大型项目的设计都是基于 OOP 实现的,掌握如何实现 OOP编程,让代码和设计更加整洁、合理。