淺析前端路由原理和實現方式


一、什么是前端路由?

  路由是根據不同的 url 地址展示不同的內容或頁面。路由的概念來源於服務端,在服務端中路由描述的是 URL 與處理函數之間的映射關系。

  而在 Web 前端單頁應用中,路由描述的是 URL 與 UI 之間的映射關系,這種映射是單向的,即 URL 變化引起 UI 更新(無需刷新頁面)。

  前端路由的興起就是把不同路由對應不同的內容或頁面的任務交給前端來做,之前是通過服務端根據 url 的不同返回不同的頁面來實現的。

二、技術准備

  故事從名叫 Oliver 的大蝦說起,這位大蝦酷愛社交網站,一天他打開了 Twitter ,從發過的 tweets 的選項卡一路切到 followers 選項卡,Oliver發現頁面的內容變化了,URL也變化了,但為什么頁面沒有閃爍刷新呢?於是Oliver打開的網絡監控器,他驚訝地發現在切換選項卡時,只有幾個XHR請求發生,但頁面的URL卻在對應着變化,這讓 Oliver 不得不去思考這一機制的原因。敘事體故事講完,進入正題。

  首先,我們知道傳統而經典的 Web 開發中,服務器端承擔了大部分業務邏輯,但隨着 2.0 時代 ajax 的到來,前端開始擔負起更多的數據通信和與之對應的邏輯。

  在過去,Server 端處理來自瀏覽器的請求時,要根據不同的 Url 路由,拼接出對應的視圖頁面,通過 Http 返回給瀏覽器進行解析渲染。Server 不得不承擔這份艱巨的責任。為了讓 Server 端更好地把重心放到實現核心邏輯和看守數據寶庫,把部分數據交互的邏輯交給前端擔負,讓前端來分擔 Server 端的壓力顯得尤為重要,前端也有這個責任和能力。

  大部分復雜的網站,都會把業務解耦為模塊進行處理。這些網站中又有很多的網站會把適合的部分應用 Ajax 進行數據交互,展現給用戶,很明顯處理這樣的數據通信交互,不可避免的會涉及到跟 URL 打交道,讓數據交互的變化反映到 URL 的變化上,進而可以給用戶機會去通過保存的 URL 鏈接,還原剛才的頁面內容板塊的布局,這其中包括 Ajax 局部刷新的變化。

  通過記錄 URL 來記錄 web 頁面板塊上 Ajax 的變化,我們可以稱之為 Ajax 標簽化 ,比較好實現可以參考 Pjax 等。而對於較大的 framework,我們稱之為 路由系統

  我們先熟悉幾個新的 H5 history Api

/*Returns the number of entries in the joint session history.*/ window . history . length /*Returns the current state object.*/ window . history . state /*Goes back or forward the specified number of steps in the joint session history.A zero delta will reload the current page.If the delta is out of range, does nothing.*/ window . history . go( [ delta ] ) /*Goes back one step in the joint session history.If there is no previous page, does nothing.*/ window . history . back() /*Goes forward one step in the joint session history.If there is no next page, does nothing.*/ window . history . forward() /*Pushes the given data onto the session history, with the given title, and, if provided and not null, the given URL.*/ window . history . pushState(data, title [url] ) /*Updates the current entry in the session history to have the given data, title, and,if provided and not null, URL.*/ window . history . replaceState(data, title [url] )

  上邊是 Mozilla 在 HTML5 中實現的幾個 History api 的官方文檔描述,我們先來關注下最后邊的兩個apihistory.pushStatehistory.replaceState ,這兩個 history 新增的 api,為前端操控瀏覽器歷史棧提供了可能性:

