前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[译] 更可靠的 React 组件:单一职责原则

[译] 更可靠的 React 组件:单一职责原则

作者头像
江米小枣
发布2020-06-15 22:44:27
1.2K0
发布2020-06-15 22:44:27
举报
文章被收录于专栏:云前端

原文摘自:https://dmitripavlutin.com/7-architectural-attributes-of-a-reliable-react-component/#6testableandtested

当只有唯一的原因能改变一个组件时,该组件就是“单一职责”的

单一职责原则(SRP - single responsibility principle)是编写 React 组件时的基础原则。

所谓职责可能指的是渲染一个列表、显示一个时间选择器、发起一次 HTTP 请求、描绘一幅图表,或是懒加载一个图片等等。组件应该只选择一个职责去实现。当修改组件所实现的唯一职责时(如对所渲染列表中的项目数量做出限制时),组件就会因此改变。

为何“只有一个改变的原因”如此重要呢?因为这样组件的修改就被隔离开来,变得可控了。

单一职责限制了组件的体积,也使其聚焦于一件事。这有利于编码,也方便了之后的修改、重用和测试。

举几个例子看看。

例子1:一个请求远端数据并做出处理的组件,其唯一的改变原因就是请求逻辑发送变化了,包括:

  • 服务器 URL 被修改了
  • 响应数据的格式被修改了
  • 换了一种 HTTP 请求库
  • 其他只关系到请求逻辑的改动

例子2:一个映射了由若干行组件形成的数组的表格组件,引起其改变的唯一原因是映射逻辑的改变:

  • 有一个限制最多渲染行数的需求,比如 25 行
  • 没有行可渲染的时候,需要给出文字提示
  • 其他只关系到数组和组件之间映射的改变

你的组件是否有多个职责呢?如果答案是肯定的话,就应将其分割成若干单一职责的组件。

在项目发布之前,早期阶段编写的代码单元会频繁的修改。这些组件要能够被轻易的隔离并修改 -- 这正是 SRP 的题中之意。

1. 多个职责的陷阱

一个组件有多个职责的情况经常被忽视,乍看起来,这并无不妥且容易理解:

  • 撸个袖子就写起了代码:不用区分去各种职责,也不用规划相应的结构
  • 形成了一个大杂烩的组件
  • 不用为相互分隔的组件间的通信创建 props 和回调函数

这种天真烂漫的结构在编码之处非常简单。当应用不断增长并变得越来越复杂,需要对组件修改的时候,麻烦就会出现。

有很多理由去改变一个同时担负了多个职责的组件;那么主要的问题就会浮现:因为一个原因去改变组件,很可能会误伤其他的职责。

这样的设计是脆弱的。无意间带来的副作用极难预知和控制。

举个例子,<ChartAndForm> 负责绘制图表,同时还负责处理为图表提供数据的表单。那么 <ChartAndForm> 就有了两个改变的原因:绘图和表单。

当改变表单域的时候(如将 <input> 改为 <select>),就有可能无意间破坏了图表的渲染。此外图表的实现也无法复用,因为它耦合了表单的细节。

要解决掉多职责的问题,需要将<ChartAndForm> 分割成 <Chart><Form>两个组件。分别负责单一的职责:绘制图表或相应的处理表单。两个组件之间的通信通过 props 完成。

多职责问题的极端情况被称为“反模式的上帝组件”。一个上帝组件恨不得要知道应用中的所有事情,通常你会见到这种组件被命名为<Application><Manager><BigContainer>或是<Page>,并有超过 500 行的代码。

对于上帝组件,应通过拆分和组合使其符合 SRP。

2. 案例学习:让组件具有单一职责

想象有这样一个组件,其向指定的服务器发送一个 HTTP 请求以查询当前天气。当请求成功后,同样由该组件使用响应中的数据显示出天气状况。

代码语言:javascript
复制
import axios from 'axios';  

// 问题:一个组件具有多个职责
class Weather extends Component {  
   constructor(props) {
     super(props);
     this.state = { temperature: 'N/A', windSpeed: 'N/A' };
   }

   render() {
     const { temperature, windSpeed } = this.state;
     return (
       <div className="weather">
         <div>Temperature: {temperature}°C</div>
         <div>Wind: {windSpeed}km/h</div>
       </div>
     );
   }

   componentDidMount() {
     axios.get('http://weather.com/api').then(function(response) {
       const { current } = response.data; 
       this.setState({
         temperature: current.temperature,
         windSpeed: current.windSpeed
       })
     });
   }
}

每当处理此类问题时,问一下自己:我是不是得把组件分割成更小的块呢?决定组件如何根据其职责发生改变,就能为以上问题提供最好的答案。

