談大型React app檔案架構

Amdis Liu
Frochu
Published in
10 min readSep 28, 2017

當時在工作上有需求,要從零打造一個前端系統,且放眼長期,預計會是個多模組(五個以上)的web application,模組之間部分相依但大部分沒有相依性,像是有專門管認證的authentication module,負責管理使用者的admin module,負責管理內容的CMS module等等。

而我期望每個模組未來的掛載、卸載都是非常輕鬆,如同plugin或library一般,最好還可以弄成Node package,未來若不同客戶有不同模組需求(咦,為何講得像是在賣SAP/ERP?),則不install或import那些不需要的模組。

基於以上構想,我與同事 Chia-Wei Li 開始在網路上搜尋”Large React application structure”、”大型React專案架構”等關鍵字“,但當時找到的文章實在不多,偶然讀到一篇 Three Rules For Structuring (Redux) Applications 覺得非常認同,

引用文章中提到的三個架構原則 :

1. Organize by features

2. Create strict module boundaries

3. Avoid circular dependencies

繼而設計出了一套現在我們開發普遍使用的架構。

基於以上原則,架構將會是模組化的概念(organize by features & with boundaries),且需思考什麼模組是共用(eg. general UI元件模組)、什麼是非共用因此不該直接引用,去避免 circular dependencies。

Application Skeleton

以上述提到的模組情況假設,應用程式JS source檔案結構將如下:

src/
js/
app/
components/
containers/
actions.js
actionTypes.js
constants.js
features.js
index.js
reducer.js
route.js
selectors.js
store.js
auth/
components/
containers/
actions.js
actionTypes.js
constants.js
index.js
reducer.js
route.js
selectors.js
admin/
cms/

通用檔案定義

route.js是react-router的routing設定,以react-router v4的大約如下

// route.js of sub module , using react-router v4import { Route, Switch } from 'react-router-dom';<Switch>
<Route exact path={match.url} component={SigninContainer} />
<Route exact path={`${match.url}/forget-pwd`} component= {ForgetPWdContainer} />
</Switch>

components/ , containers/ 顧名思義是拿來存放大家常講的Presentational Components (Dumb Components) 和 Container components (Smart Components),我通常以是否連接Redux store來做區別。

Container Components vs Presentational Components

selectors.js 是為了reselect 這個library而存在,用來寫selector functions,讓我們來看看reselect Github repo的簡介前幾句 :

  • Selectors can compute derived data, allowing Redux to store the minimal possible state.
  • Selectors are efficient. A selector is not recomputed unless one of its arguments change.
  • Selectors are composable. They can be used as input to other selectors.

簡單來說,reselect扮演的角色是Redux Store資料的getters,可以自己寫函數將store的資料運算過後再提供給介面,更重要的是,API建立的selector function提供了memoized功能,當function傳入的參數沒有變化時,就不會重新運算,因此拿來當containers和store之間拿取資料的中介者,讓container components不用多寫亂七八糟的code處理資料轉換(像是資料過濾),程式碼變得乾淨,也讓rendering效能提升,因為省去了沒有必要的運算。
API亦支援將多個selectors組合,產生的selector依然是memoized,可以參考一下簡中版Redux Gitbook關於reselect的說明

App模組與子功能模組的整合

app模組除了有自己的UI, data之外,
很重要的功能就是: “ 組裝各個子模組,將它們納為己用” ,
對於外界來說,app module就是app entrypoint
那子模組會有怎樣的統一規範,好讓app 模組順利組裝呢?

統一子模組的default export

子模組根目錄的index.js中, 至少要輸出三種東西:reducer, constants , route

// index.js of sub moduleimport * as constants from './constants';
import reducer from './reducer';
import route from './route';
export default { constants, reducer, route };

constants.js預設一個變數NAME 作為模組辨識名稱,這很重要,之後會用於reducer的名字和browser url:

// constants.jsexport const NAME = 'auth'

app module 開始組裝子模組

要組裝的部分分為兩部分 :

  1. UI — 組合routing
  2. Data — 組合reducers
Combine sub modules into app module

步驟一:在features.js定義app可使用的子模組們

// features.js of app moduleimport admin from '../admin';
import auth from '../auth';
import cms from '../cms';
export default [
admin,
auth,
cms,
]

步驟二:組合routing

// route.js of app module
// using react-router v4
import features from './features';
import { Redirect, Route, Switch } from 'react-router-dom';
const AppSwitch = ({ match }) => (
<Switch>
<Route exact path={match.url} component={LandingPage} />
{features.map(feature => (
<Route
key={feature.constants.NAME}
path={`${match.url}${feature.constants.NAME}`}
component={feature.route}
/>
))}
<Redirect to={match.url} />
</Switch>
);

此處用到子模組定義的NAME常數,各子模組頁面的url就會是這樣開頭 /#/auth/* , /#/admin/* 依模組分類。

步驟三:組合reducers

import { routerReducer } from 'react-router-redux';
import { combineReducers } from 'redux';

import features from './features';

const reducers = {};
features.forEach((feature) => {
reducers[feature.constants.NAME] = feature.reducer;
});
reducers.routing = routerReducer;

export default combineReducers(reducers);

利用定義在features.js中的模組們,透過各模組default export的reducerNAME 將它們組合成一個single reducer。

最後,app module的index.js輸出組裝結果:

// index.js of app moduleimport React from 'react';

import { Provider } from 'react-redux';
import { Route } from 'react-router-dom';
import { ConnectedRouter } from 'react-router-redux';

import route from './route';
import history from './history';
import store from './store';

export default () => (
<Provider store={store}>
<ConnectedRouter history={history}>
<Route component={route} />
</ConnectedRouter>
</Provider>
);

檔案架構介紹到此結束,當整個application越長越大、越來越肥之後,就會遇到下一個問題:“頁面初始載入時,若沒有cache,就會花許多頻寬和下載時間,造成使用者體驗不佳,且每一個平台亦可能部署的模組數量不同,不需要全部的模組”。
下一步就要利用webpack做code splitting,加上routing merge時使用asynchronous loading,來改善bundles載入的頻寬與時間。

若你喜歡這篇文章覺得有幫助,或是有其他的架構經驗,請不吝留言與分享。

--

--

Amdis Liu
Frochu
Editor for

Web frontend / mobile developer. Editor of publication Frochu: https://medium.com/frochu .