October 23, 2019
By: Tom

前端框架改造--路由篇

使用cljs开发前端也有些日子了,也总结了一点点经验。跟大家分享一下前端路由改造的心路历程。

路由框架介绍

前端路由框架使用的是kee-frame, 它是借助reitit来实现路由的。简单总结路由的过程就是url->view,就是路由映射组件过程。那我们看一下kee-frame是如何使用的。

定义reitit路由

(def routes [["/" :home] ;等价 ["/" {:name :home}]
             ["/test" {:name :test}]])

上面是reitit路由的语法,它有很多种语法格式(reitit route syntax)。 它表示每个路由都对应着一种数据,也就是url->data的映射。

映射组件

(defn root-view []
  [kf/switch-route (fn [route] (-> route :data :name))
   :home [home-page] ;; Explicit call to reagent component, ignoring route data
   :test test-page]) ;; Orders page will receive the route data as its parameter because of missing []

kee-frame获取当前路由的data,通过关键字:name的值来映射到不同的页面组件。这个关键字不限制:name。你可以自定义其他的关键字,只要保证在上面路由定义data中有该关键字就行。这个过程也就是data->view的映射。注意上面映射有两种写法的差别。我没有去做验证,就照用了作者的原话。

结合路由和组件

(kf/start!  {:routes         routes  ;;配置路由
            :initial-db     {}
            :root-component [root-view] ;;配置view
            :debug?         true})

kee-frame通过start!函数将二者结合起来,完成url->view的映射。

路由跳转的方法

  1. a标签
[:a {:href "/test"} "测试"]
  1. reg-event-fx
(require 're-frame.core :as rf)

(rf/reg-event-fx
 :core/nav
 (fn [_ [route-name x]]
    {:navigate-to [route-name x]}))

(rf/dispatch [:core/nav :test])
  1. kee-frame.core/path-for
(require 'kee-frame.core :as kf)

(kf/path-for [:test])

初次使用

首先看一下项目截图

这是我截的当前项目主页的图片,最初项目的结构就是这张图所示的结构,一个主页,左侧导航跳转不同的子页。下面通过简单代码实现最初的路由实现。

;;定义路由
(def routes [["/" :home]
    ["/category" :category]])

;;左侧导航
(defn sider-menu []
    [:div
        [:a {:href "/"} "首页"]
        [:a {:href "/category"} "品类列表"]])

;;首页
(defn index-page []
    [:div "首页"])

;;品类列表页
(defn category-page []
    [:div "品类列表页"])

;;主页面
(defn main-page []
	[:div
      [sider-menu]
      [kf/switch-route (fn [route] (-> route :data :name))
          :home [index-page]
          :category [category-page]]])

;;结合
(kf/start!  {:routes         routes
            :initial-db     {}
            :root-component [main-page]
            :debug?         true})

可以看到最初的实现延续了kee-frame官方提供的demo风格,简单明了,当你浏览完kee-frame提供的文档,可以很快的了解项目路由的跳转流程。做一个demo这样做不但没有问题,而且非常合适。但是作为一个项目这种做法会有几个弊端。假设我们在上面例子基础上再增加一个路由

(def routes [["/" :home]
    ["/category" :category]]
    ["/product" :product]) ;;新增的路由

(defn sider-menu []
    [:div
        [:a {:href "/"} "首页"]
        [:a {:href "/category"} "品类列表"]]
        [:a {:href "/product"} "商品列表"]) ;;新增的跳转

(defn product-page []
    [:div "商品列表页"])

(defn main-page []
	[:div
      [sider-menu]
      [kf/switch-route (fn [route] (-> route :data :name))
          :home [index-page]
          :category [category-page]]
          :product [product-page]]) ;;新增的页面

好了,我们一顿猛如虎的操作之后,仅仅就添加了一个新的路由。不难发现,我们只是配置一个简单的路由,却要修改三个地方。更糟糕是项目为了维护一个良好的目录组件结构。上面改动的三处可能都不在一个文件下。这下就更爽了,增加一个路由,要频繁的切换文件来改动。这样增加了繁琐的工作不说,更为git托管增加了难度,路由的修改必然是项目团队成员共同参与的,多人同时修改不同的文件增加了冲突的可能。

尝试改造

上面的修改都是针对路由来做的,那我们能不能把路由的配置都统一到同一个文件呢。前面说过,reitit路由简单来说就是url->data,在data中你可以随意的扩展你关心的属性,把相关的信息都加进来,比如,左侧导航其实是url->title的映射,kf/switch-route是url->view的映射,好的,我们把title和view统一的放在这data中

(def routes[["/" {:name :home :title "首页" :page index-page}]
            ["/category" {:name :category :title "品类列表" :page category-page}]])

左侧导航是个树状结构,而reitit的路由语法也是支持嵌套的树状结构的

(def routes[["/" {:name :home :title "首页" :page index-page}]
            ["/product" {:name :product :title "商品管理"}
                ["/list" {:name :product-list :title "商品列表" :page product-list-page}]]])

有了它,左侧的导航和主页面的kf/switch-route就都可以通过程序来解析出来了,左侧导航的自动生成比较简单,你可以通过递归遍历树来生成菜单,因为后台系统导航只有两级,我偷了懒,只是嵌套遍历了一下

(defn auto-side-menus [routes]
  [:> Menu
   (for [route routes
         :let [[path {:keys [name title]} & children] route]]
     (if (zero? (count children))
       [:> MenuItem {:key name}
        [:a {:href path} title]]
       [:> SubMenu {:key name :title title }
        (for [child children
              :let [[cpath cdata] child]]
          [:> MenuItem {:key (:name cdata)}
           [:a {:href (str path cpath)} (:title cdata)]])]))])

自动生成kf/switch-route刚开始遇到了点小阻碍,因为路由配置是树形结构的,而kf/switch-route的参数是个扁平的结构,所以首先要树形的数据转成list或vector这种扁平的数据。借助于clojure强大的数据处理能力,我也一点点的给凑成来了。但是在读reitit的文档的时候,其实官方已经提供了一些处理路由数据的工具,其中就有把嵌套的树形结构转成了vector。

(require '[reitit.core :as r])

(def router
  (r/router
    ["/api"
     ["/ping" :ping]
         ["/user/:id" :user]]))

(r/routes router)

; [["/api/ping" {:name :ping}]
;  ["/api/user/:id" {:name :user}]]

借助它我轻松实现了自动生成switch-route的功能

(defn switch-route-args
  "生成switch-route的参数"
  [routes]
  (concat
   [(fn [route] (get-in route [:data :name]))]
   (flatten (map (fn [[_ data]] [(:name data) (:page cdata)])
                 (r/routes (r/router routes))))
   [nil [:div "404"]]))

(defn auto-switch-route
  "根据路由配置自动生成switch-route"
  [routes]
  [apply kf/switch-route (switch-route-args routes)])

我们再简单的组织一下路由的结构


;;路由配置
(def routes [["/" {:name :home :title "首页"}]
             ["/category" {:name :product :title "商品管理"}
                 ["/list" {:name :product-list :title "商品列表"}]]]

;;主页面
(defn main-page []
	[:div
      [auto-side-menus routes] ;;上面实现的自动生成导航菜单
      [(auto-switch-route routes)]]) ;;上面实现的自动生成switch-routes


(kf/start!  {:routes         routes
            :initial-db     {}
            :root-component [main-page]
            :debug?         true})

好的,三国鼎立的局面到此宣告结束,我们一统了路由。

第二次改造

完成了第一次改造,我沾沾自喜的准备着手下一项工作,实现登录页面,如下图

我准备开始用刚开发的黑科技大刀阔斧的干时,我忽然发现我无法实现登录页的路由。原因很简单,我忽略了多个主页面的情况,上面都是围绕一个主页导航到不同的子页面而做的工作。而登录页和主页面是平级的,主要结构如下图

在我一筹莫展之际,我在kee-frame作者提供的demo中发现了解决的办法,原理也非常简单,就是再增加一个kf/swith-route。我提取了主要的逻辑如下:

;;路由
(def routes [["/login" :login]
             ["/main/:type" :main]])  ;;Routes with path parameters

(defn login-page []
    [:div "登录页面"])

(defn main-page []
    [:div
     [:div "左侧导航"]
     [k/switch-route (fn [route] (-> route :path-params :type)) ;;第二个switch-route根据参数映射不同子页
         "product" [product-page] ;;商品列表页
         "category" [category-page ;;品类类别页
         ]]])

(defn root-page []
  [kf/switch-route (fn [route] (-> route :data :name))  ;;第一个switch-route根据参数映射登录页和主页
   :login [login-page]
   :main [main-page]
   nil [:div "Loading..."]])

(kf/start!  {:routes         routes
            :initial-db     {}
            :root-component [root-page]
            :debug?         true})

前面说过reitit支持许多种路由表达式,比如上面routes中的/main/:type, 它表示:type是path中变量,有两种书写形式

[["/users/:user-id"]
 ["/api/:version/ping"]]

[["/users/{user-id}"]
 ["/files/file-{number}.pdf"]]

简单说一下上面路由的过程,上面定义了routes路由和一个root-page, 当url为"/login",它会映射到login-page,这个没有什么好说的。而当url为"/main/:type"的形式,比如"/main/product"、"/main/category",都会匹配到main-page,然后我们在main-page中又使用了一个switch-route,然后获取当前路由数据中:type的值,通过它映射不同的子页面。

不过上述有个不足的地方需要我们修改一下,在前面讨论的,routes定义的/main/:type,只能匹配固定格式的url,比如"/main/a"、"/main/b",而不能匹配到"/main/a/b"、"/main/a/b/c"。为了使路由配置更加灵活,我们使用reitit路由另一种表达式。

["/pulic/*path"]
["/public/{*path}"]

上面表达式中它会匹配到前缀为"/public/"的url,比如"/public/a"、"/public/a/b"、"/public/a/b/c"等等。借助于它我们来实现更灵活的配置:

;;路由
(def routes [["/login" :login]
             ["/main*path" :main]]) ;;将"/main/:type"修改为"/main*path"

(defn login-page []
    [:div "登录页面"])

(defn main-page []
    [:div
     [:div "左侧导航"]
     [kf/switch-route (fn [route] (-> route :path-params :path))
         "/category" [category-page] ;;品类页
         "/product/detail" [product-detail-page] ;;商品详情页
         ]])

(defn root-page []
  [kf/switch-route (fn [route] (-> route :data :name))
   :login [login-page]
   :main [main-page]
   nil [:div "Loading..."]])

(kf/start!  {:routes         routes
            :initial-db     {}
            :root-component [root-page]
            :debug?         true})

然后整合第一次改造的自动生成导航、自动生成switch-route。

;;root routes
(def routes [["/login" :login]
             ["/main*path" :main]])

;;main routes
(def main-routes
    [["/product" {:name :product :title "商品管理" :page product-page}]
     ["/category" {:name category :title "品类管理" :page category-page}]])

(defn main-page []
    [:div
    [auto-side-menus main-routes] ;;前面实现的自动生成导航菜单
    [(auto-switch-route main-routes)]]) ;;前面实现的自动生成switch-routes

(defn login-page []
    [:div "登录页面"])

(defn root-page []
  [kf/switch-route (fn [route] (-> route :data :name))
   :login [login-page]
   :main [main-page]
   nil [:div "Loading..."]])

(kf/start!  {:routes         routes
            :initial-db     {}
            :root-component [root-page]
            :debug?         true})

好的,我成功把登录页路由页整合进来了。

进一步优化

在使用过程中我发现了一个问题,虽然我一直感到奇怪,但至少它还没有影响使用,加上手头还有其他事情,就没有管它。之后Kevin也提到了这个问题,在讨论的过程中给了我一点灵感,花了点时间,最终把这个奇怪的问题给解决了。先说一下我遇到的这个问题吧。

(def routes [["/login" :login]
             ["/main*path" :main]])

这个是我们前面定义的路由,当url匹配到到时候,一切ok。但当url匹配不到的时候,比如"/a"、"/b"等等。就会抛出一个url未匹配的异常。

导致页面一片空白

之前我总认为是switch-route的锅

(defn root-page []
  [kf/switch-route (fn [route] (-> route :data :name))
   :login [login-page]
   :main [main-page]
   nil [:div "Loading..."]]) ;;这个nil难道不是路由未匹配到的意思吗?

后来我意识到我错怪了人家,匹配url的工作是reitit的职责。但是到目前为止,除了前面定义的routes路由表达式,我们没有在任何地方修改reitit的地方。带着这个问题,我又重新看了一下kee-frame的文档,在文档的最下面找到了蛛丝马迹

原来kee-frame的路由实现可以替换,那我看看它是怎么实现reitit路由的。然后找到了它的源码:

在刚开始说过,reitit是url->data的映射,switch-route是data->view的映射。但是kee-frame是如何获取到data的呢,在这里找到了答案。

真凶已经被我们捉拿归案,我们要让它洗心革面,重新做人。我又重新定了一个reitit的路由实现,代码基本上是从源码复制过来的,做了点小改动。主要代码如下。

;;未匹配就不要抛出异常了,返回一个关键字:not-found
(defn route-match-not-found [routes url]
  (prn "No match for URL in routes" {:url url :routes routes})
  (reitit/Match. "" {:name :not-found} nil nil ""))

(defrecord ReititRouter [routes hash?]
  api/Router
  (data->url [_ data]
    (or (match-data (reitit/router routes) data hash?)
        (url-not-found (reitit/router routes) data)))
  (url->data [data url]
    (if-let [match (match-url (reitit/router routes) url)]
        (identity match)
        (route-match-not-found routes url))))

然后在switch-route中添加一个:not-found的映射

(defn root-page []
  [:div
   [kf/switch-route (fn [route]
                      (get-in route [:data :name]))
    :login [login-page]
    :main [layout-page]
    :not-found [:div 404]
    nil [:div "Loading..."]]])

最后在替换成我们修改的reitit路由实现

(kf/start! {:debug?         (boolean debug?)
               :router (ReititRouter. routes true) ;;注意:之前配置是:routes,而这是:router
               :initial-db     (db/init-db)
               :root-component [root-component]})

好了,这样未匹配到的url,都让我映射到404页面啦。

待改善的地方

前面我们做了许多工作,都是希望让我们使用的框架越来越方便,体验越来越好。但是说实话这是需要花时间去完成的。其实第二次改造就有失我本意,那是我一个折中方案。因为你要花更多时间改造,就没时间去完成业务。在第二次改造中,其实并没有做的完全的统一路由配置

;;root-routes
(def routes [["/login" :login]
             ["/main*path" :main]])

;;main-routes
(def main-routes
    [["/product" {:name :product :title "商品管理" :page product-page}]
     ["/category" {:name category :title "品类管理" :page category-page}]])

(defn main-page []
    [:div
    [auto-side-menus main-routes]
    [(auto-switch-route main-routes)]])

(defn root-page []
  [kf/switch-route (fn [route] (-> route :data :name))
   :login [login-page]
   :main [main-page]
   nil [:div "Loading..."]])

可以看到这里定了两个routes, 其中main-routes用于自动生成main-page的swtich-route,这个是在第一次改造中已经写好的。可是root-routes对应着root-page中的switch-route。这种配置方式还是沿用了最初demo的方式。虽然就目前来说,只有两个页面,改动不会太大。但是针对框架来言,它确实不够灵活。使用过antd-pro的同学们应该知道,它里面的路由配置都是在一个文件里,使用起来还是挺方便的,这是我希望的路由改造最终目标。

在路由配置中还欠缺了权限控制这一块。当用户再访问一个没有权限的url时,返回一个403的页面或者其他提示页面。目前后台系统权限也有,不过只是看起来的,我们只是把用户有权限的菜单展示出来了,但依然可以访问没有展示出来的url。

Tags: reitit kee-frame