这个天气组件有两个原因去改变:

  • componentDidMount() 中的请求逻辑:服务端 URL 或响应格式可能会被修改
  • render() 中的天气可视化形式:组件显示天气的方式可能会改变很多次

解决之道是将 <Weather> 分割成两个组件,其中每个都有自己的唯一职责。将其分别命名为 <WeatherFetch><WeatherInfo>

第一个组件 <WeatherFetch> 负责获取天气、提取响应数据并将之存入 state。只有 fetch 逻辑会导致其改变:

代码语言:javascript
复制
import axios from 'axios';  

// 解决方案:组件只负责远程请求
class WeatherFetch extends Component {  
   constructor(props) {
     super(props);
     this.state = { temperature: 'N/A', windSpeed: 'N/A' };
   }

   render() {
     const { temperature, windSpeed } = this.state;
     return (
       <WeatherInfo temperature={temperature} windSpeed={windSpeed} />
     );
   }

   componentDidMount() {
     axios.get('http://weather.com/api').then(function(response) {
       const { current } = response.data; 
       this.setState({
         temperature: current.temperature,
         windSpeed: current.windSpeed
       });
     });
   }
}

这种结果带来了什么好处呢?

举例来说,你可能会喜欢用 async/await 语法取代 promise 来处理服务器响应。这就是一种造成 fetch 逻辑改变的原因:

代码语言:javascript
复制
// 改变的原因:用 async/await 语法
class WeatherFetch extends Component {  
   // ..... //
   async componentDidMount() {
     const response = await axios.get('http://weather.com/api');
     const { current } = response.data; 
     this.setState({
       temperature: current.temperature,
       windSpeed: current.windSpeed
     });
   }
}

因为 <WeatherFetch> 只会因为 fetch 逻辑而改变,所以对其的任何修改都不会影响其他的事情。用 async/await 就不会直接影响天气显示的方式。

<WeatherFetch> 渲染了 <WeatherInfo>,后者只负责显示天气,只有视觉方面的理由会造成改变:

代码语言:javascript
复制
// 解决方案:组件职责只是显示天气
function WeatherInfo({ temperature, windSpeed }) {  
  return (
    <div className="weather">
      <div>Temperature: {temperature}°C</div>
      <div>Wind: {windSpeed} km/h</div>
    </div>
  );
}

<WeatherInfo> 中的 "Wind: 0 km/h" 改为显示 "Wind: calm":

代码语言:javascript
复制
// Reason to change: handle calm wind  
function WeatherInfo({ temperature, windSpeed }) {  
  const windInfo = windSpeed === 0 ? 'calm' : `${windSpeed} km/h`;
  return (
    <div className="weather">
      <div>Temperature: {temperature}°C</div>
      <div>Wind: {windInfo}</div>
    </div>
  );
}

同样,对 <WeatherInfo> 的这项改变是独立的,不会影响到 <WeatherFetch>

<WeatherFetch><WeatherInfo> 各司其职。每个组件的改变对其他的组件微乎其微。这就是单一职责原则的强大之处:修改被隔离开,从而对系统中其他组件的影响是微小而可预期的

3. 案例学习:HOC 风格的单一职责原则

将分割后的组件按照职责组合在一起并不总是能符合单一职责原则。另一种被称作高阶组件(HOC - Higher order component)的有效方式可能会更适合:

HOC 就是一个以某组件作为参数并返回一个新组件的函数

HOC 的一个常见用途是为被包裹的组件添加额外的 props 或修改既有的 props。这项技术被称为属性代理(props proxy)

代码语言:javascript
复制
function withNewFunctionality(WrappedComponent) {  
 return class NewFunctionality extends Component {
   render() {
     const newProp = 'Value';
     const propsProxy = {
        ...this.props,
        // Alter existing prop:
        ownProp: this.props.ownProp + ' was modified',
        // Add new prop:
        newProp
     };
     return <WrappedComponent {...propsProxy} />;
   }
 }
}
const MyNewComponent = withNewFunctionality(MyComponent);  

甚至可以通过替换被包裹组件渲染的元素来形成新的 render 机制。这种 HOC 技术被称为渲染劫持(render highjacking)

代码语言:javascript
复制
function withModifiedChildren(WrappedComponent) {  
 return class ModifiedChildren extends WrappedComponent {
   render() {
     const rootElement = super.render();
     const newChildren = [
       ...rootElement.props.children,
       <div>New child</div> //插入新 child
     ];
     return cloneElement(
       rootElement,
       rootElement.props,
       newChildren
     );
   }
 }
}
const MyNewComponent = withModifiedChildren(MyComponent);  

如果想深入学习 HOC,可以阅读文末推荐的文章。

下面跟随一个实例来看看 HOC 的属性代理技术如何帮助我们实现单一职责。

