React單頁如何規劃路由、設計Store、劃分模塊、按需加載

  • 本項目地址:react-coat-helloworld
  • react-coat 同時支持瀏覽器渲染(SPA)服務器渲染(SSR),本 Demo 僅演示瀏覽器渲染,請先了解一下:react-coat

第一站:Helloworld

安裝

git clone https://github.com/wooline/react-coat-helloworld.git
npm install

運行

  • npm start 以開發模式運行
  • npm run build 以產品模式編譯生成文件
  • npm run prod-express-demo 以產品模式編譯生成文件並啓用一個 express 作 demo
  • npm run gen-icon 自動生成 iconfont 文件及 ts 類型

查看在線 Demo

關於腳手架

  • 採用 webpack 4.0 爲核心搭建,無二次封裝,乾淨透明
  • 採用 typescript 做開發語言,使用 Postcss 及 less 構建 css
  • 不使用 css module,用模塊化命名空間保證 css 不衝突
  • 採用 editorconfig > prettier 做統一的風格配置,建議使用 vscode 做爲 IDE,並安裝 prettier 插件以自動格式化
  • 採用 tslint、eslint、stylelint 做代碼檢查

PeerDependencies

開發環境須要不少的 dependencies,你能夠自行安裝特定版本,若是特殊要求,建議本站提供的 react-coat-pkg 以及 react-coat-dev-pkg,它們已經包含了絕大部分 dependencies。css

TS 類型的定義

使用 Typescript 意味着使用強類型,咱們把業務實體中 TS 類型定義分兩大類:API類型Entity類型react

  • API 類型:指的是來自於後臺 API 輸入的類型,它們可能直接由 swagger 生成,或是機器生成。
  • Entity 類型:指的是本系統爲業務實體建模而定義的類型,每一個業務實體(resource)都會有定義。

理想情況下,API 類型和 Entity 類型會保持一致,由於業務邏輯是同一套,但實際開發中,可能由於先後端並行開發、或者先後端視角不一樣而出現二者各表。android

爲了充分的解耦,咱們容許這種不一致,咱們把 API 類型在源頭就轉化爲 Entity 類型,而在本系統的代碼邏輯中,不直接使用 API 類型,應當使用自已定義的 Entity 類型,以減小其它系統對本系統的影響。

假定項目:旅途 web app

主要頁面:

  • 旅遊路線展現
  • 旅途小視頻展現
  • 站內信展現(需登陸)
  • 評論展現 (訪客可查看評論,發表則需登陸)

圖片描述

項目要求

  • web SPA 單頁應用
  • 主要用於 mobile 瀏覽器,也能夠適應於桌面瀏覽器
  • 無 SEO 要求,但須要能將當前頁面分享給他人
  • 初次進入本站時,顯示 welcome 廣告,並倒計時

路由規劃

SPA 單頁不就一個頁面麼?爲何還須要規劃路由呢?webpack

  • 其一,爲了用戶刷新時儘量的保持當前展現
  • 其二,爲了用戶能將當前展現經過 url 分享給他人
  • 其三,爲了後續的 SEO

path 規劃

根據項目需求及 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

  • 生成 Url 查詢條件時,對比默認值,若是相同,則省去
原值:{title:"張家界",page:1,pageSize:20} 默認值: {title:"",page:1,pageSize:20},省去後爲:{title:"張家界"}

原值:{title:"",page:1,pageSize:20} 默認值: {title:"",page:1,pageSize:20},省去後爲:空

  • 收到 Url 查詢條件時,將查詢條件和默認值 merge
/photos?search={page:2} === photos?search={title:"",page:2,pageSize:20}

/photos === photos?search={title:"",page:1,pageSize:20}

  • 處理 null、undefined

因爲接收 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))

    • 原始路由參數(SearchData)每一項都是可選的,用 TS 類型表示爲:Partial<WholeSearchData>
    • 完整路由參數(WholeSearchData)每一項都是必填的,用 TS 類型表示爲:Required<SearchData>
    • 默認路由參數(SearchData)和完整路由參數(WholeSearchData)類型一致

不直接使用路由狀態

路由及其參數本質上也是一種 Store,與 Redux Store 同樣,反映當前程序的某些狀態。但它是片面的,是瞬時的,是不穩定的,咱們把它看做是 Redux Store 的一種冗餘。因此最好不要在程序中直接依賴和使用它,而是控制住它的入口和出口,第一時間在其源頭進行消化轉換,讓其成爲整個 Redux Store 的一部分,後續的運行中,咱們直接依賴 Redux Store。這樣,咱們就將程序與路由設計解耦了,程序有更大的靈活度甚至能夠遷移到無 URL 概念的其它運行環境中。

模塊規劃

模塊與 Page 無關

劃分模塊能夠很好的拆解功能,化繁爲簡,而且對內隱藏細節,對外暴露少許接口。劃分模塊的標準是高內聚,低耦合,而不是以 Page 或是 View,一個模塊包含某些完整的業務功能,這些功能可能涉及到多個 Page 或多個 View。

