兩年React老兵的總結 - 如何組織React項目

一個複雜的應用都是由簡單的應用發展而來的, 隨着愈來愈多的功能加入項目, 代碼就會變得愈來愈難以控制. 本文章主要探討在大型項目中如何對組件進行組織, 讓項目具有可維護性.css

系列目錄html

目錄前端


1. 組件設計的基本原則

基本原則

單一職責(Single Responsibility Principle). 這本來來源於面向對象編程, 規範定義是"一個類應該只有一個發生變化的緣由", 白話說"一個類只負責一件事情". 無論是什麼編程範式, 只要是模塊化的程序設計都適用單一職責原則. 在 React 中, 組件就是模塊.typescript

單一職責要求將組件限制在一個'合適'的粒度. 這個粒度是比較主觀的概念, 換句話說'單一'是一個相對的概念. 我我的以爲單一職責並非追求職責粒度的'最小'化, 粒度最小化是一個極端, 可能會致使大量模塊, 模塊離散化也會讓項目變得難以管理. 單一職責要求的是一個適合被複用的粒度.

每每一開始咱們設計的組件均可能複合多個職責, 後來出現了代碼重複或者模塊邊界被打破(好比一個模塊依賴另外一個模塊的'細節'), 咱們纔會惰性將可複用的代碼抽離. 隨着愈來愈多的重構和迭代, 模塊職責可能會愈來愈趨於'單一'(😂 看誰, 也可能變成麪條).

固然有經驗的開發者能夠一開始就能考慮組件的各類應用場景, 能夠觀察到模塊的重合邊界. 對於入門者來講Don't repeat yourself原則更有用, 不要偷懶/多思考/重構/消除重複代碼, 你的能力就會慢慢提高

單一職責的收益:

  • 下降組件的複雜度. 職責單一組件代碼量少, 容易被理解, 可讀性高
  • 下降對其餘組件的耦合. 當變動到來時能夠下降對其餘功能的影響, 不至於牽一髮而動全身
  • 提升可複用性. 功能越單一可複用性越高, 就好比一些基礎組件

高質量組件的特徵

一個高質量的組件必定是高內聚, 低耦合, 這兩個原則或者特徵是組件獨立性的一個判斷標準.

高內聚, 要求一個組件有一個明確的組件邊界, 將緊密相關的內容彙集在一個組件下, 實現"專注"的功能. 和傳統的前端編程不同, 一個組件是一個自包含的單元, 它包含了邏輯/樣式/結構, 甚至是依賴的靜態資源. 這也使得組件自然就是一個比較獨立的個體. 固然這種獨立性是相對的, 爲了最大化這種獨立性, 須要根據單一職責將組件拆分爲更小粒度的組件, 這樣能夠被更靈活的組合和複用.

雖然組件是獨立的, 可是他須要和其餘組件進行組合才能實現應用, 這就有了'關聯'. 低耦合要求最小化這種關聯性, 好比明確模塊邊界不該該訪問其餘組件的內部細節, 組件的接口最小化, 單向數據流等等

文章後續內容主要討論實現高內聚/低耦合主要措施


2. 基本技巧

這些技巧來源於react-bits:

  • 若是組件不須要狀態, 則使用無狀態組件
  • 性能上比較: 無狀態函數 > 有狀態函數 > class 組件
  • 最小化 props(接口). 不要傳遞超過要求的 props
  • 若是組件內部存在較多條件控制流, 這一般意味着須要對組件進行抽取
  • 不要過早優化. 只要求組件在當前需求下可被複用, 而後'隨機應變'

3. 組件的分類

1️⃣ 容器組件展現組件分離

容器組件和展現組件分離是 React 開發的重要思想, 它影響的 React 應用項目的組織和架構. 下面總結一下二者的區別:

容器組件 展現組件
關注點 業務 UI
數據源 狀態管理器/後端 props
組件形式 高階組件 普通組件
  • 展現組件是一個只關注展現的'元件', 爲了能夠在多個地方被複用, 它不該該耦合'業務/功能', 或者說不該該過渡耦合. 像antd這類組件庫提供通用組件顯然就是'展現組件'

    下面是一個典型的應用目錄結構, 咱們能夠看到展現組件與業務/功能是可能有不一樣的耦合程度的, 和業務的耦合程度越低, 通用性/可複用性越強:

    node_modules/antd/     🔴 通用的組件庫, 不能和任何項目的業務耦合
    src/
      components/          🔴 項目通用的組件庫, 能夠被多個容器/頁面組件共享
      containers/
        Foo/
          components/      🔴 容器/頁面組件特有的組件庫, 和一個業務/功能深度耦合. 以至於不能被其餘容器組件共享
          index.tsx
        Bar/
          components/
          index.tsx

    對於展現組件,咱們要以一種'第三方組件庫'的標準來考慮組件的設計, 減小與業務的耦合度, 考慮各類應用的場景, 設計好公開的接口.

  • 容器組件主要關注業務處理. 容器組件通常以'高階組件'形式存在, 它通常 ① 從外部數據源(redux 這些狀態管理器或者直接請求服務端數據)獲取數據, 而後 ② 組合展現組件來構建完整的視圖.

    容器組件經過組合展現組件來構建完整視圖, 但二者未必是簡單的包含與被包含的關係.

容器組件和展現組件的分離能夠帶來好處主要是可複用性可維護性:

  • 可複用性: 展現組件能夠用於多個不一樣的數據源(容器組件). 容器組件(業務邏輯)也能夠被複用於不一樣'平臺'的展現組件
  • 展現和容器組件更好的分離,有助於更好的理解應用和 UI, 二者能夠被獨立地維護
  • 展現組件變得輕量(無狀態/或局部狀態), 更容易被測試

瞭解更多Presentational and Container Components


2️⃣ 分離邏輯和視圖

容器組件和展現組件的分離本質上是邏輯和視圖的分離. 在React Hooks出現後, 容器組件能夠被 Hooks 形式取代, Hooks 能夠和視圖層更天然的分離, 爲視圖層提供純粹的數據來源.

抽離的後業務邏輯能夠複用於不一樣的'展現平臺', 例如 web 版和 native 版:

Login/
  useLogin.ts   // 可複用的業務邏輯
  index.web.tsx
  index.tsx

上面使用了useLogin.tsx來單獨維護業務邏輯. 能夠被 web 平臺和 native 平臺的代碼複用.

不只僅是業務邏輯, 展現組件邏輯也能夠分離. 例如上圖, FilePickerImagePicker兩個組件的'文件上傳'邏輯是共享的, 這部分邏輯能夠抽取到高階組件或者 hooks, 甚至是 Context 中(能夠統一配置文件上傳行爲)

分離邏輯和視圖的主要方式有:

  • hooks
  • 高階組件
  • Render Props
  • Context

3️⃣ 有狀態組件和無狀態組件

無狀態組件內部不存儲狀態, 徹底由外部的 props 來映射. 這類組件以函數組件形式存在, 做爲低級/高複用的底層展現型組件.
無狀態組件自然就是'純組件', 若是無狀態組件的映射須要一點成本, 可使用 React.memo 包裹避免重複渲染


4️⃣ 純組件和非純組件

純組件的'純'來源於函數式編程. 指的是對於一個函數而言, 給定相同的輸入, 它老是返回相同的輸出, 過程沒有反作用, 沒有額外的狀態依賴. 對應到 React 中, 純組件指的是 props(嚴格上說還有 state 和 context, 它們也是組件的輸入)沒有變化, 組件的輸出就不會變更.

和 React 組件的輸出輸出模型相比, Cyclejs對組件輸入/輸出的抽象則作的更加完全,更加‘函數式’👇。它的組件就是一個普通的函數,只有'單向'的輸入和輸出:

函數式編程和組件式編程思想某種意義上是一致的, 它們都是'組合'的藝術. 一個大的函數能夠有多個職責單一函數組合而成. 組件也是如此. 咱們將一個大的組件拆分爲子組件, 對組件作更細粒度的控制, 保持它們的純淨性, 讓它們的職責更單一, 更獨立. 這帶來的好處就是可複用性, 可測試性和可預測性.

純組件對 React 的性能優化也有重要意義. 若是一個組件是一個純組件, 若是'輸入'沒有變更, 那麼這個組件就不須要從新渲染. 組件樹越大, 純組件帶來的性能優化收益就越高.

咱們能夠很容易地保證一個底層組件的純淨性, 由於它原本就很簡單. 可是對於一個複雜的組件樹, 則須要花點心思進行構建, 因此就有了'狀態管理'的需求. 這些狀態管理器一般都在組件樹的外部維護一個或多個狀態庫, 而後經過依賴注入形式, 將局部的狀態注入到子樹中. 經過視圖和邏輯分離的原則, 來維持組件樹的純淨性.

