原文:https://zhuanlan.zhihu.com/p/27588422
本文由淺入深觀摩vue-router源碼是如何通過hash與History interface兩種方式實現(xiàn)前端路由刑棵,介紹了相關(guān)原理牺六,并對比了兩種方式的優(yōu)缺點與注意事項烈钞。最后分析了如何實現(xiàn)可以直接從文件系統(tǒng)加載而不借助后端服務(wù)器的Vue單頁應(yīng)用肢础。
隨著前端應(yīng)用的業(yè)務(wù)功能越來越復(fù)雜焕窝、用戶對于使用體驗的要求越來越高,單頁應(yīng)用(SPA)成為前端應(yīng)用的主流形式。大型單頁應(yīng)用最顯著特點之一就是采用前端路由系統(tǒng)伴郁,通過改變URL,在不重新請求頁面的情況下蛋叼,更新頁面視圖焊傅。
“更新視圖但不重新請求頁面”是前端路由原理的核心之一,目前在瀏覽器環(huán)境中這一功能的實現(xiàn)主要有兩種方式:
利用URL中的hash(“#”)
利用History interface在 HTML5中新增的方法
vue-router是Vue.js框架的路由插件狈涮,下面我們從它的源碼入手狐胎,邊看代碼邊看原理,由淺入深觀摩vue-router是如何通過這兩種方式實現(xiàn)前端路由的歌馍。
模式參數(shù)
在vue-router中是通過mode這一參數(shù)控制路由的實現(xiàn)模式的:
const router = new VueRouter({
mode: 'history',
routes: [...]
})
創(chuàng)建VueRouter的實例對象時握巢,mode以構(gòu)造函數(shù)參數(shù)的形式傳入。帶著問題閱讀源碼骆姐,我們就可以從VueRouter類的定義入手镜粤。一般插件對外暴露的類都是定義在源碼src根目錄下的index.js文件中捏题,打開該文件,可以看到VueRouter類的定義肉渴,摘錄與mode參數(shù)有關(guān)的部分如下:
export default class VueRouter {
mode: string; // 傳入的字符串參數(shù)公荧,指示history類別
history: HashHistory | HTML5History | AbstractHistory; // 實際起作用的對象屬性,必須是以上三個類的枚舉
fallback: boolean; // 如瀏覽器不支持同规,'history'模式需回滾為'hash'模式
constructor (options: RouterOptions = {}) {
let mode = options.mode || 'hash' // 默認(rèn)為'hash'模式
this.fallback = mode === 'history' && !supportsPushState // 通過supportsPushState判斷瀏覽器是否支持'history'模式
if (this.fallback) {
mode = 'hash'
}
if (!inBrowser) {
mode = 'abstract' // 不在瀏覽器環(huán)境下運行需強(qiáng)制為'abstract'模式
}
this.mode = mode
// 根據(jù)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
// 根據(jù)history的類別執(zhí)行相應(yīng)的初始化操作和監(jiān)聽
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類暴露的以下方法實際是調(diào)用具體history對象的方法
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.push(location, onComplete, onAbort)
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.replace(location, onComplete, onAbort)
}
}
可以看出:
-
作為參數(shù)傳入的字符串屬性mode只是一個標(biāo)記循狰,用來指示實際起作用的對象屬性history的實現(xiàn)類,兩者對應(yīng)關(guān)系如下:
modehistory'history'HTML5History'hash'HashHistory'abstract'AbstractHistory
在初始化對應(yīng)的history之前券勺,會對mode做一些校驗:若瀏覽器不支持HTML5History方式(通過supportsPushState變量判斷)绪钥,則mode強(qiáng)制設(shè)為'hash';若不是在瀏覽器環(huán)境下運行关炼,則mode強(qiáng)制設(shè)為'abstract'
VueRouter類中的onReady(), push()等方法只是一個代理程腹,實際是調(diào)用的具體history對象的對應(yīng)方法,在init()方法中初始化時儒拂,也是根據(jù)history對象具體的類別執(zhí)行不同操作
在瀏覽器環(huán)境下的兩種方式寸潦,分別就是在HTML5History,HashHistory兩個類中實現(xiàn)的社痛。他們都定義在src/history文件夾下见转,繼承自同目錄下base.js文件中定義的History類。History中定義的是公用和基礎(chǔ)的方法蒜哀,直接看會一頭霧水斩箫,我們先從HTML5History,HashHistory兩個類中看著親切的push(), replace()方法的說起撵儿。
HashHistory
看源碼前先回顧一下原理:
hash(“#”)符號的本來作用是加在URL中指示網(wǎng)頁中的位置:
符號本身以及它后面的字符稱之為hash乘客,可通過window.location.hash屬性讀取。它具有如下特點:
hash雖然出現(xiàn)在URL中统倒,但不會被包括在HTTP請求中寨典。它是用來指導(dǎo)瀏覽器動作的,對服務(wù)器端完全無用房匆,因此,改變hash不會重新加載頁面
-
可以為hash的改變添加監(jiān)聽事件:
window.addEventListener("hashchange", funcRef, false)
每一次改變hash(window.location.hash)报亩,都會在瀏覽器的訪問歷史中增加一個記錄
利用hash的以上特點浴鸿,就可以來實現(xiàn)前端路由“更新視圖但不重新請求頁面”的功能了。
HashHistory.push()
我們來看HashHistory中的push()方法:
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(location, route => {
pushHash(route.fullPath)
onComplete && onComplete(route)
}, onAbort)
}
function pushHash (path) {
window.location.hash = path
}
transitionTo()方法是父類中定義的是用來處理路由變化中的基礎(chǔ)邏輯的弦追,push()方法最主要的是對window的hash進(jìn)行了直接賦值:
window.location.hash = route.fullPath
hash的改變會自動添加到瀏覽器的訪問歷史記錄中岳链。
那么視圖的更新是怎么實現(xiàn)的呢,我們來看父類History中transitionTo()方法的這么一段:
transitionTo (location: RawLocation, onComplete?: Function, 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
}
可以看到劲件,當(dāng)路由變化時掸哑,調(diào)用了History中的this.cb方法约急,而this.cb方法是通過History.listen(cb)進(jìn)行設(shè)置的∶绶郑回到VueRouter類定義中厌蔽,找到了在init()方法中對其進(jìn)行了設(shè)置:
init (app: any /* Vue component instance */) {
this.apps.push(app)
history.listen(route => {
this.apps.forEach((app) => {
app._route = route
})
})
}
根據(jù)注釋,app為Vue組件實例摔癣,但我們知道Vue作為漸進(jìn)式的前端框架奴饮,本身的組件定義中應(yīng)該是沒有有關(guān)路由內(nèi)置屬性_route,如果組件中要有這個屬性择浊,應(yīng)該是在插件加載的地方戴卜,即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()方法投剥,全局注冊一個混合,影響注冊之后所有創(chuàng)建的每個 Vue 實例担孔,該混合在beforeCreate鉤子中通過Vue.util.defineReactive()定義了響應(yīng)式的_route屬性薇缅。所謂響應(yīng)式屬性,即當(dāng)_route值改變時攒磨,會自動調(diào)用Vue實例的render()方法泳桦,更新視圖。
總結(jié)一下娩缰,從設(shè)置路由改變到視圖更新的流程如下:
$router.push() --> HashHistory.push() --> History.transitionTo() --> History.updateRoute() --> {app._route = route} --> vm.render()
HashHistory.replace()
replace()方法與push()方法不同之處在于灸撰,它并不是將新路由添加到瀏覽器訪問歷史的棧頂,而是替換掉當(dāng)前的路由:
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(location, 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()的實現(xiàn)結(jié)構(gòu)上基本相似浮毯,不同點在于它不是直接對window.location.hash進(jìn)行賦值,而是調(diào)用window.location.replace方法將路由進(jìn)行替換泰鸡。
監(jiān)聽地址欄
以上討論的VueRouter.push()和VueRouter.replace()是可以在vue組件的邏輯代碼中直接調(diào)用的债蓝,除此之外在瀏覽器中,用戶還可以直接在瀏覽器地址欄中輸入改變路由盛龄,因此VueRouter還需要能監(jiān)聽瀏覽器地址欄中路由的變化饰迹,并具有與通過代碼調(diào)用相同的響應(yīng)行為。在HashHistory中這一功能通過setupListeners實現(xiàn):
setupListeners () {
window.addEventListener('hashchange', () => {
if (!ensureSlash()) {
return
}
this.transitionTo(getHash(), route => {
replaceHash(route.fullPath)
})
})
}
該方法設(shè)置監(jiān)聽了瀏覽器事件hashchange余舶,調(diào)用的函數(shù)為replaceHash啊鸭,即在瀏覽器地址欄中直接輸入路由相當(dāng)于代碼調(diào)用了replace()方法
HTML5History
History interface是瀏覽器歷史記錄棧提供的接口,通過back(), forward(), go()等方法匿值,我們可以讀取瀏覽器歷史記錄棧的信息赠制,進(jìn)行各種跳轉(zhuǎn)操作。
從HTML5開始挟憔,History interface提供了兩個新的方法:pushState(), replaceState()使得我們可以對瀏覽器歷史記錄棧進(jìn)行修改:
window.history.pushState(stateObject, title, URL)
window.history.replaceState(stateObject, title, URL)
stateObject: 當(dāng)瀏覽器跳轉(zhuǎn)到新的狀態(tài)時钟些,將觸發(fā)popState事件烟号,該事件將攜帶這個stateObject參數(shù)的副本
title: 所添加記錄的標(biāo)題
URL: 所添加記錄的URL
這兩個方法有個共同的特點:當(dāng)調(diào)用他們修改瀏覽器歷史記錄棧后,雖然當(dāng)前URL改變了政恍,但瀏覽器不會立即發(fā)送請求該URL(the browser won't attempt to load this URL after a call to pushState())汪拥,這就為單頁應(yīng)用前端路由“更新視圖但不重新請求頁面”提供了基礎(chǔ)。
我們來看vue-router中的源碼:
push (location: RawLocation, onComplete?: Function, 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, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
replaceState(cleanPath(this.base + route.fullPath))
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
}, 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.location[replace ? 'replace' : 'assign'](url)
}
}
export function replaceState (url?: string) {
pushState(url, true)
}
代碼結(jié)構(gòu)以及更新視圖的邏輯與hash模式基本類似抚垃,只不過將對window.location.hash直接進(jìn)行賦值window.location.replace()改為了調(diào)用history.pushState()和history.replaceState()方法喷楣。
在HTML5History中添加對修改瀏覽器地址欄URL的監(jiān)聽是直接在構(gòu)造函數(shù)中執(zhí)行的:
constructor (router: Router, base: ?string) {
window.addEventListener('popstate', e => {
const current = this.current
this.transitionTo(getLocation(this.base), route => {
if (expectScroll) {
handleScroll(router, route, current, true)
}
})
})
}
當(dāng)然了HTML5History用到了HTML5的新特特性,是需要特定瀏覽器版本的支持的鹤树,前文已經(jīng)知道铣焊,瀏覽器是否支持是通過變量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模式源碼的導(dǎo)讀,這兩種模式都是通過瀏覽器接口實現(xiàn)的罕伯,除此之外vue-router還為非瀏覽器環(huán)境準(zhǔn)備了一個abstract模式曲伊,其原理為用一個數(shù)組stack模擬出瀏覽器歷史記錄棧的功能。當(dāng)然追他,以上只是一些核心邏輯坟募,為保證系統(tǒng)的魯棒性源碼中還有大量的輔助邏輯,也很值得學(xué)習(xí)邑狸。此外在vue-router中還有路由匹配懈糯、router-view視圖組件等重要部分,關(guān)于整體源碼的閱讀推薦滴滴前端的這篇文章
兩種模式比較
在一般的需求場景中单雾,hash模式與history模式是差不多的赚哗,但幾乎所有的文章都推薦使用history模式,理由竟然是:"#" 符號太丑...0_0 "
如果不想要很丑的 hash硅堆,我們可以用路由的 history 模式 ——官方文檔
當(dāng)然屿储,嚴(yán)謹(jǐn)?shù)奈覀兛隙ú粦?yīng)該用顏值評價技術(shù)的好壞。根據(jù)MDN的介紹渐逃,調(diào)用history.pushState()相比于直接修改hash主要有以下優(yōu)勢:
pushState設(shè)置的新URL可以是與當(dāng)前URL同源的任意URL够掠;而hash只可修改#后面的部分,故只可設(shè)置與當(dāng)前同文檔的URL
pushState設(shè)置的新URL可以與當(dāng)前URL一模一樣茄菊,這樣也會把記錄添加到棧中疯潭;而hash設(shè)置的新值必須與原來不一樣才會觸發(fā)記錄添加到棧中
pushState通過stateObject可以添加任意類型的數(shù)據(jù)到記錄中;而hash只可添加短字符串
pushState可額外設(shè)置title屬性供后續(xù)使用
history模式的一個問題
我們知道對于單頁應(yīng)用來講买羞,理想的使用場景是僅在進(jìn)入應(yīng)用時加載index.html袁勺,后續(xù)在的網(wǎng)絡(luò)操作通過Ajax完成,不會根據(jù)URL重新請求頁面畜普,但是難免遇到特殊情況,比如用戶直接在地址欄中輸入并回車群叶,瀏覽器重啟重新加載應(yīng)用等吃挑。
hash模式僅改變hash部分的內(nèi)容钝荡,而hash部分是不會包含在HTTP請求中的:
http://oursite.com/#/user/id // 如重新請求只會發(fā)送http://oursite.com/
故在hash模式下遇到根據(jù)URL請求頁面的情況不會有問題。
而history模式則會將URL修改得就和正常請求后端的URL一樣
http://oursite.com/user/id
在此情況下重新向后端發(fā)送請求舶衬,如后端沒有配置對應(yīng)/user/id的路由處理埠通,則會返回404錯誤。官方推薦的解決辦法是在服務(wù)端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態(tài)資源逛犹,則應(yīng)該返回同一個 index.html 頁面端辱,這個頁面就是你 app 依賴的頁面。同時這么做以后虽画,服務(wù)器就不再返回 404 錯誤頁面舞蔽,因為對于所有路徑都會返回 index.html 文件。為了避免這種情況码撰,在 Vue 應(yīng)用里面覆蓋所有的路由情況渗柿,然后在給出一個 404 頁面〔钡海或者朵栖,如果是用 Node.js 作后臺,可以使用服務(wù)端的路由來匹配 URL柴梆,當(dāng)沒有匹配到路由的時候返回 404陨溅,從而實現(xiàn) fallback。
直接加載應(yīng)用文件
Tip: built files are meant to be served over an HTTP server.
Opening index.html over file:// won't work.
Vue項目通過vue-cli的webpack打包完成后绍在,命令行會有這么一段提示门扇。通常情況,無論是開發(fā)還是線上揣苏,前端項目都是通過服務(wù)器訪問悯嗓,不存在 "Opening index.html over file://" ,但程序員都知道卸察,需求和場景永遠(yuǎn)是千奇百怪的脯厨,只有你想不到的,沒有產(chǎn)品經(jīng)理想不到的坑质。
本文寫作的初衷就是遇到了這樣一個問題:需要快速開發(fā)一個移動端的展示項目合武,決定采用WebView加載Vue單頁應(yīng)用的形式,但沒有后端服務(wù)器提供涡扼,所以所有資源需從本地文件系統(tǒng)加載:
// 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://" 了稼跳,為此,我首先要進(jìn)行了一些設(shè)置
在項目config.js文件中將assetsPublicPath字段的值改為相對路徑 './'
調(diào)整生成的static文件夾中圖片等靜態(tài)資源的位置與代碼中的引用地址一致
這是比較明顯的需要改動之處吃沪,但改完后依舊無法順利加載汤善,經(jīng)過反復(fù)排查發(fā)現(xiàn),項目在開發(fā)時,router設(shè)置為了history模式(為了美觀...0_0")红淡,當(dāng)改為hash模式后就可正常加載了不狮。
為什么會出現(xiàn)這種情況呢?我分析原因可能如下:
當(dāng)從文件系統(tǒng)中直接加載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獲取到的桶蝎,它的結(jié)尾是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
}
...
})
}
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)
}
我們看到在代碼邏輯中,多次出現(xiàn)一個函數(shù)ensureSlash()绍豁,當(dāng)#符號后緊跟著的是'/'芯咧,則返回true,否則強(qiáng)行插入這個'/'竹揍,故我們可以看到敬飒,即使是從文件系統(tǒng)打開index.html,URL依舊會變?yōu)橐韵滦问剑?/p>
file:///C:/Users/dist/index.html#/
getHash()方法返回的path為 '/' 芬位,可與首頁視圖的路由匹配无拗。
故要想從文件系統(tǒng)直接加載Vue單頁應(yīng)用而不借助后端服務(wù)器,除了打包后的一些路徑設(shè)置外昧碉,還需確保vue-router使用的是hash模式英染。