瀏覽器渲染(SPA)
和服務器渲染(SSR)
,本 Demo 僅演示瀏覽器渲染
,請先了解一下:react-coat git clone https://github.com/wooline/react-coat-helloworld.git npm install
npm start
以開發模式運行npm run build
以產品模式編譯生成文件npm run prod-express-demo
以產品模式編譯生成文件並啓用一個 express 作 demonpm run gen-icon
自動生成 iconfont 文件及 ts 類型開發環境須要不少的 dependencies,你能夠自行安裝特定版本,若是特殊要求,建議本站提供的 react-coat-pkg 以及 react-coat-dev-pkg,它們已經包含了絕大部分 dependencies。css
使用 Typescript 意味着使用強類型,咱們把業務實體中 TS 類型定義分兩大類:API類型
和Entity類型
。react
理想情況下,API 類型和 Entity 類型會保持一致,由於業務邏輯是同一套,但實際開發中,可能由於先後端並行開發、或者先後端視角不一樣而出現二者各表。android
爲了充分的解耦,咱們容許這種不一致,咱們把 API 類型在源頭就轉化爲 Entity 類型,而在本系統的代碼邏輯中,不直接使用 API 類型,應當使用自已定義的 Entity 類型,以減小其它系統對本系統的影響。
SPA 單頁不就一個頁面麼?爲何還須要規劃路由呢?webpack
根據項目需求及 UI 圖,咱們初步規劃主要路由 path 以下:git
旅行路線列表 photosList
:/photos旅行路線詳情 photosItem
:/photos/:photoId分享小視頻列表 videosList
:/videos分享小視頻詳情 videosItem
:/videos/:videoId站內信列表 messagesList
:/messages由於列表頁是有分頁、有搜索的,因此列表類型的路由是有參數的,好比:github
/photos?title=張家界&page=3&pageSize=20
咱們估且將這部分查詢列表條件叫"ListSearch",但除了ListSearch
以外,也可能會出現別的路由參數,用來控制其它條件(本 demo 暫未涉及),好比:web
/photos?title=張家界&page=3&pageSize=20&showComment=true
因此,若是參數一多,用扁平的一維結構就變得很差表達。並且,利用 URL 參數存數據,數據將全變成爲字符串。好比id=2
,你沒法知道 2 是數字型仍是字符型,這樣會讓後續接收處理變得繁重。因此,咱們使用 JSON 來序列化第二級參數,好比:typescript
/photos?search={title:"張家界",page:3,pageSize:20}&showComment=true
這樣作也有個很差的地方,就是須要 encodeURI,而後特殊字符會變得比較醜。express
爲了縮短 URL 長度,本框架設計了參數默認值,若是某參數和默認值相同,能夠省去。咱們須要作兩項工做:npm
原值:{title:"張家界",page:1,pageSize:20} 默認值: {title:"",page:1,pageSize:20},省去後爲:{title:"張家界"}原值:{title:"",page:1,pageSize:20} 默認值: {title:"",page:1,pageSize:20},省去後爲:空
/photos?search={page:2} === photos?search={title:"",page:2,pageSize:20}/photos === photos?search={title:"",page:1,pageSize:20}
因爲接收 Url 參數時,若是某 key 爲 undefined,咱們會用相應的默值將其填充,因此不能將 undefined 做爲路由參數值定義,改成使用 null。也就是說,路由參數中的每一項,都是必填的,好比:
// 路由參數定義時,每一項都必填,如下爲錯誤示例 interface ListSearch{ title?:string, age?:number } // 改成以下正肯定義: interface ListSearch{ title:string | null, age:number | null }
區分:原始路由參數(SearchData) 默認路由參數(SearchData) 和 完整路由參數(WholeSearchData)。完整路由參數(WholeSearchData) = merage(默認路由參數(SearchData), 原始路由參數(SearchData))
Partial<WholeSearchData>
Required<SearchData>
路由及其參數本質上也是一種 Store,與 Redux Store 同樣,反映當前程序的某些狀態。但它是片面的,是瞬時的,是不穩定的,咱們把它看做是 Redux Store 的一種冗餘。因此最好不要在程序中直接依賴和使用它,而是控制住它的入口和出口,第一時間在其源頭進行消化轉換,讓其成爲整個 Redux Store 的一部分,後續的運行中,咱們直接依賴 Redux Store。這樣,咱們就將程序與路由設計解耦了,程序有更大的靈活度甚至能夠遷移到無 URL 概念的其它運行環境中。
劃分模塊能夠很好的拆解功能,化繁爲簡,而且對內隱藏細節,對外暴露少許接口。劃分模塊的標準是高內聚,低耦合
,而不是以 Page 或是 View,一個模塊包含某些完整的業務功能,這些功能可能涉及到多個 Page 或多個 View。
因此回過頭,看咱們的項目需求和 UI 圖,大致上能夠分爲三個模塊:
這三個模塊顯而易見,可是咱們注意到:「圖片詳情」和「視頻詳情」都包含「評論展現」,而「評論展現」自己又具備分頁、排序、詳情展現、建立回覆等功能,它具備自已獨立的邏輯,只不過在 view 上被 photoDetail 和 videoDetail 嵌套了,因此將「評論展現」獨立劃分紅一個模塊是合適的。
另個,整個程序應當有個啓動模塊,它是「上帝視角模塊」,它能夠作一些公共事業,必要的時候也能夠用來作多個模塊之間的協調和調度,咱們叫把它叫作 applicatioin 模塊。
因此最終,本 Demo 被劃分爲 5 個模塊:
每一個模塊可能包含一組 View,View 反映某些特定的業務邏輯。View 就是 React 中的 Component,那反過來 Component 就是 View 麼?非也,它們之間仍是有些區別的:
回過頭,看咱們的項目需求和 UI 圖,大致上劃分如下 view:
通過上面的分析,咱們有了項目大至的骨架,因爲模塊比較少,因此咱們就再也不用二級目錄分類了:
src ├── asset // 存放公共靜態資源 │ ├── css │ ├── imgs │ └── font ├── entity // 存放業務實體TS類型定義 ├── common // 存放公共代碼 ├── components // 存放React公共組件 ├── modules │ ├── app │ │ ├── views │ │ │ ├── TopNav │ │ │ ├── BottomNav │ │ │ ├── ... │ │ │ └── index.ts //導出給其它模塊使用的view │ │ ├── model.ts //定義ModuleState和ModuleActions │ │ ├── api //將本模塊須要的後臺api封裝一下 │ │ ├── facade.ts //導出本模塊對外的邏輯接口(類型、Actions、路由默認參數) │ │ └── index.ts //導出本模塊實體(view和model) │ ├── photos │ │ ├── views │ │ ├── model.ts │ │ ├── api │ │ ├── facade.ts │ │ └── index.ts │ ├── videos │ ├── messages │ ├── comments │ ├── names.ts //定義模塊名,使用枚舉類型來保證不重複 │ └── index.ts //導出模塊的全局設置,如RootState類型、模塊載入方式等 └──index.tsx 啓動入口
其它目錄都好理解,注意到每一個 module 目錄中,有一個 facade.ts 的文件,冒似它與 index.ts 同樣都是導出本模塊,那爲何不合併成一個呢?
問:在 react-coat 中怎麼配置一個模塊?包括打包、加載、註冊、管理其生命週期等?
答:./src/modules 根目錄下的 index.ts 文件爲模塊總的配置文件,增長一個模塊,只須要在此配置一下
// ./src/modules/index.ts // 一個驗證器,利用TS類型來確保增長一個module時,相關的配置都同時增長了 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>; // 驗證一下是否有模塊忘了配置 // 定義整站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>; // 驗證一下是否有模塊忘了配置
本 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 時自動載入其相關的模塊並初始化 Model。沒有 Model,view 是沒有「靈魂」的,因此在載入 View 時,框架會自動載入其 Model 並完成初始化,這個過程包含 3 步:
module 的劃分不只體如今工程目錄上,而體如今 Redux Store 中:
router: { // 由 connected-react-router 生成 location: { pathname: '/photos', search: '', hash: '#refresh=true', key: 'gb9ick' }, action: 'PUSH' }, app: {...}, // app ModuleState photos: { // photos ModuleState isModule: true, // 框架自動生成,標明該節點爲一個ModuleState listSearch: { // 列表搜索條件 title: '', page: 1, pageSize: 10 }, listItems: [ // 列表數據 { id: '1', title: '新加坡+吉隆坡+馬六甲6或7日跟團遊', departure: '無錫', type: '跟團遊', price: 2499, hot: 265, coverUrl: '/imgs/1.jpg' }, ... ], listSummary: { page: 1, pageSize: 5, totalItems: 10, totalPages: 2 } }, messages: {...}, // messages ModuleState comments: {...}, // comments ModuleState }
見 Demo 源碼,有註釋
到目前爲止,本 Demo 完成了項目要求中的內容,接下來,業務看了以後提出了幾個問題:
目前能夠分享的路由只有 5 種:
- /photos - /photos/1 - /videos - /videos/1 - /messages
看樣子,咱們得增長:
/photos/1/comments/3 //展現id爲3的評論
/photos/1?comments-search={page:2,sort:"createDate"}
思考:android 用戶點擊手機下面的返回鍵會引發瀏覽器的後退,後退關閉彈窗,那就須要在彈出彈窗時增長一條 URL 記錄
結論:Url 路由不僅用來記錄展現哪一個 Page、哪一個 View,還得標識一些交互操做,徹底顛覆了傳統的路由觀念了。
看樣子,路由會愈來愈複雜,到目前爲止,咱們尚未在 TS 中很好的管理路由參數,拼接 URL 時沒有作 TS 類型的校驗。對於 pathname 咱們都是直接用字符串寫死在程序中,好比:
if(pathname === "/photos"){ .... } const arr = pathname.match(/^\/photos\/(\d+)$/);
這樣直接 hardcode 似利不是很好,若是後其產品想換一下名稱怎麼搞。
注意到,photos/model.ts、videos/model.ts 中,90%的代碼是同樣的,爲何?由於它們兩個模塊基本上功能都是差很少的:列表展現、搜索、獲取詳情...
其實不僅是 photos 和 videos,套用 RestFul 的理念,咱們用網頁交互的過程就是在對「資源 Resource」進行維護,無外乎「增刪改查」這些基本操做,大部分狀況下,它們的邏輯是類似的。由其是在後臺系統中,基本上連 UI 界面也能夠標準化,若是將這部分「增刪改查」的邏輯提取出來,模塊能夠省去很多重複的代碼。
既然有這麼多美中不足,那咱們就期待在下一個 Demo 中一步步解決它吧
進階:SPA(單頁應用)