React 元件設計和分解思考

LucasHC發表於2017-06-27

之前分享過幾篇關於React技術棧的文章:

今天再來同大家討論 React 元件設計的一個有趣話題:分解 React 元件的幾種進階方法。

React 元件魔力無窮,同時靈活性超強。我們可以在元件的設計上,玩轉出很多花樣。但是保證元件的Single responsibility principle: 單一原則非常重要,它可以使得我們的元件更簡單、更方便維護,更重要的是使得元件更加具有複用性。

但是,如何對一個功能複雜且臃腫的 React 元件進行分解,也許並不是一件簡單的事情。本文由淺入深,介紹三個分解 React 元件的方法。

切割 render() 方法

這是一個最容易想到的方法:當一個元件渲染了很多元素時,就需要嘗試分離這些元素的渲染邏輯。最迅速的方式就是切割 render() 方法為多個 sub-render 方法。

看下面的例子會更加直觀:

class Panel extends React.Component {
  renderHeading() {
    // ...
  }

  renderBody() {
    // ...
  }

  render() {
    return (
      < div>
        {this.renderHeading()}
        {this.renderBody()}
      < /div>
    );
  }複製程式碼

細心的讀者很快就能發現,其實這並沒有分解元件本身,該 Panel 元件仍然保持有原先的 state, props, 以及 class methods。

如何真正地做到減少複雜度呢?我們需要建立一些子元件。此時,採用最新版 React 支援並推薦的函式式元件/無狀態元件一定會是一個很好的嘗試:

const PanelHeader = (props) => (
  // ...
);

const PanelBody = (props) => (
  // ...
);

class Panel extends React.Component {
  render() {
    return (
      < div>
        // Nice and explicit about which props are used
        < PanelHeader title={this.props.title}/>
        < PanelBody content={this.props.content}/>
      < /div>
    );
   }
 }複製程式碼

同之前的方式相比,這個微妙的改進是革命性的。我們新建了兩個單元元件:PanelHeader 和 PanelBody。這樣帶來了測試的便利,我們可以直接分離測試不同的元件。同時,藉助於 React 新的演算法引擎 React Fiber,兩個單元元件在渲染的效率上,樂觀地預計會有較大幅度的提升。

模版化元件

回到問題的起點,為什麼一個元件會變的臃腫而複雜呢?其一是渲染元素較多且巢狀,另外就是元件內部變化較多,或者存在多種 configurations 的情況。

此時,我們便可以將元件改造為模版:父元件類似一個模版,只專注於各種 configurations。

還是要舉例來說,這樣理解起來更加清晰。

比如我們有一個 Comment 元件,這個元件存在多種行為或事件。同時元件所展現的資訊根據使用者的身份不同而有所變化:使用者是否是此 comment 的作者,此 comment 是否被正確儲存,各種許可權不同等等都會引起這個元件的不同展示行為。這時候,與其把所有的邏輯混淆在一起,也許更好的做法是利用 React 可以傳遞 React element 的特性,我們將 React element 進行元件間傳遞,這樣就更加像一個模版:

class CommentTemplate extends React.Component {
  static propTypes = {
    // Declare slots as type node
    metadata: PropTypes.node,
    actions: PropTypes.node,
  };

