瀏覽器渲染(SPA)
和服務器渲染(SSR)
,本 Demo 僅演示瀏覽器渲染
,請先了解一下:react-coat v4.0react-coat使用Typescript開發,集成Redux,由淺入深請看3個Demo:css
進階:SPA(單頁應用)android
升級:SPA(單頁應用)+SSR(服務器渲染)webpack
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 類型本 Demo 並不採用 CSS Module 來進行 css 模塊化,由於編譯以後可讀性很差,並且增長複雜度和編譯時間。使用統一的 css 命名空間約定,咱們也能夠很簡單的防止 css 命名衝突。git
咱們將 css 分爲三大類:全局(global)CSS、模塊(Module)CSS、組件(Component)CSS
github
全局(global)CSS
:跨模塊、跨組件使用的公共 css,咱們約定以"g-
"開頭,存放到/src/asset/css/global.css模塊(Module)CSS
:某模塊私有使用的 css,咱們約定以"模塊名-
"開頭,跟隨模塊文件夾存放
視圖(View)CSS
:在模塊 css 中,若是某些 css 僅爲某個 view 私有使用,咱們約定以"模塊名-視圖名-
"開頭,跟隨視圖文件夾存放組件(Component)CSS
:某組件私有使用的 css,咱們約定以"comp-組件名-
"開頭,跟隨組件文件夾存放相似的,對於項目中用到的圖片,若是是跨模塊、跨組件使用的,咱們放到/src/asset/imgs/,而對於其它模塊私有、視圖私有、組件私有
,咱們跟隨它們各自的文件夾存放web
使用 Typescript 意味着使用強類型,咱們把業務實體中 TS 類型定義分兩大類:API類型
和Entity類型
。typescript
理想情況下,API 類型和 Entity 類型會保持一致,由於業務邏輯是同一套,但實際開發中,可能由於先後端並行開發、或者先後端視角不一樣而出現二者各表。express
爲了充分的解耦,咱們容許這種不一致,咱們把 API 類型在源頭就轉化爲 Entity 類型,而在本系統的代碼邏輯中,不直接使用 API 類型,應當使用自已定義的 Entity 類型,以減小其它系統對本系統的影響。npm
SPA 單頁不就一個頁面麼?爲何還須要規劃路由呢?
根據項目需求及 UI 圖,咱們初步規劃主要路由 path 以下:
旅行路線列表 photosList
:/photos旅行路線詳情 photosItem
:/photos/:photoId分享小視頻列表 videosList
:/videos分享小視頻詳情 videosItem
:/videos/:videoId站內信列表 messagesList
:/messages由於列表頁是有分頁、有搜索的,因此列表類型的路由是有參數的,好比:
/photos?title=張家界&page=3&pageSize=20
咱們估且將這部分查詢列表條件叫"ListSearch",但除了ListSearch
以外,也可能會出現別的路由參數,用來控制其它條件(本 demo 暫未涉及),好比:
/photos?title=張家界&page=3&pageSize=20&showComment=true
因此,若是參數一多,用扁平的一維結構就變得很差表達。並且,利用 URL 參數存數據,數據將全變成爲字符串。好比id=2
,你沒法知道 2 是數字型仍是字符型,這樣會讓後續接收處理變得繁重。因此,咱們使用 JSON 來序列化第二級參數,好比:
/photos?search={title:"張家界",page:3,pageSize:20}&showComment=true
這樣作也有個很差的地方,就是須要 encodeURI,而後特殊字符會變得比較醜。
爲了縮短 URL 長度,本框架設計了參數默認值,若是某參數和默認值相同,能夠省去。咱們須要作兩項工做:
原值:{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
}
複製代碼
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 中一步步解決它吧