<PersistentForm> 组件由一个输入框 input 和一个负责保存到存储的 button 组成。输入框的值被读取并存储到本地。

代码语言:javascript
复制
<div id="root"></div>
代码语言:javascript
复制
class PersistentForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = { inputValue: localStorage.getItem('inputValue') };
    this.handleChange = this.handleChange.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }

  render() {
    const { inputValue } = this.state;
    return (
      <div>
        <input type="text" value={inputValue} 
          onChange={this.handleChange}/> 
        <button onClick={this.handleClick}>Save to storage</button>
      </div>
    )
  }

  handleChange(event) {
    this.setState({
      inputValue: event.target.value
    });
  }

  handleClick() {
    localStorage.setItem('inputValue', this.state.inputValue);
  }
}

ReactDOM.render(<PersistentForm />, document.getElementById('root'));

当 input 变化时,在 handleChange(event) 中更新了组件的 state;当 button 点击时,在 handleClick() 中将上述值存入本地存储。

糟糕的是 <PersistentForm> 同时有两个职责:管理表单数据并将 input 值存入本地。

<PersistentForm> 似乎不应该具有第二个职责,即不应关心如何直接操作本地存储。那么按此思路先将组件优化成单一职责:渲染表单域,并附带事件处理函数。

代码语言:javascript
复制
class PersistentForm extends Component {  
  constructor(props) {
    super(props);
    this.state = { inputValue: props.initialValue };
    this.handleChange = this.handleChange.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }

  render() {
    const { inputValue } = this.state;
    return (
      <div className="persistent-form">
        <input type="text" value={inputValue} 
          onChange={this.handleChange}/> 
        <button onClick={this.handleClick}>Save to storage</button>
      </div>
    );
  }

  handleChange(event) {
    this.setState({
      inputValue: event.target.value
    });
  }

  handleClick() {
    this.props.saveValue(this.state.inputValue);
  }
}

组件从属性中接受 input 初始值 initialValue,并通过同样从属性中传入的 saveValue(newValue) 函数存储 input 的值;而这两个属性,是由叫做 withPersistence() 的属性代理 HOC 提供的。

现在 <PersistentForm> 符合 SRP 了。表单的更改称为了唯一导致其变化的原因。

查询和存入本地存储的职责被转移到了 withPersistence() HOC 中:

代码语言:javascript
复制
function withPersistence(storageKey, storage) {  
  return function(WrappedComponent) {
    return class PersistentComponent extends Component {
      constructor(props) {
        super(props);
        this.state = { initialValue: storage.getItem(storageKey) };
      }

      render() {
         return (
           <WrappedComponent
             initialValue={this.state.initialValue}
             saveValue={this.saveValue}
             {...this.props}
           />
         );
      }

      saveValue(value) {
        storage.setItem(storageKey, value);
      }
    }
  }
}

withPersistence() 是一个负责持久化的 HOC;它并不知道表单的任何细节,而是只聚焦于一项工作:为被包裹的组件提供 initialValue 字符串和 saveValue() 函数。

<PersistentForm> 和 withPersistence() 连接到一起就创建了一个新组件 <LocalStoragePersistentForm>:

代码语言:javascript
复制
const LocalStoragePersistentForm  
  = withPersistence('key', localStorage)(PersistentForm);

const instance = <LocalStoragePersistentForm />; 

只要 <PersistentForm> 正确使用 initialValue 和 saveValue() 两个属性,则对自身的任何修改都无法破坏被 withPersistence() 持有的本地存储相关逻辑,反之亦然。

这再次印证了 SRP 的功效:使修改彼此隔离,对系统中其余部分造成的影响很小。

此外,代码的可重用性也增强了。换成其他 <MyOtherForm> 组件,也能实现持久化逻辑了:

代码语言:javascript
复制
const LocalStorageMyOtherForm  
  = withPersistence('key', localStorage)(MyOtherForm);

const instance = <LocalStorageMyOtherForm />;   

也可以轻易将存储方式改为 sessionStorage:

代码语言:javascript
复制
const SessionStoragePersistentForm  
  = withPersistence('key', sessionStorage)(PersistentForm);

const instance = <SessionStoragePersistentForm />;  

对修改的隔离以及可重用性遍历,在初始版本的多职责 <PersistentForm> 组件中都是不存在的。

在组合无法生效的情景下,HOC 属性代理和渲染劫持技术往往能帮助组件实现单一职责。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2018-06-15,如有侵权请联系 cloudcommunity@tencent.com 删除

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 多个职责的陷阱
  • 2. 案例学习:让组件具有单一职责
  • 3. 案例学习:HOC 风格的单一职责原则
相关产品与服务
文件存储
文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档