Router-view 背後的想法

題葉發表於2015-11-05

原帖在論壇發了一遍 http://react-china.org/t/router-view/2940

什麼是 router-view

router-view 是我為簡聊開發的一個路由元件
本來自己寫的, 後來用 CoffeeScript 重構放到 teambition 團隊維護了
https://github.com/teambition/router-view
原本簡聊用的是 react-router, 但我還是冒險替換掉了
從結果看, 好處達到了, 但可維護性並不滿意

這篇文章我當然是想解釋一遍 router-view 究竟好在哪值得冒險
特別是元件背後的對於路由的理解, 這是對整體架構至關重要的
另外 router-view 受到 elm 和 redux 影響實際上不小
前面的文章介紹的 actions-recorder 也是 router-view 的肇因
而 router-view 初稿時 redux-router 還沒釋出, 談不上借鑑

路由的類別

在 actions-recorder 或者 redux 的觀念當中, single store 非常明確了
特別是涉及到整個 store 的回溯, 這一點必須優先保證的
然而在路由問題上, 當時發現了問題, 就是除錯回溯不能將路由納入控制
這主要是在除錯工具的可用性上大打折扣了, 所以我開始重新思考
目標主要是路由可以被 single store 所控制, 以及回溯

早在 2013 年秋冬, 我和寸志討論 Backbone 路由問題時就想到過
Backbone 的路由簡單精悍, 但對於巢狀的路由實現比較吃力
當時我們覺得介面是隨著路由渲染的, 那就渲染唄. 然而不知道怎麼實現
Backbone 的路由類似事件, 能繫結 controller 方法, 然後操作
這是可以去呼叫渲染, 只是這樣終究只是呼叫, 不是普通的渲染過程
回頭看我認為這是對服務端路由的模仿, 帶一點誤解在裡邊

以我 React 的開發經驗再審視路由, 我認為前端的路由就是一個 View
比如說, 讓你實現一遍位址列, 前進後退按鈕, 用 React, 簡單吧
我寫一下虛擬碼:

React.createClass
  displayName: `addressbar`

  propTypes:
    router: React.PropTypes.string # 表示路由的資料或者字串
    onChange: React.PropTypes.func # 路由更新的事件
  
  getInitialState: ->
    history: [@props.router] # 歷史記錄, 用於返回
    pointer: 0 # 在歷史記錄的位置上切換

  onBack: -> # 處理
  onForward: -> # 處理
  onChange: -> # 處理

  render: -> # 兩個按鈕, 一個輸入框
    div null,
      div null, onClick: @onBack, `<`
      div null, onClick: @onForward, `>`
      textarea value: @props.router, onChange: @onChange

從這個角度看, 路由就是和元件基本一致的, 包含一下一些特徵:

  • 根據一個當前的狀態渲染, 狀態改變時呼叫回撥函式

  • 有對應介面, 以及互動

  • 內部有私有狀態

只是區別在於, 位址列是瀏覽器原生實現的, 要去封裝, 需要些奇技淫巧
那我說想的重要的一點就是, 路由屬於 MVC 的 V, 而不是 C
…補充一下, 或者說位址列是 V, 因為路由確實包含一些別的東西, 繼續下文

Single Source of Truth (SSOT)

回到資料流的角度, 也就是 SSOT, 同樣也是 single store 所陳述的問題
如果路由是個獨立於 single store 存在的部分, 那麼它是什麼角色?
store 作為 Model 控制著介面的狀態以及顯示, 可是路由也有這個功能
所以我認為, 明確前面的地址以後, 那麼路由的當前狀態是屬於 store 的

這裡說的路由其實一直很模糊, 而且在各種框架裡也顯得很不一樣
那這裡, 我按照 MVC 把路由進行拆解, M 是狀態, V 是位址列, 很明確
而 C 是對 M 進行操作的程式碼, 即便在 React 中也模糊, 這裡不細化
而 router-view 給出的方案, 就是對位址列進行封裝, 對 V 進行明確
而 M 自然作為 single store 的一部分, 附著在 Model 當中

因而在我的方案當中, View, 也就是位址列, 大概就是元件的形態了:

React.createElement addressbar,
  route: store.get(`router`) # 當前狀態
  onPopstate: (info, event) -> # 回撥函式
  rules: routes # 一些路由規則
  inHash: false # 是否使用 Hash 的路由
  skipRendering: false # 處理一些特殊的渲染情況

而路由中對應 Model 的資料, 我用更方便操作的物件來表示:

initialStore =
  message: {}
  topics: {}
  router:
    name: `topic`
    data: {topicId: "c4d6a940d"}
    query: {}

這樣做之後, View 和 Model 都成了整個大的 View 和 Model 的部分
於是應用的整體也就往 single store 更靠攏了一步
之前的路由不受回溯控制的問題, 自然而然得到了解決

作為試驗, 你可以開啟 http://r.nodejs-china.org/
然後通過 Command+Shift+a 快捷鍵開啟除錯工具
找到 "router" 欄位, 然後選擇左側的 Action 位置, 來嘗試效果
或者直接看開發元件用的 Demo http://router-view.mvc-works.org/

不足

最主要的問題是 Model 和 View 分離之後, 封裝特殊邏輯不方便了
現在 Store 和 Component 當中分別有 router 程式碼, 很多需要手寫
具體我在下面展開:

首先是路由的巢狀寫法問題, 本來 router-view 是帶來了好處的
因為路由狀態是用資料儲存的, 任意深度或者奇怪的巢狀都能寫
只需要在想判斷的 render 程式碼里加上 switch, 後面就輕鬆實現了
事實上越是複雜的路由, switch 就會越長, 對可讀性有不小的影響
特別是和 react-router 的宣告式寫法相比. 還好, 只是觀感的差別

其次是初次載入, 或者切換時, 自動計算路由結果的問題
對比 react-router 直接宣告, 在 router-view 裡不好做
因為初始化時需要把位址列的資訊翻譯到 Store 的物件上去
這中間存在一些囉嗦的程式碼, 而且為了 Store 獨立, 不能隨意抽象

前面主要是影響程式碼風格和長度, 其實還有渲染的問題
更明確地說是瀏覽器處理機制的影響, 就是 popstate 事件不能取消
https://developer.mozilla.org/en-US/docs/Web/Events/popstate
想象一下, 簡聊通過後退按鈕切換話題, 中間可能需要抓取對應資料
為了介面顯示準確, 我們用在先發請求抓取資料, 完成後切換路由渲染頁面
然而瀏覽器預設行為是點選後退直接改路由, 這就導致了狀態不一致
可能出現的問題是路由出現多次的切換, 破壞掉歷史記錄的機制
無奈只能加 skipRendering 引數, 在載入過程允許介面狀態不一致

另外還有個意外的 Hash 地址的問題, 也算實現上
基於 Hash 的路由事件, 除了不能取消, 甚至 JavaScript 程式碼都能觸發事件
於是需要在載入過程當中遮蔽掉位址列回撥事件.. 總之很奇怪
但我想這個問題過於冷僻, 應該很少有人會遇到了

總結

從整體效果看, router-view 是比 react-router 控制起開更靈活的
然而從可維護性上, 加上是自己從頭實現的, 相當不完善
我推薦看我文章的同學嘗試跑 Demo, 觀察單向資料流是怎樣運作的
但是想在生成環境用, 至少先看懂元件不到兩百行的程式碼
addressbar.coffee 是元件部分, path 是路由的匹配邏輯

我認為 React 應用的核心就是單向資料流怎樣設計
以及, 所有的 Store 的部分, 所有的 View 的部分, 怎樣契合這套資料流
梳理清楚單向資料流之後, 路由就是瀏覽器實現不好對付的特例罷了

相關文章