前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用 SOLID 原则保驾 React 组件开发

用 SOLID 原则保驾 React 组件开发

作者头像
江米小枣
发布2020-06-16 10:24:00
8090
发布2020-06-16 10:24:00
举报
文章被收录于专栏:云前端

概述

本世纪初,美国计算机专家和作者 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 依赖倒转原则
人称Uncle Bob的 Robert Cecil Martin
人称Uncle Bob的 Robert Cecil Martin
又是一位大叔 Michael Feathers,实际上本次中老年专场还不止于此
又是一位大叔 Michael Feathers,实际上本次中老年专场还不止于此

作为一门弱类型并在函数式和面向对象之间左右摇摆的语言,JavaScript 中的 SOLID 原则与在 Java 或 C# 这样的语言中还是有所不同的;不过 SOLID 作为软件开发领域通用的原则,在 JavaScript 也还是能得到很好的应用。

React 应用就是由各种 React Component 组成的,本质上都是继承自 React.Component 的子类,也可以靠继承或包裹实现灵活的扩展。虽然不应生硬的套用概念,但在 React 开发过程中延用并遵守既有的 SOLID 原则,能让我们创建出更可靠、更易复用,以及更易扩展的组件。

注:文中各定义中提到的“模块”,换做“类”、“函数”或是“组件”,都是一样的意义。

单一职责(Single responsibility)

每个模块应该只专注于做一件事情

该原则意味着,如果承担的职责多于一个,那么代码就具有高度的耦合性,以至其难以被理解,扩展和修改。

在 OOP 中,如果一个类承担了过多职责,一般的做法就是将其拆解为不同的类:

代码语言:javascript
复制
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();

很明显,原先这个类既要承担步进器的功能,又要关心现金是否超额,管的事情太多了。

应将其不同的职责提取为单独的类,如下:

代码语言:javascript
复制
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 中,也是类似的做法,应尽可能将组件提取为可复用的最小单位:

代码语言:javascript
复制
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)的形式:

代码语言:javascript
复制
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)

模块应该对扩展开放,而对修改关闭

换句话说,如果某人要扩展你的模块,应该可以在不修改模块本身源代码的前提下进行。

例如:

代码语言:javascript
复制
let iceCreamFlavors=["巧克力","香草"];
let iceCreamMaker={
makeIceCream (flavor) {
 if(iceCreamFlavors.indexOf(flavor)>-1){
  console.log(`给你${flavor}口味的冰淇淋~`)
 }else{
  console.log("没有!")
 }
}
};
export default iceCreamMaker;

对于这个模块,如果想定义并取得新的口味,显然无法在不修改源代码的情况下完成;可改为如下形式:

代码语言:javascript
复制
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)

程序中的对象都应该能够被各自的子类实例替换,而不会影响到程序的行为

作为五大原则里唯一以人名命名的,其实是直接引用了更厉害的两位大姐大的成果:

芭芭拉·利斯科夫(Barbara Liskov),图灵奖得主、约翰·冯诺依曼奖得主,于 1987 年提出里氏替换理论的设想
芭芭拉·利斯科夫(Barbara Liskov),图灵奖得主、约翰·冯诺依曼奖得主,于 1987 年提出里氏替换理论的设想
微软全球资深副总裁周以真(Jeannette M. Wing)博士,在 1994 年与 Liskov 一起发表了里氏替换原则
微软全球资深副总裁周以真(Jeannette M. Wing)博士,在 1994 年与 Liskov 一起发表了里氏替换原则

类的继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。

里氏替换原则通俗的来讲就是:子类对象能够替换其基类对象被使用;引申开来就是 子类可以扩展父类的功能,但不能改变父类原有的功能

"龙生龙,凤生凤,杰瑞的儿子会打洞"

用于解释这个原则的经典例子就是长方形和正方形:

代码语言:javascript
复制
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” 的假设,则正方形子类就无法实现该假设,而这样的实现就是违背里氏替换原则的不良实践。

一种可行的更改方案为:

代码语言:javascript
复制
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 来举例说明:

代码语言:javascript
复制
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 实现的。

代码语言:javascript
复制
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)” 概念其实是一回事。

一个例子,渲染传入的列表而不负责构建具体的项目:

代码语言:javascript
复制
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 组件时,希望使用另一种增强版的列表项,就要去改列表的具体实现甚至再弄一个另外的列表出来了。

代码语言:javascript
复制
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>
);

此处用“依赖倒转”原则来处理的话,可以解开两个“依赖具体而非抽象”的点,分别是列表项的组件类型以及列表项上的属性。

代码语言:javascript
复制
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/RobertC.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
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2018-06-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云前端 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 单一职责(Single responsibility)
  • 开放/封闭(Open/closed)
  • 里氏替换(Liskov substitution)
  • 接口隔离(Interface segregation)
  • 依赖倒转(Dependency inversion)
  • 总结
  • 参考资料
相关产品与服务
腾讯云 BI
腾讯云 BI(Business Intelligence,BI)提供从数据源接入、数据建模到数据可视化分析全流程的BI能力,帮助经营者快速获取决策数据依据。系统采用敏捷自助式设计,使用者仅需通过简单拖拽即可完成原本复杂的报表开发过程,并支持报表的分享、推送等企业协作场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档