/** *parameters *@data {object} state對象,這是一個javascript對象,一般是JSON格式的對象 *字面量。 *@title {string} 可以理解為document.title,在這里是作為新頁面傳入參數的。 *@url {string} 增加或改變的記錄,對應的url,可以是相對路徑或者絕對路徑, *url的具體格式可以自定。 */ history.pushState(data, title, url) //向瀏覽器歷史棧中增加一條記錄。
history.replaceState(data, title, url) //替換歷史棧中的當前記錄。

  這兩個 Api 都會操作瀏覽器的歷史棧,而不會引起頁面的刷新。不同的是,pushState 會增加一條新的歷史記錄,而 replaceState 則會替換當前的歷史記錄。所需的參數相同,在將新的歷史記錄存入棧后,會把傳入的 data(即 state 對象)同時存入,以便以后調用。同時,這倆 api 都會更新或者覆蓋當前瀏覽器的 titleurl 為對應傳入的參數。

  url 參數可以為絕對路徑,如: http://tonylee.pw?name=tonyleehttps://www.tonylee.pw/name/tonylee ;也可以為相對路徑:?name=tonylee , /name/tonylee ;等等的形式。

  url 作為一個改變當前瀏覽器地址的參數,用法是很靈活的,replaceStatepushState 具有和上邊測試相同的特性,傳入的url如果可能,總會被做適當的處理,這種處理默以”/”相隔,也可以自己指定為”?”等。要注意,這兩個 api 都是不能跨域的!比如在 http://tonylee.pw 下,只能在同域下進行調用,如二級域名http://www.tonylee.pw 就會產生錯誤。沒錯,我想你已經猜到了前邊講到的 Oliver 看到 URL 變化,頁面板塊變化,頁面發出 XHR 請求,頁面沒有 reload 等等特性,都是因此而生!

  至於 api 中的 data 參數,實際上是一個 state 對象,也即是 JavaScript 對象。Firefox 的實現中,它們是存在用戶的本地硬盤上的,最大支持到 640k,如果不夠用,按照 FF 的說法你可以用 sessionStorage or localStorage 。如:

var stateObj = { foo: "bar" }; history.pushState(stateObj, "the blog", "name = next");

  如果當前頁面經過這樣的過程,歷史棧對應的條目,被存入了stateObj,那么我們可以隨時主動地取出它,如果頁面只是一個普通的歷史記錄,那么這個 state 就是 null

  了解這倆 api 還不夠,還需要看下 popstate 事件,我擔心解釋的不到位,所以看看 mozilla 官方文檔的解釋:

An event handler for the popstate event on the window. A popstate event is dispatched to the window every time the active history entry changes 
between two history entries for the same document.
If the history entry being activated was created by a call to history.pushState() or was affected by a call to history.replaceState(),
the popstateevent's state property contains a copy of the history entry's state object. Note that just calling history.pushState() or history.replaceState() won't trigger apopstate event.
The popstate event is only triggered by doing a browser action such as clicking on the back button (or calling history.back() in JavaScript).
And the event is only triggered when the user navigates between two history entries for the same document.
Browsers tend to handle the popstate event differently on page load.

Chrome (prior to v34) and Safari always emit a popstate event on page load, but Firefox doesn't. Syntax window.onpopstate = funcRef; //funcRef is a handler function.

  簡而言之,就是說當同一個頁面在歷史記錄間切換時,就會產生 popstate 事件。正常情況下,如果用戶點擊后退按鈕或者開發者調用:history.back() or history.Go(),頁面根本就沒有處理事件的機會,因為這些操作會使得頁面 reload。所以 popstate 只在不會讓瀏覽器頁面刷新的歷史記錄之間切換才能觸發,這些歷史記錄一般由 pushState/replaceState 或者是由 hash 錨點等操作產生。並且在事件的句柄中可以訪問 state 對象的引用副本!而且單純的調用 pushState/replaceState 並不會觸發 popstate 事件。頁面初次加載時,是否會主動觸發 popstate 事件,不同的瀏覽器實現也不一樣。下邊是官方的一個 demo:

window.onpopstate = function(event) { alert("location: " + document.location + ", state: " +   JSON.stringify(event.state)); }; history.pushState({page: 1}, "title 1", "?page=1"); history.pushState({page: 2}, "title 2", "?page=2"); history.replaceState({page: 3}, "title 3", "?page=3"); history.back(); // alerts "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back(); // alerts "location: http://example.com/example.html, state: null
history.go(2); // alerts "location: http://example.com/example.html?page=3, state: {"page":3}

  這里便是通過event.state拿到的 state 的引用副本!

  H5還新增了一個 hashchange 事件,也是很有用途的一個新事件:

