ClojureScript前端路由

什么是前端路由?

在单页应用里,前端路由(即Client side routing)通过在浏览器执行JavaScript更新DOM实现页面跳转。最为对比,后台路由需要通过后台请求重新加载整个HTML页面。

为什么要使用路由?

在ClojureScript前端项目里,我们通常使用reagentre-frame实现单页应用。只需通过注册on-click事件,更新reagent.core/atom的状态即可更新所有DOM(包括整个页面)。既然如此,那为何还需要使用路由呢?下面是我总结的几个主要原因:

  1. 通过路由使前端逻辑结构更清晰,页面更容易管理。通过路由,我们的整个前端应用更像是一个有限状态机,而非on-click事件组成的无序DOM集合。
  2. 浏览器按钮(前进、后退与刷新)的处理。这三个按键都与浏览器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-pageuser-pageReagent组件,通常为返回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]])

相关文档

Comment