概述
本世紀初,美國計算機專家和作者 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
長按二維碼或搜尋 fewelife 關注我們哦