React 世界的一等公民 – 組件

豬齒魚Choerodon平台使用 React 作為前端應用框架,對前端的展示做了一定的封裝和處理,並配套提供了前端組件庫Choerodon UI。結合實際業務情況,不斷對組件優化設計,提高程式碼品質。

本文將結合Choerodon豬齒魚平台使用案例,簡單說明組件的分類、設計原則和設計模式,幫助開發者在不同場景下選擇正確的設計和方案編寫組件(示例程式碼基於ES6/ES7的語法,適於有一定前端基礎的讀者)。

文章的主要內容包括:

  • React 組件簡介
  • 組件分類
  • 組件設計原則、最佳實踐
  • 組件設計模式簡介

React 組件簡介

React是指用於構建用戶介面的 JavaScript 庫。換言之,React是一個構建視圖層的類庫(或框架)。不管 React 本身如何複雜,不管其生態如何龐大,構建視圖始終是它的核心。

可以用個公式說明:

UI = f(data)

React的基礎原則有三條,分別是:

  1. React 介面完全由數據驅動;
  2. React 中一切都是組件;
  3. props 是 React 組件之間通訊的基本方式。

那麼組件又是什麼?

組件是一個函數或者一個 Class(當然 Class 也是 function),它根據輸入參數,最終返回一個 React Element。簡單地說,React Element 描述了「你想」在螢幕上看到的事物。抽象地說,React Element 元素是一個描述了 Dom Node 的對象。

所以實際上使用 React Component 來生成 React Element,對於開發體驗有巨大的提升,比如不需要手寫React.createElement等。

那麼所有 React Component 都需要返回 React Element 嗎?顯然是不需要的。 return null; 或者返回其他的 React 組件都有存在的意義,它能完成並實現很多巧妙的設計、思想和副作用,在下文會有所擴展。

可以說,在 React 中一切皆為組件: – 用戶介面就是組件; – 組件可以嵌套包裝組成複雜功能; – 組件可以用來實現副作用。

React 也提供了多種編寫組件的方法適用於各種場景實例。

組件分類

如何在場景下快速正確地選擇組件設計模式和方案,首先得有一個自己接受和常用的組件分類,以便從分類中快速確定編寫方法,再考慮設計模式等後續問題。

Vue的作者尤雨溪在一場Live中也表達過自己對前端組件的看法,「組件可以是函數,是有分類的。」從功能維度對組件進行了分類,這四種分類方式也適用於Choerodon豬齒魚前端開發中的業務場景:

  • 純展示型組件:數據進,DOM出,直觀明了
  • 接入型組件:在React場景下的container
  • component,這種組件會跟數據層的service打交道,會包含一些跟伺服器或者說數據源打交道的邏輯,container會把數據向下傳遞給展示型組件、
  • 交互型組件:典型的例子是對於表單組件的封裝和加強,大部分的組件庫都是以交互型組件為主,比如說Element UI,特點是有比較複雜的交互邏輯,但是是比較通用的邏輯,強調組件的復用
  • 功能型組件:以Vue的應用場景舉例,路由的router-view組件、transition組件,本身並不渲染任何內容,是一個邏輯型的東西,作為一種擴展或者是抽象機制存在

在此以Choerodon豬齒魚平台的一個創建介面來分析。

 – 紅色布局:功能型組件 – 藍色菜單:交互型組件,菜單項:遍歷菜單數據輸出DOM的純展示型組件 – 右塊內容:接入型組件(容器組件) – Table、btn等:交互型組件

可以看到,一個複雜介面可以分割成很多簡單或複雜的組件,複雜組件還包括子組件等。此外,除了從功能維度對組件進行劃分,也可以從開發者對組件的使用習慣進行分類(以下分類非對立關係):

  • 無狀態組件
  • 有狀態組件
  • 容器組件
  • 高階組件
  • Render Callback組件

簡單說明一下幾種組件:

  • 無狀態組件:無狀態組件(Stateless Component)是最基礎的組件形式,由於沒有狀態的影響所以就是純靜態展示的作用。基本組成結構就是屬性(props)加上一個渲染函數(render)。由於不涉及到狀態的更新,所以這種組件的復用性也最強。例如在各UI庫中開發的按鈕、輸入框、圖標等等。
  • 有狀態組件:組件內部包含狀態(state)且狀態隨著事件或者外部的消息而發生改變的時候,這就構成了有狀態組件(Stateful Component)。有狀態組件通常會帶有生命周期(lifecycle),用以在不同的時刻觸髮狀態的更新。在寫業務邏輯時常用到,不同場景所用的狀態和生命周期也會不同。
  • 容器組件:為使組件的職責更加單一,耦合性進一步地降低,引入了容器組件(Container Component)的概念。重要負責對數據獲取以及處理的邏輯。下文的設計模式也會提到。
  • 高階組件:「高階組件(HoC)」也算是種組件設計模式。做為一個高階組件,可以在原有組件的基礎上,對其增加新的功能和行為。如列印日誌,獲取數據和校驗數據等和展示無關的邏輯的時候,抽象出一個高階組件,用以給基礎的組件增加這些功能,減少公共的程式碼。
  • Render Callback組件:組件模式是在組件中使用渲染回調的方式,將組件中的渲染邏輯委託給其子組件。也是種重用組件邏輯的方式,也叫render props 模式。

