美文网首页
18.揭秘 React 真谛:组件设计

18.揭秘 React 真谛:组件设计

作者: ikonan | 来源:发表于2021-06-10 13:55 被阅读0次

    组件不是 React 特有的概念,但是 React 将组件化的思想发扬光大,可谓用到了极致。良好的组件设计会是良好的应用开发基础,这一讲就让我们谈一谈组件设计的奥秘。

    相关知识点如下:


    我们将以 React 组件为例,但是其中的设计思想具有共性,不管是其他框架还是原生 Web component 都将适用。

    单一职责没那么简单

    单一职责我们并不陌生,原则上讲,组件只应该做一件事情。但是对于应用来说,全部组件都拆散,只有单一职责并没有必要,反而增加了编写的繁琐程度。那什么时候需要拆分组件,保证单一职责呢?我认为如果一个功能集合有可能发生变化,那么就需要最大程度地保证单一职责。

    单一职责带来的最大好处就是在修改组件时,能够做到全在掌控下,不必担心对其他组件造成影响。举个例子:我们的组件需要通过网络请求获取数据并展示数据内容,这样一来潜在的功能集合改变就有:

    • 请求 API 地址发生变化
    • 请求返回数据格式变化
    • 开发者想更换网络请求第三方库,比如 jQuery.ajax 改成 axios
    • 更改请求数据逻辑

    再看一个例子:我们需要一个 table 组件,渲染一个 list,那么潜在更改的可能有:

    • 限制一次性渲染的 item 个数(只渲染前 10 个,剩下的懒加载)
    • 当数据列表为空时显示 「This list is empty」
    • 任何渲染逻辑的更改

    这个图很好地说明了问题:


    我们来实际看一个场景:

    import axios from 'axios';
    
    class Weather extends Component {
      constructor(props) {
        super(props);
        this.state = { temperature: 'N/A', windSpeed: 'N/A' };
      }
    
      componentDidMount() {
        axios.get('http://weather.com/api').then((response) => {
          const { current } = response.data;
          this.setState({
            temperature: current.temperature,
            windSpeed: current.windSpeed,
          });
        });
      }
    
      render() {
        const { temperature, windSpeed } = this.state;
        return (
          <div classname="weather">
            <div>Temperature: {temperature} °C </div>
            <div>Wind: {windSpeed} km/h</div>
          </div>
        );
      }
    }
    

    这个组件很容易理解,并且看上去没什么大问题,但是并不符合单一职责。比如这个 Weather 组件将数据获取与渲染逻辑耦合在一起,如果数据请求有变化,就需要在 componentDidMount 生命周期中进行改动;如果展示天气的逻辑有变化,render 方法又需要变动。

    如果我们将这个组件拆分成:WeatherFetch 和 WeatherInfo 两个组件,这两个组件各自只做一件事情,保持单一职责:

    import axios from 'axios';
    import WeatherInfo from './WeatherInfo'
    class Weather extends Component {
      constructor(props) {
        super(props);
        this.state = { temperature: 'N/A', windSpeed: 'N/A' };
      }
    
      componentDidMount() {
        axios.get('http://weather.com/api').then((response) => {
          const { current } = response.data;
          this.setState({
            temperature: current.temperature,
            windSpeed: current.windSpeed,
          });
        });
      }
    
      render() {
        const { temperature, windSpeed } = this.state;
        return <WeatherInfo temperature={temperature} windspeed={windSpeed} />;
      }
    }
    

    另一个文件中:

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

    如果我们想进行重构,使用 async/await 代替 Promise,只需要直接更改 WeatherFetch 组件:

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

    而不会对 WeatherInfo 组件有任何影响。

    或者显示风速的逻辑从 Wind: 0 km/h 改为文字描述 Wind: 风平浪静,也只需要改动 WeatherInfo:

    const 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>
      );
    };
    

    这只是一个简单的例子,在真实项目中,保持组件的单一职责将会非常重要,甚至我们可以使用 HoC 强制组件的单一职责性。

    来思考这样的代码:

    class PersistentForm extends Component {
      constructor(props) {
        super(props);
        this.state = { inputValue: localStorage.getItem('inputValue') };
        this.handleChange = this.handleChange.bind(this);
        this.handleClick = this.handleClick.bind(this);
      }
    
      handleChange(event) {
        this.setState({
          inputValue: event.target.value,
        });
      }
    
      handleClick() {
        localStorage.setItem('inputValue', this.state.inputValue);
      }
    
      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>
        );
      }
    }
    

    这是一个持久化存储的表单,我们将表单字段内容存储在 localStorage 中,这样不管是刷新页面还是重新进入页面,都会保存上一次点击提交时的内容。可惜 PersistentForm 组件也是包含了两部分职责:存储内容和渲染内容。

    这次我们的重构不再是简单的拆分组件,而是使用 HoC 来完成职责单一的实现:

    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);
      }
    
      handleChange(event) {
        this.setState({
          inputValue: event.target.value,
        });
      }
    
      handleClick() {
        this.props.saveValue(this.state.inputValue);
      }
    
      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>
        );
      }
    }
    

    我们只是改变了两行代码,初始 state 不再直接读取 localStorage,而是由 this.props.initialValue 提供;handleClick 逻辑调用 this.props.saveValue,而不再直接操作 localStorage,this.props.saveValue 将会由 withPersistence 这个 HoC 提供:

    function withPersistence(storageKey, storage) {
      return function (WrappedComponent) {
        return class PersistentComponent extends Component {
          constructor(props) {
            super(props);
            this.state = { initialValue: storage.getItem(storageKey) };
          }
    
          saveValue(value) {
            storage.setItem(storageKey, value);
          }
          
          render() {
            return (
              <WrappedComponent
                initialValue={this.state.initialValue}
                saveValue={this.saveValue}
                {...this.props}
              />
            );
          }
        };
      };
    }
    

    使用方式:

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

    这种方式是组件单一职责和组件复用的结合体现,其他组件当然也可以使用这个 HoC:

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

    存储和渲染职责解耦,我们便可以随时切换存储方式,比如切换为 sessionStorage 代替 localStorage:

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

    组件通信和封装

    另一个和组件职责单一相关的话题是组件的封装,封装又涉及到组件间的通信。因为我们知道,组件再封装,还是要和其他组件去交互通信的,那么当我们说封装时在说些什么呢?

    组件关联有紧耦合和松耦合之分,紧耦合是指两个或多个组件之间需要了解彼此的组件内设计,这样的情况是我们不想看到的,这破坏了组件的独立性,「牵一发动全身」。这么看来,松耦合带来的好处是很直接的:

    • 一处组件的改动完全独立,不影响其他组件
    • 更好的复用设计
    • 更好的可测试性

    我们直接来看场景代码,一个简单的计数器足以说明问题:

    class App extends Component {
      constructor(props) {
        super(props);
        this.state = { number: 0 };
      }
    
      render() {
        return (
          <div className="app">
            <div className="number">{this.state.number}</div>
            <Controls parent={this} />
          </div>
        );
      }
    }
    
    class Controls extends Component {
      updateNumber(toAdd) {
        this.props.parent.setState((prevState) => ({
          number: prevState.number + toAdd,
        }));
      }
    
      render() {
        return (
          <div className="controls">
            <button onClick={() => this.updateNumber(+1)}>Increase</button>
            <button onClick={() => this.updateNumber(-1)}>Decrease</button>
          </div>
        );
      }
    }
    

    这样的组件实现问题很明显:App 组件不具有封装性,它将实例传给 Controls 组件,Controls 组件可以直接更改 App state 的内容。事实上,我们并不是不允许 Controls 组件修改 App 组件,只是 Controls 组件直接调用 App 组件的 setState 方法是不被建议的,因为 Controls 组件如果要调用 App 的 setState,就得需要知道 App 组件 state 的结构,需要感知 this.props.parent.state.number 等详情。

    同时上述代码也不利于测试,这个我们将在后面进行说明。那么该如何重构呢?我们应该用更加「含蓄」或者更加「粗暴、直接地」方式修改 number 值。秉承封装性:「只有组件自己知道自己的 state 结构」,将 updateNumber 迁移至 App 组件内:

    class App extends Component {
      constructor(props) {
        super(props);
        this.state = { number: 0 };
      }
    
      updateNumber(toAdd) {
        this.setState((prevState) => ({
          number: prevState.number + toAdd,
        }));
      }
    
      render() {
        return (
          <div className="app">
            <span className="number">{this.state.number}</span>
            <Controls
              onIncrease={() => this.updateNumber(+1)}
              onDecrease={() => this.updateNumber(-1)}
            />
          </div>
        );
      }
    }
    
    const Controls = ({ onIncrease, onDecrease }) => {
      return (
        <div className="controls">
          <button onClick={onIncrease}>Increase</button>
          <button onClick={onDecrease}>Decrease</button>
        </div>
      );
    };
    
    

    这样一来,Controls 组件就不需要再知道 App 组件的内部情况,实现了更好的复用性和可测试性,App 组件因此也具有了更好的封装性。

    组合性是灵魂

    如果说组件单一职责确定了如何拆分组件,封装性明确了组件如何组织,那么组合性就完成了整个应用的拼接。

    React 具有天生的组合基因:


    对应声明式代码:

    const app = (
      <application>
        <header></header>
        <slidebar></slidebar>
        <article></article>
        <footer></footer>
      </application>
    );
    

    如果两个组件 Composed1 和 Composed2 具有相同的逻辑,我们可以使用组合性进行拆分重组:

    const instance1 = (
      <composed1>
        // Composed1 逻辑 
        // 重复逻辑
      </composed1>
    );
    
    const instance2 = (
      <composed2>
        // 重复逻辑 
        // Composed2 逻辑
      </composed2>
    );
    
    

    重复逻辑提取为 Common 组件:

    const instance1 = (  
      <composed1>
        <logic1/>
        <common/>
      </composed1>
    )
    
    const instance2 = (  
      <composed2>
        <common />
        <logic2/>
      </composed2>
    )
    

    另外一个典型应用就是 render prop 模式,这个我们前面已经介绍过,这里给出一个很简单的示例,具体不再展开:

    const ByDevice = ({ children: { mobile, other } }) => {
      return Utils.isMobile() ? mobile : other;
    };
    
    <ByDevice>
      {{
        mobile: 'Mobile detected!',
        other: 'Not a mobile device',
      }}
    </ByDevice>;
    
    

    副作用和(准)纯组件
    纯函数和非纯函数概念大家并不陌生,简单来说,通过函数参数能够唯一确定函数返回值的函数,我们称之为纯函数,反之就是有副作用的非纯函数。纯/非纯函数延伸到组件中,就是纯/非纯组件。

    在理想主义者眼中,最好的情况是应用组件全部由纯组件组成,这样对于组件的调试和强健性非常重要。但这只能是理想情况,在真实环境中,我们需要发送网络请求以获取数据(副作用,因为数据不固定,需要从网络获取),进行条件渲染等操作,如何最大限度地保证纯组件或者(准)纯组件呢?我们先来下一个定义:

    (准)纯组件是渲染数据全部来自于 props,但是会产生副作用的组件
    从非纯组件中提取纯组件部分,是一个很常见有效的做法。

    const globalConfig = {
      siteName: 'Animals in Zoo',
    };
    
    const Header = ({ children }) => {
      const heading = globalConfig.siteName ? globalConfig.siteName : null;
      return (
        <div>
          {heading}
          {children}
        </div>
      );
    };
    
    

    这个组件是典型的非纯组件,因为它依赖全局变量 siteName,可能渲染出:

    Animals in Zoo
    Some content
    

    或者:

    Some content
    

    在编写测试用例时,还需要考虑 globalConfig.siteName,使得逻辑更加复杂:

    import assert from 'assert';
    import { shallow } from 'enzyme';
    import { globalConfig } from './config';
    import Header from './Header';
    
    describe('<Header/>', function () {
      it('should render the heading', function () {
        const wrapper = shallow(<Header>Some content</Header>);
        assert(wrapper.contains(<h1>Animals in Zoo</h1>));
      });
    
      it('should not render the heading', function () {
        // 改动全局变量
        globalConfig.siteName = null;
        const wrapper = shallow(<Header>Some content</Header>);
        assert(wrapper.find('h1').length === 0);
      });
    });
    

    在测试 Header 组件时,多了一种 case 不说,我们还需要手动改写全局变量的值。

    一个常用的优化方式是使全局变量作为 Header 的 props 出现,而不再是一个外部变量,那么函数式组件 Header 就完全依赖其参数:

    const Header = ({ children, siteName }) => {
      const heading = siteName ? { siteName } : null;
      return (
        <div>
          {heading}
          {children}
        </div>
      );
    };
    
    Header.defaultProps = {
      siteName: globalConfig.siteName,
    };
    
    

    另一个重构非纯组件的典型案例就是针对有网络请求的副作用情况,重放我们在组件单一职责中的代码:

    class WeatherFetch extends Component {
      constructor(props) {
        super(props);
        this.state = { temperature: 'N/A', windSpeed: 'N/A' };
      }
    
      componentDidMount() {
        axios.get('http://weather.com/api').then((response) => {
          const { current } = response.data;
          this.setState({
            temperature: current.temperature,
            windSpeed: current.windSpeed,
          });
        });
      }
    
      render() {
        const { temperature, windSpeed } = this.state;
        return <WeatherInfo temperature={temperature} windspeed={windSpeed} />;
      }
    }
    

    从表面上看,WeatherFetch 组件不得不「非纯」,因为网络请求不可避免,但是我们可以将请求的主体逻辑分离出组件,而组件只负责调用请求,这样的操作我称之为「(准)纯组件」:

    import { connect } from 'react-redux';
    import { fetch } from './action';
    
    export class WeatherFetch extends Component {
      componentDidMount() {
        this.props.fetch();
      }
    
      render() {
        const { temperature, windSpeed } = this.props;
        return <WeatherInfo temperature={temperature} windspeed={windSpeed} />;
      }
    
    }
    
    function mapStateToProps(state) {
      return {
        temperature: state.temperate,
        windSpeed: state.windSpeed,
      };
    }
    
    export default connect(mapStateToProps, { fetch });
    

    我们使用 Redux 来完成,这样一来 WeatherFetch 组件至少可以保证「相同的 props,会渲染相同的结果」。测试也就变得可行

    组件命名是意识和态度问题

    我为什么要把组件命名放在最后一部分呢?因为组件命名太简单了,任何一个开发者只要有意识,能用心,都能完成很好的命名;同时组件命名又太重要了,良好的组件命名就是「行走着的注释」。但意识是一个很虚的概念,有的程序员也许天生就不具备,有的程序员即便具备了,也懒得去琢磨。这里,我不赘述太多道理,读者只需观察两段代码即可,其中第一段,我加了大量的注释辅助:

    // 返回一组 game 信息
    // data 是一个数组,包含了所有 game 信息
    function Games({ data }) {
      // 选出前 10 条 games
      const data1 = data.slice(0, 10);
      // list 是包含了 10 条 games 的 Game 组件集合
      const list = data1.map(function (v) {
        // v 代码当前 game
        return <Game key={v.id} name={v.name} />;
      });
      return <ul>{list}</ul>;
    }
    
    <Games
      data={[
        { id: 1, name: 'Mario' },
        { id: 2, name: 'Doom' },
      ]}
    />;
    
    

    第二段代码不需要一行注释:

    const GAMES_LIMIT = 10;
    
    const GamesList = ({ items }) => {
      const itemsSlice = items.slice(0, GAMES_LIMIT);
      const games = itemsSlice.map((gameItem) => (
        <Game key={gameItem.id} name={gameItem.name} />
      ));
      return <ul>{games}</ul>;
    };
    <GamesList
      items={[
        { id: 1, name: 'Mario' },
        { id: 2, name: 'Doom' },
      ]}
    />;
    

    组件设计功力,其实一个命名就能看出来;在做 code review 时,一个命名也能出卖你的深浅。

    总结

    本讲我们剖析了组件设计的基本原则,在原则范畴内,展现了组件的灵活性,并将组件复用性融汇在课程中。其实不光 React 组件如此,任何框架的组件也都是如此,超脱于组件范畴之外,API 设计也应用着相同的原则。这是编程最本质的思想,甚至从某种程度上,在编程之外,原子组建成大千世界的哲学道理都是异曲同工的。

    相关文章

      网友评论

          本文标题:18.揭秘 React 真谛:组件设计

          本文链接:https://www.haomeiwen.com/subject/awyeeltx.html