SOLID原则本身是为面向对象的强类型静态语言,比如C#,C++,JAVA提出的一些设计原则。 而由于JS是动态语言,而且是弱语言类型,本身的灵活性就极强,所以这些原则非常小众,不为人所知。 但是随着TS的流行,前端也开始有了继承,接口,抽象类,半残废的强类型检测等概念,SOLID原则也开始在前端有一定的用武之地了。
SOLID面向对象编程的原则,其最佳实践就是23种设计模式了。他们为了解决的是软件工程中一个难题:
程序员的梦:高内聚,低耦合
耦合与内聚的概念
内聚定义:
度量一个模块内部各个元素彼此结合的紧密程度。
内聚类别(内聚性由低到高排列)
-
偶然内聚:指一个模块内的各处理元素之间没有任何联系。
(类似于把一推不相关的代码都组合在一个类里) -
逻辑内聚:指模块内执行若干个逻辑上相似的功能,通过参数确定该模块完成哪一个功能。
(类似于完成加法运算,有多个加法运算代码块,分别处理参数为int或float或double等。这些代码块之所以聚在一起,只是它们都是为了完成加法运算而已) -
时间内聚:指把需要同时执行的动作组合在一起形成的模块。
(类似于利用抽象工厂模式生成一碗粥,你可以先放水,也可以先放米,这两个动作之间没有必然的顺序,但为了生成一碗粥,需要同时执行这两个动作)
1.过程内聚:指一个模块完成多个任务,这些任务必须按照指定的过程执行。
(类似于利用原生JDBC操纵数据库。你需要先连接JDBC获得connection对象,然后才能创建Statement对象,最后才能执行sql语句)
1.通信内聚:指模块内的所有处理元素都在同一个数据结构上操作,或者各处理使用相同的输入数据或者产生相同的输出数据。
(类似于有一个数组,你只把它作为你遍历数组,增加数组节点,删除数组节点的参数)
1.顺序内聚:指一个模块中的各个处理元素都密切相关于同一功能且必须顺序执行,前一功能元素的输出就是下一功能元素的输入。
(类似于程序模拟车间生产的某条流水线)
1.功能内聚:指模块内的所有元素共同作用完成一个功能,缺一不可。
(类似于某排序算法的代码,不能缺少任意一行代码,否则整个排序功能失效)
也就是说,模块功能越单一,内聚性就越高,模块的独立性就越强。一个模块应该做好一个功能就可以了,不要面面俱到,不然难以维护。
需要说明的一点是,不同程度的内聚之间并非是线性关系的。
上面的所有内聚类型中,偶然内聚和逻辑内聚是非常糟糕的内聚,而其它内聚都不错。只不过在不错之中,它们又能分出个高下。
耦合定义:
度量模块之间互相连接的紧密程度。
耦合类别:(耦合性由低到高排列)
- 无直接耦合:指两个模块之间没有直接的关系,它们分别从属于不同模块的控制与调用,它们之间不传递任何消息。
- 数据耦合:指两个模块之间有调用关系,传递的是简单的数据值。
(类似于高级语言中的值传递) - 标记耦合:指两个模块之间传递的是数据结构。
(类似于高级语言中的引用传递) - 控制耦合:指一个模块调用另一个模块时,传递的是控制变量,被调用模块通过该控制变量的值有选择地执行模块内的某一功能。因此,被调用模块应具有多个功能,哪个功能起作用受调用模块控制。
(类似于计算工资模块,调用它的模块先区别是普通员工还是经理,进而生成控制变量,传递给计算工资模块进而选择其中一种计算功能) - 外部耦合:模块间通过软件之外的环境联结。
(如I/O将模块耦合到特定的设备、格式、通信协议上) - 公共耦合:指通过一个公共数据环境相互作用的那些模块间的耦合。
(例如某一模块把用户放到http session后,另外一些模块需要从http session取用户,那么它们之间就形成了公共耦合。如果必须存在公共耦合,应限制公共耦合的范围) - 内容耦合:当一个模块直接使用另一个模块的内部数据,或通过非正常入口转入另一个模块内部时。
(类似于Java中不通过方法操作另一个类的数据,而是直接以类似于People.foot那样访问。)\
也就是说,模块之间联系少,耦合性就越低,模块之间的相对独立性就越强。模块应该管理好自己的事情就可以了,这样即不会太复杂,也便于专注的完成自己的事情。微服务的思想也是这样。
对于耦合,如果模块间必须存在耦合,应尽量使用数据耦合,少用控制耦合,限制使用公共耦合的范围,坚决避免使用内容耦合。
什么是SOLID
SOLID 是面向对象设计五大重要原则的首字母缩写
想更好的实践SOLID原则,就可以先从设计模式开始。尤其是常用的设计模式,比如工厂模式,装饰器模式,发布订阅模式,策略模式等。
SOLID原则的三大法宝:
1. 接口/抽象类
2. 组合
3. 反射 /泛型
Single Responsibility Principle(SRP):单一职责原则
一个类或者一个模块只做一件事。
其实不仅仅在类或者模块中,在《重构》一书中,作者认为每一个单独的方法和函数也应当遵循此原则
核心在于,你如何去理解“职责“这个事情。开发者对这个概念的理解,职责可以往下拆分到无限小,过细的颗粒既没必要,也不太好阅读。
比如我们要把大象塞进冰箱。
最大的职责颗粒度,就是:把大象塞进冰箱,一个职责
小一点的颗粒度,就是:1,打开冰箱 2,把大象搞进去 3,关上冰箱门
更小的颗粒度 1,打开冰箱 2,看看冰箱的空间要不要清理 3,在冰箱里放大象喜欢的事物,让大象自己走进去 4.关上冰箱门
后面两个颗粒度,其实都有一定的道理。
适当的拆分,考验的是开发者的抽象能力,以及对业务的理解能力。在两者之间进行权衡,开发者的大部分时间,其实都是在做权衡,没有最好的,只有最合适的。
但是需要注意的是,上面第一种方式肯定是不合适的。如果你拆分的代码,单一职责的代码行数超过了100行,就应该考虑继续拆分。
选择合适的拆分粒度之后,就可以进行实践了。
一般来说,最佳实践有以下几种方式,
- 使用基于接口的组合(简单画个图)
- 策略模式,策略模式其实也是基于接口的组合的一种,只是它组合的接口,实现的方式可能有点多。使用策略模式,可以很好的清掉代码中的If else和switch分支。
- 装饰者模式,Spring中的AOP,React中的HOC,ES6中的deractor最常见的还是HTTP 协议的实现。
思考:
为什么大家提倡组合,而不是继承?
Open Closed Principle(OCP):开闭原则
对扩展开放,对修改关闭。
对这个概念的理解,有所分歧。更贴近我们实际开发场景是,应该是:
在扩展功能这种场景下,可以做到只扩展代码,而不修改源代码。
体现在实践当中,就是类和模块之间的依赖,应该基于接口,而不是具体的实现。应该基于父类,而不是子类。
前端有一个实践,有点类似这种思想:VUE 的Slot
Liskov Substitution Principle(LSP):里氏替换原则
所有基类出现的地方都可以用派生类替换而不会让程序产生错误,派生类可以扩展基类的功能,但不能改变基类原有的功能。
简单来说就是子类可以扩展父类的功能,但不能改变父类原有的功能。子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
根据上述理解,对里氏替换原则的定义可以总结如下:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
- 子类中可以增加自己特有的方法
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等
假如我们有一个接口IPerson
,一个父类Person implete IPerson
, 一个子类Coder extends Person
。
那么在父类中存在这样的方法
{
//参数是Person类型,而返回值是IPerson类型
public getPerson(Person:person):IPerson {
retrun person;
}
}
那么它的子类Coder
这样写才比较合理
{
//参数是IPerson类型,而返回值是Person类型
public getPerson(IPerson:person):Person {
retrun person as Person;
}
}
另外,里氏替换原则也指导我们如何去建立父类和子类关系。 例如,企鹅、鸵鸟和几维鸟从生物学的角度来划分,它们属于鸟类;
但从类的继承关系来看,由于它们不能继承“鸟”会飞的功能,所以它们不能定义成“鸟”的子类。同样,由于“玩具鱼”不会游泳,所以不能定义成“鱼”的子类;“玩具炮”炸不了敌人,所以不能定义成“炮”的子类等。
Interface Segregation Principle(ISP):接口隔离原则
一个接口应该拥有尽可能少的行为,使其精简单一。对于不同的功能的模块分别使用不同接口,而不是使用同一个通用的接口。
接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:
- 单一职责原则注重的是职责,而接口隔离原则注重的是对接口依赖的隔离。
- 单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。
Dependence Inversion Principle(DIP):依赖注入原则
依赖注入经常伴随另一个概念,控制反转。这两个概念在Spring中非常流行,因为它们是Spring的起家之本。关系上是,依赖注入,是实现控制反转的手段。
理解依赖注入,字面意思,第一是要有依赖,第二是注入。
比如这么一段代码:
class person{
playGame(){
let phone = new Phone();
phone.game()
}
}
这里产生了依赖,但是存在问题。对外部来讲,我们不知道依赖关系,并且不i利于我们写单元测试
代码修改为
class person{
playGame(iPhone:phobe){
phone.game()
}
}
我们在添加了参数,这个过程就叫依赖注入。依赖注入有三种,构造函数,function,setter访问器。
注意,我们这里的依赖,写的是接口,而不是具体的实现。