以上這些組件編寫模式基本上可以覆蓋目前工作中所需要的模式。在寫一些複雜的框架組件的時候,仔細設計和研究組件間的解耦和組合方式,能夠使後續的項目可維護性大大增強。

對立的兩大分類:

  • 基於類的組件:基於類的組件(Class based components)是包含狀態和方法的。
  • 基於函數的組件:基於函數的組件(Functional Components)是沒有狀態和方法的。它們是純粹的、易讀的。儘可能的使用它們。

當然,React v16.7.0-alpha 中第一次引入了 Hooks 的概念,Hooks 的目的是讓開發者不需要再用 class 來實現組件。這是React的未來,基於函數的組件也可處理狀態。

了解了這些以後就需要有一個自己開發新組件前的思考,遵循組件設計原則,快速確定分類開始編寫Code。

設計原則/最佳實踐

React 的組件其實是軟體設計中的模組,其設計原則也需遵從通用的組件設計原則,簡單說來,就是要減少組件之間的耦合性(Coupling),讓組件簡單,這樣才能讓整體系統易於理解、易於維護。

即,設計原則:

  1. 介面小,props 數量少;
  2. 劃分組件,充分利用組合(composition);
  3. 把 state 往上層組件提取,讓下層組件只需要實現為純函數。

就像搭積木,複雜的應用和組件都是由簡單的介面和組件組成的。劃分組件也沒有絕對的方法,選擇在當下場景合適的方式劃分,充分利用組合即可。實際編寫程式碼也是逐步精進的過程,努力做到:

  1. 功能正常;
  2. 程式碼整潔;
  3. 高性能。

取Choerodon豬齒魚平台Devops項目的應用管理模組實例,導入應用:

 這個介面看起來很簡單,功能簡介 + 導入步驟條,實際因為存在步驟條,內容很豐富。

首先組件叫做AppImport,組件內包含簡介和步驟條,需要記錄當前步驟條第幾步狀態』current『,所以需要維持狀態(state),可以肯定,AppImport 是一個有狀態的組件,不能只是一個純函數,而是一個繼承自 Component 的類。

class AppImport extends React.Component {
    constructor() {
    super(...arguments);
    this.state = {
      current: 0,
    };
  }
  render() {
     //TODO: 返回所有JSX
  }
}

接下來劃分組件,按照數據邊界來分割組件:

  • 使用了choerodon-front-boot 中定義好的容器組件,Page、Header、Content;
  • 渲染 Header,返回上級菜單,渲染當前介面title。
  • 渲染 Content,封裝好的組件處理了導入應用和其詳情簡介;
  • 渲染 Steps 卡片,步驟條卡片渲染,state 為當前步以及後續需要導入提交的數據 data;
  • 最後,Steps 每一步數據需求都不同,均拆成單獨子組件。

在 React 中,有一個誤區,就是把 render 中的程式碼分拆到多個 renderXXXX 函數中去,比如下面這樣:

class AppImport extends React.Component {
  render() {
    const Header = this.renderHeader();
    const Content = this.renderContent();
    const Steps = this.renderSteps();

    return (
       <Page>
          {Header}
          {Content}
          {Steps}
       </Page>
    );
  }

  renderHeader() {
     //TODO: 返回上級菜單,渲染當前介面title
  }

  renderContent() {
     //TODO: 導入應用和其詳情簡介
  }

  renderSteps() {
     //TODO: 返回步驟條卡片
  }
}

用上面的方法組織程式碼,當然比寫一個巨大的 render 函數要強,但是,實現這麼多 renderXXXX 函數並不是一個明智之舉,因為這些 renderXXXX 函數訪問的是同樣的 props 和 state,這樣程式碼依然耦合在了一起。更好的方法是把這些 renderXXXX 重構成各自獨立的 React 組件,像下面這樣

class AppImport extends React.Component {
  constructor() {
    super(...arguments);
    this.state = {
      data: {},
      current: 0,
    };
  }

