用 SOLID 原則保駕 React 元件開發

江米小棗tonylua發表於2018-05-25

概述

本世紀初,美國計算機專家和作者 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 依賴倒轉原則
用 SOLID 原則保駕 React 元件開發 人稱 Uncle Bob 的 Robert Cecil Martin
用 SOLID 原則保駕 React 元件開發 又是一位大叔 Michael Feathers,實際上本次中老年專場還不止於此

作為一門弱型別並在函式式和麵向物件之間左右搖擺的語言,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)

程式中的物件都應該能夠被各自的子類例項替換,而不會影響到程式的行為

作為五大原則裡唯一以人名命名的,其實是直接引用了更厲害的兩位大姐大的成果:

用 SOLID 原則保駕 React 元件開發 芭芭拉·利斯科夫(Barbara Liskov),圖靈獎得主、約翰·馮諾依曼獎得主,於 1987 年提出里氏替換理論的設想
用 SOLID 原則保駕 React 元件開發 微軟全球資深副總裁周以真(Jeannette M. Wing)博士,在 1994 年與 Liskov 一起發表了里氏替換原則

類的繼承包含這樣一層含義:父類中凡是已經實現好的方法(相對於抽象方法而言),實際上是在設定一系列的規範和契約,雖然它不強制要求所有的子類必須遵從這些契約,但是如果子類對這些非抽象方法任意修改,就會對整個繼承體系造成破壞。而里氏替換原則就是表達了這一層含義。

里氏替換原則通俗的來講就是:子類物件能夠替換其基類物件被使用;引申開來就是 子類可以擴充套件父類的功能,但不能改變父類原有的功能

"龍生龍,鳳生鳳,傑瑞的兒子會打洞"
用 SOLID 原則保駕 React 元件開發 關我毛事...

用於解釋這個原則的經典例子就是長方形和正方形:

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 關注我們哦

用 SOLID 原則保駕 React 元件開發

相關文章