在單頁應用上,前端路由並不陌生。很多前端框架也會有獨立開發或推薦配套使用的路由系統。那麼,當我們在談前端路由的時候,還可以談些什麼?本文將簡要分析並實現一個的前端路由,並對 react-router 進行分析。
一個極簡前端路由實現
說一下前端路由實現的簡要原理,以 hash 形式(也可以使用 History API 來處理)為例,當 url 的 hash 發生變化時,觸發 hashchange 註冊的回撥,回撥中去進行不同的操作,進行不同的內容的展示。直接看程式碼或許更直觀。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function Router() { this.routes = {}; this.currentUrl = ''; } Router.prototype.route = function(path, callback) { this.routes[path] = callback || function(){}; }; Router.prototype.refresh = function() { this.currentUrl = location.hash.slice(1) || '/'; this.routes[this.currentUrl](); }; Router.prototype.init = function() { window.addEventListener('load', this.refresh.bind(this), false); window.addEventListener('hashchange', this.refresh.bind(this), false); } window.Router = new Router(); window.Router.init(); |
上面路由系統 Router 物件實現,主要提供三個方法
- init 監聽瀏覽器 url hash 更新事件
- route 儲存路由更新時的回撥到回撥陣列routes中,回撥函式將負責對頁面的更新
- refresh 執行當前url對應的回撥函式,更新頁面
Router 呼叫方式以及呈現效果如下:點選觸發 url 的 hash 改變,並對應地更新內容(這裡為 body 背景色)
1 2 3 4 5 |
<ul> <li><a href="#/">turn white</a></li> <li><a href="#/blue">turn blue</a></li> <li><a href="#/green">turn green</a></li> </ul> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var content = document.querySelector('body'); // change Page anything function changeBgColor(color) { content.style.backgroundColor = color; } Router.route('/', function() { changeBgColor('white'); }); Router.route('/blue', function() { changeBgColor('blue'); }); Router.route('/green', function() { changeBgColor('green'); }); |
以上為一個前端路由的簡單實現,點選檢視完整程式碼,雖然簡單,但實際上很多路由系統的根基都立於此,其他路由系統主要是對自身使用的框架機制的進行配套及優化,如與 react 配套的 react-router。
react-router 分析
react-router 與 history 結合形式
react-router 是基於 history 模組提供的 api 進行開發的,結合的形式本文記為 包裝方式。所以在開始對其分析之前,先舉一個簡單的例子來說明如何進行物件的包裝。
可看到 historyModule 中含有機制:historyModule.updateLocation() -> listener( ),Router 通過對其進行包裝開發,針對 historyModule 的機制對 Router 也起到了作用,即historyModule.updateLocation() 將觸發 Router.listen 中的回撥函式 。點選檢視完整程式碼
這種包裝形式能夠充分利用原物件(historyModule )的內部機制,減少開發成本,也更好的分離包裝函式(Router)的邏輯,減少對原物件的影響。
react-router 使用方式
react-router 以 react component 的元件方式提供 API, 包含 Router,Route,Redirect,Link 等等,這樣能夠充分利用 react component 提供的生命週期特性,同時也讓定義路由跟寫 react component 達到統一,如下
1 2 3 4 5 6 7 8 9 10 11 |
render(( <Router history={browserHistory}> <Route path="/" component={App}> <Route path="about" component={About}/> <Route path="users" component={Users}> <Route path="/user/:userId" component={User}/> </Route> <Route path="*" component={NoMatch}/> </Route> </Router> ), document.body) |
就這樣,宣告瞭一份含有 path to component 的各個對映的路由表。
react-router 還提供的 Link 元件(如下),作為提供更新 url 的途徑,觸發 Link 後最終將通過如上面定義的路由表進行匹配,並拿到對應的 component 及 state 進行 render 渲染頁面。
1 |
<Link to={`/user/89757`}>'joey'</Link> |
這裡不細講 react-router 的使用,詳情可見:https://github.com/reactjs/react-router
從點選 Link 到 render 對應 component ,路由中發生了什麼
為何能夠觸發 render component ?
主要是因為觸發了 react setState 的方法從而能夠觸發 render component。
從頂層元件 Router 出發(下面程式碼從 react-router/Router 中摘取),可看到 Router 在 react component 生命週期之元件被掛載前 componentWillMount 中使用 this.history.listen 去註冊了 url 更新的回撥函式。回撥函式將在 url 更新時觸發,回撥中的 setState 起到 render 了新的 component 的作用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Router.prototype.componentWillMount = function componentWillMount() { // .. 省略其他 var createHistory = this.props.history; this.history = _useRoutes2['default'](createHistory)({ routes: _RouteUtils.createRoutes(routes || children), parseQueryString: parseQueryString, stringifyQuery: stringifyQuery }); this._unlisten = this.history.listen(function (error, state) { _this.setState(state, _this.props.onUpdate); }); }; |
上面的 _useRoutes2 對 history 操作便是對其做一層包裝,所以呼叫的 this.history 實際為包裝以後的物件,該物件含有 _useRoutes2 中的 listen 方法,如下
1 2 3 4 5 6 7 8 |
function listen(listener) { return history.listen(function (location) { // .. 省略其他 match(location, function (error, redirectLocation, nextState) { listener(null, nextState); }); }); } |
可看到,上面程式碼中,主要分為兩部分
1. 使用了 history 模組的 listen 註冊了一個含有 setState 的回撥函式(這樣就能使用 history 模組中的機制)
2. 回撥中的 match 方法為 react-router 所特有,match 函式根據當前 location 以及前面寫的 Route 路由表匹配出對應的路由子集得到新的路由狀態值 state,具體實現可見 react-router/matchRoutes ,再根據 state 得到對應的 component ,最終執行了 match 中的回撥 listener(null, nextState) ,即執行了 Router 中的監聽回撥(setState),從而更新了展示。
以上,為起始註冊的監聽,及回撥的作用。
如何觸發監聽的回撥函式的執行?
這裡還得從如何更新 url 說起。一般來說,url 更新主要有兩種方式:簡單的 hash 更新或使用 history api 進行地址更新。在 react-router 中,其提供了 Link 元件,該元件能在 render 中使用,最終會表現為 a 標籤,並將 Link 中的各個引數組合放它的 href 屬性中。可以從 react-router/ Link 中看到,對該元件的點選事件進行了阻止了瀏覽器的預設跳轉行為,而改用 history 模組的 pushState 方法去觸發 url 更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
Link.prototype.render = function render() { // .. 省略其他 props.onClick = function (e) { return _this.handleClick(e); }; if (history) { // .. 省略其他 props.href = history.createHref(to, query); } return _react2['default'].createElement('a', props); }; Link.prototype.handleClick = function handleClick(event) { // .. 省略其他 event.preventDefault(); this.context.history.pushState(this.props.state, this.props.to, this.props.query); }; |
對 history 模組的 pushState 方法對 url 的更新形式,同樣分為兩種,分別在 history/createBrowserHistory 及 history/createHashHistory 各自的 finishTransition 中,如 history/createBrowserHistory 中使用的是 window.history.replaceState(historyState, null, path); 而 history/createHashHistory 則使用 window.location.hash = url,呼叫哪個是根據我們一開始建立 history 的方式。
更新 url 的顯示是一部分,另一部分是根據 url 去更新展示,也就是觸發前面的監聽。這是在前面 finishTransition 更新 url 之後實現的,呼叫的是 history/createHistory 中的 updateLocation 方法,changeListeners 中為 history/createHistory 中的 listen 中所新增的,如下
1 2 3 4 5 6 7 8 9 10 11 |
function updateLocation(newLocation) { // 示意程式碼 location = newLocation; changeListeners.forEach(function (listener) { listener(location); }); } function listen(listener) { // 示意程式碼 changeListeners.push(listener); } |
總結
可以將以上 react-router 的整個包裝閉環總結為
- 回撥函式:含有能夠更新 react UI 的 react setState 方法。
- 註冊回撥:在 Router componentWillMount 中使用 history.listen 註冊的回撥函式,最終放在 history 模組的 回撥函式陣列 changeListeners 中。
- 觸發回撥:Link 點選觸發 history 中回撥函式陣列 changeListeners 的執行,從而觸發原來 listen 中的 setState 方法,更新了頁面
至於前進與後退的實現,是通過監聽 popstate 以及 hashchange 的事件,當前進或後退 url 更新時,觸發這兩個事件的回撥函式,回撥的執行方式 Link 大致相同,最終同樣更新了 UI ,這裡就不再說明。
react-router 主要是利用底層 history 模組的機制,通過結合 react 的架構機制做一層包裝,實際自身的內容並不多,但其包裝的思想筆者認為很值得學習,有興趣的建議閱讀下原始碼,相信會有其他收穫。