  next = () => {}

  cancel = () => {}

  render() {
    return (
      <Page>
        <Header title='xxx' backPath='xxxxxx' />
        <Content code="app.import" values={{ appName }}>
          <div className="c7n-app-import-wrap">
            <Steps current={current} className="steps-line">
              <Step key={item.key} title={item.title} />
            </Steps>
            <div className="steps-content">
              <Step0 onNext={this.next} onCancel={this.cancel} values={data} />
            </div>
          </div>
        </Content>
      </Page>
    );
  }
}

const Step = (props) => {
  //TODO: 返回步驟條Content
};

const Steps = (props) => {
  //TODO: Steps
};

const Page = (props) => {
  //TODO: Page
}

// Header / Content 

// 根據程式碼量,盡量每個組件都有自己專屬的源程式碼文件 導出,再導入
// 示例程式碼中 Page、Header、Content 使用了choerodon-front-boot 中定義好的容器組件,
// Steps 使用了choerodon-ui 庫
// 所以在頭部導入即可
// import { Steps } from 'choerodon-ui';
// import { Content, Header, Page } from 'choerodon-front-boot';

實際情況下,步驟條不止一步,處理函數也不止那麼簡單,但是經過劃分和抽取,作為展示組件的 AppImport 結構清晰,程式碼整潔,介面少(props只涉及公共的 store、history 等 )。再處理下StepN(子組件根據實際內容處理,這裡略過),整個 AppImport 程式碼不超過150行,相比不劃分組件,程式碼隨便超過1000+行,劃分優化後思路清晰,可維護性高。

最終程式碼:

import React, { Component, Fragment } from 'react';
import { observer } from 'mobx-react';
import { withRouter } from 'react-router-dom';
import { injectIntl, FormattedMessage } from 'react-intl';
import { Steps } from 'choerodon-ui';
import { Content, Header, Page, stores } from 'choerodon-front-boot';
import '../../../main.scss';
import './AppImport.scss';
import { Step0, Step1, Step2, Step3 } from './steps/index';

const { AppState } = stores;
const Step = Steps.Step;

@observer
class AppImport extends Component {
  constructor() {
    super(...arguments);
    this.state = {
      data: {},
      current: 0,
    };
  }

  next = (values) => {
    // 點擊下一步處理函數,略
  };

  prev = () => {
    // 點擊上一步處理函數,略
  };

  cancel = () => {
    // 點擊取消處理函數,略
  };

  importApp = () => {
    // 點擊導入,數據處理,略
  };

  render() {
    const { current, data } = this.state;
    // const ...

    const steps = [{
      key: 'step0',
      title: <FormattedMessage id="app.import.step1" />,
      content: <Step0 onNext={this.next} onCancel={this.cancel} store={AppStore} values={data} />,
    }, {
      key: 'step1',
      title: <FormattedMessage id="app.import.step2" />,
      content: <Step1 onNext={this.next} onPrevious={this.prev} onCancel={this.cancel} store={AppStore} values={data} />,
    }, {
      key: 'step2',
      title: <FormattedMessage id="app.import.step3" />,
      content: <Step2 onNext={this.next} onPrevious={this.prev} onCancel={this.cancel} store={AppStore} values={data} />,
    }, {
      key: 'step3',
      title: <FormattedMessage id="app.import.step4" />,
      content: <Step3 onImport={this.importApp} onPrevious={this.prev} onCancel={this.cancel} store={AppStore} values={data} />,
    }];

    return (
      <Page>
        <Header title='xxx' backPath='xxxxxx' />
        <Content code="app.import" values={{ name }}>
          <div className="c7n-app-import-wrap">
            <Steps current={current} className="steps-line">
              {steps.map(item => <Step key={item.key} title={item.title} />)}
            </Steps>
            <div className="steps-content">{steps[current].content}</div>
          </div>
        </Content>
      </Page>
    );
  }
}

export default withRouter(injectIntl(AppImport));

過程中會接觸到一些最佳實踐和技巧: 1. 避免 renderXXXX 函數 2. 給回調函數類型的 props 加統一前綴(onNext、onXXX 或 handleXXX 規範,可讀性好) 3. 使用 propTypes 來定義組件的 props 4. 盡量每個組件都有自己專屬的源程式碼文件(StepN) 5. 用解構賦值(destructuring assignment)的方法獲取參數 props 的每個屬性值 6. 利用屬性初始化(property initializer)來定義 state 和成員函數

組件設計模式

不同的業務情境下使用合適的設計模式能大大提高開發效率和可維護性。了解以上內容後能更好的理解和選擇設計模式。

