概述
本世纪初,美国计算机专家和作者 Robert Cecil Martin 针对 OOP 编程,提出了可以很好配合的五个独立模式;后由重构等领域的专家 Michael Feathers 根据其首字母组合成 SOLID 模式,并逐渐广为人知,直至成为了公认的 OOP 开发的基础准则。
S
– Single Responsibility Principle 单一职责原则O
– Open/Closed Principle 开放/封闭原则L
– Liskov Substitution Principle 里氏替换原则I
– Interface Segregation Principle 接口隔离原则D
– Dependency Inversion Principle 依赖倒转原则
作为一门弱类型并在函数式和面向对象之间左右摇摆的语言,JavaScript 中的 SOLID 原则与在 Java 或 C# 这样的语言中还是有所不同的;不过 SOLID 作为软件开发领域通用的原则,在 JavaScript 也还是能得到很好的应用。
React 应用就是由各种 React Component 组成的,本质上都是继承自 React.Component
的子类,也可以靠继承或包裹实现灵活的扩展。虽然不应生硬的套用概念,但在 React 开发过程中延用并遵守既有的 SOLID 原则,能让我们创建出更可靠、更易复用,以及更易扩展的组件。
注:文中各定义中提到的“模块”,换做“类”、“函数”或是“组件”,都是一样的意义。
单一职责(Single responsibility)
每个模块应该只专注于做一件事情
该原则意味着,如果承担的职责多于一个,那么代码就具有高度的耦合性,以至其难以被理解,扩展和修改。
在 OOP 中,如果一个类承担了过多职责,一般的做法就是将其拆解为不同的类:
class CashStepper { constructor() { this.num = 0; } plus() { this.num++; } minus() { this.num--; } checkIfOverage() { if (this.num > 3) { console.log('超额了'); } else { console.log('数额正常'); } }}const cs = new CashStepper;cs.plus();cs.plus();cs.plus();cs.plus();cs.checkIfOverage();复制代码
很明显,原先这个类既要承担步进器的功能,又要关心现金是否超额,管的事情太多了。
应将其不同的职责提取为单独的类,如下:
class Stepper { constructor() { this.num = 0; } plus() { this.num++; } minus() { this.num--; }}class CashOverageChecker { check(stepper) { if (stepper.num > 3) { console.log('超额了'); } else { console.log('数额正常'); } }}const s = new Stepper;s.plus();s.plus();s.plus();s.minus();s.plus();console.log('num is', s.num);const chk = new CashOverageChecker;chk.check(s);复制代码
如此就使得每个组件可复用,且修改某种逻辑时不影响其他逻辑。
而在 React 中,也是类似的做法,应尽可能将组件提取为可复用的最小单位:
class ProductsStepper extends React.Component { constructor(props) { super(props); this.state = { value: 0 }; } render() { return ( this.props.onhand > 0 ?{this.state.value}: "无货" ); } onMinus() { this.setState({ value: this.state.value - 1 }); } onPlus() { this.setState({ value: this.state.value + 1 }); }}ReactDOM.render(, document.getElementById('root'));复制代码
同样是一个步进器的例子,这里想在库存为 0 时做出提示,但是逻辑和增减数字糅杂在了一起;如果想在项目中其他地方只想复用一个数字步进器,就要额外捎上很多其他不相关的业务逻辑,这显然是不合理的。
解决的方法同样是提取成各司其职的单独组件,比如可以借助高阶组件(HOC)的形式:
class Stepper extends React.Component { constructor(props) { super(props); this.state = { value: 0 }; } render() { return ({this.state.value}); } onMinus() { this.setState({ value: this.state.value - 1 }); } onPlus() { this.setState({ value: this.state.value + 1 }); }}const HOC = (StepperComp)=>{ return (props)=>{ if (props.onhand > 0) { return; } else { return "无货"; } }};const ProductsStepper2 = HOC(Stepper);ReactDOM.render( , document.getElementById('root2'));复制代码
这样,项目中其他地方就可以直接复用 Stepper,或者借助不同的 HOC 扩展其功能了。
关于 HOC 的更多细节可以关注文章结尾公众号中的其他文章。
“单一职责”原则类似于 Unix 中提倡的 “Do one thing and do it well” ,理解起来容易,但做好不一定简单。
从经验上来讲,这条原则可以说是五大原则中最重要的一个;理解并遵循好该原则一般就可以解决大部分的问题。
开放/封闭(Open/closed)
模块应该对扩展开放,而对修改关闭
换句话说,如果某人要扩展你的模块,应该可以在不修改模块本身源代码的前提下进行。
例如:
let iceCreamFlavors=["巧克力","香草"];let iceCreamMaker={ makeIceCream (flavor) { if(iceCreamFlavors.indexOf(flavor)>-1){ console.log(`给你${flavor}口味的冰淇淋~`) }else{ console.log("没有!") } }};export default iceCreamMaker;复制代码
对于这个模块,如果想定义并取得新的口味,显然无法在不修改源代码的情况下完成;可改为如下形式:
let iceCreamFlavors=["巧克力","香草"];let iceCreamMaker={ makeIceCream (flavor) { if(iceCreamFlavors.indexOf(flavor)>-1){ console.log(`给你${flavor}口味的冰淇淋~`) }else{ console.log("没有!") } }, addFlavor(flavor){ iceCreamFlavors.push(flavor); }};export default iceCreamMaker;复制代码
通过增加 addFlaver() 方法重新定义此模块,就满足了“开放/封闭”原则,在外界需要扩展时(增加新口味)并不用修改原来的内部实现。
具体到 React 来说,提倡通过不同组件间的嵌套实现聚合的行为,这会在一定程度上防止频繁对已有组件的直接修改。自己定义的组件也应该谨记这一原则,比如在一个 <RedButton>
里包裹 <Button>
,并通过修改 props 来实现扩展按钮颜色的功能,而非直接找到 Button 的源码并增加颜色逻辑。
另外,“单一职责”中的两个例子也可以很好地解释“开放/封闭”原则,职责单一的情况下,通过继承或包裹就可以扩展新功能;反之就还要回到原模块的源代码中修修补补,让局势更混乱。
君子纳于言而敏于行,模块纳于改代码而敏于扩展。
里氏替换(Liskov substitution)
程序中的对象都应该能够被各自的子类实例替换,而不会影响到程序的行为
作为五大原则里唯一以人名命名的,其实是直接引用了更厉害的两位大姐大的成果:
类的继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
里氏替换原则通俗的来讲就是:子类对象能够替换其基类对象被使用;引申开来就是 子类可以扩展父类的功能,但不能改变父类原有的功能。
用于解释这个原则的经典例子就是长方形和正方形:
class Rectangle { set width(w) { this._w = w; } set height(h) { this._h = h; } get area() { return this._w * this._h; }}const r = new Rectangle;r.width = 2;r.height = 5;console.log(r.area); //10class Square extends Rectangle { set width(w) { this._w = this._h = w; } set height(h) { this._w = this._h = h; }}const s = new Square;s.width = 2;s.height = 5;console.log(s.area); //25复制代码
对于正方形的设置,到底以宽还是高为准,上面的代码就产生了歧义;并且关键在于,如果基于现有的 API(允许分别设置宽高)有一个 “设置宽2高5就能得到面积10” 的假设,则正方形子类就无法实现该假设,而这样的实现就是违背里氏替换原则的不良实践。
一种可行的更改方案为:
class Rectangle2 { constructor(width, height) { this._w = width; this._h = height; } get area() { return this._w * this._h; }}const r2 = new Rectangle2(2, 5);console.log(r2.area); //10class Square2 extends Rectangle2 { constructor(side) { super(side, side); }}const s2 = new Square2(5);console.log(s2.area); //25复制代码
通过重写父类的方法来完成新的功能,写起来虽然简单,但是整个继承体系的可复用性会比较差。
在 React 中,大部分时候是靠父子元素正常的组合嵌套来工作,而非继承,天然的就有了无法修改被包裹组件细节的一定保障;组件间互相的接口就是 props,通过向下传递增强或修改过的 props 来实现通信。这里关于里氏替换原则的意义很好理解,比如类似 <RedButton>
的组件,除了扩展样式外不会破坏且应遵循被包裹的 <Button>
的点击功能。
再举一个直观点的例子就是:如果一个地方放置了一个 Modal 弹窗,且该弹窗右上角有一个可以关闭的 [close] 按钮;那么无论以后在同样的位置替换 Modal 的子类或是用 Modal 包裹组合出来的组件,即便不再有 [close] 按钮,也要提供点击蒙版层、ESC 快捷键等方式保证能够关闭,这样才能履行 “能弹出弹窗且能自主关闭” 的原有契约,满足必要的使用流程。
接口隔离(Interface segregation)
多个专用的接口比一个通用接口好
在一些 OOP 语言中,接口被用来描述类必须实现的一些功能。原生 JS 中是没有这码事的,这里用 TypeScript 来举例说明:
interface IClock { currentTime: Date; setTime(d: Date);}interface IAlertClock { alertWhenPast: Function }class Clock implements IClock, IAlertClock { currentTime: Date; setTime(d: Date) { this.currentTime = d; console.log(this.currentTime); } alertWhenPast() { if ( this.currentTime <= Date.now() ) { console.log('time has pasted!'); } } constructor() { }}const c = new Clock;c.setTime( Date.now() - 2000 );c.alertWhenPast();// 1527227168790// "time has pasted!"复制代码
一个时钟要能够 setTime,还要能够获得 currentTime,这些是核心功能,放在 IClock 接口中;只要实现了 IClock 接口,就是合法的时钟。
其他接口被认为是可选功能或增强包,根据需要分别实现,互不干扰;当然 TS 接口中有可选的语法,在此仅做概念演示,不展开说明。
而 React 类似中的做法是靠 PropTypes 的必选/可选设定,以及搭配 DefaultProps 实现的。
class Clock extends React.Component { static propTypes = { hour: PropTypes.number.isRequired, minute: PropTypes.number.isRequired, second: PropTypes.number, onClick: PropTypes.func }; static defaultProps = { onClick: null }; constructor(props) { super(props); } render() { return{this.props.hour}:{this.props.minute} {this.props.second ? ':' + this.props.second : null}; } _onClick() { if (this.props.onClick) { this.props.onClick(this.props.hour) } }}ReactDOM.render(, document.querySelector('.root'));ReactDOM.render( , document.querySelector('.root2'));ReactDOM.render( alert("hour is "+hour)} />, document.querySelector('.root3'));复制代码
只需要 hour 和 minute,一个最基本的时钟就能显示出来;而是否显示秒数、是否在点击时响应等,就都归为可选的接口了。
依赖倒转(Dependency inversion)
依赖抽象,而不是依赖具体的实现
解释起来就是,一个特定的类不应该直接依赖于另外一个类,但是可以依赖于这个类的抽象(接口)。
这和同样闻名已久的 “控制反转(Inversion of Controls)” 概念其实是一回事。
一个例子,渲染传入的列表而不负责构建具体的项目:
const Team = ({name,points})=>(
- { data.map(team=>(
看起来问题不大甚至一切正常,不过如果有另一个页面也使用 List1 组件时,希望使用另一种增强版的列表项,就要去改列表的具体实现甚至再弄一个另外的列表出来了。
const TeamWithLevel = ({name,points})=>(
- { data.map(team=>( //??? )) }
此处用“依赖倒转”原则来处理的话,可以解开两个“依赖具体而非抽象”的点,分别是列表项的组件类型以及列表项上的属性。
const List2 = ({data, ItemComp})=>(
- { data.map(team=>(
如此一来,<List2>
就成了可以真正通用在各种页面的一个较通用的组件了;比如电商场景的已选货品列表、后台管理报表筛选项等场景,都是高度适用此方案的。
总结
面向对象思想在 UI 层面的自然延伸,就是各种界面组件;用 SOLID 指导其开发同样稳妥,会让组件更健壮可靠,并拥有更好的可扩展性。
和设计模式一样,这些“原则”也都是一些“经验法则”(rules of thumb),且几个原则互为关联、相辅相成,并非完全独立的。
简单的说:照着这些原则来,代码就会更好;而对于一些习以为常的做法,不遵循 SOLID 原则 -- 写出的代码出问题的几率将会大大增加。
参考资料
- https://dev.to/kayis/is-react-solid-630
- https://blog.csdn.net/zhengzhb/article/details/7281833
- https://github.com/xitu/gold-miner/blob/master/TODO/solid-principles-the-definitive-guide.md
- http://www.infoq.com/cn/news/2014/01/solid-principles-javascript
- https://www.guokr.com/article/439742/
- https://baike.baidu.com/item/Barbara%20Liskov
- https://www.csdn.net/article/2011-03-07/293173
- https://thefullstack.xyz/solid-javascript/
- https://en.wikipedia.org/wiki/Robert_C._Martin#cite_note-3
- https://softwareengineering.stackexchange.com/questions/170138/is-this-a-violation-of-the-liskov-substitution-principle
- https://medium.com/@samueleresca/solid-principles-using-typescript-adb76baf5e7c
长按二维码或搜索 fewelife 关注我们哦