React乾貨:SPA單頁如何規劃路由、設計Store、劃分模組、按需載入

wooline發表於2019-01-18
  • 本專案地址:react-coat-helloworld
  • react-coat 同時支援瀏覽器渲染(SPA)伺服器渲染(SSR),本 Demo 僅演示瀏覽器渲染,請先了解一下:react-coat v4.0

react-coat使用Typescript開發,整合Redux,由淺入深請看3個Demo:

入手:Helloworld(本 demo

進階:SPA(單頁應用)

升級:SPA(單頁應用)+SSR(伺服器渲染)


第一站:Helloworld

安裝

git clone https://github.com/wooline/react-coat-helloworld.gitnpm 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 作程式碼檢查

約定規則靜態檢查

react-coat 中很多是用約定代替了配置,但如果開發者粗心大意違返了約定,將導致程式出現錯誤,為了提前感知這些違返約定,本 demo 作了一些靜態程式碼掃描 check 操作。目前僅支援少量規則,更多更嚴格 check 將在後續補充。目前支援的有:

  • @reducer 裝飾器修飾的方法必須返回 State 型別
  • @effect 裝飾器修飾的方法必須是 async 函式
  • 在 ModuleActionHandlers 中,所有 public 方法必須使用@reducer 或 @effect 裝飾,否則請使用 protected 或 private

PeerDependencies

開發環境需要很多的 dependencies,你可以自行安裝特定版本,如果特殊要求,建議本站提供的 react-coat-pkg 以及 react-coat-dev-pkg,它們已經包含了絕大部分 dependencies。

Mock 伺服器

執行 Demo 需要從後臺 api 中獲取資料,通常得另外開一個 api 伺服器,為此本 Demo 特意寫了一個簡單的 mock 中介軟體來合併到 webpackDevServer 中。

為什麼不用 mock.js?

  • 想生成貼合實際有意義的假資料,而不是一堆佔位資料
  • 想模似真實的 http 請求和返回

簡單功能:

  • 記錄真實 api:如果啟用記錄功能,該中介軟體會攔截真實 api 的 Resphonse,將其以檔案形式儲存在/mock/temp/目錄下
  • mock 假資料:在/mock/下以檔案形式建立一個 Resphonse、或者將/mock/temp/下的 api 歷史記錄 copy 到/mock/下,如果檔名與請求 URL 匹配,則直接攔截並返回該檔案內容。
  • 檔名與請求 URL 匹配規則:由於檔名不能存特殊字元,所以 url 中的特殊字元簡單替換為-,為了支援正則,可以採用 base64 後的正則作為檔名

CSS 及圖片的模組化

本 Demo 並不採用 CSS Module 來進行 css 模組化,因為編譯之後可讀性不好,而且增加複雜度和編譯時間。使用統一的 css 名稱空間約定,我們也可以很簡單的防止 css 命名衝突。

我們將 css 分為三大類:全域性(global)CSS、模組(Module)CSS、元件(Component)CSS

  • 全域性(global)CSS:跨模組、跨元件使用的公共 css,我們約定以”g-“開頭,存放到/src/asset/css/global.css
  • 模組(Module)CSS:某模組私有使用的 css,我們約定以”模組名-“開頭,跟隨模組資料夾存放
    • 檢視(View)CSS:在模組 css 中,如果某些 css 僅為某個 view 私有使用,我們約定以”模組名-檢視名-“開頭,跟隨檢視資料夾存放
  • 元件(Component)CSS:某元件私有使用的 css,我們約定以”comp-元件名-“開頭,跟隨元件資料夾存放

類似的,對於專案中用到的圖片,如果是跨模組、跨元件使用的,我們放到/src/asset/imgs/,而對於其它模組私有、檢視私有、元件私有,我們跟隨它們各自的資料夾存放

TS 型別的定義

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

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

理想狀況下,API 型別和 Entity 型別會保持一致,因為業務邏輯是同一套,但實際開發中,可能因為前後端並行開發、或者前後端視角不同而出現兩者各表。

為了充分的解耦,我們允許這種不一致,我們把 API 型別在源頭就轉化為 Entity 型別,而在本系統的程式碼邏輯中,不直接使用 API 型別,應當使用自已定義的 Entity 型別,以減少其它系統對本系統的影響。


假定專案:旅途 web app

主要頁面:

  • 旅遊路線展示
  • 旅途小視訊展示
  • 站內信展示(需登入)
  • 評論展示 (訪客可檢視評論,發表則需登入)
UI圖

專案要求

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

路由規劃

SPA 單頁不就一個頁面麼?為什麼還需要規劃路由呢?

  • 其一,為了使用者重新整理時儘可能的保持當前展示
  • 其二,為了使用者能將當前展示通過 url 分享給他人
  • 其三,為了後續的 SEO

path 規劃

根據專案需求及 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 長度,本框架設計了引數預設值,如果某引數和預設值相同,可以省去。我們需要做兩項工作:

  • 生成 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 Statesinterface States {
[ModuleNames.app]: AppState;
[ModuleNames.photos]: PhotosState;
[ModuleNames.videos]: VideosState;
[ModuleNames.messages]: MessagesState;
[ModuleNames.comments]: CommentsState;

}// 定義整站的Root Stateexport 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(單頁應用)

來源:https://juejin.im/post/5c41b3b451882525380642d6#comment

相關文章