常用的設計模式有:

  1. 容器組件和展示組件(Container and Presentational Components);
  2. 高階組件;
  3. render props 模式;
  4. 提供者模式(Provider Pattern);
  5. 組合組件。

網上介紹這些模式的文章有很多,每個模式都可以長篇詳解。但是,模式就是特定於一種問題場景的解決辦法。

模式(Pattern) = 問題場景(Context) + 解決辦法(Solution)

明確使用場景才能正確發揮模式的功能。所以,簡單介紹一下各模式實際應用於什麼場景較好。

容器組件和展示組件

React最簡單也是最常用的一種組件模式就是「容器組件和展示組件」。其本質就是把一個功能分配到兩個組件中,形成父子關係,外層的父組件負責管理數據狀態,內層的子組件只負責展示,讓一個模組都專註於一個功能,這樣更利於程式碼的維護。

上文步驟條的實例就是把獲取和管理數據這件事和介面渲染這件事分開。做法就是,把獲取和管理數據的邏輯放在父組件,也就是容器組件;把渲染介面的邏輯放在子組件,也就是展示組件。有關數據處理的變動就只需要對容器組件進行修改,例如修改數據狀態管理方式,完全不影響展示組件。

高階組件

高階組件適用場景於「不要重複自己」(DRY,Don』t Repeat Yourself)編碼原則,某些功能是多個組件通用的,在每個組件都重複實現邏輯,浪費、可維護行低。第一想法是共用邏輯提取為一個 React 組件,但是共用邏輯單獨無法使用,不足以抽象成組件,僅僅是對其他組件的功能加強。當然,高階組件並不是 React 中唯一的重用組件邏輯的方式,下文的 render props 模式也可處理。

例如,很多網站應用,有些模組都需要在用戶已經登錄的情況下才顯示。比如,對於一個電商類網站,「退出登錄」按鈕、「購物車」這些模組,就只有用戶登錄之後才顯示,對應這些模組的 React 組件如果連「只有在登錄時才顯示」的功能都重複實現,那就浪費了。

render props 模式

所謂 render props,指的是讓 React 組件的 props 支援函數這種模式。因為作為 props 傳入的函數往往被用來渲染一部分介面,所以這種模式被稱為 render props。適用場景和高階組件差不多,但是與其還是有一些差別:

  1. render props 模式的應用,是一個 React 組件,而高階組件,雖然名為「組件」,其實只是一個產生 React 組件的函數
  2. 高階組件可鏈式調用,因為實質是函數
  3. render props 相對於高階組件還有一個顯著優勢,就是對於新增的 props 更加靈活

所以以上對比,當需要重用 React 組件的邏輯時,建議首先看這個功能是否可以抽象為一個簡單的組件;如果行不通的話,考慮是否可以應用 render props 模式;再不行的話,才考慮應用高階組件模式。當然,沒有絕對的使用順序,實際場景為準。

提供者模式

在 React 中,props 是組件之間通訊的主要手段,但是,有一種場景單純靠 props 來通訊是不恰當的,那就是兩個組件之間間隔著多層其他組件。避免 props 逐級傳遞,即是提供者模式的適用場景。實現方式也分老Context API和新Context API。新版本的 Context API 才是未來,在 React v17 中,可能就會刪除對老版 Context API 的支援,所以,現在大家都應該使用第二種實現方式。新版API詳解

典型用例就是實現「樣式主題」(Theme),多語言支援等。

組合組件

組合組件模式要解決的是這樣一類問題:父組件想要傳遞一些資訊給子組件,但是,如果用 props 傳遞又顯得十分麻煩。利用 Context?當然還有其他解決方案,就是組合組件模式。

應用組合組件場景的往往是共享組件庫,把一些常用的功能封裝在組件里,讓應用層直接用就行。在 antd 和 bootstrap 這樣的共享庫中,都使用了組合組件這種模式。將複雜度都封裝起來了,從使用者角度,連 props 都看不見。實例擴展

總 結

對前端來說,前端不是不用設計模式,而是已經把設計模式融入到了開發的基礎當中。Choerodon豬齒魚平台前端真實的業務場景往往需要應用多個設計模式,介面也會包含多個大小不一的組件。開發設計時,符合程式設計的原則:「高內聚,低耦合」即可。本文只是簡單總結,提供一些思路和簡單的應用場景給開發者,真正的熟練把握和應用還得多實踐開發使用,多對自己欠缺的知識點去深挖學習和思考,不斷進步。

參考/引用資料: – React 官網


本文由豬齒魚技術團隊原創,轉載請註明出處