概述
本世纪初,美国计算机专家和作者 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
? <div>
<button ref="minus"
onClick={this.onMinus.bind(this)}> - </button>
<span ref="val">{this.state.value}</span>
<button ref="plus"
onClick={this.onPlus.bind(this)}> + </button>
</div>
: "无货"
);
}
onMinus() {
this.setState({
value: this.state.value - 1
});
}
onPlus() {
this.setState({
value: this.state.value + 1
});
}
}
ReactDOM.render(
<ProductsStepper onhand={1} />,
document.getElementById('root')
);
同样是一个步进器的例子,这里想在库存为 0 时做出提示,但是逻辑和增减数字糅杂在了一起;如果想在项目中其他地方只想复用一个数字步进器,就要额外捎上很多其他不相关的业务逻辑,这显然是不合理的。
解决的方法同样是提取成各司其职的单独组件,比如可以借助高阶组件(HOC)的形式:
class Stepper extends React.Component {
constructor(props) {
super(props);
this.state = {
value: 0
};
}
render() {
return (
<div>
<button ref="minus"
onClick={this.onMinus.bind(this)}> - </button>
<span ref="val">{this.state.value}</span>
<button ref="plus"
onClick={this.onPlus.bind(this)}> + </button>
</div>
);
}
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 <StepperComp />;
} else {
return "无货";
}
}
};
const ProductsStepper2 = HOC(Stepper);
ReactDOM.render(
<ProductsStepper2 onhand={1} />,
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); //10
class 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); //10
class 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 <div onClick={this._onClick.bind(this)}>
{this.props.hour}:{this.props.minute}
{this.props.second
? ':' + this.props.second
: null}
</div>;
}
_onClick() {
if (this.props.onClick) {
this.props.onClick(this.props.hour)
}
}
}
ReactDOM.render(
<Clock hour={20} minute={33} />,
document.querySelector('.root')
);
ReactDOM.render(
<Clock hour={18} minute={23} second={50} />,
document.querySelector('.root2')
);
ReactDOM.render(
<Clock hour={10} minute={15}
onClick={hour=>alert("hour is "+hour)} />,
document.querySelector('.root3')
);
只需要 hour 和 minute,一个最基本的时钟就能显示出来;而是否显示秒数、是否在点击时响应等,就都归为可选的接口了。
依赖倒转(Dependency inversion)
依赖抽象,而不是依赖具体的实现
解释起来就是,一个特定的类不应该直接依赖于另外一个类,但是可以依赖于这个类的抽象(接口)。
这和同样闻名已久的 “控制反转(Inversion of Controls)” 概念其实是一回事。
一个例子,渲染传入的列表而不负责构建具体的项目:
const Team = ({name,points})=>(
<li>{name}'s points is {points}</li>
);
const List1 = ({data})=>(
<ul>{
data.map(team=>(
<Team key={team.name}
name={team.name} points={team.points} />
))
}</ul>
);
ReactDOM.render(
<List1 data={[
{name:"广州队",points:15},
{name:"武汉队",points:40},
{name:"新疆队",points:30}
]} />,
document.getElementById('root')
);
看起来问题不大甚至一切正常,不过如果有另一个页面也使用 List1 组件时,希望使用另一种增强版的列表项,就要去改列表的具体实现甚至再弄一个另外的列表出来了。
const TeamWithLevel = ({name,points})=>(
<li>⚽️ {name} - {points > 30
? <strong>{ points }</strong>
: points > 20
? <em>{ points }</em>
: points }</li>
);
const List1 = ({data})=>(
<ul>{
data.map(team=>(
//???
))
}</ul>
);
此处用“依赖倒转”原则来处理的话,可以解开两个“依赖具体而非抽象”的点,分别是列表项的组件类型以及列表项上的属性。
const List2 = ({data, ItemComp})=>(
<ul>{
data.map(team=>(
<ItemComp key={team.name}
{...team} />
))
}</ul>
);
ReactDOM.render(
<List2
data={[
{name:"河北队",points:20},
{name:"福建队",points:30},
{name:"香港队",points:40}
]}
ItemComp={TeamWithLevel}
/>,
document.getElementById('root2')
);
如此一来,<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
(end)
长按二维码或搜索 fewelife 关注我们哦