Redux 就是一個典型的解決方案, 在 Redux 的世界裏能夠認爲一個複雜的組件樹就是一顆狀態樹的映射, 只要狀態樹(須要依靠不可變數據來保證狀態的可預測性)不變, 組件樹就不變. Redux 建議保持組件的純淨性, 將組件狀態交給 Redux 和配套的異步處理工具來維護, 這樣就將整個應用抽象成了一個"單向的數據流", 這是一種簡單的"輸入/輸出"關係

無論是 Cyclejs 仍是 Redux,抽象是須要付出一點代價的,就好比 redux 代碼可能會很羅嗦; 一個複雜的狀態樹, 若是缺少良好的組織,整個應用會變得很難理解。實際上, 並非全部場景都可以順利/優雅經過'數據驅動'進行表達(能夠看一下這篇文章Modal.confirm 違反了 React 的模式嗎?), 例如文本框焦點, 或者模態框. 因此沒必要極端追求無反作用或者數據驅動

後續會專門寫篇文章來回顧總結狀態管理.

擴展:


5️⃣ 按照 UI 劃分爲佈局組件內容組件

  • 佈局組件用於控制頁面的佈局,爲內容組件提供佔位。經過 props 傳入組件來進行填充. 好比Grid, Layout, HorizontalSplit
  • 內容組件會包含一些內容,而不只有佈局。內容組件一般被佈局組件約束在佔位內. 好比Button, Label, Input

例以下圖, List/List.Item 就是佈局組件,而 Input,Address 則是內容組件

將佈局從內容組件中抽取出來,分離佈局和內容,可讓二者更好維護,好比佈局變更不會影響內容,內容組件能夠被應用不一樣的佈局; 另外一方面組件是一個自包含內聚的隔離單元, 不該該影響其外部的狀態, 例如一個按鈕不該該修改外部的佈局, 另外也要避免影響全局的樣式


6️⃣ 接口一致的數據錄入組件

數據錄入組件, 或者稱爲表單, 是客戶端開發必不可少的元素. 對於自定義表單組件, 我認爲應該保持一致的 API:

interface Props<T> {
  value?: T;
  onChange: (value?: T) => void;
}

這樣作的好處:

  • 接近原生表單元素原語. 自定義表單組件通常不須要封裝到 event 對象中
  • 幾乎全部組件庫的自定義表單都使用這種 API. 這使得咱們的自定義組件能夠和第三方庫兼容, 好比antd 的表單驗證機制
  • 更容易被動態渲染. 由於接口一致, 能夠方便地進行動態渲染或集中化處理, 減小代碼重複
  • 回顯問題. 狀態回顯是表單組件的功能之一, 我我的的最佳實踐是value應該是自包含的:

    好比一個支持搜索的用戶選擇器, option 都是異步從後端加載, 若是 value 只保存用戶 id, 那麼回顯的時候就沒法顯示用戶名, 按照個人實踐的 value 的結構應該爲: {id: string, name: string}, 這樣就解決了回顯問題. 回顯須要的數據都是由父節點傳遞進來, 而不是組件本身維護

  • 組件都是受控的. 在實際的 React 開發中, 非受控組件的場景很是少, 我認爲自定義組件均可以忽略這種需求, 只提供徹底受控表單組件, 避免組件本身維護緩存狀態

4. 目錄劃分

1️⃣ 基本目錄結構

關於項目目錄結構的劃分有兩種流行的模式:

  • Rails-style/by-type: 按照文件的類型劃分爲不一樣的目錄,例如componentsconstantstypingsviews
  • Domain-style/by-feature: 按照一個功能特性或業務建立單獨的文件夾,包含多種類型的文件或目錄

實際的項目環境咱們通常使用的是混合模式,下面是一個典型的 React 項目結構:

src/
  components/      # 🔴 項目通用的‘展現組件’
    Button/
      index.tsx    # 組件的入口, 導出組件
      Groups.tsx   # 子組件
      loading.svg  # 靜態資源
      style.css    # 組件樣式
    ...
    index.ts       # 處處全部組件
  containers/      # 🔴 包含'容器組件'和'頁面組件'
    LoginPage/     # 頁面組件, 例如登陸
      components/  # 頁面級別展現組件,這些組件不能複用與其餘頁面組件。
        Button.tsx # 組件未必是一個目錄形式,對於一個簡單組件能夠是一個單文件形式. 但仍是推薦使用目錄,方便擴展
        Panel.tsx
      reducer.ts   # redux reduces
      useLogin.ts  # (可選)放置'邏輯', 按照👆分離邏輯和視圖的原則,將邏輯、狀態處理抽取到hook文件
      types.ts     # typescript 類型聲明
      style.css
      logo.png
      message.ts
      constants.ts
      index.tsx
    HomePage/
    ...
    index.tsx      # 🔴應用根組件
  hooks/           # 🔴可複用的hook
    useList.ts
    usePromise.ts
  ...
  index.tsx        # 應用入口, 在這裏使用ReactDOM對跟組件進行渲染
  stores.ts        # redux stores
  contants.ts      # 全局常量

上面使用Domain-style風格劃分了LoginPageHomePage目錄, 將全部該業務或者頁面相關的文件聚合在一塊兒; 這裏也使用Rails-style模式根據文件類型/職責劃分不一樣的目錄, 好比components, hooks, containers; 你會發如今LoginPage內部也有相似Rails-Style的結構, 如components, 只不過它的做用域不一樣, 它只歸屬於LoginPage, 不能被其餘 Page 共享

前端項目通常按照頁面路由來拆分組件, 這些組件咱們暫且稱爲‘頁面組件’, 這些組件是和業務功能耦合的,並且每一個頁面之間具備必定的獨立性.

這裏將頁面組件放置在containers, 如其名,這個目錄本來是用來放置容器組件的, 實際項目中一般是將‘容器組件’和‘頁面組件’混合在了一塊兒, 現階段若是要實現純粹的邏輯分離,我我的以爲仍是應該抽取到 hook 中. 這個目錄也能夠命名爲 views, pages...(whatever), 命名爲 containers 只是一種習慣(來源於 Redux).

擴展:


2️⃣ 多頁應用的目錄劃分

對於大型應用可能有多個應用入口, 例如不少 electron 應用有多個 windows; 再好比不少應用除了 App 還有後臺管理界面. 我通常會這樣組織多頁應用:

src/
  components/       # 共享組件
  containers/
    Admin/          # 後臺管理頁面
      components/   # 後臺特定的組件庫
      LoginPage/
      index.tsx
      ...
    App/
      components/  # App特定的組件庫
      LoginPage/   # App頁面
      index.tsx
      stores.ts    # redux stores
    AnotherApp/    # 另一個App頁面
  hooks/
  ...
  app.tsx          # 應用入口
  anotherApp.tsx   # 應用入口
  admin.tsx        # 後臺入口

webpack 支持多頁應用的構建, 我通常會將應用入口文件命名爲*.page.tsx, 而後在 src 自動掃描匹配的文件做爲入口.

利用 webpack 的SplitChunksPlugin能夠自動爲多頁應用抽取共享的模塊, 這個對於功能差很少和有較多共享代碼的多頁應用頗有意義. 意味着資源被一塊兒優化, 抽取共享模塊, 有利於減小編譯文件體積, 也便於共享瀏覽器緩存.

html-webpack-plugin4.0 開始支持注入共享 chunk. 在此以前須要經過 SplitChunksPlugin 顯式定義共享的 chunk, 而後也要 html-webpack-plugin 顯式注入該 chunk, 比較挫.

3️⃣ 多頁應用的目錄劃分: monorepo 模式

上面的方式, 全部頁面都彙集在一個項目下面, 共享同樣的依賴和 npm 模塊. 這可能會帶了一些問題:

  1. 不能容許不一樣頁面有不一樣版本的依賴
  2. 對於毫無相關的應用, 這種組織方式會讓代碼變得混亂, 例如 App 和後臺, 他們使用的技術棧/組件庫/交互體驗均可能相差較大, 並且容易形成命名衝突.
  3. 構建性能. 你但願單獨對某個頁面進行構建和維護, 而不是全部頁面混合在一塊兒構建

這種場景能夠利用lerna或者 yarn workspace 這裏 monorepo 機制, 將多頁應用隔離在不一樣的 npm 模塊下, 以 yarn workspace 爲例:

package.json
yarn.lock
node_modules/      # 全部依賴都會安裝在這裏, 方便yarn對依賴進行優化
share/             # 🔴 共享模塊
  hooks/
  utils/