The 'hashchange' event is fired when the fragment identifier of the URL has changed 
(the part of the URL that follows the # symbol, including the # symbol).

  當頁面hash(#)變化時,即會觸發 hashchange。錨點Hash起到引導瀏覽器將這次記錄推入歷史記錄棧頂的作用, window.location 對象處理“#”的改變並不會重新加載頁面,而是將之當成新頁面,放入歷史棧里。並且,當前進或者后退或者觸發 hashchange 事件時,我們可以在對應的事件處理函數中注冊 ajax 等操作!

  但是 hashchange 這個事件不是每個瀏覽器都有,低級瀏覽器需要用輪詢檢測URL是否在變化,來檢測錨點的變化。當錨點內容(location.hash)被操作時,如果錨點內容發生改變瀏覽器才會將其放入歷史棧中,如果錨點內容沒發生變化,歷史棧並不會增加,並且也不會觸發 hashchange 事件。

  想必你猜到了,這里說的低級瀏覽器,指的就是可愛的IE了。比如我有一個url從 http://tonylee.pw#hash_start=1 變化到 http://tonylee.pw#hash_start=2 ,實現良好的瀏覽器是會觸發一個名為 hashchange 的事件,但是對於低版本的IE(稍后我會對具體的兼容性做個總結),我們只能通過設置一個 Inerval 來不斷的輪詢url是否發生變化,來判斷是否發生了類似 hashchange 的事件,同時可以聲明對應的事件處理函數,從而模擬事件的處理。

  到這里,說了這么多 api, 其實我們對標簽化/路由系統應該有了一個大概的了解。如果考慮H5的api,過去 facebooktwitter 實現路由系統時,約定用”#!”實現,這估計也是一個為了照顧搜索引擎的約定。畢竟前端路由系統涉及到大量的 ajax,而這些 ajax 對應 url 路徑對於搜索引擎來說,是很難匹配起來的。

  路由大概的實現過程可以這么理解, 對於高級瀏覽器,利用 H5 的新 Api 做好頁面上不同板塊 ajax 等操作與 url 的映射關系,甚至可以自己用 javascript 書寫一套歷史棧管理模塊,從而繞過瀏覽器自己的歷史棧。而當用戶的操作觸發 popstate 時,可以判斷此時的 url 與板塊的映射關系,從而加載對應的 ajax 板塊。這樣你就可以把一個具有很復雜 ajax 版面結構頁面的 url 發送給你的朋友了,而你的朋友在瀏覽器中打開這個鏈接時,前端路由系統 url 和板塊映射關系會解析並還原出整個頁面的原貌!一般 SPA(單頁面應用)和一些復雜的社交站應用,會普遍擁有自己的前端路由系統。

三、如何實現前端路由?

  要實現前端路由,需要解決兩個核心問題:

  (1)如何改變 URL 卻不引起頁面刷新?

  (2)如何檢測 URL 變化了?

  下面分別使用 hash 和 history 兩種實現方式回答上面的兩個核心問題。

 1、hash 實現

   hash 是 URL 中 hash (#) 及后面的那部分,常用作錨點在頁面內進行導航,改變 URL 中的 hash 部分不會引起頁面刷新。

   通過 hashchange 事件監聽 URL 的變化,改變 URL 的方式只有這幾種:通過瀏覽器前進后退改變 URL、通過標簽改變 URL、通過window.location改變URL,這幾種情況改變 URL 都會觸發 hashchange 事件

2、history 實現

  history 提供了 pushState 和 replaceState 兩個方法,這兩個方法改變 URL 的 path 部分不會引起頁面刷新。

  history 提供類似 hashchange 事件的 popstate 事件,但 popstate 事件有些不同:

  通過瀏覽器前進后退改變 URL 時會觸發 popstate 事件,通過pushState/replaceState或標簽改變 URL 不會觸發 popstate 事件。

  好在我們可以攔截 pushState/replaceState的調用和標簽的點擊事件來檢測 URL 變化,所以監聽 URL 變化可以實現,只是沒有 hashchange 那么方便。

四、原生JS版前端路由實現

  基於上節討論的兩種實現方式,分別實現 hash 版本和 history 版本的路由,示例使用原生 HTML/JS 實現,不依賴任何框架。

1、基於 hash 實現

  url鏈接:localhost/#/home

<body>
  <ul>
    <!-- 定義路由 -->
    <li><a href="#/home">home</a></li>
    <li><a href="#/about">about</a></li>

    <!-- 渲染路由對應的 UI -->
    <div id="routeView"></div>
  </ul>
</body>
// 頁面加載完不會觸發 hashchange,這里主動觸發一次 hashchange 事件
window.addEventListener('DOMContentLoaded', onLoad) // 監聽路由變化
window.addEventListener('hashchange', onHashChange) // 路由視圖
var routerView = null function onLoad () { routerView = document.querySelector('#routeView') onHashChange() } // 路由變化時,根據路由渲染對應 UI
function onHashChange () { switch (location.hash) { case '#/home': routerView.innerHTML = 'Home'
      return
    case '#/about': routerView.innerHTML = 'About'
      return
    default: return } }

2、基於 history 實現

  url鏈接:localhost/about

<body>
  <ul>
    <li><a href='/home'>home</a></li>
    <li><a href='/about'>about</a></li>

    <div id="routeView"></div>
  </ul>
</body>
// 頁面加載完不會觸發 hashchange,這里主動觸發一次 hashchange 事件
window.addEventListener('DOMContentLoaded', onLoad) // 監聽路由變化
window.addEventListener('popstate', onPopState) // 路由視圖
var routerView = null function onLoad () { routerView = document.querySelector('#routeView') onPopState() // 攔截 <a> 標簽點擊事件默認行為, 點擊時使用 pushState 修改 URL並更新手動 UI,從而實現點擊鏈接更新 URL 和 UI 的效果。
  var linkList = document.querySelectorAll('a[href]') linkList.forEach(el => el.addEventListener('click', function (e) { e.preventDefault() history.pushState(null, '', el.getAttribute('href')) onPopState() })) } // 路由變化時,根據路由渲染對應 UI
function onPopState () { switch (location.pathname) { case '/home': routerView.innerHTML = 'Home'
      return
    case '/about': routerView.innerHTML = 'About'
      return
    default: return } }

五、Vue 版本前端路由實現

1、基於hash實現

  使用方式和 vue-router 類似(vue-router 通過插件機制注入路由,但是這樣隱藏了實現細節,為了保持代碼直觀,這里沒有使用 Vue 插件封裝):

<div>
    <ul>
        <li><router-link to="/home">home</router-link></li>
        <li><router-link to="/about">about</router-link></li>
    </ul>
    <router-view></router-view>
</div>

const routes = { '/home': { template: '<h2>Home</h2>' }, '/about': { template: '<h2>About</h2>' } } const app = new Vue({ el: '.vue.hash', components: { 'router-view': RouterView, 'router-link': RouterLink }, beforeCreate () { this.$routes = routes } })

  router-view 實現

<template>
  <component :is="routeView" />
</template>

<script> import utils from '~/utils.js' export default { data () { return { routeView: null } }, created () { this.boundHashChange = this.onHashChange.bind(this) }, beforeMount () { window.addEventListener('hashchange', this.boundHashChange) }, mounted () { this.onHashChange() }, beforeDestroy() { window.removeEventListener('hashchange', this.boundHashChange) }, methods: { onHashChange () { const path = utils.extractHashPath(window.location.href) this.routeView = this.$root.$routes[path] || null console.log('vue:hashchange:', path) } } } </script>

  router-link 實現

<template>
  <a @click.prevent="onClick" href=''><slot></slot></a>
</template>

<script> export default { props: { to: String }, methods: { onClick () { window.location.hash = '#' + this.to } } } </script>

  我們看到就是在router-link點擊的時候改變url的hash,然后在router-view里監聽了hashchange事件,取出hash的值,然后去routes里取是對應的那個模板

2、基於history實現

  使用方式和 vue-router 類似:

<div>
      <ul>
        <li><router-link to="/home">home</router-link></li>
        <li><router-link to="/about">about</router-link></li>
      </ul>
      <router-view></router-view>
</div>

const routes = { '/home': { template: '<h2>Home</h2>' }, '/about': { template: '<h2>About</h2>' } } const app = new Vue({ el: '.vue.history', components: { 'router-view': RouterView, 'router-link': RouterLink }, created () { this.$routes = routes this.boundPopState = this.onPopState.bind(this) }, beforeMount () { window.addEventListener('popstate', this.boundPopState) }, beforeDestroy () { window.removeEventListener('popstate', this.boundPopState) }, methods: { onPopState (...args) { this.$emit('popstate', ...args) } } })

  router-view 實現:

<template>
  <component :is="routeView" />
</template>

<script> import utils from '~/utils.js' export default { data () { return { routeView: null } }, created () { this.boundPopState = this.onPopState.bind(this) }, beforeMount () { this.$root.$on('popstate', this.boundPopState) }, beforeDestroy() { this.$root.$off('popstate', this.boundPopState) }, methods: { onPopState (e) { const path = utils.extractUrlPath(window.location.href) this.routeView = this.$root.$routes[path] || null console.log('[Vue] popstate:', path) } } } </script>

  router-link 實現

<template>
  <a @click.prevent="onClick" href=''><slot></slot></a>
</template>

<script> export default { props: { to: String }, methods: { onClick () { history.pushState(null, '', this.to) this.$root.$emit('popstate') } } } </script>

  這里就是用到了事件發射和接收去監聽的popstate事件,然后取出url里的path,再去routes里取對應的模板。可以看下前幾天寫的這篇博客:vue事件派發和廣播、發射與接收(可實現頁面傳值和非父子組件傳值)

  前端路由的核心實現原理很簡單,但是結合具體框架后,框架增加了很多特性,如動態路由、路由參數、路由動畫等等,這些導致路由實現變的復雜。本文去粗取精只針對前端路由最核心部分的實現進行分析,並基於 hash 和 history 兩種模式,分別提供原生JS/Vue 實現,希望對你有所幫助。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM