積夢前端的路由方案 ruled-router
積夢(https://jimeng.io) 是一個為製造業製作的一個平臺.
積夢的前端基於 React 做開發的. 早期使用 React Router.
後來出現了一些 TypeScript 整合還有定製化的需求, 自己探索了一套方案.
使用 React Router 遇到的問題
React Router 本身是一個較為穩妥而且全面的方案, 早期我們使用了它.
後面隨著積夢資料平臺的頁面的重構, 遇到了一些問題.
積夢的管理介面從頂層往記憶體在多個層級, 複雜的情況會出現五六層巢狀,
導航欄 -> 子導航 -> 標籤頁 -> 功能 -> 子頁面
雖然一般的情況只是三四個層級, 但是頁面的巢狀大量存在,
早期的我們辦法是定義一個basePath
變數用來表示外層路由,
<Route path={`${this.props.basePath}/:page`} render={(props) => { return this.renderSubPage(props.match.params.page); }} />
然後在內部跳轉時, 也會使用basePath
變數快速生成路徑,
<Redirect to={`${this.props.basePath}/${EWorkflowPage.Step}`} />
這樣手動傳遞偶爾會出錯, 特別是當頁面結構發生一些修改的時候.
經過一兩次導航的重構, 我們在區域性出現了一些程式碼, 無法正確跳轉.
雖然靠著測試逐步修復了問題, 但是隨著頁面增多, 這個問題不能輕視.
我覺得這個問題是兩部分,
一方面是 TypeScript 的型別檢查沒有幫助到的路由部分,
React Router 當中基本上通過字串定義的路徑, 這些不容易被型別檢查.
特別是拼接的路由, 發生改變以後就難以準確追蹤了.
另一方面, 我認為 React Router 的規則也限制了 JavaScript 程式碼的使用.
相對於 React Router 通過 Context 傳遞路由狀態的方案, 更傾向於程式碼.
基於switch/case
還有函式組成的控制流, 有更為靈活的應對的辦法.
路由的解析ruled-router
我同事和我都有一些使用基於路由配置生成路由的經驗, 商量後我打算嘗試.
我的想法是定義路由規則, 然後將路由解析稱為物件, 然後通過程式碼進行控制.
比如這樣一個路徑:
/plants/152883204915/qualityManagement/measurementData/components/21712526851768321/processes/39125230470234114
進行拆解以後我認為就是幾個層級:
/plants/152883204915 /qualityManagement /measurementData/components/21712526851768321/processes/39125230470234114
跟 React Router 直接用標籤做匹配的寫法不同, 我認為路由應該先被解析,
該路由包含了頁面的資訊, 也包含了響應的引數, 實際上對應一個連結串列, 用物件表示是:
{ "name": "plants", // <--- 第一層路由 "matches": true, "restPath": null, "data": { "plantId": "152883204915" }, "query": {}, "next": { "name": "qualityManagement", // <--- 第二層路由 "matches": true, "restPath": null, "data": {}, "query": {}, "next": { "name": "measurementData", // <--- 第三層路由 "matches": true, "restPath": null, "data": { "componentId": "21712526851768321", "processId": "39125230470234114" }, "query": {}, "next": null } } }
這是一個比較清晰的層級的結構, 很容易用switch/case
判斷渲染對應的子頁面.
而解析這個路由所需要的規則, 也可以通過大致這樣的程式碼定義出來.
let pageRules = [ { path: "plants/:plantId", next: [ { path: "qualityManagement", next: [ { path: "measurementData/components/:componentId/processes/:processId" } ] } ] }, ];
這樣基於路由規則和解析函式, 路由定位的方案就變成了:
location.hash switch/case
示例程式碼比如:
render() { const nextRoute = this.props.route.next; switch (nextRoute && nextRoute.name) { case RouteOutgoing.Records: return <Records route={nextRoute.next} plantId={plantId} />; case RouteOutgoing.Settings: return <Settings route={nextRoute} />; } return ( <Redirect to={router.getPath(RouteOutgoing.Records, { plantId, })} /> ); }
解析的程式碼在ruled-router 可以找到, 使用 TypeScript 開發, 有基礎的型別約束.
從程式碼看, 由於路由層級的顯式處理, 會存在不少的.next
需要手工維護, 對於維護有些囉嗦.
當然這個寫法好的一面是路由資訊隨時可以列印和除錯, 方便定位問題.
路由的跳轉(code generator)
在 React Router 當中路由的跳轉相對簡單, 提供路徑的字串表示即可完成:
history.push('/a/b/${c}/d')
但是前面說了, 這樣無法進行型別檢測, 無法定位出現問題的路由位置.
我們嘗試了幾個方案, 用比較多的一個方案是給路由定義唯一的 ID 的列舉值, 然後查詢列舉值跳轉.
後來我從另一個思路開始嘗試, 試著用不同的方案來搭配 TypeScript.
比如說這樣的一套規則, 定義 3 個頁面:
let routeRules = [ { path: "home" }, { path: "content" }, { path: "else" }, { path: "", name: "home" } ]
那麼對應這個路由我就生成響應的程式碼, 這段程式碼, 就是 TypeScript 可以做型別檢查的了,
export let genRouter = { home: { name: "home", raw: "home", path: () => `/home`, go: () => switchPath(`/home`), }, content: { name: "content", raw: "content", path: () => `/content`, go: () => switchPath(`/content`), }, else: { name: "else", raw: "else", path: () => `/else`, go: () => switchPath(`/else`), }, _: { name: "home", raw: "", path: () => `/`, go: () => switchPath(`/`), }, };
其中.go()
方法用於跳轉,.path()
方法用於生成其他元件需要的字串形態.
當然, 維護這樣的一段程式碼, 成本並不低, 但是好在這樣高度重複的程式碼是可以用程式碼生成的,
於是我們增加了router-code-generator 這個指令碼, 用於生成路由程式碼.
這樣, 新增新路由的時候就需要,
genRouter
實際業務當中的程式碼當然會複雜很多, 專案最終生成出來是兩千多行的路由檔案,
export let genRouter = { plants_: { name: "plants", raw: "plants/:plantId", path: (plantId: Id) => `/plants/${plantId}`, go: (plantId: Id) => switchPath(`/plants/${plantId}`), information: { name: "information", raw: "information", path: (plantId: Id) => `/plants/${plantId}/information`, go: (plantId: Id) => switchPath(`/plants/${plantId}/information`), products: { name: "material.finished", raw: "products", path: (plantId: Id) => `/plants/${plantId}/information/products`, go: (plantId: Id) => switchPath(`/plants/${plantId}/information/products`),
實際專案當中的指令碼生成也是個需要處理的地方, 我們用 Webpack 將這部分程式碼打包執行,
效能上還好, 關掉型別檢查的話幾秒鐘內可以完成, 具體看示例的程式碼:
https://github.com/jimengio/t...型別檢查的覆蓋
前面的兩部分, 覆蓋了路由的解析, 還有路由的跳轉, 完成了基本的路由的功能.
路由解析部分, 路由規則可以通過 JSON 結構定義, 基本能得到 TypeScript 的提示.
路由的解析結果, 是一棵大的 JSON 的樹, 這中間有不少動態的部分, 需要開發時自己留意.
路由跳轉的程式碼, 整個 Object 定義的結構可以被 TypeScript 解析, 基本上有完整的補全.
雖然並不完美, 但是很大程度利用了 TypeScript 的自動補全以及型別檢查簡化了書寫.
當路由有增改時, 通過執行指令碼還有執行型別檢查, 比較容易定位到發生改變的部分.
該方案的不足
- 路由劫持等功能
React Router 提供的功能顯然遠不止解析和跳轉, 還有一些頁面跳轉相關的鉤子, 甚至漸變等效果.
ruled-router 的方案沒有去實現相關的功能.
- 腳手架比較麻煩
從前面的描述也能看出來, 這一整套寫法, 特別是後面跳轉的寫法, 引入了大量腳手架.
需要專門寫一個 Webpack 配置來生成路由, 一般專案來說覺得非常繁瑣了.
實際在專案當中, 由於我們有著較深的路由層級, 實際程式碼看上去又長又囉嗦:
genRouter.plants_.product.batch_.ooc.go(plantId, value); genRouter.plants_.model.projects._status.go(this.props.plantId, record.id);
這程式碼是靠著 VS Code 提供的程式碼補全才能很快寫出來... 也就是和 TypeScript 以及 VS Code 等工具繫結死了.
結尾
除了上面介紹的, 其他一些功能也在 ruled-router 方案裡做了一些支援:
- Query 引數. 可以被解析, 也可以在跳轉程式碼當中被生成出來. 基本可用. 只是型別有缺失.
-
效能優化的問題, 需要配合
shouldComponentUpdate
或者useMemo
來優化, 就用到易於匹配的字串形態.
特別是隨著專案規模增加, 幾百個大小頁面的木有, 更多會需要型別檢查工具來幫助我們做校驗.
當然目前的方案在開發當中依然有著細節上的各種需要優化的地方, 要後續再想辦法進一步優化.