從vue-router看前端路由的兩種實現
隨著前端應用的業務功能越來越複雜、使用者對於使用體驗的要求越來越高,單頁應用(SPA)成為前端應用的主流形式。大型單頁應用最顯著特點之一就是採用前端路由系統,通過改變URL,在不重新請求頁面的情況下,更新頁面檢視。
“更新檢視但不重新請求頁面”是前端路由原理的核心之一,目前在瀏覽器環境中這一功能的實現主要有兩種方式:
- 利用URL中的hash(“#”)
- 利用History interface在 HTML5中新增的方法
vue-router是Vue.js框架的路由外掛,下面我們從它的原始碼入手,邊看程式碼邊看原理,由淺入深觀摩vue-router是如何通過這兩種方式實現前端路由的。
模式引數
在vue-router中是通過mode這一引數控制路由的實現模式的:
const router = new VueRouter({ mode: 'history',routes: [...] })
建立VueRouter的例項物件時,mode以建構函式引數的形式傳入。帶著問題閱讀原始碼,我們就可以從VueRouter類的定義入手。一般外掛對外暴露的類都是定義在原始碼src根目錄下的index.js檔案中,開啟該檔案,可以看到VueRouter類的定義,摘錄與mode引數有關的部分如下:
export default class VueRouter {
mode: string; // 傳入的字串引數,指示history類別
history: HashHistory | HTML5History | 程式設計客棧AbstractHistory; // 實際起作用的物件屬性,必須是以上三個類的列舉
fallback: boolean; // 如瀏覽器不支援,'history'模式需回滾為'hash'模式
constructor (options: RouterOptions = {}) {
let mode = options.mode || 'hash' // 預設為'hash'模式
this.fallback = mode === 'history' && !supportsPushState // 通過supportsPushState判斷瀏覽器是否支援'history'模式
if (this.fallback) {
mode = 'hash'
}
if (!inBrowser) {
mode = 'abstract' // 不在瀏覽器環境下執行需強制為'abstract'模式
}
this.mode = mode
// 根據mode確定history實際的類並例項化
switch (mode) {
case 'history':
this.history = new HTML5History(this,options.base)
break
case 'hash':
this.history = new HashHistory(this,options.base,this.fallback)
break
case 'abstract':
this.history = new AbstractHistory(this,options.base)
break
default:
if (process.env.NODE_ENV !== 'production') {
assert(false,`invalid mode: ${mode}`)
}
}
}
init (app: any /* Vue component instance */) {
const history = this.history
// 根據history的類別執行相應的初始化操作和監聽
if (history instanceof HTML5History) {
history.transitionTo(history.getCurrentLocation())
} else if (history instanceof HashHistory) {
const setupHashListener = () => {
history.setupListeners()
}
history.transitionTo(
history.getCurrentLocation(),setupHashListener,setupHashListener
)
}
history.listen(route => {
this.apps.forEach((app) => {
app._route = route
})
})
}
// VueRouter類暴露的以下方法實際是呼叫具體history物件的方法
push (location: RawLocation,onComplete?: Function,onAbort?: Function) {
this.history.push(location,onComplete,onAbort)
}
replace (location: RawLocation,onAbort?: Function) {
this.history.replace(location,onAbort)
}
}
可以看出:
作為引數傳入的字串屬性mode只是一個標記,用來指示實際起作用的物件屬性history的實現類,兩者對應關係如下:
mode | history | hash | abstract |
---|---|---|---|
history | HTML5History | HashHistory | AbstractHistory |
在初始化對應的history之前,會對mode做一些校驗:若瀏覽器不支援HTML5History方式(通過supportsPushState變數判斷),則mode強制設為'hash';若不是在瀏覽器環境下執行,則mode強制設為'abstract'
VueRouter類中的onReady(),push()等方法只是一個代理,實際是呼叫的具體history物件的對應方法,在init()方法中初始化時,也是根據history物件具體的類別執行不同操作
在瀏覽器環境下的兩種方式,分別就是在HTML5History,HashHistory兩個類中實現的。他們都定義在src/history資料夾下,繼承自同目錄下base.js檔案中定義的History類。History中定義的是公用和基礎的方法,直接看會一頭霧水,我們先從HTML5History,HashHistory兩個類中看著親切的push(),replace()方法的說起。
HashHistory
看原始碼前先回顧一下原理:
hash(“#”)符號的本來作用是加在URL中指示網頁中的位置:
www.example.com/index.html#…
#符號本身以及它後面的字元稱之為hash,可通過window.location.hash屬性讀取。它具有如下特點:
- hash雖然出現在URL中,但不會被包括在HTTP請求中。它是用來指導瀏覽器動作的,對伺服器端完全無用,因此,改變hash不會重新載入頁面
- 可以為hash的改變新增監聽事件:
window.addEventListener("hashchange",funcRef,false)
每一次改變hash(window.location.hash),都會在瀏覽器的訪問歷史中增加一個記錄
利用hash的以上特點,就可以來實現前端路由“更新檢視但不重新請求頁面”的功能了。
HashHistory.push()
我們來看HashHistory中的push()方法:
push (location: RawLocation,onAbort?: Function) { this.transitionTo(location,route => { pushHash(route.fullPath) onComplete && onComplete(route) },onAbort) } function pushHash (path) { window.location.hash = path }
transitionTo()方法是父類中定義的是用來處理路由變化中的基礎邏輯的,push()方法最主要的是對window的hash進行了直接賦值:
window.location.hash = route.fullPath
hash的改變會自動新增到瀏覽器的訪問歷史記錄中。
那麼檢視的更新是怎麼實現的呢,我們來看父類History中transitionTo()方法的這麼一段:
transitionTo (location: RawLocation,onAbort?: Function) { const route = this.router.match(location,this.current) this.confirmTransition(route,() => { this.updateRoute(route) ... }) } updateRoute (route: Route) { this.cb && this.cb(route) } listen (cb: Function) { this.cb = cb }
可以看到,當路由變化時,呼叫了History中的this.cb方法,而this.cb方法是通過History.listen(cb)進行設定的。回到VueRouter類定義中,找到了在init()方法中對其進行了設定:
init (app: any /* Vue component i程式設計客棧nstance */) { this.apps.push(app) history.listen(route => { this.apps.forEach((app) => { app._route = route }) }) }
根據註釋,app為Vue元件例項,但我們知道Vue作為漸進式的前端框架,本身的元件定義中應該是沒有有關路由內建屬性_route,如果元件中要有這個屬性,應該是在外掛載入的地方,即VueRouter的install()方法中混合入Vue物件的,檢視install.js原始碼,有如下一段:
export function install (Vue) { Vue.mixin({ beforeCreate () { if (isDef(this.$options.router)) { this._router = this.$options.router this._router.init(this) Vue.util.defineReactive(this,'_route',this._router.history.current) } registerInstance(this,this) },}) }
通過Vue.mixin()方法,全域性註冊一個混合,影響註冊之後所有建立的每個 Vue 例項,該混合在beforeCreate鉤子中通過Vue.util.defineReactive()定義了響應式的_route屬性。所謂響應式屬性,即當_route值改變時,會自動呼叫Vue例項的render()方法,更新檢視。
總結一下,從設定路由改變到檢視更新的流程如下:
$router.push() --> HashHistory.push() --> History.transitionTo() --> History.updateRoute() --> {app._route = route} --> vm.render()
HashHistory.replace()
replace()方法與push()方法不同之處在於,它並不是將新路由新增到瀏覽器訪問歷史的棧頂,而是替換掉當前的路由:
replace (location: RawLocation,route => { replaceHash(route.fullPath) onComplete && onComplete(route) },onAbort) } function replaceHash (path) { const i = window.location.href.indexOf('#') window.location.replace( window.location.href.slice(0,i >= 0 ? i : 0) + '#' + path ) }
可以看出,它與push()的實現結構上基本相似,不同點在於它不是直接對window.location.hash進行賦值,而是呼叫window.location.replace方法將路由進行替換。
監聽位址列
以上討論的VueRouter.push()和VueRouter.replace()是可以在vue元件的邏輯程式碼中直接呼叫的,除此之外在瀏覽器中,使用者還可以直接在瀏覽器位址列中輸入改變路由,因此VueRouter還需要能監聽瀏覽器位址列中路由的變化,並具有與通過程式碼呼叫相同的響應行為。在HashHistory中這一功能通過setupListeners實現:
setupListeners () { window.addEventListener('hashchange',() => { if (!ensureSlash()) { return } this.transitionTo(getHash(),route => { replaceHash(route.fullPath) }) }) }
該方法設定監聽了瀏覽器事件hashchange,呼叫的函式為replaceHash,即在瀏覽器位址列中直接輸入路由相當於程式碼呼叫了replace()方法
HTML5History
History interface是瀏覽器歷史記錄棧提供的介面,通過back(),forward(),go()等方法,我們可以讀取瀏覽器歷史記錄棧的資訊,進行各種跳轉操作。
從HTML5開始,History interface提供了兩個新的方法:pushState(),replaceState()使得我們可以對瀏覽器歷史記錄棧進行修改:
window.history.pushState(stateObject,title,URL) window.history.replaceState(stateObject,URL)
- stateObject: 當瀏覽器跳轉到新的狀態時,將觸發popState事件,該事件將攜帶這個stateObject引數的副本
- title: 所新增記錄的標題
- URL: 所新增記錄的URL
這兩個方法有個共同的特點:當呼叫他們修改瀏覽器歷史記錄棧後,雖然當前URL改變了,但瀏覽器不會立即傳送請求該URL(the browser won't attempt to load this URL after a call to pushState()),這就為單頁應用前端路由“更新檢視但不重新請求頁面”提供了基礎。
我們來看vue-router中的原始碼:
push (location: RawLocation,onAbort?: Function) { const { current: fromRoute } = this this.transitionTo(location,route => { pushState(cleanPath(this.base + route.fullPath)) handleScroll(this.router,route,fromRoute,false) onComplete && onComplete(route) },onAbort) } replace (location: RawLocation,route => { replaceState(cleanPath(this.base + route.fullPath)) handleScroll(this.router,onAbort) } // src/util/push-state.js export function pushState (url?: string,replace?: boolean) { saveScrollPosition() // try...catch the pushState call to get around Safari // DOM Exception 18 where it limits to 100 pushState calls const history = window.history try { if (replace) { history.replaceState({ key: _key },'',url) } else { _key = genKey() history.pushState({ key: _key },url) } } catch (e) { window.locathttp://www.cppcns.comion[replace ? 'replace' : 'assign'](url) } } export function replaceState (url?: string) { pushState(url,true) }
程式碼結構以及更新檢視的邏輯與hash模式基本類似,只不過將對window.location.hash直接進行賦值window.location.replace()改為了呼叫history.pushState()和history.replaceState()方法。
在HTML5History中新增對修改瀏覽器位址列URL的監聽是直接在建構函式中執行的:
constructor (router: Router,base: ?string) { window.addEventListener('popstate',e => { const current = this.current this.transitionTo(getLocation(this.base),route => { if (expectScroll) { handleScroll(router,current,true) } }) }) }
當然了HTML5History用到了HTML5的新特特性,是需要特定瀏覽器版本的支援的,前文已經知道,瀏覽器是否支援是通過變數supportsPushState來檢查的:
// src/util/push-state.js export const supportsPushState = inBrowser && (function () { const ua = window.navigator.userAgent if ( (ua.indexOf('android 2.') !== -1 || ua.indexOf('Android 4.0') !== -1) && ua.indexOf('Mobile Safari') !== -1 && ua.indexOf('Chrome') === -1 && ua.indexOf('Windows Phone') === -1 ) { return false } return window.history && 'pushState' in window.history })()
以上就是hash模式與history模式原始碼的導讀,這兩種模式都是通過瀏覽器介面實現的,除此之外vue-router還為非瀏覽器環境準備了一個abstract模式,其原理為用一個數組stack模擬出瀏覽器歷史記錄棧的功能。當然,以上只是一些核心邏輯,為保證系統的魯棒性,原始碼中還有大量的輔助邏輯,也很值得學習。此外在vue-router中還有路由匹配、router-view檢視元件等重要部分
兩種模式比較
在一般的需求場景中,hash模式與history模式是差不多的,但幾乎所有的文章都推薦使用history模式,理由竟然是:"#" 符號太醜...0_0 "
如果不想要很醜的 hash,我們可以用路由的 history 模式 ——官方文件
當然,嚴謹的我們肯定不應該用顏值評價技術的好壞。根據MDN的介紹,呼叫history.pushState()相比於直接修改hash主要有以下優勢:
- pushState設定的新URL可以是與當前URL同源的任意URL;而hash只可修改#後面的部分,故只可設定與當前同文檔的URL
- pushState設定的新URL可以與當前URL一模一樣,這樣也會把記錄新增到棧中;而hash設定的新值必須與原來不一樣才會觸發記錄新增到棧中
- pushState通過stateObject可以新增任意型別的資料到記錄中;而hash只可新增短字串
- pushState可額外設定title屬性供後續使用
history模式的一個問題
我們知道對於單頁應用來講,理想的使用場景是僅在進入應用時載入index.html,後續在的網路操作通過Ajax完成,不會根據URL重新請求頁面,但是難免遇到特殊情況,比如使用者直接在位址列中輸入並回車,瀏覽器重啟重新載入應用等。
hash模式僅改變hash部分的內容,而hash部分是不會包含在HTTP請求中的:
http://oursite.com/#/user/id // 如重新請求只會傳送http://oursite.com/
故在hash模式下遇到根據URL請求頁面的情況不會有問題。
而history模式則會將URL修改得就和正常請求後端的URL一樣
http://oursite.com/user/id
在此情況下重新向後端傳送請求,如後端沒有配置對應/user/id的路由處理,則會返回404錯誤。官方推薦的解決辦法是在服務端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態資源,則應該返回同一個 index.html 頁面,這個頁面就是你 app 依賴的頁面。同時這麼做以後,伺服器就不再返回 404 錯誤頁面,因為對於所有路徑都會返回 index.html 檔案。為了避免這種情況,在 Vue 應用裡面覆蓋所有的路由情況,然後在給出一個 404 頁面。或者,如果是用 Node.js 作後臺,可以使用服務端的路由來匹配 URL,當沒有匹配到路由的時候返回 404,從而實現 fallback。
直接載入應用檔案
Tip: built files are meant to be served over an HTTP server.
Opening index.html over file:// won't work.
Vue專案通過vue-cli的webpack打包完成後,命令列會有這麼一段提示。通常情況,無論是開發還是線上,前端專案都是通過伺服器訪問,不存在 "Opening index.html over file://" ,但程式設計師都知道,需求和場景永遠是千奇百怪的,只有你想不到的,沒有產品經理想不到的。
本文寫作的初衷就是遇到了這樣一個問題:需要快速開發一個移動端的展示專案,決定採用WebView載入Vue單頁應用的形式,但沒有後端伺服器提供,所以所有資源需從本地檔案系統載入:
// AndroidAppWrapper public class MainActivity extends AppCompatActivity { private WebView webView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); webView = new WebView(this); webView.getSettings().setjavascriptEnabled(true); webView.loadUrl("file:///android_asset/index.html"); setContentView(webView); } @Override public boolean onKeyDown(int keyCode,KeyEvent event) { if ((keyCode == KeyEvent.KEYCODE_BACK) && webView.canGoBack()) { webView.goBack(); return true; } return false; } }
此情此景看來是必須 "Opening index.html over file://" 了,為此,我首先要進行了一些設定
- 在專案config.js檔案中將assetsPublicPath欄位的值改為相對路徑 './'
- 調整生成的static資料夾中圖片等靜態資源的RKhrMmSZ位置與程式碼中的引用地址一致
這是比較明顯的需要改動之處,但改完後依舊無法順利載入,經過反覆排查發現,專案在開發時,router設定為了history模式(為了美觀...0_0"),當改為hash模式後就可正常載入了。
為什麼會出現這種情況呢?我分析原因可能如下:
當從檔案系統中直接載入index.html時,URL為:
file:///android_asset/index.html
而首頁檢視需匹配的路徑為path: '/' :
export default new Router({ mode: 'history',routes: [ { path: '/',name: 'index',component: IndexView } ] })
我們先來看history模式,在HTML5History中:
ensureURL (push?: boolean) { if (getLocation(this.base) !== this.current.fullPath) { const current = cleanPath(this.base + this.current.fullPath) push ? pushState(current) : replaceState(current) } } export function getLocation (base: string): string { let path = window.location.pathname if (base && path.indexOf(base) === 0) { path = path.slice(base.length) } return (path || '/') + window.location.search + window.location.hash }
邏輯只會確保存在URL,path是通過剪下的方式直接從window.location.pathname獲取到的,它的結尾是index.html,因此匹配不到 '/' ,故 "Opening index.html over file:// won't work" 。
再看hash模式,在HashHistory中:
export class HashHistory extends History { constructor (router: Router,base: ?string,fallback: boolean) { ... ensureSlash() } // this is delayed until the app mounts // to avoid the hashchange listener being fired too early setupListeners () { window.addEventListener('hashchange',() => { if (!ensureSlash()) { return } ... http://www.cppcns.com }) } getCurrentLocation () { return getHash() } } function ensureSlash (): boolean { const path = getHash() if (path.charAt(0) === '/') { return true } replaceHash('/' + path) return false } export function getHash (): string { const href = window.location.href const index = href.indexOf('#') return index === -1 ? '' : href.slice(index + 1) }
我們看到在程式碼邏輯中,多次出現一個函式ensureSlash(),當#符號後緊跟著的是'/',則返回true,否則強行插入這個'/',故我們可以看到,即使是從檔案系統開啟index.html,URL依舊會變為以下形式:
file:///C:/Users/dist/index.html#/
getHash()方法返回的path為 '/' ,可與首頁檢視的路由匹配。
故要想從檔案系統直接載入Vue單頁應用而不借助後端伺服器,除了打包後的一些路徑設定外,還需確保vue-router使用的是hash模式。
以上就是從vue-router看前端路由的兩種實現的詳細內容,更多關於vue前端路由的兩種實現的資料請關注我們其它相關文章!