一個複雜的應用都是由簡單的應用發展而來的, 隨着愈來愈多的功能加入項目, 代碼就會變得愈來愈難以控制. 本文章主要探討在大型項目中如何對組件進行組織, 讓項目具有可維護性.css
系列目錄html
目錄前端
單一職責(Single Responsibility Principle). 這本來來源於面向對象編程, 規範定義是"一個類應該只有一個發生變化的緣由", 白話說"一個類只負責一件事情". 無論是什麼編程範式, 只要是模塊化的程序設計都適用單一職責原則. 在 React 中, 組件就是模塊.typescript
單一職責要求將組件限制在一個'合適'的粒度. 這個粒度是比較主觀的概念, 換句話說'單一'是一個相對的概念. 我我的以爲單一職責並非追求職責粒度的'最小'化, 粒度最小化是一個極端, 可能會致使大量模塊, 模塊離散化也會讓項目變得難以管理. 單一職責要求的是一個適合被複用的粒度.
每每一開始咱們設計的組件均可能複合多個職責, 後來出現了代碼重複或者模塊邊界被打破(好比一個模塊依賴另外一個模塊的'細節'), 咱們纔會惰性將可複用的代碼抽離. 隨着愈來愈多的重構和迭代, 模塊職責可能會愈來愈趨於'單一'(😂 看誰, 也可能變成麪條).
固然有經驗的開發者能夠一開始就能考慮組件的各類應用場景, 能夠觀察到模塊的重合邊界. 對於入門者來講Don't repeat yourself
原則更有用, 不要偷懶/多思考/重構/消除重複代碼, 你的能力就會慢慢提高
單一職責的收益:
一個高質量的組件必定是高內聚, 低耦合
的, 這兩個原則或者特徵是組件獨立性的一個判斷標準.
高內聚, 要求一個組件有一個明確的組件邊界, 將緊密相關的內容彙集在一個組件下, 實現"專注"的功能. 和傳統的前端編程不同, 一個組件是一個自包含的單元, 它包含了邏輯/樣式/結構, 甚至是依賴的靜態資源. 這也使得組件自然就是一個比較獨立的個體. 固然這種獨立性是相對的, 爲了最大化這種獨立性, 須要根據單一職責將組件拆分爲更小粒度的組件, 這樣能夠被更靈活的組合和複用.
雖然組件是獨立的, 可是他須要和其餘組件進行組合才能實現應用, 這就有了'關聯'. 低耦合要求最小化這種關聯性, 好比明確模塊邊界不該該訪問其餘組件的內部細節, 組件的接口最小化, 單向數據流等等
文章後續內容主要討論實現高內聚/低耦合主要措施
這些技巧來源於react-bits:
容器組件和展現組件分離是 React 開發的重要思想, 它影響的 React 應用項目的組織和架構. 下面總結一下二者的區別:
容器組件 | 展現組件 | |
---|---|---|
關注點 | 業務 | UI |
數據源 | 狀態管理器/後端 | props |
組件形式 | 高階組件 | 普通組件 |
展現組件是一個只關注展現的'元件', 爲了能夠在多個地方被複用, 它不該該耦合'業務/功能', 或者說不該該過渡耦合. 像antd
這類組件庫提供通用組件顯然就是'展現組件'
下面是一個典型的應用目錄結構, 咱們能夠看到展現組件與業務/功能是可能有不一樣的耦合程度的, 和業務的耦合程度越低, 通用性/可複用性越強:
node_modules/antd/ 🔴 通用的組件庫, 不能和任何項目的業務耦合 src/ components/ 🔴 項目通用的組件庫, 能夠被多個容器/頁面組件共享 containers/ Foo/ components/ 🔴 容器/頁面組件特有的組件庫, 和一個業務/功能深度耦合. 以至於不能被其餘容器組件共享 index.tsx Bar/ components/ index.tsx
對於展現組件,咱們要以一種'第三方組件庫'的標準來考慮組件的設計, 減小與業務的耦合度, 考慮各類應用的場景, 設計好公開的接口.
容器組件經過組合展現組件來構建完整視圖, 但二者未必是簡單的包含與被包含的關係.
容器組件和展現組件的分離
能夠帶來好處主要是可複用性和可維護性:
瞭解更多Presentational and Container Components
容器組件和展現組件
的分離本質上是邏輯和視圖
的分離. 在React Hooks
出現後, 容器組件能夠被 Hooks 形式取代, Hooks 能夠和視圖層更天然的分離, 爲視圖層提供純粹的數據來源.
抽離的後業務邏輯能夠複用於不一樣的'展現平臺', 例如 web 版和 native 版:
Login/ useLogin.ts // 可複用的業務邏輯 index.web.tsx index.tsx
上面使用了useLogin.tsx
來單獨維護業務邏輯. 能夠被 web 平臺和 native 平臺的代碼複用.
不只僅是業務邏輯, 展現組件邏輯也能夠分離. 例如上圖, FilePicker
和ImagePicker
兩個組件的'文件上傳'邏輯是共享的, 這部分邏輯能夠抽取到高階組件或者 hooks, 甚至是 Context 中(能夠統一配置文件上傳行爲)
分離邏輯和視圖的主要方式有:
無狀態組件內部不存儲狀態, 徹底由外部的 props 來映射. 這類組件以函數組件形式存在, 做爲低級/高複用的底層展現型組件.
無狀態組件自然就是'純組件', 若是無狀態組件的映射須要一點成本, 可使用 React.memo 包裹避免重複渲染
純組件的'純'來源於函數式編程. 指的是對於一個函數而言, 給定相同的輸入, 它老是返回相同的輸出, 過程沒有反作用, 沒有額外的狀態依賴. 對應到 React 中, 純組件指的是 props(嚴格上說還有 state 和 context, 它們也是組件的輸入)沒有變化, 組件的輸出就不會變更.
和 React 組件的輸出輸出模型相比, Cyclejs對組件輸入/輸出的抽象則作的更加完全,更加‘函數式’👇。它的組件就是一個普通的函數,只有'單向'的輸入和輸出:
函數式編程和組件式編程思想某種意義上是一致的, 它們都是'組合'的藝術. 一個大的函數能夠有多個職責單一函數組合而成. 組件也是如此. 咱們將一個大的組件拆分爲子組件, 對組件作更細粒度的控制, 保持它們的純淨性, 讓它們的職責更單一, 更獨立. 這帶來的好處就是可複用性, 可測試性和可預測性.
純組件對 React 的性能優化也有重要意義. 若是一個組件是一個純組件, 若是'輸入'沒有變更, 那麼這個組件就不須要從新渲染. 組件樹越大, 純組件帶來的性能優化收益就越高.
咱們能夠很容易地保證一個底層組件的純淨性, 由於它原本就很簡單. 可是對於一個複雜的組件樹, 則須要花點心思進行構建, 因此就有了'狀態管理'的需求. 這些狀態管理器一般都在組件樹的外部維護一個或多個狀態庫, 而後經過依賴注入形式, 將局部的狀態注入到子樹中. 經過視圖和邏輯分離的原則, 來維持組件樹的純淨性.
Redux 就是一個典型的解決方案, 在 Redux 的世界裏能夠認爲一個複雜的組件樹就是一顆狀態樹的映射, 只要狀態樹(須要依靠不可變數據來保證狀態的可預測性)不變, 組件樹就不變. Redux 建議保持組件的純淨性, 將組件狀態交給 Redux 和配套的異步處理工具來維護, 這樣就將整個應用抽象成了一個"單向的數據流", 這是一種簡單的"輸入/輸出"關係
無論是 Cyclejs 仍是 Redux,抽象是須要付出一點代價的,就好比 redux 代碼可能會很羅嗦; 一個複雜的狀態樹, 若是缺少良好的組織,整個應用會變得很難理解。實際上, 並非全部場景都可以順利/優雅經過'數據驅動'進行表達(能夠看一下這篇文章Modal.confirm 違反了 React 的模式嗎?), 例如文本框焦點, 或者模態框. 因此沒必要極端追求無反作用或者數據驅動
後續會專門寫篇文章來回顧總結狀態管理.
擴展:
佈局組件
和內容組件
Grid
, Layout
, HorizontalSplit
Button
, Label
, Input
例以下圖, List/List.Item 就是佈局組件,而 Input,Address 則是內容組件
將佈局從內容組件中抽取出來,分離佈局和內容,可讓二者更好維護,好比佈局變更不會影響內容,內容組件能夠被應用不一樣的佈局; 另外一方面組件是一個自包含內聚的隔離單元, 不該該影響其外部的狀態, 例如一個按鈕不該該修改外部的佈局, 另外也要避免影響全局的樣式
數據錄入組件, 或者稱爲表單, 是客戶端開發必不可少的元素. 對於自定義表單組件, 我認爲應該保持一致的 API:
interface Props<T> { value?: T; onChange: (value?: T) => void; }
這樣作的好處:
value
應該是自包含的:好比一個支持搜索的用戶選擇器, option 都是異步從後端加載, 若是 value 只保存用戶 id, 那麼回顯的時候就沒法顯示用戶名, 按照個人實踐的 value 的結構應該爲: {id: string, name: string}
, 這樣就解決了回顯問題. 回顯須要的數據都是由父節點傳遞進來, 而不是組件本身維護
關於項目目錄結構的劃分有兩種流行的模式:
components
、constants
、 typings
、views
實際的項目環境咱們通常使用的是混合模式,下面是一個典型的 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
風格劃分了LoginPage
和HomePage
目錄, 將全部該業務或者頁面相關的文件聚合在一塊兒; 這裏也使用Rails-style
模式根據文件類型/職責劃分不一樣的目錄, 好比components
, hooks
, containers
; 你會發如今LoginPage
內部也有相似Rails-Style
的結構, 如components
, 只不過它的做用域不一樣, 它只歸屬於LoginPage
, 不能被其餘 Page 共享
前端項目通常按照頁面路由來拆分組件, 這些組件咱們暫且稱爲‘頁面組件’, 這些組件是和業務功能耦合的,並且每一個頁面之間具備必定的獨立性.
這裏將頁面組件放置在containers
, 如其名,這個目錄本來是用來放置容器組件的, 實際項目中一般是將‘容器組件’和‘頁面組件’混合在了一塊兒, 現階段若是要實現純粹的邏輯分離,我我的以爲仍是應該抽取到 hook 中. 這個目錄也能夠命名爲 views, pages...(whatever), 命名爲 containers 只是一種習慣(來源於 Redux).
擴展:
對於大型應用可能有多個應用入口, 例如不少 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-plugin
4.0 開始支持注入共享 chunk. 在此以前須要經過 SplitChunksPlugin 顯式定義共享的 chunk, 而後也要 html-webpack-plugin 顯式注入該 chunk, 比較挫.
上面的方式, 全部頁面都彙集在一個項目下面, 共享同樣的依賴和 npm 模塊. 這可能會帶了一些問題:
這種場景能夠利用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模塊的依賴
擴展:
使用 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就是這樣組織的.
對於國內的開發者來講,跨平臺可不僅 Native 那麼簡單,咱們還有各類各樣的小程序、小應用。終端的碎片化讓前端的開發工做愈來愈有挑戰性.
Taro 就這樣誕生了, Taro 基於 React 的標準語法(DSL), 結合編譯原理的思想, 將一套代碼轉換爲多種終端的目標代碼, 並提供一套統一的內置組件庫和 SDK 來抹平多端的差別
由於 Taro 使用 React 的標準語法和 API,這使得咱們按照原有的 React 開發約定和習慣來開發多端應用,且只保持一套代碼. 可是不要忘了抽象都是有代價的
能夠查看 Taro 官方文檔 瞭解更多
Flutter是近期比較或的跨平臺方案,可是跟本文主題無關
下圖是一個某頁面的模塊導入,至關混亂,這還算能夠接受,筆者還見過上千行的組件,其中模塊導入語句就佔一百多行. 這有一部分緣由多是 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/Foo
、 components/Bar
和constants.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 提供的的某個方法
最後再總結一下:
根據模塊邊界原則(如上圖): 一個模塊能夠訪問兄弟(同個做用域下)、 祖先及祖先的兄弟模塊. 例如:
../Foo/types.ts
, 但能夠訪問它的出口文件../Foo
Named export
vs default export
這兩種導出方式都有各自的適用場景,這裏不該該一棒子打死就不使用某種導出方式. 首先看一下named export 有什麼優勢:
命名肯定
方便 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
也有一些缺點:
default export
: require('./xx').default
named import
優勢就是default export
的缺點因此總結一下:
按照這個規則能夠這樣子組織 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';
循環依賴是模塊糟糕設計的一個表現, 這時候你須要考慮拆分和設計模塊文件, 例如
// --- 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' ...
當項目愈來愈複雜, 目錄可能會愈來愈深, 這時候會出現這樣的導入路徑:
import { hide } from '../../../utils/dom';
首先這種導入語句很是不優雅, 並且可讀性不好. 當你在不清楚當前文件的目錄上下文時, 你不知道具體模塊在哪; 即便你知道當前文件的位置, 你也須要跟隨導入路徑在目錄樹中向上追溯在能定位到具體模塊. 因此這種相對路徑是比較反人類的.
另外這種導入路徑不方便模塊遷移(儘管 Vscode 支持移動文件時重構導入路徑), 文件遷移須要重寫這些相對導入路徑.
因此通常推薦相對路徑導入不該該超過兩級, 即只能是../
和./
. 能夠嘗試將相對路徑轉換成絕對路徑形式, 例如webpack
中能夠配置resolve.alias
屬性來實現:
... resolve: { ... alias: { // 能夠直接使用~訪問相對於src目錄的模塊 // 如 ~/components/Button '~': context, }, }
如今咱們能夠這樣子導入相對於src
的模塊:
import { hide } from '~/utils/dom';
擴展
babel-plugin-module-resolver
插件來轉換爲相對路徑當 render 方法的 JSX 結構很是複雜的時候, 首先應該嘗試分離這些 JSX, 最簡單的作法的就是拆分爲多個子 render 方法:
固然這種方式只是暫時讓 render 方法看起來沒有那麼複雜, 它並無拆分組件自己, 全部輸入和狀態依然彙集在一個組件下面. 因此一般拆分 render 方法只是重構的第一步: 隨着組件愈來愈複雜, 表現爲文件愈來愈長, 筆者通常將 300 行做爲一個閾值, 超過 300 行則說明須要對這個組件進進一步拆分
若是已經按照 👆 上述方法對組件的 render 拆分爲多個子 render, 當一個組件變得臃腫時, 就能夠方便地將這些子 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> ) }
邏輯和視圖分離
的原則, 將邏輯控制部分抽離到 hooks 或高階組件中咱們通常會從 UI 原型圖中分析和劃分組件, 在 React 官方的Thinking in react也提到經過 UI 來劃分組件層級: "_這是由於 UI 和數據模型每每遵循着相同的信息架構,這意味着將 UI 劃分紅組件的工做每每是很容易的。只要把它劃分紅能準確表示你數據模型的一部分的組件就能夠_". 組件劃分除了須要遵循上文 👆 提到的一些原則, 他還依賴於你的開發經驗.
本節經過一個簡單的應用講述劃分組件的過程. 這是某政府部門的服務申報系統, 一共由四個頁面組成:
頁面一般是最頂層的組件單元, 劃分頁面很是簡單, 咱們根據原型圖就能夠劃分四個頁面: ListPage
, CreatePage
, PreviewPage
, DetailPage
src/ containers/ ListPage/ CreatePage/ PreviewPage/ DetailPage/ index.tsx # 根組件, 通常在這裏定義路由
首先看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:
對於 ListPage 來講狀態比較簡單, 這裏主要討論 CreatePage 的狀態. CreatePage 的特色:
因爲須要在 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>
組件的文檔化推薦使用Storybook, 這是一個組件 Playground
, 有如下特性
React 示例. 因爲篇幅緣由, Storybook 就不展開細節, 有興趣的讀者能夠參考官方文檔.