React乾貨(二):提取公共程式碼、建立路由Store、Check路由引數型別

wooline發表於2019-02-25

你可能覺得本Demo中對路由封裝過於重度,以及不喜歡使用Class繼承的方式來組織Model。沒關係,本Demo只是演示眾多解決方案中的一種,並不是唯一。

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

安裝

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 點:

  • 路由控制需要更細粒度
  • 路由引數需要 TS 型別檢查
  • 公共程式碼需要提取

路由控制需要更細粒度

某市場人員說:評論內容很吸引眼球,希望分享連結的時候,能指定某些評論

某腦洞大開的產品經理說,我希望你能在 android 手機上模擬原生 APP,點選手機工具欄的“返回”鍵,能撤銷上一步操作,比如:點返回鍵能關閉彈窗。

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

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

看到這個長長的 URL,我們不禁想想路由的本質是什麼?

  • 路由是程式狀態的切片。路由中包含的資訊越多越細,程式的切片就能越多越細。
  • 路由是程式的狀態機。跟 ReduxStore 一樣,路由也是一種 Store,我們可以稱其為 RouterStore,它記錄了程式執行的某些狀態,只不過 ReduxStore 存在記憶體中,而 RouterStore 存在位址列。

Router Store 概念

如果接受 RouterStore 這個概念,那我們程式中就不是單例 Store,而是兩個 Store 了,可是 Redux 不是推薦單例 Store 麼?兩個 Store 那會不會把維護變複雜呢?

所以,我們要特殊一點看待 RouterStore,僅把它當作是 ReduxStore 的一種冗餘設計。也就是說,“你大爺還是你大爺”,程式的執行還是圍繞 ReduxStore 來展開,除了 router 資訊的流入流出源頭之外,你就當 RouterStore 不存在,RouterStore 中有的資訊,ReduxStore 中全都有。

  • 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(伺服器渲染)

相關文章