React乾貨(二):提取公共代碼、創建路由Store、Check路由參數類型

你可能以爲本Demo中對路由封裝過於重度,以及不喜歡使用Class繼承的方式來組織Model。不要緊,本Demo只是演示衆多解決方案中的一種,並非惟一。react

本Demo中使用Class繼承的方式來組織Model,並不要求對React組件使用Class Component風格,並不違反React FP 編程思想和趨勢。隨着React Hooks的正式發佈,本框架將保持API不變的前提下,使用其替換掉Redux及相關庫。android

安裝

git clone https://github.com/wooline/react-coat-spa-demo.git
npm install
複製代碼

運行

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

查看在線 Demo

項目背景

項目要求及說明請看:第一站 Helloworld,在第一站點,咱們總結了某些「美中不足」,主要是 3 點:git

  • 路由控制須要更細粒度
  • 路由參數須要 TS 類型檢查
  • 公共代碼須要提取

路由控制須要更細粒度

某市場人員說:評論內容很吸引眼球,但願分享連接的時候,能指定某些評論github

某腦洞大開的產品經理說,我但願你能在 android 手機上模擬原生 APP,點擊手機工具欄的「返回」鍵,能撤銷上一步操做,好比:點返回鍵能關閉彈窗。express

因此每一步操做,都要用一條 URL 來驅動?好比:旅行路線詳情+彈出評論彈窗+評論列表(按最新排序、第 2 頁)npm

/photos/1/comments?comments-search={"articleId":"1","isNewest":true,"page":2}&photos-showComment=true編程

看到這個長長的 URL,咱們不由想一想路由的本質是什麼?api

  • 路由是程序狀態的切片。路由中包含的信息越多越細,程序的切片就能越多越細。
  • 路由是程序的狀態機。跟 ReduxStore 同樣,路由也是一種 Store,咱們能夠稱其爲 RouterStore,它記錄了程序運行的某些狀態,只不過 ReduxStore 存在內存中,而 RouterStore 存在地址欄。

Router Store 概念

若是接受 RouterStore 這個概念,那咱們程序中就不是單例 Store,而是兩個 Store 了,但是 Redux 不是推薦單例 Store 麼?兩個 Store 那會不會把維護變複雜呢?瀏覽器

因此,咱們要特殊一點看待 RouterStore,僅把它看成是 ReduxStore 的一種冗餘設計。也就是說,「你大爺仍是你大爺」,程序的運行仍是圍繞 ReduxStore 來展開,除了 router 信息的流入流出源頭以外,你就當 RouterStore 不存在,RouterStore 中有的信息,ReduxStore 中全都有。bash

  • RouterStore 是瞬時局部的,而 ReduxStore 是完整的。
  • 程序中不要直接依賴 RouterStore 中的狀態,而是依賴 ReduxStore 狀態。
  • 控制住 RouterStore 流入流出的源頭,第一時間將其消化轉換爲 ReduxStore。
  • RouterStore 是隻讀的。RouterStore 對程序自己是透明的,因此也不存在修改它。

好比在 photos 模塊中,詳情頁面須要控制評論的顯示與隱藏,因此咱們必須在 Store 中定義 showComment: boolean,而咱們還想經過 url 來控制它,因此在 url 中也有&photos-showComment=true,這時就出現 RouterStore 和 ReduxStore 中都有 showComment 這個狀態。你可能會想,那能不能把 ReduxStore 中的這個 showComment 去掉,直接使用 RouterStore 中的 showComment 就好?答案是不行的,不只不能省,並且在 photos.Details 這個 view 中依賴的狀態還必須是 ReduxStore 中的這個 showComment。

SearchData: {showComment: boolean}; // Router Store中的 showComment 不要直接在view中依賴
State: {showComment?: boolean}; // Redux Store中的 showComment
複製代碼

Router Store 結構

RouterStore 有着與 ReduxStore 相似的結構。

  • 首先,它是一個全部模塊共用的 Store,每一個模塊均可以往這個 Store 中存取狀態。
  • 既然是公共 Store,就存在命名空間的管理。與 ReduxStore 同樣,每一個模塊在 RouterStore 下面分配一個節點,模塊能夠在此節點中定義和維護自已的路由狀態。
  • 根據 URL 的結構,咱們進一步將 RouterStore 細分爲:pathData、searchData、hashData。好比:
/photos/1/comments?comments-search={"articleId":"1","isNewest":true,"page":2}&photos-showComment=true#app-forceRefresh=true

將這個 URL 反序列化爲 RouterStore:

{
  pathData: {
    photos: {itemId: "1"},
    comments: {type: "photos", typeId: "1"},
  },
  searchData: {
    comments: {search: {articleId: "1", isNewest: true, page: 2}},
    photos: {showComment: true},
  },
  hashData: {
    app: {forceRefresh: true},
  },
}

複製代碼

從以上示例看出,爲了解決命名空間的問題,在序列化爲 URL 時,爲每筆 key 增長了一個moduleName-做爲前綴,而反序列化時,將該前綴去掉轉換成 JS 的數據結構,固然這個過程是能夠由某函數統一自動處理的。其它都好明白,就是pathData是怎麼得來的?

/photos/1/comments

怎麼得出:

pathData: {
    photos: {itemId: "1"},
    comments: {type: "photos", typeId: "1"},
}
複製代碼

pathname 和 view 的映射

對於同一個 pathname,photos 模塊分析得出的 pathData 是 {itemId: "1"},而 comments 模塊分析得出的 pathData 是 {type: "photos", typeId: "1"},這是由於咱們配置了 pathname 與 view 的映射規則 viewToPath

// ./src/modules/index.ts

// 定義整站路由與 view 的匹配模式
export const viewToPath: {[K in keyof ModuleGetter]: {[V in keyof ReturnModule<ModuleGetter[K]>["views"]]+?: string}} = {
  app: {Main: "/"},
  photos: {Main: "/photos", Details: "/photos/:itemId"},
  videos: {Main: "/videos", Details: "/videos/:itemId"},
  messages: {Main: "/messages"},
  comments: {Main: "/:type/:typeId/comments", Details: "/:type/:typeId/comments/:itemId"},
};
複製代碼

反過來也能夠反向推導出 pathToView

// ./src/common/routers.ts

// 根據 modules/index.ts中定義的 viewToPath 反推導出來,形如:
{
  "/": ["app", "Main"],
  "/photos": ["photos", "Main"],
  "/photos/:itemId": ["photos", "Details"],
  "/videos": ["videos", "Main"],
  "/videos/:itemId": ["videos", "Details"],
  "/messages": ["messages", "Main"],
  "/:type/:typeId/comments": ["comments", "Main"],
  "/:type/:typeId/comments/:itemId": ["comments", "Details"],
}
複製代碼

好比當前 pathname 爲:/photos/1/comments,匹配這條的有:

  • "/": ["app", "Main"]
  • "/photos": ["photos", "Main"]
  • "/photos/:itemId": ["photos", "Details"],
  • "/:type/:typeId/comments": ["comments", "Main"],
// 原來模塊自已去寫正則解析pathname
const arr = pathname.match(/^\/photos\/(\d+)$/);

// 變成集中統一處理,在使用時只須要引入強類型 PathData
pathData: {
    photos: {itemId: "1"},
    comments: {type: "photos", typeId: "1"},
}
複製代碼

結論

  • 這樣一來,咱們既用 TS 強類型來規範了 pathname 中的參數,還可讓每一個模塊自由定義參數名稱

定義 searchData 和 hashData

RouterStore 是一個公共的 Store,每一個模塊均可以往裏面存取信息,在反序列化時,因爲會統一自動加上 moduleName 做前綴,因此也不用擔憂命名衝突。

每一個 Module 自已定義自已的 router 結構,將全部 Module 的 router 結構合併起來,就組成了整個 RouterState。

咱們在上文:第一站 Helloworld中,提到過「參數默認值」的概念:

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

因此,在模塊中對於路由部分有兩個工做要作:1.定義路由結構,2.定義默認參數,如:

// ./src/modules/photos/facade.ts

export const defRouteData: ModuleRoute<PathData, SearchData, HashData> = {
  pathData: {},
  searchData: {
    search: {
      title: "",
      page: 1,
      pageSize: 10,
    },
    showComment: false,
  },
  hashData: {},
};
複製代碼

咱們完整的 RouterStore 數據結構示例爲:

{
  router: {
    location: { // 由 connected-react-router 生成
      pathname: '/photos/1/comments',
      search: '?comments-search=%7B%22articleId%22...&photos-showComment=true',
      hash: '',
      key: 'dw8hbu'
    },
    action: 'PUSH', // 由 connected-react-router 生成
    views: { // 根據 pathname 自動解析出當前展現哪些 module 的哪些 view
      app: {Main: true},
      photos: {Main: true, Details: true},
      comments: {Main: true}
    },
    pathData: { // 根據 pathname 自動解析出參數
      app: {},
      photos: {itemId: '1'},
      comments: {type: 'photos', typeId: '1'}
    },
    searchData: { // 根據 urlSearch 自動解析出參數
      comments: {search: {articleId: '1', isNewest: true, page: 2}},
      photos: {showComment: true}
    },
    hashData: {}, // 根據 urlHash 自動解析出參數
    wholeSearchData: { // urlSearch merge 默認參數後的完整數據
      comments: {search: {articleId: '1', isNewest: true, page: 2, pageSize: 10}},
      photos: {search: {title: "", page: 1, pageSize: 10}, showComment: true},
      app: {showSearch: false, showLoginPop: false, showRegisterPop: false }
    },
    wholeHashData: { //urlHash merge 默認參數後的完整數據
      app: {forceRefresh: null},
      photos: {},
      comments: {}
    }
  },
}
複製代碼

定義 RouterParser

也就是說,咱們根據:

/photos/1/comments?comments-search={"articleId":"1","isNewest":true,"page":2}&photos-showComment=true#app-forceRefresh=true

這個簡單的 URL 字符串,解析能得出上面複雜的路由數據結構。固然,這一切都是能夠自動處理的,react-coat 提供了自定義路由解析 hook:

export declare type RouterParser<T = any> = (nextRouter: T, prevRouter?: T) => T;

咱們只要定義好這個 RouterParser,磨刀不誤砍柴功,在後續使用時就至關方便了,具體定義的代碼見源文件:./src/common/routers.ts

在源頭消化 RouterStore 爲 ReduxStore

前面說過,咱們須要在第一時間將 RouterStore 轉換爲 ReduxStore,這個源頭在哪裏?就是監控路由變化了,react-coat 集成了 connected-react-router,在路由變化時會 dispatch @@router/LOCATION_CHANGE 這個 Action。而框架自己也支持觀察者模式,因此就簡單了,只須要在模塊中監聽@@router/LOCATION_CHANGE就行,例如:

// ./src/modules/app/model.ts

// 定義本模塊的Handlers
class ModuleHandlers extends BaseModuleHandlers<State, RootState, ModuleNames> {
  ...
  protected async parseRouter() {
    // this.rootState 指向整個 ReduxStore
    const searchData = this.rootState.router.wholeSearchData.app;
    this.updateState({
      showSearch: Boolean(searchData.showSearch),
    });
  }

  // 監聽路由變化的 action
  @effect(null)
  protected async ["@@router/LOCATION_CHANGE"]() {
    this.parseRouter();
  }

  // 模塊第一次初始化時也須要
  @effect()
  protected async [ModuleNames.app + "/INIT"]() {
    this.parseRouter();

  }
}
複製代碼

最後看看細粒度的效果

提取公共代碼

路由問題告一段落,剩下還有一個大問題,就是如何避免重複代碼。在上文:第一站 Helloworld中提到:

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

實際上,套用 RestFul 的理念,咱們用網頁交互的過程就是在對「資源 Resource」進行維護,無外乎「增刪改查」這些基本操做,大部分狀況下,它們的邏輯是類似的,由其是在後臺管理系統中,不少都是 table + 彈窗 的交互方式。

使用繼承解決

繼承和組合均可以用來抽象和提煉公共邏輯,由於 react-coat 支持 actionHandler 的繼承,因此咱們先嚐試使用繼承的方案來解決。

要繼承,先得抽象基類。

定義 Resource 相關的抽象類型

./src/entity/resource.ts 中,咱們爲 resource 統必定義了這些基本的抽象類型:

export interface Defined {
  State: {};
  SearchData: {};
  PathData: {};
  HashData: {};
  ListItem: {};
  ListSearch: {};
  ListSummary: {};
  ItemDetail: {};
  ItemEditor: {};
  ItemCreateData: {};
  ItemUpdateData: {};
  ItemCreateResult: {};
  ItemUpdateResult: {};
}

export type ResourceDefined = Defined & {
  State: BaseModuleState;
  PathData: {itemId?: string};
  ListItem: {
    id: string;
  };
  ListSearch: {
    page: number;
    pageSize: number;
  };
  ListSummary: {
    page: number;
    pageSize: number;
    totalItems: number;
    totalPages: number;
  };
  ItemDetail: {
    id: string;
  };
  ItemEditor: {
    type: EditorType;
  };
  ItemUpdateData: {
    id: string;
  };
  ItemCreateResult: DefaultResult<{id: string}>;
  ItemUpdateResult: DefaultResult<void>;
};

export interface Resource<D extends ResourceDefined = ResourceDefined> {
  ListItem: D["ListItem"];
  ListSearch: D["ListSearch"];
  ListSummary: D["ListSummary"];
  ListOptions: Partial<D["ListSearch"]>;
  ItemDetail: D["ItemDetail"];
  ItemEditor: D["ItemEditor"];
  ItemCreateData: D["ItemCreateData"];
  ItemUpdateData: D["ItemUpdateData"];
  ItemCreateResult: D["ItemCreateResult"];
  SearchData: D["SearchData"] & {search: D["ListSearch"]};
  HashData: D["HashData"];
  PathData: D["PathData"];
  State: D["State"] & {
    listItems?: Array<D["ListItem"]>;
    listSearch?: D["ListSearch"];
    listSummary?: D["ListSummary"];
    itemDetail?: D["ItemDetail"];
    itemEditor?: D["ItemEditor"];
    selectedIds?: string[];
  };
  API: {
    hitItem?(id: string): Promise<void>;
    getItemDetail?(id: string): Promise<D["ItemDetail"]>;
    searchList(request: D["ListSearch"]): Promise<{listItems: Array<D["ListItem"]>; listSummary: D["ListSummary"]}>;
    createItem?(request: D["ItemCreateData"]): Promise<D["ItemCreateResult"]>;
    updateItem?(request: D["ItemUpdateData"]): Promise<D["ItemUpdateResult"]>;
    deleteItems?(ids: string[]): Promise<void>;
  };
}
複製代碼

使用繼承基類的好處:

  • 一是代碼和邏輯重用。
  • 二是用 TS 類型來強制統一命名。searchList? queryList? getList?,好吧,都沒錯,不過仍是統一名詞比較好

定義 Resource 相關的 ActionHandler

定義完抽象類型,就要定義抽象實現了,咱們在 ./src/common/ResourceHandlers.ts 中爲 Resource 定義了抽象的 ActionHandler 基類,無非就是增刪改查,代碼就不在此展現了,直接看源文件吧。

逐層泛化

Resource 是咱們定義的最基本的資源模型,適合普遍有增刪改查操做的業務實體,在其之上對某些具體的特性,咱們能夠進一步抽象和提取。好比本 Demo 中的三種業務實體: Message、Photo、Video,它們都支持 title 的搜索條件,因此咱們定義了 Article 繼承於 Resource,而 Photo 相比 Video,多了能夠控制評論的展現與隱藏,因此 Photo 又在 Article 上進一步擴展。

使用繼承簡化後的 videos model 已經變得很簡潔了:

// ./src/modules/videos/model.ts

export {State} from "entity/video";

class ModuleHandlers extends ArticleHandlers<State, VideoResource> {
  constructor() {
    super({api});
  }
  @effect()
  protected async [ModuleNames.videos + "/INIT"]() {
    await super.onInit();
  }
}
複製代碼

下一個 Demo

至此,上文中提出的主要問題就已經解決完了。固然,不少人不喜歡繼承,也不喜歡將路由封裝得太重,並且在實際開發中,也可能並無那麼腦洞大開的產品經理,因此本 Demo 只是拋磚引玉,react-coat 框架自己也沒有作任何強制性的封裝,你們見仁見智,因項目而變。

接下來在此基礎上,咱們須要演示一下 react-coat 的另外一重大利器,開啓同構服務器渲染(SSR)。

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

相關文章
相關標籤/搜索