前端框架改造--路由篇
使用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的映射。
路由跳转的方法
- a标签
[:a {:href "/test"} "测试"]
- 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])
- 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。