React元件設計實踐總結02 - 元件的組織

_ivan發表於2019-05-13

一個複雜的應用都是由簡單的應用發展而來的, 隨著越來越多的功能加入專案, 程式碼就會變得越來越難以控制. 本文章主要探討在大型專案中如何對元件進行組織, 讓專案具備可維護性.

系列目錄


目錄




1. 元件設計的基本原則

基本原則

單一職責(Single Responsibility Principle). 這原本來源於物件導向程式設計, 規範定義是"一個類應該只有一個發生變化的原因", 白話說"一個類只負責一件事情". 不管是什麼程式設計正規化, 只要是模組化的程式設計都適用單一職責原則. 在 React 中, 元件就是模組.

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

往往一開始我們設計的元件都可能複合多個職責, 後來出現了程式碼重複或者模組邊界被打破(比如一個模組依賴另一個模組的'細節'), 我們才會惰性將可複用的程式碼抽離. 隨著越來越多的重構和迭代, 模組職責可能會越來越趨於'單一'(? 看誰, 也可能變成麵條).

當然有經驗的開發者可以一開始就能考慮元件的各種應用場景, 可以觀察到模組的重合邊界. 對於入門者來說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 這些狀態管理器或者直接請求服務端資料)獲取資料, 然後 ② 組合展示元件來構建完整的檢視.

    React元件設計實踐總結02 - 元件的組織

    容器元件通過組合展示元件來構建完整檢視, 但兩者未必是簡單的包含與被包含的關係.


容器元件和展示元件的分離可以帶來好處主要是可複用性可維護性:

  • 可複用性: 展示元件可以用於多個不同的資料來源(容器元件). 容器元件(業務邏輯)也可以被複用於不同'平臺'的展示元件
  • 展示和容器元件更好的分離,有助於更好的理解應用和 UI, 兩者可以被獨立地維護
  • 展示元件變得輕量(無狀態/或區域性狀態), 更容易被測試

瞭解更多Presentational and Container Components




2️⃣ 分離邏輯和檢視

容器元件和展示元件的分離本質上是邏輯和檢視的分離. 在React Hooks出現後, 容器元件可以被 Hooks 形式取代, Hooks 可以和檢視層更自然的分離, 為檢視層提供純粹的資料來源.

抽離的後業務邏輯可以複用於不同的'展示平臺', 例如 web 版和 native 版:

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

上面使用了useLogin.tsx來單獨維護業務邏輯. 可以被 web 平臺和 native 平臺的程式碼複用.


React元件設計實踐總結02 - 元件的組織

不僅僅是業務邏輯, 展示元件邏輯也可以分離. 例如上圖, FilePickerImagePicker兩個元件的'檔案上傳'邏輯是共享的, 這部分邏輯可以抽取到高階元件或者 hooks, 甚至是 Context 中(可以統一配置檔案上傳行為)

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

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



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

無狀態元件內部不儲存狀態, 完全由外部的 props 來對映. 這類元件以函式元件形式存在, 作為低階/高複用的底層展示型元件. 無狀態元件天然就是'純元件', 如果無狀態元件的對映需要一點成本, 可以使用 React.memo 包裹避免重複渲染




4️⃣ 純元件和非純元件

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

React元件設計實踐總結02 - 元件的組織

和 React 元件的輸出輸出模型相比, Cyclejs對元件輸入/輸出的抽象則做的更加徹底,更加‘函式式’?。它的元件就是一個普通的函式,只有'單向'的輸入和輸出:

React元件設計實踐總結02 - 元件的組織

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

純元件對 React 的效能優化也有重要意義. 如果一個元件是一個純元件, 如果'輸入'沒有變動, 那麼這個元件就不需要重新渲染. 元件樹越大, 純元件帶來的效能優化收益就越高.

我們可以很容易地保證一個底層元件的純淨性, 因為它本來就很簡單. 但是對於一個複雜的元件樹, 則需要花點心思進行構建, 所以就有了'狀態管理'的需求. 這些狀態管理器通常都在元件樹的外部維護一個或多個狀態庫, 然後通過依賴注入形式, 將區域性的狀態注入到子樹中. 通過檢視和邏輯分離的原則, 來維持元件樹的純淨性.

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

React元件設計實踐總結02 - 元件的組織

不管是 Cyclejs 還是 Redux,抽象是需要付出一點代價的,就比如 redux 程式碼可能會很羅嗦; 一個複雜的狀態樹, 如果缺乏良好的組織,整個應用會變得很難理解。實際上, 並不是所有場景都能夠順利/優雅通過'資料驅動'進行表達(可以看一下這篇文章Modal.confirm 違反了 React 的模式嗎?), 例如文字框焦點, 或者模態框. 所以不必極端追求無副作用或者資料驅動

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

擴充套件:




5️⃣ 按照 UI 劃分為佈局元件內容元件

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

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

React元件設計實踐總結02 - 元件的組織

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




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 來抹平多端的差異

React元件設計實踐總結02 - 元件的組織

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

可以檢視 Taro 官方文件瞭解更多

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




5. 模組

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

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

React元件設計實踐總結02 - 元件的組織

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

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 提供的的某個方法
複製程式碼

最後再總結一下:

React元件設計實踐總結02 - 元件的組織

根據模組邊界原則(如上圖): 一個模組可以訪問兄弟(同個作用域下)、 祖先及祖先的兄弟模組. 例如:

  • 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 方法:

React元件設計實踐總結02 - 元件的組織

當然這種方式只是暫時讓 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 劃分成元件的工作往往是很容易的。只要把它劃分成能準確表示你資料模型的一部分的元件就可以". 元件劃分除了需要遵循上文 ? 提到的一些原則, 他還依賴於你的開發經驗.

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

React元件設計實踐總結02 - 元件的組織

1️⃣ 劃分頁面

頁面通常是最頂層的元件單元, 劃分頁面非常簡單, 我們根據原型圖就可以劃分四個頁面: ListPage, CreatePage, PreviewPage, DetailPage

src/
  containers/
    ListPage/
    CreatePage/
    PreviewPage/
    DetailPage/
    index.tsx     # 根元件, 一般在這裡定義路由
複製程式碼



2️⃣ 劃分基礎 UI 元件

首先看ListPage

React元件設計實踐總結02 - 元件的組織

ListPage 根據 UI 可以劃分為下面這些元件:

ScrollView        # 滾動檢視, 提供下拉重新整理, 無限載入等功能
  List            # 列表容器, 佈局元件
    Item          # 列表項, 佈局元件, 提供header, body等佔位符
      props - header
         Title       # 渲染標題
      props - after
         Time        # 渲染時間
      props - body
         Status      # 渲染列表項的狀態
複製程式碼

再看看CreatePage

React元件設計實踐總結02 - 元件的組織

這是一個表單填寫頁面, 為了提高表單填寫體驗, 這裡劃分為多個步驟; 每個步驟裡有還有多個表單分組; 每個表單的結構都差不多, 左邊是 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:

React元件設計實踐總結02 - 元件的組織


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 就不展開細節, 有興趣的讀者可以參考官方文件.




擴充套件

相關文章