單頁瀏覽器渲染(SPA)
和服務器渲染(SSR)
,本 Demo 僅演示瀏覽器渲染
,請先了解一下:react-coat v4.0你可能以爲本Demo中對路由封裝過於重度,以及不喜歡使用Class繼承的方式來組織Model。不要緊,本Demo只是演示衆多解決方案中的一種,並非惟一。react
本Demo中使用Class繼承的方式來組織Model,並不要求對React組件使用Class Component風格,並不違反React FP 編程思想和趨勢。隨着React Hooks的正式發佈,本框架將保持API不變的前提下,使用其替換掉Redux及相關庫。android
git clone https://github.com/wooline/react-coat-spa-demo.git
npm install
複製代碼
npm start
以開發模式運行npm run build
以產品模式編譯生成文件npm run prod-express-demo
以產品模式編譯生成文件並啓用一個 express 作 demonpm run gen-icon
自動生成 iconfont 文件及 ts 類型項目要求及說明請看:第一站 Helloworld,在第一站點,咱們總結了某些「美中不足」
,主要是 3 點:git
某市場人員說:評論內容很吸引眼球,但願分享連接的時候,能指定某些評論github
某腦洞大開的產品經理說,我但願你能在 android 手機上模擬原生 APP,點擊手機工具欄的
「返回」
鍵,能撤銷上一步操做,好比:點返回鍵能關閉彈窗。express
因此每一步操做,都要用一條 URL 來驅動?好比:旅行路線詳情+彈出評論彈窗+評論列表(按最新排序、第 2 頁)npm
看到這個長長的 URL,咱們不由想一想路由的本質是什麼?api
若是接受 RouterStore 這個概念,那咱們程序中就不是單例 Store,而是兩個 Store 了,但是 Redux 不是推薦單例 Store 麼?兩個 Store 那會不會把維護變複雜呢?瀏覽器
因此,咱們要特殊一點看待 RouterStore,僅把它看成是 ReduxStore 的一種冗餘設計
。也就是說,「你大爺仍是你大爺」,程序的運行仍是圍繞 ReduxStore 來展開,除了 router 信息的流入流出源頭以外,你就當 RouterStore 不存在,RouterStore 中有的信息,ReduxStore 中全都有。bash
好比在 photos 模塊中,詳情頁面須要控制評論的顯示與隱藏,因此咱們必須在 Store 中定義 showComment: boolean,而咱們還想經過 url 來控制它,因此在 url 中也有&photos-showComment=true,這時就出現 RouterStore 和 ReduxStore 中都有 showComment 這個狀態。你可能會想,那能不能把 ReduxStore 中的這個 showComment 去掉,直接使用 RouterStore 中的 showComment 就好?答案是不行的,不只不能省,並且在 photos.Details 這個 view 中依賴的狀態還必須是 ReduxStore 中的這個 showComment。
SearchData: {showComment: boolean}; // Router Store中的 showComment 不要直接在view中依賴
State: {showComment?: boolean}; // Redux Store中的 showComment
複製代碼
RouterStore 有着與 ReduxStore 相似的結構。
/photos/1/comments?comments-search={"articleId":"1","isNewest":true,"page":2}&photos-showComment=true#app-forceRefresh=true
將這個 URL 反序列化爲 RouterStore:
{
pathData: {
photos: {itemId: "1"},
comments: {type: "photos", typeId: "1"},
},
searchData: {
comments: {search: {articleId: "1", isNewest: true, page: 2}},
photos: {showComment: true},
},
hashData: {
app: {forceRefresh: true},
},
}
複製代碼
從以上示例看出,爲了解決命名空間的問題,在序列化爲 URL 時,爲每筆 key 增長了一個moduleName-
做爲前綴,而反序列化時,將該前綴去掉轉換成 JS 的數據結構,固然這個過程是能夠由某函數統一自動處理的。其它都好明白,就是pathData
是怎麼得來的?
/photos/1/comments
怎麼得出:
pathData: {
photos: {itemId: "1"},
comments: {type: "photos", typeId: "1"},
}
複製代碼
對於同一個 pathname,photos 模塊分析得出的 pathData 是 {itemId: "1"},而 comments 模塊分析得出的 pathData 是 {type: "photos", typeId: "1"},這是由於咱們配置了 pathname 與 view 的映射規則 viewToPath:
// ./src/modules/index.ts
// 定義整站路由與 view 的匹配模式
export const viewToPath: {[K in keyof ModuleGetter]: {[V in keyof ReturnModule<ModuleGetter[K]>["views"]]+?: string}} = {
app: {Main: "/"},
photos: {Main: "/photos", Details: "/photos/:itemId"},
videos: {Main: "/videos", Details: "/videos/:itemId"},
messages: {Main: "/messages"},
comments: {Main: "/:type/:typeId/comments", Details: "/:type/:typeId/comments/:itemId"},
};
複製代碼
反過來也能夠反向推導出 pathToView:
// ./src/common/routers.ts
// 根據 modules/index.ts中定義的 viewToPath 反推導出來,形如:
{
"/": ["app", "Main"],
"/photos": ["photos", "Main"],
"/photos/:itemId": ["photos", "Details"],
"/videos": ["videos", "Main"],
"/videos/:itemId": ["videos", "Details"],
"/messages": ["messages", "Main"],
"/:type/:typeId/comments": ["comments", "Main"],
"/:type/:typeId/comments/:itemId": ["comments", "Details"],
}
複製代碼
好比當前 pathname 爲:/photos/1/comments,匹配這條的有:
// 原來模塊自已去寫正則解析pathname
const arr = pathname.match(/^\/photos\/(\d+)$/);
// 變成集中統一處理,在使用時只須要引入強類型 PathData
pathData: {
photos: {itemId: "1"},
comments: {type: "photos", typeId: "1"},
}
複製代碼
結論
RouterStore 是一個公共的 Store,每一個模塊均可以往裏面存取信息,在反序列化時,因爲會統一自動加上 moduleName 做前綴,因此也不用擔憂命名衝突。
每一個 Module 自已定義自已的 router 結構,將全部 Module 的 router 結構合併起來,就組成了整個 RouterState。
咱們在上文:第一站 Helloworld中,提到過「參數默認值
」的概念:
Partial<WholeSearchData>
Required<SearchData>
因此,在模塊中對於路由部分有兩個工做要作:1.定義路由結構,2.定義默認參數,如:
// ./src/modules/photos/facade.ts
export const defRouteData: ModuleRoute<PathData, SearchData, HashData> = {
pathData: {},
searchData: {
search: {
title: "",
page: 1,
pageSize: 10,
},
showComment: false,
},
hashData: {},
};
複製代碼
咱們完整的 RouterStore 數據結構示例爲:
{
router: {
location: { // 由 connected-react-router 生成
pathname: '/photos/1/comments',
search: '?comments-search=%7B%22articleId%22...&photos-showComment=true',
hash: '',
key: 'dw8hbu'
},
action: 'PUSH', // 由 connected-react-router 生成
views: { // 根據 pathname 自動解析出當前展現哪些 module 的哪些 view
app: {Main: true},
photos: {Main: true, Details: true},
comments: {Main: true}
},
pathData: { // 根據 pathname 自動解析出參數
app: {},
photos: {itemId: '1'},
comments: {type: 'photos', typeId: '1'}
},
searchData: { // 根據 urlSearch 自動解析出參數
comments: {search: {articleId: '1', isNewest: true, page: 2}},
photos: {showComment: true}
},
hashData: {}, // 根據 urlHash 自動解析出參數
wholeSearchData: { // urlSearch merge 默認參數後的完整數據
comments: {search: {articleId: '1', isNewest: true, page: 2, pageSize: 10}},
photos: {search: {title: "", page: 1, pageSize: 10}, showComment: true},
app: {showSearch: false, showLoginPop: false, showRegisterPop: false }
},
wholeHashData: { //urlHash merge 默認參數後的完整數據
app: {forceRefresh: null},
photos: {},
comments: {}
}
},
}
複製代碼
也就是說,咱們根據:
/photos/1/comments?comments-search={"articleId":"1","isNewest":true,"page":2}&photos-showComment=true#app-forceRefresh=true
這個簡單的 URL 字符串,解析能得出上面複雜的路由數據結構。固然,這一切都是能夠自動處理的,react-coat 提供了自定義路由解析 hook:
export declare type RouterParser<T = any> = (nextRouter: T, prevRouter?: T) => T;
咱們只要定義好這個 RouterParser,磨刀不誤砍柴功,在後續使用時就至關方便了,具體定義的代碼見源文件:./src/common/routers.ts
前面說過,咱們須要在第一時間將 RouterStore 轉換爲 ReduxStore,這個源頭在哪裏?就是監控路由變化了,react-coat 集成了 connected-react-router,在路由變化時會 dispatch @@router/LOCATION_CHANGE
這個 Action。而框架自己也支持觀察者模式,因此就簡單了,只須要在模塊中監聽@@router/LOCATION_CHANGE
就行,例如:
// ./src/modules/app/model.ts
// 定義本模塊的Handlers
class ModuleHandlers extends BaseModuleHandlers<State, RootState, ModuleNames> {
...
protected async parseRouter() {
// this.rootState 指向整個 ReduxStore
const searchData = this.rootState.router.wholeSearchData.app;
this.updateState({
showSearch: Boolean(searchData.showSearch),
});
}
// 監聽路由變化的 action
@effect(null)
protected async ["@@router/LOCATION_CHANGE"]() {
this.parseRouter();
}
// 模塊第一次初始化時也須要
@effect()
protected async [ModuleNames.app + "/INIT"]() {
this.parseRouter();
}
}
複製代碼
路由問題告一段落,剩下還有一個大問題,就是如何避免重複代碼。在上文:第一站 Helloworld中提到:
注意到,photos/model.ts、videos/model.ts 中,90%的代碼是同樣的,爲何?由於它們兩個模塊基本上功能都是差很少的:列表展現、搜索、獲取詳情...
實際上,套用 RestFul 的理念,咱們用網頁交互的過程就是在對「資源 Resource」進行維護,無外乎「增刪改查」這些基本操做,大部分狀況下,它們的邏輯是類似的,由其是在後臺管理系統中,不少都是 table + 彈窗
的交互方式。
繼承和組合均可以用來抽象和提煉公共邏輯,由於 react-coat 支持 actionHandler 的繼承,因此咱們先嚐試使用繼承的方案來解決。
要繼承,先得抽象基類。
在 ./src/entity/resource.ts
中,咱們爲 resource 統必定義了這些基本的抽象類型:
export interface Defined {
State: {};
SearchData: {};
PathData: {};
HashData: {};
ListItem: {};
ListSearch: {};
ListSummary: {};
ItemDetail: {};
ItemEditor: {};
ItemCreateData: {};
ItemUpdateData: {};
ItemCreateResult: {};
ItemUpdateResult: {};
}
export type ResourceDefined = Defined & {
State: BaseModuleState;
PathData: {itemId?: string};
ListItem: {
id: string;
};
ListSearch: {
page: number;
pageSize: number;
};
ListSummary: {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
ItemDetail: {
id: string;
};
ItemEditor: {
type: EditorType;
};
ItemUpdateData: {
id: string;
};
ItemCreateResult: DefaultResult<{id: string}>;
ItemUpdateResult: DefaultResult<void>;
};
export interface Resource<D extends ResourceDefined = ResourceDefined> {
ListItem: D["ListItem"];
ListSearch: D["ListSearch"];
ListSummary: D["ListSummary"];
ListOptions: Partial<D["ListSearch"]>;
ItemDetail: D["ItemDetail"];
ItemEditor: D["ItemEditor"];
ItemCreateData: D["ItemCreateData"];
ItemUpdateData: D["ItemUpdateData"];
ItemCreateResult: D["ItemCreateResult"];
SearchData: D["SearchData"] & {search: D["ListSearch"]};
HashData: D["HashData"];
PathData: D["PathData"];
State: D["State"] & {
listItems?: Array<D["ListItem"]>;
listSearch?: D["ListSearch"];
listSummary?: D["ListSummary"];
itemDetail?: D["ItemDetail"];
itemEditor?: D["ItemEditor"];
selectedIds?: string[];
};
API: {
hitItem?(id: string): Promise<void>;
getItemDetail?(id: string): Promise<D["ItemDetail"]>;
searchList(request: D["ListSearch"]): Promise<{listItems: Array<D["ListItem"]>; listSummary: D["ListSummary"]}>;
createItem?(request: D["ItemCreateData"]): Promise<D["ItemCreateResult"]>;
updateItem?(request: D["ItemUpdateData"]): Promise<D["ItemUpdateResult"]>;
deleteItems?(ids: string[]): Promise<void>;
};
}
複製代碼
使用繼承基類的好處:
定義完抽象類型,就要定義抽象實現了,咱們在 ./src/common/ResourceHandlers.ts 中爲 Resource 定義了抽象的 ActionHandler 基類,無非就是增刪改查,代碼就不在此展現了,直接看源文件吧。
Resource 是咱們定義的最基本的資源模型,適合普遍有增刪改查操做的業務實體,在其之上對某些具體的特性,咱們能夠進一步抽象和提取。好比本 Demo 中的三種業務實體: Message、Photo、Video,它們都支持 title 的搜索條件,因此咱們定義了 Article 繼承於 Resource,而 Photo 相比 Video,多了能夠控制評論的展現與隱藏,因此 Photo 又在 Article 上進一步擴展。
使用繼承簡化後的 videos model 已經變得很簡潔了:
// ./src/modules/videos/model.ts
export {State} from "entity/video";
class ModuleHandlers extends ArticleHandlers<State, VideoResource> {
constructor() {
super({api});
}
@effect()
protected async [ModuleNames.videos + "/INIT"]() {
await super.onInit();
}
}
複製代碼
至此,上文中提出的主要問題就已經解決完了。固然,不少人不喜歡繼承,也不喜歡將路由封裝得太重,並且在實際開發中,也可能並無那麼腦洞大開的產品經理,因此本 Demo 只是拋磚引玉,react-coat 框架自己也沒有作任何強制性的封裝,你們見仁見智,因項目而變。
接下來在此基礎上,咱們須要演示一下 react-coat 的另外一重大利器,開啓同構服務器渲染(SSR)。