admin/             # 🔴 後臺管理應用
  components/
  containers/
  index.tsx
  package.json     # 聲明本身的模塊以及share模塊的依賴
app/               # 🔴 後臺管理應用
  components/
  containers/
  index.tsx
  package.json     # 聲明本身的模塊以及share模塊的依賴

擴展:


4️⃣ 跨平臺應用

使用 ReactNative 能夠將 React 衍生到原生應用的開發領域. 儘管也有react-native-web這樣的解決方案, Web 和 Native 的 API/功能/開發方式, 甚至產品需求上可能會相差很大, 長此以往就可能出現大量沒法控制的適配代碼; 另外 react-native-web 自己也可能成爲風險點。 因此一些團隊須要針對不一樣平臺進行開發, 通常按照下面風格來組織跨平臺應用:

src/
  components/
    Button/
      index.tsx     # 🔴 ReactNative 組件
      index.web.tsx # 🔴 web組件, 以web.tsx爲後綴
      loading.svg   # 靜態資源
      style.css     # 組件樣式
    ...
    index.ts
    index.web.ts
  containers/
    LoginPage/
      components/
      ....
      useLogin.ts   # 🔴 存放分離的邏輯,能夠在React Native和Web組件中共享
      index.web.tsx
      index.tsx
    HomePage/
    ...
    index.tsx
  hooks/
    useList.ts
    usePromise.ts
  ...
  index.web.tsx        # web應用入口
  index.tsx            # React Native 應用入口

能夠經過 webpack 的resolve.extensions來配置擴展名補全的優先級. 早期的antd-mobile就是這樣組織的.


5️⃣ 跨平臺的另一種方式: taro

對於國內的開發者來講,跨平臺可不僅 Native 那麼簡單,咱們還有各類各樣的小程序、小應用。終端的碎片化讓前端的開發工做愈來愈有挑戰性.

Taro 就這樣誕生了, Taro 基於 React 的標準語法(DSL), 結合編譯原理的思想, 將一套代碼轉換爲多種終端的目標代碼, 並提供一套統一的內置組件庫和 SDK 來抹平多端的差別

由於 Taro 使用 React 的標準語法和 API,這使得咱們按照原有的 React 開發約定和習慣來開發多端應用,且只保持一套代碼. 可是不要忘了抽象都是有代價的

能夠查看 Taro 官方文檔 瞭解更多

Flutter是近期比較或的跨平臺方案,可是跟本文主題無關


5. 模塊

1️⃣ 建立嚴格的模塊邊界

下圖是一個某頁面的模塊導入,至關混亂,這還算能夠接受,筆者還見過上千行的組件,其中模塊導入語句就佔一百多行. 這有一部分緣由多是 VsCode 自動導入功能致使(可使用 tslint 規則對導入語句進行排序和分組規範),更大的緣由是這些模塊缺少組織。

我以爲應該建立嚴格的模塊邊界,一個模塊只有一個統一的'出口'。例如一個複雜的組件:

ComplexPage/
  components/
    Foo.tsx
    Bar.tsx
  constants.ts
  reducers.ts
  style.css
  types.ts
  index.tsx # 出口

能夠認爲一個‘目錄’就是一個模塊邊界. 你不該該這樣子導入模塊:

import ComplexPage from '../ComplexPage';
import Foo from '../ComplexPage/components/Foo';
import Foo from '../ComplexPage/components/Bar';
import { XXX } from '../ComplexPage/components/constants';
import { User, ComplexPageProps } from '../ComplexPage/components/type';

一個模塊/目錄應該由一個‘出口’文件來統一管理模塊的導出,限定模塊的可見性. 好比上面的模塊,components/Foocomponents/Barconstants.ts這些文件實際上是 ComplexPage 組件的'實現細節'. 這些是外部模塊不該該去耦合實現細節,但這個在語言層面並無一個限定機制,只能依靠規範約定.

當其餘模塊依賴某個模塊的'細節'時, 多是一種重構的信號: 好比依賴一個模塊的一個工具函數或者是一個對象類型聲明, 這時候可能應該將其擡升到父級模塊, 讓兄弟模塊共享它.

在前端項目中 index 文件最適合做爲一個'出口'文件, 當導入一個目錄時,模塊查找器會查找該目錄下是否存在的 index 文件. 開發者設計一個模塊的 API 時, 須要考慮模塊各類使用方式, 並使用 index 文件控制模塊可見性:

// 導入外部模塊須要使用的類型
export * from './type';
export * from './constants';
export * from './reducers';

// 不暴露外部不須要關心的實現細節
// export * from './components/Foo'
// export * from './components/Bar'

// 模塊的默認導出
export { ComplexPage as default } from './ComplexPage';

如今導入語句能夠更加簡潔:

import ComplexPage, { ComplexPageProps, User, XXX } from '../ComplexPage';

這條規則也能夠用於組件庫. 在 webpack 的 Tree-shaking 特性還不成熟以前, 咱們都使用了各類各樣的技巧來實現按需導入. 例如babel-plugin-import或直接子路徑導入:

import TextField from '~/components/TextField';
import SelectField from '~/components/SelectField';
import RaisedButton from '~/components/RaisedButton';

如今可使用Named import直接導入,讓 webpack 來幫你優化:

import { TextField, SelectField, RaisedButton } from '~/components';

但不是全部目錄都有出口文件, 這時候目錄就不是模塊的邊界了. 典型的有utils/, utils 只是一個模塊命名空間, utils 下面的文件都是一些互不相關或者不一樣類型的文件:

utils/
  common.ts
  dom.ts
  sdk.ts

咱們習慣直接引用這些文件, 而不是經過一個入口文件, 這樣能夠更明確導入的是什麼類型的:

import { hide } from './utils/dom'; // 經過文件名能夠知道, 這多是隱藏某個DOM元素
import { hide } from './utils/sdk'; // webview sdk 提供的的某個方法

最後再總結一下:

根據模塊邊界原則(如上圖): 一個模塊能夠訪問兄弟(同個做用域下)、 祖先及祖先的兄弟模塊. 例如:

  • Bar 能夠訪問 Foo, 但不能再向下訪問它的細節, 即不能訪問../Foo/types.ts, 但能夠訪問它的出口文件../Foo
  • src/types.ts 不能訪問 containers/HomePage
  • LoginPage 和訪問 HomePage
  • LoginPage 能夠訪問 utils/sdk

2️⃣ Named export vs default export

這兩種導出方式都有各自的適用場景,這裏不該該一棒子打死就不使用某種導出方式. 首先看一下named export 有什麼優勢:

  • 命名肯定

    • 方便 Typescript 進行重構
    • 方便智能提醒和自動導入(auto-import)識別
    • 方便 reexport

      // named
      export * from './named-export';
      
      // default
      export { default as Foo } from './default-export';
  • 一個模塊支持多個named export

再看一下default export有什麼優勢?:

  • default export通常表明‘模塊自己’, 當咱們使用‘默認導入’導入一個模塊時, 開發者是天然而然知道這個默認導入的是一個什麼對象。

    例如 react 導出的是一個 React 對象; LoginPage 導出的是一個登陸頁面; somg.png 導入的是一張圖片. 這類模塊總有一個肯定的'主體對象'. 因此默認導入的名稱和模塊的名稱通常是保持一致的(Typescript 的 auto-import 就是基於文件名).

    固然'主體對象'是一種隱式的概念, 你只能經過規範去約束它

  • default export的導入語句更加簡潔。例如lazy(import('./MyPage'))

default export也有一些缺點:

  • 和其餘模塊機制(commonjs)互操做時比較難以理解. 例如咱們會這樣子導入default export: require('./xx').default
  • named import 優勢就是default export的缺點

因此總結一下:

  1. 對於'主體對象'明確的模塊須要有默認導出, 例如頁面組件,類
  2. 對於'主體對象'不明確的模塊不該該使用默認導出,例如組件庫、utils(放置各類工具方法)、contants 常量

按照這個規則能夠這樣子組織 components 目錄:

components/
    Foo/
      Foo.tsx
      types.ts
      constants.ts
      index.ts         # 導出Foo組件
    Bar/
      Bar.tsx
      index.tsx
    index.ts           # 導出全部組件

對於 Foo 模塊來講, 存在一個主體對象即 Foo 組件, 因此這裏使用default export導出的 Foo 組件, 代碼爲:

// index.tsx
// 這三個文件所有使用named export導出
export * from './contants';
export * from './types';
export * from './Foo';

// 導入主體對象
export { Foo as default } from './Foo';

如今假設 Bar 組件依賴於 Foo:

// components/Bar/Bar.tsx
import React from 'react';

