react-coat支持 SPA 單頁和 SSR 的 React 微框架
react 生態(tài)圈的開放、自由、繁榮,也導(dǎo)致開發(fā)配置繁瑣、選擇迷茫。react-coat 放棄某些靈活性、以約定替代某些配置,固化某些最佳實踐方案,從而提供給開發(fā)者一個更簡潔的糖衣外套。
你還在老老實實按照原生redux教程維護(hù)store么?試試簡單到幾乎不用學(xué)習(xí)就能上手的react-coat吧:
4.0 發(fā)布
去除 redux-saga,改用原生的 async 和 await 來組織和管理 effect
同時支持 SPA(單頁應(yīng)用)和 SSR(服務(wù)器渲染)、完整的支持客戶端與服務(wù)端同構(gòu)
react-coat 特點
集成 react、redux、react-router、history 等相關(guān)框架
僅為以上框架的糖衣外套,不改變其基本概念,無強侵入與破壞性
結(jié)構(gòu)化前端工程、業(yè)務(wù)模塊化,支持按需加載
同時支持 SPA(單頁應(yīng)用)和 SSR(服務(wù)器渲染)
使用 typescript 嚴(yán)格類型,更好的靜態(tài)檢查與智能提示
開源微框架,源碼不到千行,幾乎不用學(xué)習(xí)即可上手
安裝 react-coat
$ npm install react-coat
依賴周邊生態(tài)庫:
"peerDependencies": {
"@types/node": "^9.0.0 || ^10.0.0",
"@types/history": "^4.0.0",
"@types/react": "^16.0.0",
"@types/react-dom": "^16.0.0",
"@types/react-redux": "^5.0.0 || ^6.0.0",
"@types/react-router-dom": "^4.0.0",
"connected-react-router": "^4.0.0 || ^5.0.0",
"history": "^4.0.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-redux": "^5.0.0",
"react-router-dom": "^4.0.0",
"redux": "^3.0.0 || ^4.0.0"
},
如果你想省心,并且對以上依賴版本沒有特別要求,你可以安裝"all in 1"的 react-coat-pkg,它將自動包含以上庫,并測試通過各版本不沖突:
$ npm install react-coat-pkg
兼容性
各主流瀏覽器、IE9 或 IE9 以上
本框架依賴于完整版"Promise",低版本瀏覽器請自行安裝 polyfill,推薦安裝@babel/polyfill,該庫可模擬 unhandledrejection error,當(dāng)你需要在客戶端捕捉錯誤并上報時需要。
快速上手及 Demo
本框架上手簡單
-
8 個新概念:
Effect、ActionHandler、Module、ModuleState、RootState、Model、View、Component
-
4 步創(chuàng)建:
exportModel(), exportView(), exportModule(), createApp()
-
3 個 Demo,循序漸進(jìn):
API 一覽
BaseModuleHandlers, BaseModuleState, buildApp, delayPromise, effect, ERROR, errorAction, exportModel, exportModule, exportView, GetModule, INIT, LoadingState, loadModel, loadView, LOCATION_CHANGE, logger, ModelStore, Module, ModuleGetter, reducer, renderApp, RootState, RouterParser, setLoading, setLoadingDepthTime
與 螞蟻金服 Dav 的異同
本框架與
Dvajs理念略同,主要差異:
引入 ActionHandler 觀察者模式,更優(yōu)雅的處理模塊之間的協(xié)作
去除 redux-saga,使用 async、await 替代,簡化代碼的同時對 TS 類型支持更全面
原生使用 typescript 組織和開發(fā),更全面的類型安全
路由組件化、無 Page 概念、更自然的 API 和更簡單的組織結(jié)構(gòu)
更大的靈活性和自由度,不強封裝腳手架等
支持 SPA(單頁應(yīng)用)和 SSR(服務(wù)器渲染)一鍵切換,
支持模塊異步按需加載和同步加載一鍵切換
差異示例:使用強類型組織所有 reducer 和 effect
// Dva中常這樣寫
dispatch({ type: 'moduleA/query', payload:{username:"jimmy"}} })
//本框架中可直接利用ts類型反射和檢查:
this.dispatch(moduleA.actions.query({username:"jimmy"}))
差異示例:State 和 Actions 支持繼承
// Dva不支持繼承
// 本框架可以直接繼承
class ModuleHandlers extends ArticleHandlers<State, PhotoResource> {
constructor() {
super({}, {api});
}
@effect()
protected async parseRouter() {
const result = await super.parseRouter();
this.dispatch(this.actions.putRouteData({showComment: true}));
return result;
}
@effect()
protected async [ModuleNames.photos + "/INIT"]() {
await super.onInit();
}
}
差異示例:在 Dva 中,因為使用 redux-saga,假設(shè)在一個 effect 中使用 yield put 派發(fā)一個 action,以此來調(diào)用另一個 effect,雖然 yield 可以等待 action 的派發(fā),但并不能等待后續(xù) effect 的處理:
// 在Dva中,updateState并不會等待otherModule/query的effect處理完畢了才執(zhí)行
effects: {
* query (){
yield put({type: 'otherModule/query',payload:1});
yield put({type: 'updateState', payload: 2});
}
}
// 在本框架中,可使用awiat關(guān)鍵字, updateState 會等待otherModule/query的effect處理完畢了才執(zhí)行
class ModuleHandlers {
async query (){
await this.dispatch(otherModule.actions.query(1));
this.dispatch(thisModule.actions.updateState(2));
}
}
差異示例:如果 ModuleA 進(jìn)行某項操作成功之后,ModuleB 或 ModuleC 都需要 update 自已的 State,由于缺少 action 的觀察者模式,所以只能將 ModuleB 或 ModuleC 的刷新動作寫死在 ModuleA 中:
// 在Dva中需要主動Put調(diào)用ModuleB或ModuleC的Action
effects: {
* update (){
...
if(callbackModuleName==="ModuleB"){
yield put({type: 'ModuleB/update',payload:1});
}else if(callbackModuleName==="ModuleC"){
yield put({type: 'ModuleC/update',payload:1});
}
}
}
// 在本框架中,可使用ActionHandler觀察者模式:
class ModuleB {
//在ModuleB中兼聽"ModuleA/update" action
async ["ModuleA/update"] (){
....
}
}
class ModuleC {
//在ModuleC中兼聽"ModuleA/update" action
async ["ModuleA/update"] (){
....
}
}
基本概念與名詞
前提:假設(shè)你已經(jīng)熟悉了 React 和 Redux,有過一定的開發(fā)經(jīng)驗
Store、Reducer、Action、State、Dispatch
以上概念與 Redux 基本一致,本框架無強侵入性,遵循 react 和 redux 的理念和原則:
M 和 V 之間使用單向數(shù)據(jù)流
整站保持單個 Store
Store 為 Immutability 不可變數(shù)據(jù)
改變 Store 數(shù)據(jù),必須通過 Reducer
調(diào)用 Reducer 必須通過顯式的 dispatch Action
Reducer 必須為 pure function 純函數(shù)
有副作用的行為,全部放到 Effect 函數(shù)中
每個 reducer 只能修改 Store 下的某個節(jié)點,但可以讀取所有節(jié)點
路由組件化,不使用集中式配置
Effect
我們知道在 Redux 中,改變 State 必須通過 dispatch action 以觸發(fā) reducer,在 reducer 中返回一個新的 state, reducer 是一個 pure function 純函數(shù),無任何副作用,只要入?yún)⑾嗤?,其返回結(jié)果也是相同的,并且是同步執(zhí)行的。而 effect 是相對于 reducer 而言的,與 reducer 一樣,它也必須通過 dispatch action 來觸發(fā),不同的是:
它是一個非純函數(shù),可以包含副作用,可以無返回,也可以是異步的。
它不能直接改變 State,要改變 State,它必須再次 dispatch action 來觸發(fā) reducer
ActionHandler
我們可以簡單的認(rèn)為:在 Redux 中 store.dispatch(action),可以觸發(fā)一個注冊過的 reducer,看起來似乎是一種觀察者模式。推廣到以上的 effect 概念,effect 同樣是一個觀察者。一個 action 被 dispatch,可能觸發(fā)多個觀察者被執(zhí)行,它們可能是 reducer,也可能是 effect。所以 reducer 和 effect 統(tǒng)稱為:ActionHandler
-
如果有一組 actionHandler 在兼聽某一個 action,那它們的執(zhí)行順序是什么呢?
答:當(dāng)一個 action 被 dispatch 時,最先執(zhí)行的是所有的 reducer,它們被依次同步執(zhí)行。所有的 reducer 執(zhí)行完畢之后,才開始所有 effect 執(zhí)行。
我想等待這一組 actionHandler 全部執(zhí)行完畢之后,再下一步操作,可是 effect 是異步執(zhí)行的,我如何知道所有的 effect 都被處理完畢了? 答:本框架改良了 store.dispatch()方法,如果有 effect 兼聽此 action,它會返回一個 Promise,所以你可以使用 await store.dispatch({type:"search"}); 來等待所有的 effect 處理完成。
Module
當(dāng)我們接到一個復(fù)雜的前端項目時,首先要化繁為簡,進(jìn)行功能拆解。通常以高內(nèi)聚、低偶合的原則對其進(jìn)行模塊劃分,一個 Module 是相對獨立的業(yè)務(wù)功能的集合,它通常包含一個 Model(用來處理業(yè)務(wù)邏輯)和一組 View(用來展示數(shù)據(jù)與交互),需要注意的是:
SPA 應(yīng)用已經(jīng)沒有了 Page 的邊界,不要以 Page 的概念來劃分模塊
一個 Module 可能包含一組 View,不要以 View 的概念來劃分模塊
Module 雖然是邏輯上的劃分,但我們習(xí)慣于用文件夾目錄來組織與體現(xiàn),例如:
src ├── modules │ ├── user │ │ ├── userOverview(Module) │ │ ├── userTransaction(Module) │ │ └── blacklist(Module) │ ├── agent │ │ ├── agentOverview(Module) │ │ ├── agentBonus(Module) │ │ └── agentSale(Module) │ └── app(Module)
通過以上可以看出,此工程包含 7 大模塊 app、userOverview、userTransaction、blacklist、agentOverview、agentBonus、agentSale,雖然 modules 目錄下面還有子目錄 user、angent,但它們僅屬于歸類,不屬于模塊。我們約定:
每個 Module 是一個獨立的文件夾
Module 本身只有一級,但是可以放在多級的目錄中進(jìn)行歸類
每個 Module 文件夾名即為該 Module 名,因為所有 Module 都是平級的,所以需要保證 Module 名不重復(fù),實踐中,我們可以通過 Typescript 的 enum 類型來保證,你也可以將所有 Module 都放在一級目錄中。
每個 Module 保持一定的獨立性,它們可以被同步、異步、按需、動態(tài)加載
ModuleState、RootState
系統(tǒng)被劃分為多個相對獨立且平級的 Module,不僅體現(xiàn)在文件夾目錄,更體現(xiàn)在 Store 上。每個 Module 負(fù)責(zé)維護(hù)和管理 Store 下的一個節(jié)點,我們稱之為 ModuleState,而整個 Store 我們習(xí)慣稱之為RootState
例如:某個 Store 數(shù)據(jù)結(jié)構(gòu):
{
router:{...},// StoreReducer
app:{...}, // ModuleState
userOverview:{...}, // ModuleState
userTransaction:{...}, // ModuleState
blacklist:{...}, // ModuleState
agentOverview:{...}, // ModuleState
agentBonus:{...}, // ModuleState
agentSale:{...} // ModuleState
}
每個 Module 管理并維護(hù) Store 下的某一個節(jié)點,我們稱之為 ModuleState
每個 ModuleState 都是 Store 的根子節(jié)點,并以 Module 名為 Key
每個 Module 只能修改自已的 ModuleState,但是可以讀取其它 ModuleState
每個 Module 修改自已的 ModuleState,必須通過 dispatch action 來觸發(fā)
每個 Module 可以觀察者身份,監(jiān)聽其它 Module 發(fā)出的 action,來配合修改自已的 ModuleState
你可能注意到上面 Store 的子節(jié)點中,第一個名為 router,它并不是一個 ModuleState,而是一個由第三方 Reducer 生成的節(jié)點。我們知道 Redux 中允許使用多個 Reducer 來共同維護(hù) Stroe,并提供 combineReducers 方法來合并。由于 ModuleState 的 key 名即為 Module 名,所以:Module名自然也不能與其它第三方Reducer生成節(jié)點重名。
Model
在 Module 內(nèi)部,我們可進(jìn)一步劃分為一個model(維護(hù)數(shù)據(jù))和一組view(展現(xiàn)交互),此處的 Model 實際上指的是 view model,它主要包含兩大功能:
ModuleState 的定義
ModuleState 的維護(hù),前面有介紹過 ActionHandler,實際上就是對 ActionHandler 的編寫
數(shù)據(jù)流是從 Model 單向流入 View,所以 Model 是獨立的,是不依賴于 View 的。所以理論上即使沒有 View,整個程序依然是可以通過命令行來驅(qū)動的。
我們約定:
集中在一個名為model.js的文件中編寫 Model,并將此文件放在本模塊根目錄下
集中在一個名為ModuleHandlers的 class 中編寫 所有的 ActionHandler,每個 reducer、effect 都對應(yīng)該 class 中的一個方法
例如,userOverview 模塊中的 Model:
src ├── modules │ ├── user │ │ ├── userOverview(Module) │ │ │ ├──views │ │ │ └──model.ts │ │ │
src/modules/user/userOverview/model.ts
// 定義本模塊的ModuleState類型
export interface State extends BaseModuleState {
listSearch: {username:string; page:number; pageSize:number};
listItems: {uid:string; username:string; age:number}[];
listSummary: {page:number; pageSize:number; total:number};
loading: {
searchLoading: LoadingState;
};
}
// 定義本模塊所有的ActionHandler
class ModuleHandlers extends BaseModuleHandlers<State, RootState, ModuleNames> {
constructor() {
// 定義本模塊ModuleState的初始值
const initState: State = {
listSearch: {username:null, page:1, pageSize:20},
listItems: null,
listSummary: null,
loading: {
searchLoading: LoadingState.Stop,
},
};
super(initState);
}
// 一個reducer,用來update本模塊的ModuleState
@reducer
public putSearchList({listItems, listSummary}): State {
return {...this.state, listItems, listSummary};
}
// 一個effect,使用ajax查詢數(shù)據(jù),然后dispatch action來觸發(fā)以上putSearchList
// this.dispatch是store.dispatch的引用
// searchLoading指明將這個effect的執(zhí)行狀態(tài)注入到State.loading.searchLoading中
@effect("searchLoading")
public async searchList(options: {username?:string; page?:number; pageSize?:number} = {}) {
// this.state指向本模塊的ModuleState
const listSearch = {...this.state.listSearch, ...options};
const {listItems, listSummary} = await api.searchList(listSearch);
this.dispatch(this.action.putSearchList({listItems, listSummary}));
}
// 一個effect,監(jiān)聽其它Module發(fā)出的Action,然后改變自已的ModuleState
// 因為是監(jiān)聽其它Module發(fā)出的Action,所以它不需要主動觸發(fā),使用非public權(quán)限對外隱藏
// @effect(null)表示不需要跟蹤此effect的執(zhí)行狀態(tài)
@effect(null)
protected async ["@@router/LOCATION_CHANGE]() {
// this.rootState指向整個Store
if(this.rootState.router.location.pathname === "/list"){
// 使用await 來等待所有的actionHandler處理完成之后再返回
await this.dispatch(this.action.searchList());
}
}
}
需要特別說明的是以上代碼的最后一個 ActionHandler:
protected async ["@@router/LOCATION_CHANGE](){
// this.rootState指向整個Store
if(this.rootState.router.location.pathname === "/list"){
await this.dispatch(this.action.searchList());
}
}
前面有強調(diào)過兩點:
Module 可以兼聽其它 Module 發(fā)出的 Action,并配合來完成自已 ModuleState 的更新。
Module 只能更新自已的 ModuleState 節(jié)點,但是可以讀取整個 Store。
另外注意到語句:await this.dispatch(this.action.searchList()):
-
dispatch 派發(fā)一個名為 searchList 的 action 可以理解,可是為什么前面還能 awiat?難道 dispatch action 也是異步的?
答:dispatch 派發(fā) action 本身是同步的,我們前面講過 ActionHandler 的概念,一個 action 被 dispatch 時,可能有一組 reducer 或 effect 在兼聽它,reducer 是同步處理的,可是 effect 可能是異步處理的,如果你想等所有的兼聽都執(zhí)行完成之后,再做下一步操作,此處就可以使用 await,否則,你可以不使用 await。
View、Component
在 Module 內(nèi)部,我們可進(jìn)一步劃分為一個model(維護(hù)數(shù)據(jù))和一組view(展現(xiàn)交互)。所以一個 Module 中的 view 可能有多個,我們習(xí)慣在 Module 根目錄下創(chuàng)建一個名為 views 的文件夾:
例如,userOverview 模塊中的 views:
src ├── modules │ ├── user │ │ ├── userOverview(Module) │ │ │ ├──views │ │ │ │ ├──imgs │ │ │ │ ├──List │ │ │ │ │ ├──index.css │ │ │ │ │ └──index.ts │ │ │ │ ├──Main │ │ │ │ │ ├──index.css │ │ │ │ │ └──index.ts │ │ │ │ └──index.ts │ │ │ │ │ │ │ │ │ │ │ └──model.ts │ │ │
每個 view 其實是一個 React Component 類,所以使用大寫字母打頭
對于 css 和 img 等附屬資源,如果是屬于某個 view 私有的,跟隨 view 放到一起,如果是多個 view 公有的,提出來放到公共目錄中。
view 可以嵌套,包括可以給別的 Module 中的 view 嵌套,如果需要給別的 Module 使用,必須在 views/index.ts 中使用
exportView()導(dǎo)出。在 view 中通過 dispatch action 的方式觸發(fā) Model 中的 ActionHandler,除了可以 dispatch 本模塊的 action,也能 dispatch 其它模塊的 action
例如,某個 LoginForm:
interface Props extends DispatchProp {
logining: boolean;
}
class Component extends React.PureComponent<Props> {
public onLogin = (evt: any) => {
evt.stopPropagation();
evt.preventDefault();
// 發(fā)出本模塊的action,將觸發(fā)本model中定義的名為login的ActionHandler
this.props.dispatch(thisModule.actions.login({username: "", password: ""}));
};
public render() {
const {logining} = this.props;
return (
<form className="app-Login" onSubmit={this.onLogin}>
<h3>請登錄</h3>
<ul>
<li><input name="username" placeholder="Username" /></li>
<li><input name="password" type="password" placeholder="Password" /></li>
<li><input type="submit" value="Login" disabled={logining} /></li>
</ul>
</form>
);
}
}
const mapStateToProps = (state: RootState) => {
return {
logining: state.app.loading.login !== LoadingState.Stop,
};
};
export default connect(mapStateToProps)(Component);
從以上代碼可看出,View 就是一個 Component,那 View 和 Component 有區(qū)別嗎?編碼上沒有,邏輯上是有的:
view 體現(xiàn)的是 ModuleState 的視圖展現(xiàn),更偏重于表現(xiàn)特定的具體的業(yè)務(wù)邏輯,所以它的 props 一般是直接用 mapStateToProps connect 到 store。
component 體現(xiàn)的是一個沒有業(yè)務(wù)邏輯上下文的純組件,它的 props 一般來源于父級傳遞。
component 通常是公共的,而 view 通常非公用
路由與動態(tài)加載
react-coat 贊同 react-router 4 組件化路由的理念,路由即組件,嵌套路由好比嵌套 component 一樣簡單,無需繁瑣的配置。如:
import {BottomNav} from "modules/navs/views"; // BottomNav 來自于 navs 模塊
import LoginForm from "./LoginForm"; // LoginForm 來自于本模塊
// PhotosView 和 VideosView 分別來自于 photos 模塊和 videos 模塊,使用異步按需加載
const PhotosView = loadView(moduleGetter, ModuleNames.photos, "Main");
const VideosView = loadView(moduleGetter, ModuleNames.videos, "Main");
<div className="g-page">
<Switch>
<Route exact={false} path="/photos" component={PhotosView} />
<Route exact={false} path="/videos" component={VideosView} />
<Route exact={true} path="/login" component={LoginForm} />
</Switch>
<BottomNav />
</div>
以上某個 view 中以不同加載方式嵌套了多個其它 view:
BottomNav 是一個名為 navs 模塊下的 view,直接嵌套意味著它會同步加載到本 view 中
LoginForm 是本模塊下的一個 view,所以直接用相對路徑引用,同樣直接嵌套,意味著它會同步加載
PhotosView 和 VideosView 來自于別的模塊,但是是通過 loadView()獲取和 Route 嵌套,意味著它們會異步按需加載,當(dāng)然你也可以直接 import {PhotosView} from "modules/photos/views"來同步按需加載
所以本框架對于模塊和視圖的加載靈活簡單,無需復(fù)雜配置與修改:
不管是同步、異步、按:需、動態(tài)加載,要改變的僅僅是加載方式,而不用修改被加載的模塊。模塊本身并不需要事先擬定自已將被誰、以何種方式加載,保證的模塊的獨立性。
前面講過,view 是 model 數(shù)據(jù)的展現(xiàn),那嵌入其它模塊 view 時,是否還要導(dǎo)入其它模塊的 model 呢?無需,框架將自動導(dǎo)入。
幾個特殊的 Action
@@router/LOCATION_CHANGE:本框架集成了 connected-react-router,路由發(fā)生變化時將觸發(fā)此 action,你可以在 moduleHandlers 中監(jiān)聽此 action
"@@framework/ERROR:本框架 catch 了未處理的 error,發(fā)生 error 時將自動派發(fā)此 action,你可以在 moduleHandlers 中監(jiān)聽此 action
module/INIT:模塊初次載入時會觸發(fā)此 action,來向 store 注入初始 moduleState
module/LOADING:觸發(fā)加載進(jìn)度時會觸發(fā)此 action,比如 @effect(login)
