什么是前端路由?
在单页应用里,前端路由(即Client side routing)通过在浏览器执行JavaScript更新DOM实现页面跳转。最为对比,后台路由需要通过后台请求重新加载整个HTML页面。
为什么要使用路由?
在ClojureScript前端项目里,我们通常使用reagent或re-frame实现单页应用。只需通过注册on-click
事件,更新reagent.core/atom
的状态即可更新所有DOM(包括整个页面)。既然如此,那为何还需要使用路由呢?下面是我总结的几个主要原因:
- 通过路由使前端逻辑结构更清晰,页面更容易管理。通过路由,我们的整个前端应用更像是一个有限状态机,而非
on-click
事件组成的无序DOM集合。 - 浏览器按钮(前进、后退与刷新)的处理。这三个按键都与浏览器URL直接相关,若不使用路由,单页应用DOM更新时不会更新URL,导致用户按下前进/后退按钮时跳到其他网页,点击刷新按钮时整个页面会重新加载并回到单页应用的主页。这些很可能都不是用户想要的结果。使用路由及必要的hack,可以避免这些问题。
在ClojureScript单页应用里使用路由
引入reagent, secretary, accountant依赖
使用lein new reagent myproject
创建项目后, 在project.clj
里加入secretary及accountant依赖:
:dependencies [;; ...
[secretary "1.2.3"]
[venantius/accountant "0.2.5"]
;; ...
]
在core.cljs
里:
(ns myproject.core
(:require
[myproject.views.home :refer [user-page login-page audio-player hidden-audio-player playlist-page]]
[accountant.core :as a]
[secretary.core :as secretary :refer-macros [defroute]]
[goog.events :as events]
[goog.history.EventType :as EventType]
[reagent.core :as r]))
定义全局状态
app-state
为全局状态,这里只定义了一个状态atom,你可以根据需要定义多个。
;; 使用reagent.core/atom保存应用全局状态
;; 登录成功后将设置:user值
(defonce app-state (r/atom {:user nil}))
;; 当前页面,为reagent渲染函数
;; 参考下文page及mount-components函数
(defonce page-state (r/atom nil))
单页应用路由URI前缀
此处假设你的单页应用部署在
/index.html
路径。
secretary默认使用#
作为单页应用路由URI前缀,你可以设置为其他字符或不使用前缀。
;; 设置单页应用路由URI前缀:
(secretary/set-config! :prefix "#")
;; 或不使用单页应用路由URI前缀:
;; 注意:用户按下浏览器刷新按钮时,请求会发送给后端
;; (secretary/set-config! :prefix "")
对比是否使用前缀:
- 使用默认的
#
前缀时:与secretary路径/user?page=5
对应的完整浏览器URI为/index.html#/user?page=5
。accountant跳转时使用(a/navigate! "#/user?page=5")
。 - 不使用前缀时::与secretary路径
/user?page=5
对应的完整浏览器URI变为/user?page=5
。accountant跳转时使用(a/navigate! "/user?page=5")
。
从上面的对比可见:如果不使用前缀,用户按下浏览器刷新按钮时,此URI会作为GET请求发送给后端,后端需要做相应处理,否则可能返回404一类的错误。
定义页面加载入口
定义页面加载入口,只需更新page-state状态就可以切换页面:
(defn page []
;; 使用page-state保存的页面
[@page-state])
(defn mount-components []
(r/render [page] (get-element-by-id "app")))
使用defroute定义路由
使用secretary.core/defroute
定义路由
;; 使用secretary定义路由
(defroute "/" []
(if (:user @app-state)
(a/navigate! "#/user")
(a/navigate! "#/login")))
(defroute "/login" [query-params]
(if (:user @app-state)
(a/navigate! (:return-url query-params "#/user"))
(reset! page-state login-page)))
(defroute "/user"
[query-params]
(.log JavaScript/console "query-params" query-params)
(if (:user @app-state)
(let [{:keys [path offset filter-term]} query-params]
(swap! app-state assoc :filter-term filter-term :filter? (not (s/blank? filter-term)))
;; do some ajax query ....
(reset! page-state user-page))
(let [url (get-uri-hash)]
(a/navigate! "#/login" {:return-url (if (s/blank? url) "#/user" url)}))))
其中login-page
及 user-page
是Reagent组件,通常为返回Hiccup数据的函数:
(defn login-page []
[:div "login page"])
(defn user-page []
[:div (str "hello, " (:user @app-store))])
其中的get-uri-hash
用于获取当前页面的单页应用路径,其实现如下:
(defn get-uri-hash []
(.-hash JavaScript/location))
使用a/navigate!
进行前端页面跳转,可选的第二个参数为map类型的查询参数,例如
(a/navigate! "#/user" {:page 5})
对应的secretary路径为#/user?page=5
。secretary解析query-params
时会自动把key
转换为keyword
,但value
均为字符串格式,若有必要则需要在defroute
处做转换。
需要注意参数里的特殊字符。例如参数里存在的
&
符号在解析时会被当作另一参数的开始,造成解析出错误的结果。解决办法是使用js/encodeURIComponent
对参数值进行编码。
浏览器历史及前进/后退事件
以下两个函数用于处理浏览器历史记录跳转。
hook-browser-navigation!
用于注册secretary页面跳转事件
。configure-pop-state!
用于处理前进/后退事件,这里使用(a/navigate! uri)
主动页面跳转。如果不使用主动跳转,你会发现后退到同一URI页面时,只有第一次有效。
;; 配置secretary dispatch
(defn hook-browser-navigation! []
(doto (History.)
(events/listen
EventType/NAVIGATE
(fn [event]
(secretary/dispatch! (.-token event))))
(.setEnabled true)))
;; 处理浏览器后退事件
(defn configure-pop-state! []
(.pushState JavaScript/history nil nil (.-URL JavaScript/document))
(.addEventListener JavaScript/window
"popstate"
(fn []
(let [url (.-URL JavaScript/document)
uri (or (url-to-uri url) "#/")]
(.pushState JavaScript/history nil nil url)
;; 主动页面跳转
(a/navigate! uri)))))
浏览器刷新及URL跳转
secretary通常能正确处理浏览器刷新按钮点击事件。必要时可以通过query-params
保存状态,(例如由于session超时,用户重新登录后需要回到登录前的页面),参考前面的路由定义。
定义JavaScript入口
初始化函数定义如下,注意对于只需执行一次的初始化函数,我们使用delay包裹后执行,目的是防止在开发阶段figwheel重复执行初始化函数:
(defonce init!
;; 使用delay包裹初始化函数
(delay
(a/configure-navigation!
{:nav-handler
(fn [path]
(secretary/dispatch! path))
:path-exists?
(fn [path]
(secretary/locate-route path))})
(a/dispatch-current!)
(configure-pop-state!)
(hook-browser-navigation!)))
(defn ^:export main []
@init!
(mount-components))
(main)
关于reagent.core/atom
需要注意一个atom状态更新时,每个引用到此atom的DOM都会更新。所以为避免不必要的刷新可以定义多个应用状态。
另一种办法是使用re-frame.core/reg-sub
,它可以订阅关注的状态更新,即只在定义的查询的函数值有变化时才会更新页面,参考re-frame subscriptions。
关于reagent组建重复加载
使用路由后,你可能会发现某些DOM出现重复加载(注意是重新加载而非更新),造成component-did-mount
函数重复执行。
以下的组件为例,将我们的入口reagent组件修改为:
(defn page []
[:div.main
[@page-store]
[audio-player]])
其中的audio-player
是一个播放器,实现如下:
(defn audio-player []
(create-class
{:reagent-render
(fn []
[:audio#audioplayer {:src "" :preload "none"}])
:component-did-mount (fn [_]
(f/reg-audio-player-events!))}))
f/reg-audio-player-events!
用于初始化播放器(如订阅播放、停止、进度等事件),如果此组建重新加载会引起此函数重复执行,从而造成播放中断等异常。
造成此问题的根本原因是reagent根据组件的key判断此组件是否需要更新,当组件未声明key时,React会根据当前的URI自动生成一个唯一key。
所以解决办法是使用audio-player
时,声明一个固定的key
:
(defn page []
[:div.main
[@page-store]
^{:key "audio-player"} ;; 手动设置固定的key
[audio-player]])