// 導入Foo組件, 根據模塊邊界規則, 不能直接引用../Foo/Foo.tsx
import Foo from '../Foo';

export const Bar = () => {
  return (
    <div>
      <Foo />
    </div>
  );
};

export default Bar;

對於components模塊來講,它的全部子模塊都是平等的,因此不存在一個主體對象,default export在這裏不適用。 components/index.ts代碼:

// components/index.ts
export * from './Foo';
export * from './Bar';

3️⃣ 避免循環依賴

循環依賴是模塊糟糕設計的一個表現, 這時候你須要考慮拆分和設計模塊文件, 例如

// --- Foo.tsx ---
import Bar from './Bar';

export interface SomeType {}

export const Foo = () => {};
Foo.Bar = Bar;

// --- Bar.tsx ----
import { SomeType } from './Foo';
...

上面 Foo 和 Bar 組件就造成了一個簡單循環依賴, 儘管它不會形成什麼運行時問題. 解決方案就是將 SomeType 抽取到單獨的文件:

// --- types.ts ---
export interface SomeType {}

// --- Foo.tsx ---
import Bar from './Bar';
import {SomeType} from './types'

export const Foo = () => {};
...
Foo.Bar = Bar;

// --- Bar.tsx ----
import {SomeType} from './types'
...

4️⃣ 相對路徑不要超過兩級

當項目愈來愈複雜, 目錄可能會愈來愈深, 這時候會出現這樣的導入路徑:

import { hide } from '../../../utils/dom';

首先這種導入語句很是不優雅, 並且可讀性不好. 當你在不清楚當前文件的目錄上下文時, 你不知道具體模塊在哪; 即便你知道當前文件的位置, 你也須要跟隨導入路徑在目錄樹中向上追溯在能定位到具體模塊. 因此這種相對路徑是比較反人類的.

另外這種導入路徑不方便模塊遷移(儘管 Vscode 支持移動文件時重構導入路徑), 文件遷移須要重寫這些相對導入路徑.

因此通常推薦相對路徑導入不該該超過兩級, 即只能是.././. 能夠嘗試將相對路徑轉換成絕對路徑形式, 例如webpack中能夠配置resolve.alias屬性來實現:

...
    resolve: {
      ...
      alias: {
        // 能夠直接使用~訪問相對於src目錄的模塊
        // 如 ~/components/Button
        '~': context,
      },
    }

如今咱們能夠這樣子導入相對於src的模塊:

import { hide } from '~/utils/dom';

擴展


6. 拆分

1️⃣ 拆分 render 方法

當 render 方法的 JSX 結構很是複雜的時候, 首先應該嘗試分離這些 JSX, 最簡單的作法的就是拆分爲多個子 render 方法:

固然這種方式只是暫時讓 render 方法看起來沒有那麼複雜, 它並無拆分組件自己, 全部輸入和狀態依然彙集在一個組件下面. 因此一般拆分 render 方法只是重構的第一步: 隨着組件愈來愈複雜, 表現爲文件愈來愈長, 筆者通常將 300 行做爲一個閾值, 超過 300 行則說明須要對這個組件進進一步拆分


2️⃣ 拆分爲組件

若是已經按照 👆 上述方法對組件的 render 拆分爲多個子 render, 當一個組件變得臃腫時, 就能夠方便地將這些子 render 方法拆分爲組件. 通常組件抽離有如下幾種方式:

  1. 純渲染拆分: 子 render 方法通常是純渲染的, 他們能夠很直接地抽離爲無狀態組件
public render() {
  const { visible } = this.state
  return (
    <Modal
      visible={visible}
      title={this.getLocale('title')}
      width={this.width}
      maskClosable={false}
      onOk={this.handleOk}
      onCancel={this.handleCancel}
      footer={<Footer {...}></Footer>}
    >
    <Body {...}></Body>
  </Modal>
  )
}
  1. 純邏輯拆分: 按照邏輯和視圖分離的原則, 將邏輯控制部分抽離到 hooks 或高階組件中
  2. 邏輯和渲染拆分: 將相關的視圖和邏輯抽取出去造成一個獨立的組件, 這是更爲完全的拆分方式, 貫徹單一職責原則.

7. 組件劃分示例

咱們通常會從 UI 原型圖中分析和劃分組件, 在 React 官方的Thinking in react也提到經過 UI 來劃分組件層級: "_這是由於 UI 和數據模型每每遵循着相同的信息架構,這意味着將 UI 劃分紅組件的工做每每是很容易的。只要把它劃分紅能準確表示你數據模型的一部分的組件就能夠_". 組件劃分除了須要遵循上文 👆 提到的一些原則, 他還依賴於你的開發經驗.

本節經過一個簡單的應用講述劃分組件的過程. 這是某政府部門的服務申報系統, 一共由四個頁面組成:

1️⃣ 劃分頁面

頁面一般是最頂層的組件單元, 劃分頁面很是簡單, 咱們根據原型圖就能夠劃分四個頁面: ListPage, CreatePage, PreviewPage, DetailPage

src/
  containers/
    ListPage/
    CreatePage/
    PreviewPage/
    DetailPage/
    index.tsx     # 根組件, 通常在這裏定義路由

2️⃣ 劃分基礎 UI 組件

首先看ListPage

ListPage 根據 UI 能夠劃分爲下面這些組件:

ScrollView        # 滾動視圖, 提供下拉刷新, 無限加載等功能
  List            # 列表容器, 佈局組件
    Item          # 列表項, 佈局組件, 提供header, body等佔位符
      props - header
         Title       # 渲染標題
      props - after
         Time        # 渲染時間
      props - body
         Status      # 渲染列表項的狀態

再看看CreatePage

這是一個表單填寫頁面, 爲了提升表單填寫體驗, 這裏劃分爲多個步驟; 每一個步驟裏有還有多個表單分組; 每一個表單的結構都差很少, 左邊是 label 展現, 右邊是實際表單組件, 因此根據 UI 能夠對組件進行這樣的劃分:

CreatePage
  Steps            # 步驟容器, 提供了步驟佈局和步驟切換等功能
    Step           # 單一步驟容器
      List         # 表單分組
        List.Item  # 表單容器, 支持設置label
          Input    # 具體表單類型
          Address
          NumberInput
          Select
          FileUpload
組件命名的建議: 對於集合型組件, 通常會使用單複數命名, 例如上面的 Steps/Step; List/Item 這種形式也比較常見, 例如 Form/Form.Item, 這種形式比較適合做爲子組件形式. 能夠學習一下第三方組件庫是怎麼給組件命名的.

再看一下PreviewPage, PreviewPage 是建立後的數據預覽頁面, 數據結構和頁面結構和 CreatePage 差很少. 將 Steps 對應到 Preview 組件, Step 對應到 Preview.Item. Input 對應到 Input.Preview:


3️⃣ 設計組件的狀態

對於 ListPage 來講狀態比較簡單, 這裏主要討論 CreatePage 的狀態. CreatePage 的特色:

  • 表單組件使用受控模式, 自己不會存儲表單的狀態. 另外表單之間的狀態多是聯動的
  • 狀態須要在 CreatePage 和 PreviewPage 之間共享
  • 須要對錶單進行統一校驗
  • 草稿保存

因爲須要在 CreatePage 和 PreviewPage 中共享數據, 表單的狀態應該抽取和提高到父級. 在這個項目的實際開發中, 個人作法是建立一個 FormStore 的 Context 組件, 下級組件經過這個 context 來統一存儲數據. 另外我決定使用配置的方式, 來渲染動態這些表單. 大概的結構以下:

// CreatePage/index.tsx
<FormStore defaultValue={draft} onChange={saveDraft}>
  <Switch>
    <Route path="/create/preview" component={Preview} />
    <Route path="/create" component={Create} />
  </Switch>
</FormStore>

// CreatePage/Create.tsx
<Steps>
  {steps.map(i =>
    <Step key={i.name}>
      <FormRenderer forms={i.forms}  /> {/* forms爲表單配置, 根據配置的表單類型渲染表單組件, 從FormStore的獲取和存儲值 */}
    </Step>
  )}
</Steps>

8. 文檔

組件的文檔化推薦使用Storybook, 這是一個組件 Playground, 有如下特性

  • 可交互的組件示例
  • 能夠用於展現組件的文檔. 支持 props 生成和 markdown
  • 能夠用於組件測試. 支持組件結構測試, 交互測試, 可視化測試, 可訪問性或者手動測試
  • 豐富的插件生態

React 示例. 因爲篇幅緣由, Storybook 就不展開細節, 有興趣的讀者能夠參考官方文檔.


擴展

相關文章
相關標籤/搜索