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

2,416 阅读9分钟

原文摘自: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 和回调函数

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

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

The pitfall of multiple responsibilities

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

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

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

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

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

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

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

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

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 逻辑会导致其改变:

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 逻辑改变的原因:

// 改变的原因:用 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>,后者只负责显示天气,只有视觉方面的理由会造成改变:

// 解决方案:组件职责只是显示天气
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":

// 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)

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)

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 组成。输入框的值被读取并存储到本地。

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

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 中:

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>:

const LocalStoragePersistentForm  
  = withPersistence('key', localStorage)(PersistentForm);

const instance = <LocalStoragePersistentForm />;  

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

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

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

const LocalStorageMyOtherForm  
  = withPersistence('key', localStorage)(MyOtherForm);

const instance = <LocalStorageMyOtherForm />;  

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

const SessionStoragePersistentForm  
  = withPersistence('key', sessionStorage)(PersistentForm);

const instance = <SessionStoragePersistentForm />;  

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

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

扩展阅读:


(end)


----------------------------------------

转载请注明出处


长按二维码或搜索 fewelife 关注我们哦