因此回過頭,看咱們的項目需求和 UI 圖,大致上能夠分爲三個模塊:

  • photos //旅遊線路展現
  • videos //分享視頻展現
  • messages //站內消息展現

這三個模塊顯而易見,可是咱們注意到:「圖片詳情」和「視頻詳情」都包含「評論展現」,而「評論展現」自己又具備分頁、排序、詳情展現、建立回覆等功能,它具備自已獨立的邏輯,只不過在 view 上被 photoDetail 和 videoDetail 嵌套了,因此將「評論展現」獨立劃分紅一個模塊是合適的。

另個,整個程序應當有個啓動模塊,它是「上帝視角模塊」,它能夠作一些公共事業,必要的時候也能夠用來作多個模塊之間的協調和調度,咱們叫把它叫作 applicatioin 模塊。

因此最終,本 Demo 被劃分爲 5 個模塊:

  • app // 啓動模塊
  • photos //旅遊線路展現
  • videos //分享視頻展現
  • messages //站內消息展現
  • comments //評論展現

爲模塊劃分 View

每一個模塊可能包含一組 View,View 反映某些特定的業務邏輯。View 就是 React 中的 Component,那反過來 Component 就是 View 麼?非也,它們之間仍是有些區別的:

  • view 展示的是 Store 數據,更偏重於表現特定的具體的業務邏輯,因此它的 props 通常是直接用 mapStateToProps connect 到 store。
  • component 體現的是一個沒有業務邏輯上下文的純組件,它的 props 通常來源於父級傳遞。
  • component 一般是公共的,而 view 一般非公用

回過頭,看咱們的項目需求和 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

目錄結構

通過上面的分析,咱們有了項目大至的骨架,因爲模塊比較少,因此咱們就再也不用二級目錄分類了:

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 啓動入口

facade.ts

其它目錄都好理解,注意到每一個 module 目錄中,有一個 facade.ts 的文件,冒似它與 index.ts 同樣都是導出本模塊,那爲何不合併成一個呢?

  • index.ts 導出的是整個模塊的物理代碼,由於模塊是較爲獨立的,因此咱們通常但願將整個模塊的代碼打包成一個獨立的 chunk 文件。
  • facade.ts 僅導出本模塊的一些類型和邏輯接口,咱們知道 TS 類型在編譯以後是會被完全抹去的,而接口僅僅是一個空的句柄。假如在 ModuleA 中須要 dispatch ModuleB 的 action,咱們僅須要 import ModuleB 的 facade.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 步:

  • 1.載入模塊對應的 JS Chunk 包
  • 2.初始化模塊 Model,派發 module/INIT Action
  • 3.模塊能夠監聽自已的 module/INIT Action,做出初始化行爲,如獲取遠程數據等

Redux Store 結構

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的評論
  • 評論內容對之後的 SEO 很重要,咱們但願路由能控制評論列表翻頁和排序:
/photos/1?comments-search={page:2,sort:"createDate"}
  • 目前咱們的項目主要用於移動瀏覽器訪問,不少 android 用戶習慣用手機下面的返回鍵,來撤消操做,如關閉彈窗等,可否模擬一下原生 APP?

思考:android 用戶點擊手機下面的返回鍵會引發瀏覽器的後退,後退關閉彈窗,那就須要在彈出彈窗時增長一條 URL 記錄
結論:Url 路由不僅用來記錄展現哪一個 Page、哪一個 View,還得標識一些交互操做,徹底顛覆了傳統的路由觀念了。

路由效驗的不足

看樣子,路由會愈來愈複雜,到目前爲止,咱們尚未在 TS 中很好的管理路由參數,拼接 URL 時沒有作 TS 類型的校驗。對於 pathname 咱們都是直接用字符串寫死在程序中,好比:
if(pathname === "/photos"){
  ....
}

const arr = pathname.match(/^\/photos\/(\d+)$/);

這樣直接 hardcode 似利不是很好,若是後其產品想換一下名稱怎麼搞。

Model 中重複寫一樣的代碼

注意到,photos/model.ts、videos/model.ts 中,90%的代碼是同樣的,爲何?由於它們兩個模塊基本上功能都是差很少的:列表展現、搜索、獲取詳情...

其實不僅是 photos 和 videos,套用 RestFul 的理念,咱們用網頁交互的過程就是在對「資源 Resource」進行維護,無外乎「增刪改查」這些基本操做,大部分狀況下,它們的邏輯是類似的。由其是在後臺系統中,基本上連 UI 界面也能夠標準化,若是將這部分「增刪改查」的邏輯提取出來,模塊能夠省去很多重複的代碼。

下一個 Demo

既然有這麼多美中不足,那咱們就期待在下一個 Demo 中一步步解決它吧

進階:SPA(單頁應用)
相關文章
相關標籤/搜索