  render() {
    return (
      < div>
        < CommentHeading>
          < Avatar user={...}/>

          // Slot for metadata
          < span>{this.props.metadata}< /span>

        < /CommentHeading>

        < CommentBody/>

        < CommentFooter>
          < Timestamp time={...}/>

          // Slot for actions
          < span>{this.props.actions}< /span>

        < /CommentFooter>
      < /div>
      ...複製程式碼

此時,我們真正的 Comment 元件組織為:

class Comment extends React.Component {
  render() {
    const metadata = this.props.publishTime ?
      < PublishTime time={this.props.publishTime} /> :
      < span>Saving...< /span>;

    const actions = [];
    if (this.props.isSignedIn) {
      actions.push(< LikeAction />);
      actions.push(< ReplyAction />);
    }
    if (this.props.isAuthor) {
      actions.push(< DeleteAction />);
    }

    return < CommentTemplate metadata={metadata} actions={actions} />;
  }複製程式碼

metadata 和 actions 其實就是在特定情況下需要渲染的 React element。

比如,如果 this.props.publishTime 存在,metadata 就是 < PublishTime time={this.props.publishTime} />;反正則為 < span>Saving...< /span>。

如果使用者已經登陸,則需要渲染(即actions值為) < LikeAction /> 和 < ReplyAction />,如果是作者本身,需要渲染的內容就要加入 < DeleteAction />。

高階元件

在實際開發當中,元件經常會被其他需求所汙染。

比如,我們想統計頁面中所有連結的點選資訊。在連結點選時,傳送統計請求,同時包含此頁面 document 的 id 值。常見的做法是在 Document 元件的生命週期函式 componentDidMount 和 componentWillUnmount 增加程式碼邏輯:

class Document extends React.Component {
  componentDidMount() {
    ReactDOM.findDOMNode(this).addEventListener('click', this.onClick);
  }

  componentWillUnmount() {
    ReactDOM.findDOMNode(this).removeEventListener('click', this.onClick);
  }

  onClick = (e) => {
    if (e.target.tagName === 'A') { // Naive check for  elements
      sendAnalytics('link clicked', {
        documentId: this.props.documentId // Specific information to be sent
      });
    }
  };

  render() {
    // ...複製程式碼

這麼做的幾個問題在於:

  • 相關元件 Document 除了自身的主要邏輯:顯示主頁面之外,多了其他統計邏輯;
  • 如果 Document 元件的生命週期函式中,還存在其他邏輯,那麼這個元件就會變的更加含糊不合理;
  • 統計邏輯程式碼無法複用;
  • 元件重構、維護都會變的更加困難。

為了解決這個問題,我們提出了高階元件這個概念: higher-order components (HOCs)。不去晦澀地解釋這個名詞,我們來直接看看使用高階元件如何來重構上面的程式碼:

function withLinkAnalytics(mapPropsToData, WrappedComponent) {
  class LinkAnalyticsWrapper extends React.Component {
    componentDidMount() {
      ReactDOM.findDOMNode(this).addEventListener('click', this.onClick);
    }

    componentWillUnmount() {
      ReactDOM.findDOMNode(this).removeEventListener('click', this.onClick);
    }

    onClick = (e) => {
      if (e.target.tagName === 'A') { // Naive check for  elements
        const data = mapPropsToData ? mapPropsToData(this.props) : {};
        sendAnalytics('link clicked', data);
      }
    };

    render() {
      // Simply render the WrappedComponent with all props
      return < WrappedComponent {...this.props} />;
    }
  }複製程式碼

需要注意的是,withLinkAnalytics 函式並不會去改變 WrappedComponent 元件本身,更不會去改變 WrappedComponent 元件的行為。而是返回了一個被包裹的新元件。實際用法為:

class Document extends React.Component {
  render() {
    // ...
  }
}

export default withLinkAnalytics((props) => ({
  documentId: props.documentId
}), Document);複製程式碼

這樣一來,Document 元件仍然只需關心自己該關心的部分,而 withLinkAnalytics 賦予了複用統計邏輯的能力。

高階元件的存在,完美展示了 React 天生的複合(compositional)能力,在 React 社群當中,react-redux,styled-components,react-intl 等都普遍採用了這個方式。值得一提的是,recompose 類庫又利用高階元件,併發揚光大,做到了“腦洞大開”的事情。

總結

React 及其周邊社群的崛起,讓函數語言程式設計風靡一時,受到追捧。其中關於 decomposing 和 composing 的思想,我認為非常值得學習。同時,對開發設計的一個建議是,不要猶豫將你的元件拆分的更小、更單一,因為這樣能換來強健和複用。

本文意譯了David Tang的:Techniques for decomposing React components一文。

Happy Coding!

PS: 作者Github倉庫,歡迎通過程式碼各種形式交流。

相關文章