React单页如何规划路由、设计Store、划分模块、按需加载
- 本項(xiàng)目地址:react-coat-helloworld
- react-coat 同時支持瀏覽器渲染(SPA)和服務(wù)器渲染(SSR),本 Demo 僅演示瀏覽器渲染,請先了解一下:react-coat
第一站:Helloworld
安裝
git clone https://github.com/wooline/react-coat-helloworld.git npm install運(yùn)行
- npm start 以開發(fā)模式運(yùn)行
- npm run build 以產(chǎn)品模式編譯生成文件
- npm run prod-express-demo 以產(chǎn)品模式編譯生成文件并啟用一個 express 做 demo
- npm run gen-icon 自動生成 iconfont 文件及 ts 類型
查看在線 Demo
- 點(diǎn)擊查看在線 Demo
關(guān)于腳手架
- 采用 webpack 4.0 為核心搭建,無二次封裝,干凈透明
- 采用 typescript 作開發(fā)語言,使用 Postcss 及 less 構(gòu)建 css
- 不使用 css module,用模塊化命名空間保證 css 不沖突
- 采用 editorconfig > prettier 作統(tǒng)一的風(fēng)格配置,建議使用 vscode 作為 IDE,并安裝 prettier 插件以自動格式化
- 采用 tslint、eslint、stylelint 作代碼檢查
PeerDependencies
開發(fā)環(huán)境需要很多的 dependencies,你可以自行安裝特定版本,如果特殊要求,建議本站提供的 react-coat-pkg 以及 react-coat-dev-pkg,它們已經(jīng)包含了絕大部分 dependencies。
TS 類型的定義
使用 Typescript 意味著使用強(qiáng)類型,我們把業(yè)務(wù)實(shí)體中 TS 類型定義分兩大類:API類型和Entity類型。
- API 類型:指的是來自于后臺 API 輸入的類型,它們可能直接由 swagger 生成,或是機(jī)器生成。
- Entity 類型:指的是本系統(tǒng)為業(yè)務(wù)實(shí)體建模而定義的類型,每個業(yè)務(wù)實(shí)體(resource)都會有定義。
理想狀況下,API 類型和 Entity 類型會保持一致,因?yàn)闃I(yè)務(wù)邏輯是同一套,但實(shí)際開發(fā)中,可能因?yàn)榍昂蠖瞬⑿虚_發(fā)、或者前后端視角不同而出現(xiàn)兩者各表。
為了充分的解耦,我們允許這種不一致,我們把 API 類型在源頭就轉(zhuǎn)化為 Entity 類型,而在本系統(tǒng)的代碼邏輯中,不直接使用 API 類型,應(yīng)當(dāng)使用自已定義的 Entity 類型,以減少其它系統(tǒng)對本系統(tǒng)的影響。假定項(xiàng)目:旅途 web app
主要頁面:
- 旅游路線展示
- 旅途小視頻展示
- 站內(nèi)信展示(需登錄)
- 評論展示 (訪客可查看評論,發(fā)表則需登錄)
項(xiàng)目要求
- web SPA 單頁應(yīng)用
- 主要用于 mobile 瀏覽器,也可以適應(yīng)于桌面瀏覽器
- 無 SEO 要求,但需要能將當(dāng)前頁面分享給他人
- 初次進(jìn)入本站時,顯示 welcome 廣告,并倒計(jì)時
路由規(guī)劃
SPA 單頁不就一個頁面么?為什么還需要規(guī)劃路由呢?
- 其一,為了用戶刷新時盡可能的保持當(dāng)前展示
- 其二,為了用戶能將當(dāng)前展示通過 url 分享給他人
- 其三,為了后續(xù)的 SEO
path 規(guī)劃
根據(jù)項(xiàng)目需求及 UI 圖,我們初步規(guī)劃主要路由 path 如下:
- 旅行路線列表 photosList:/photos
- 旅行路線詳情 photosItem:/photos/:photoId
- 分享小視頻列表 videosList:/videos
- 分享小視頻詳情 videosItem:/videos/:videoId
- 站內(nèi)信列表 messagesList:/messages
參數(shù)規(guī)劃
因?yàn)榱斜眄撌怯蟹猪摗⒂兴阉鞯?#xff0c;所以列表類型的路由是有參數(shù)的,比如:
/photos?title=張家界&page=3&pageSize=20我們估且將這部分查詢列表?xiàng)l件叫"ListSearch",但除了ListSearch之外,也可能會出現(xiàn)別的路由參數(shù),用來控制其它條件(本 demo 暫未涉及),比如:
/photos?title=張家界&page=3&pageSize=20&showComment=true所以,如果參數(shù)一多,用扁平的一維結(jié)構(gòu)就變得不好表達(dá)。而且,利用 URL 參數(shù)存數(shù)據(jù),數(shù)據(jù)將全變成為字符串。比如id=2,你無法知道 2 是數(shù)字型還是字符型,這樣會讓后續(xù)接收處理變得繁重。所以,我們使用 JSON 來序列化第二級參數(shù),比如:
/photos?search={title:"張家界",page:3,pageSize:20}&showComment=true這樣做也有個不好的地方,就是需要 encodeURI,然后特殊字符會變得比較丑。
路由參數(shù)默認(rèn)值
為了縮短 URL 長度,本框架設(shè)計(jì)了參數(shù)默認(rèn)值,如果某參數(shù)和默認(rèn)值相同,可以省去。我們需要做兩項(xiàng)工作:
- 生成 Url 查詢條件時,對比默認(rèn)值,如果相同,則省去
原值:{title:"",page:1,pageSize:20} 默認(rèn)值: {title:"",page:1,pageSize:20},省去后為:空
- 收到 Url 查詢條件時,將查詢條件和默認(rèn)值 merge
/photos === photos?search={title:"",page:1,pageSize:20}
- 處理 null、undefined
由于接收 Url 參數(shù)時,如果某 key 為 undefined,我們會用相應(yīng)的默值將其填充,所以不能將 undefined 作為路由參數(shù)值定義,改為使用 null。也就是說,路由參數(shù)中的每一項(xiàng),都是必填的,比如:
// 路由參數(shù)定義時,每一項(xiàng)都必填,以下為錯誤示例 interface ListSearch{title?:string,age?:number } // 改為如下正確定義: interface ListSearch{title:string | null,age:number | null }-
區(qū)分:原始路由參數(shù)(SearchData) 默認(rèn)路由參數(shù)(SearchData) 和 完整路由參數(shù)(WholeSearchData)。完整路由參數(shù)(WholeSearchData) = merage(默認(rèn)路由參數(shù)(SearchData), 原始路由參數(shù)(SearchData))
- 原始路由參數(shù)(SearchData)每一項(xiàng)都是可選的,用 TS 類型表示為:Partial<WholeSearchData>
- 完整路由參數(shù)(WholeSearchData)每一項(xiàng)都是必填的,用 TS 類型表示為:Required<SearchData>
- 默認(rèn)路由參數(shù)(SearchData)和完整路由參數(shù)(WholeSearchData)類型一致
不直接使用路由狀態(tài)
路由及其參數(shù)本質(zhì)上也是一種 Store,與 Redux Store 一樣,反映當(dāng)前程序的某些狀態(tài)。但它是片面的,是瞬時的,是不穩(wěn)定的,我們把它看作是 Redux Store 的一種冗余。所以最好不要在程序中直接依賴和使用它,而是控制住它的入口和出口,第一時間在其源頭進(jìn)行消化轉(zhuǎn)換,讓其成為整個 Redux Store 的一部分,后續(xù)的運(yùn)行中,我們直接依賴 Redux Store。這樣,我們就將程序與路由設(shè)計(jì)解耦了,程序有更大的靈活度甚至可以遷移到無 URL 概念的其它運(yùn)行環(huán)境中。
模塊規(guī)劃
模塊與 Page 無關(guān)
劃分模塊可以很好的拆解功能,化繁為簡,并且對內(nèi)隱藏細(xì)節(jié),對外暴露少量接口。劃分模塊的標(biāo)準(zhǔn)是高內(nèi)聚,低耦合,而不是以 Page 或是 View,一個模塊包含某些完整的業(yè)務(wù)功能,這些功能可能涉及到多個 Page 或多個 View。
所以回過頭,看我們的項(xiàng)目需求和 UI 圖,大體上可以分為三個模塊:
- photos //旅游線路展示
- videos //分享視頻展示
- messages //站內(nèi)消息展示
這三個模塊顯而易見,但是我們注意到:“圖片詳情”和“視頻詳情”都包含“評論展示”,而“評論展示”本身又具有分頁、排序、詳情展示、創(chuàng)建回復(fù)等功能,它具有自已獨(dú)立的邏輯,只不過在 view 上被 photoDetail 和 videoDetail 嵌套了,所以將“評論展示”獨(dú)立劃分成一個模塊是合適的。
另個,整個程序應(yīng)當(dāng)有個啟動模塊,它是“上帝視角模塊”,它可以做一些公共事業(yè),必要的時候也可以用來做多個模塊之間的協(xié)調(diào)和調(diào)度,我們叫把它叫做 applicatioin 模塊。
所以最終,本 Demo 被劃分為 5 個模塊:
- app // 啟動模塊
- photos //旅游線路展示
- videos //分享視頻展示
- messages //站內(nèi)消息展示
- comments //評論展示
為模塊劃分 View
每個模塊可能包含一組 View,View 反映某些特定的業(yè)務(wù)邏輯。View 就是 React 中的 Component,那反過來 Component 就是 View 么?非也,它們之間還是有些區(qū)別的:
- view 展現(xiàn)的是 Store 數(shù)據(jù),更偏重于表現(xiàn)特定的具體的業(yè)務(wù)邏輯,所以它的 props 一般是直接用 mapStateToProps connect 到 store。
- component 體現(xiàn)的是一個沒有業(yè)務(wù)邏輯上下文的純組件,它的 props 一般來源于父級傳遞。
- component 通常是公共的,而 view 通常非公用
回過頭,看我們的項(xiàng)目需求和 UI 圖,大體上劃分以下 view:
- app views:Main、TopNav、BottomNav、LoginPop、Welcome、Loading
- photos views:Main、List、Details
- videos views:Main、List、Details
- messages views:Main、List
- comments views:Main、List、Details、Editor
目錄結(jié)構(gòu)
經(jīng)過上面的分析,我們有了項(xiàng)目大至的骨架,由于模塊比較少,所以我們就不再用二級目錄分類了:
src ├── asset // 存放公共靜態(tài)資源 │ ├── css │ ├── imgs │ └── font ├── entity // 存放業(yè)務(wù)實(shí)體TS類型定義 ├── common // 存放公共代碼 ├── components // 存放React公共組件 ├── modules │ ├── app │ │ ├── views │ │ │ ├── TopNav │ │ │ ├── BottomNav │ │ │ ├── ... │ │ │ └── index.ts //導(dǎo)出給其它模塊使用的view │ │ ├── model.ts //定義ModuleState和ModuleActions │ │ ├── api //將本模塊需要的后臺api封裝一下 │ │ ├── facade.ts //導(dǎo)出本模塊對外的邏輯接口(類型、Actions、路由默認(rèn)參數(shù)) │ │ └── index.ts //導(dǎo)出本模塊實(shí)體(view和model) │ ├── photos │ │ ├── views │ │ ├── model.ts │ │ ├── api │ │ ├── facade.ts │ │ └── index.ts │ ├── videos │ ├── messages │ ├── comments │ ├── names.ts //定義模塊名,使用枚舉類型來保證不重復(fù) │ └── index.ts //導(dǎo)出模塊的全局設(shè)置,如RootState類型、模塊載入方式等 └──index.tsx 啟動入口facade.ts
其它目錄都好理解,注意到每個 module 目錄中,有一個 facade.ts 的文件,冒似它與 index.ts 一樣都是導(dǎo)出本模塊,那為什么不合并成一個呢?
- index.ts 導(dǎo)出的是整個模塊的物理代碼,因?yàn)槟K是較為獨(dú)立的,所以我們一般希望將整個模塊的代碼打包成一個獨(dú)立的 chunk 文件。
- facade.ts 僅導(dǎo)出本模塊的一些類型和邏輯接口,我們知道 TS 類型在編譯之后是會被徹底抹去的,而接口僅僅是一個空的句柄。假如在 ModuleA 中需要 dispatch ModuleB 的 action,我們僅需要 import ModuleB 的 facade.ts,它只是一個空的句柄而以,并不會引起兩個模塊代碼的物理依賴。
配置模塊
問:在 react-coat 中怎么配置一個模塊?包括打包、加載、注冊、管理其生命周期等?答:./src/modules 根目錄下的 index.ts 文件為模塊總的配置文件,增加一個模塊,只需要在此配置一下
// ./src/modules/index.ts// 一個驗(yàn)證器,利用TS類型來確保增加一個module時,相關(guān)的配置都同時增加了 type ModulesDefined<T extends {[key in ModuleNames]: any}> = T;// 定義模塊的加載方案,同步或者異步均可 export const moduleGetter = {[ModuleNames.app]: () => {return import(/* webpackChunkName: "app" */ "modules/app");},[ModuleNames.photos]: () => {return import(/* webpackChunkName: "photos" */ "modules/photos");},[ModuleNames.videos]: () => {return import(/* webpackChunkName: "videos" */ "modules/videos");},[ModuleNames.messages]: () => {return import(/* webpackChunkName: "messages" */ "modules/messages");},[ModuleNames.comments]: () => {return import(/* webpackChunkName: "comments" */ "modules/comments");}, };export type ModuleGetter = ModulesDefined<typeof moduleGetter>; // 驗(yàn)證一下是否有模塊忘了配置// 定義整站Module States interface States {[ModuleNames.app]: AppState;[ModuleNames.photos]: PhotosState;[ModuleNames.videos]: VideosState;[ModuleNames.messages]: MessagesState;[ModuleNames.comments]: CommentsState; }// 定義整站的Root State export type RootState = BaseState & ModulesDefined<States>; // 驗(yàn)證一下是否有模塊忘了配置路由和加載
本 Demo 直接使用 react-router V4,路由即組件,所以并不需要什么特別的路由配置,直接在./app/views/Main.tsx 中:
const PhotosView = loadView(moduleGetter, ModuleNames.photos, "Main"); const VideosView = loadView(moduleGetter, ModuleNames.videos, "Main"); const MessagesView = loadView(moduleGetter, ModuleNames.messages, "Main");<Switch><Redirect exact={true} path="/" to="/photos" /><Route exact={false} path="/photos" component={PhotosView} /><Route exact={false} path="/videos" component={VideosView} /><Route exact={false} path="/messages" component={MessagesView} /><Route component={NotFound} /> </Switch>使用 loadView()表示異步按需加載一個 View,如果你不想按需加載,完全可以直接 import:
import {Main as PhotosView} from "modules/photos/views"載入 View 時自動載入其相關(guān)的模塊并初始化 Model。沒有 Model,view 是沒有“靈魂”的,所以在載入 View 時,框架會自動載入其 Model 并完成初始化,這個過程包含 3 步:
- 1.載入模塊對應(yīng)的 JS Chunk 包
- 2.初始化模塊 Model,派發(fā) module/INIT Action
- 3.模塊可以監(jiān)聽自已的 module/INIT Action,作出初始化行為,如獲取遠(yuǎn)程數(shù)據(jù)等
Redux Store 結(jié)構(gòu)
module 的劃分不僅體現(xiàn)在工程目錄上,而體現(xiàn)在 Redux Store 中:
router: { // 由 connected-react-router 生成location: {pathname: '/photos',search: '',hash: '#refresh=true',key: 'gb9ick'},action: 'PUSH'},app: {...}, // app ModuleStatephotos: { // photos ModuleStateisModule: true, // 框架自動生成,標(biāo)明該節(jié)點(diǎn)為一個ModuleStatelistSearch: { // 列表搜索條件title: '',page: 1,pageSize: 10},listItems: [ // 列表數(shù)據(jù){id: '1',title: '新加坡+吉隆坡+馬六甲6或7日跟團(tuán)游',departure: '無錫',type: '跟團(tuán)游',price: 2499,hot: 265,coverUrl: '/imgs/1.jpg'},...],listSummary: {page: 1,pageSize: 5,totalItems: 10,totalPages: 2}},messages: {...}, // messages ModuleStatecomments: {...}, // comments ModuleState }具體實(shí)現(xiàn)
見 Demo 源碼,有注釋
美中不足
路由規(guī)劃的不足
到目前為止,本 Demo 完成了項(xiàng)目要求中的內(nèi)容,接下來,業(yè)務(wù)看了之后提出了幾個問題:
- 無法分享指定的“評論”,評論是很重要的吸引眼球的內(nèi)容,我們希望分享鏈接時,可以指定評論。
目前可以分享的路由只有 5 種:
- /photos - /photos/1 - /videos - /videos/1 - /messages看樣子,我們得增加:
/photos/1/comments/3 //展示id為3的評論- 評論內(nèi)容對以后的 SEO 很重要,我們希望路由能控制評論列表翻頁和排序:
- 目前我們的項(xiàng)目主要用于移動瀏覽器訪問,很多 android 用戶習(xí)慣用手機(jī)下面的返回鍵,來撤消操作,如關(guān)閉彈窗等,能否模擬一下原生 APP?
思考:android 用戶點(diǎn)擊手機(jī)下面的返回鍵會引起瀏覽器的后退,后退關(guān)閉彈窗,那就需要在彈出彈窗時增加一條 URL 記錄
結(jié)論:Url 路由不只用來記錄展示哪個 Page、哪個 View,還得標(biāo)識一些交互操作,完全顛覆了傳統(tǒng)的路由觀念了。
路由效驗(yàn)的不足
看樣子,路由會越來越復(fù)雜,到目前為止,我們還沒有在 TS 中很好的管理路由參數(shù),拼接 URL 時沒有做 TS 類型的校驗(yàn)。對于 pathname 我們都是直接用字符串寫死在程序中,比如: if(pathname === "/photos"){.... }const arr = pathname.match(/^\/photos\/(\d+)$/);這樣直接 hardcode 似利不是很好,如果后其產(chǎn)品想換一下名稱怎么搞。
Model 中重復(fù)寫同樣的代碼
注意到,photos/model.ts、videos/model.ts 中,90%的代碼是一樣的,為什么?因?yàn)樗鼈儍蓚€模塊基本上功能都是差不多的:列表展示、搜索、獲取詳情...
其實(shí)不只是 photos 和 videos,套用 RestFul 的理念,我們用網(wǎng)頁交互的過程就是在對“資源 Resource”進(jìn)行維護(hù),無外乎“增刪改查”這些基本操作,大部分情況下,它們的邏輯是相似的。由其是在后臺系統(tǒng)中,基本上連 UI 界面也可以標(biāo)準(zhǔn)化,如果將這部分“增刪改查”的邏輯提取出來,模塊可以省去不少重復(fù)的代碼。
下一個 Demo
既然有這么多美中不足,那我們就期待在下一個 Demo 中一步步解決它吧
進(jìn)階:SPA(單頁應(yīng)用)總結(jié)
以上是生活随笔為你收集整理的React单页如何规划路由、设计Store、划分模块、按需加载的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于MaxCompute打造轻盈的人人车
- 下一篇: YYCache 源码学习(二):YYDi