隨著前端應(yīng)用的業(yè)務(wù)功能起來越復(fù)雜,用戶對(duì)于使用體驗(yàn)的要求越來越高,單面(SPA)成為前端應(yīng)用的主流形式椭蹄。大型單頁應(yīng)用最顯著特點(diǎn)之一就是采用的前端路由系統(tǒng),通過改變URL净赴,在不重新請(qǐng)求頁面的情況下绳矩,更新頁面視圖。
更新視圖但不重新請(qǐng)求頁面玖翅,是前端路由原理的核心之一翼馆,目前在瀏覽器環(huán)境中這一功能的實(shí)現(xiàn)主要有2種方式:
- 利用URL中的hash("#");
- 利用History interface在HTML5中新增的方法;
vue-router是Vue.js框架的路由插件,它是通過mode這一參數(shù)控制路由的實(shí)現(xiàn)模式的:
const router=new VueRouter({
mode:'history',
routes:[...]
})
創(chuàng)建VueRouter的實(shí)例對(duì)象時(shí)金度,mode以構(gòu)造參數(shù)的形式傳入应媚。
src/index.js
export default class VueRouter{
mode: string; // 傳入的字符串參數(shù),指示history類別
history: HashHistory | HTML5History | AbstractHistory; // 實(shí)際起作用的對(duì)象屬性猜极,必須是以上三個(gè)類的枚舉
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)境下運(yùn)行需強(qiáng)制為'abstract'模式
}
this.mode = mode
// 根據(jù)mode確定history實(shí)際的類并實(shí)例化
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類暴露的以下方法實(shí)際是調(diào)用具體history對(duì)象的方法
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只是一個(gè)標(biāo)記,用來指示實(shí)際起作用的對(duì)象屬性history的實(shí)現(xiàn)類魔吐,兩者對(duì)應(yīng)關(guān)系:
modehistory:
'history':HTML5History;
'hash':HashHistory;
'abstract':AbstractHistory;
- 在初始化對(duì)應(yīng)的history之前扎筒,會(huì)對(duì)mode做一些校驗(yàn):若瀏覽器不支持HTML5History方式(通過supportsPushState變量判斷)莱找,則mode設(shè)為hash;若不是在瀏覽器環(huán)境下運(yùn)行酬姆,則mode設(shè)為abstract;
- VueRouter類中的onReady(),push()等方法只是一個(gè)代理,實(shí)際是調(diào)用的具體history對(duì)象的對(duì)應(yīng)方法奥溺,在init()方法中初始化時(shí)辞色,也是根據(jù)history對(duì)象具體的類別執(zhí)行不同操作
HashHistory
hash("#")符號(hào)的本來作用是加在URL指示網(wǎng)頁中的位置:
http://www.example.com/index.html#print
#
本身以及它后面的字符稱之為hash可通過window.location.hash屬性讀取.
- hash雖然出現(xiàn)在url中,但不會(huì)被包括在http請(qǐng)求中浮定,它是用來指導(dǎo)瀏覽器動(dòng)作的相满,對(duì)服務(wù)器端完全無用,因此桦卒,改變hash不會(huì)重新加載頁面立美。
- 可以為hash的改變添加監(jiān)聽事件:
window.addEventListener("hashchange",funcRef,false)
- 每一次改變hash(window.location.hash),都會(huì)在瀏覽器訪問歷史中增加一個(gè)記錄方灾。
利用hash的以上特點(diǎn)建蹄,就可以來實(shí)現(xiàn)前端路由"更新視圖但不重新請(qǐng)求頁面"的功能了。
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()方法最主要的是對(duì)window的hash進(jìn)行了直接賦值:
window.location.hash=route.fullPath
hash的改變會(huì)自動(dòng)添加到瀏覽器的訪問歷史記錄中洞慎。
那么視圖的更新是怎么實(shí)現(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)路由變化時(shí)劲腿,調(diào)用了Hitory中的this.cb方法,而this.cb方法是通過History.listen(cb)進(jìn)行設(shè)置的鸟妙,回到VueRouter類定義中焦人,找到了在init()中對(duì)其進(jìn)行了設(shè)置:
init (app: any /* Vue component instance */) {
this.apps.push(app)
history.listen(route => {
this.apps.forEach((app) => {
app._route = route
})
})
}
app為Vue組件實(shí)例挥吵,但是Vue作為漸進(jìn)式的前端框架,本身的組件定義中應(yīng)該是沒有有關(guān)路由內(nèi)置屬性_route,如果組件中要有這個(gè)屬性垃瞧,應(yīng)該是在插件加載的地方蔫劣,即VueRouter的install()方法中混入Vue對(duì)象的,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()方法个从,全局注冊(cè)一個(gè)混合脉幢,影響注冊(cè)之后所有創(chuàng)建的每個(gè)Vue實(shí)例,該混合在beforeCreate鉤子中通過Vue.util.defineReactive()定義了響應(yīng)式的_route屬性嗦锐。所謂響應(yīng)式屬性嫌松,即當(dāng)_route值改變時(shí),會(huì)自動(dòng)調(diào)用Vue實(shí)例的render()方法奕污,更新視圖萎羔。
$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()的實(shí)現(xiàn)結(jié)構(gòu)基本相似,不同點(diǎn)它不是直接對(duì)window.location.hash進(jìn)行賦值嘱根,而是調(diào)用window.location.replace方法將路由進(jìn)行替換髓废。
監(jiān)聽地址欄
上面的VueRouter.push()和VueRouter.replace()是可以在vue組件的邏輯代碼中直接調(diào)用的,除此之外在瀏覽器中该抒,用戶還可以直接在瀏覽器地址欄中輸入改變路由慌洪,因此還需要監(jiān)聽瀏覽器地址欄中路由的變化 ,并具有與通過代碼調(diào)用相同的響應(yīng)行為凑保,在HashHistory中這一功能通過setupListeners監(jiān)聽hashchange實(shí)現(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提供了2個(gè)新的方法:pushState()
,replaceState()
使得我們可以對(duì)瀏覽器歷史記錄棧進(jìn)行修改:
window.history.pushState(stateObject,title,url)
window.history.replaceState(stateObject,title,url)
- stateObject:當(dāng)瀏覽器跳轉(zhuǎn)到新的狀態(tài)時(shí)憋肖,將觸發(fā)popState事件,該事件將攜帶這個(gè)stateObject參數(shù)的副本
- title:所添加記錄的標(biāo)題
- url:所添加記錄的url
這2個(gè)方法有個(gè)共同的特點(diǎn):當(dāng)調(diào)用他們修改瀏覽器歷史棧后癌蓖,雖然當(dāng)前url改變了瞬哼,但瀏覽器不會(huì)立即發(fā)送請(qǐng)求該url,這就為單頁應(yīng)用前端路由租副,更新視圖但不重新請(qǐng)求頁面提供了基礎(chǔ)坐慰。
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模式基本類似,只不過將對(duì)window.location.hash()直接進(jìn)行賦值window.location.replace()改為了調(diào)用history.pushState()和history.replaceState()方法。
在HTML5History中添加對(duì)修改瀏覽器地址欄URL的監(jiān)聽popstate是直接在構(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)
}
})
})
}
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模式源碼導(dǎo)讀,這2種模式都是通過瀏覽器接口實(shí)現(xiàn)的糟港,除此之外攀操,vue-router還為非瀏覽器環(huán)境準(zhǔn)備了一個(gè)abstract模式,其原理為用一個(gè)數(shù)組stack模擬出瀏覽器歷史記錄棧的功能秸抚。
兩種模式比較
一般的需求場景中速和,hash模式與history模式是差不多的,根據(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一模一樣,這樣也會(huì)把記錄添加到棧中吭敢,而hash設(shè)置的新值必須與原來不一樣才會(huì)觸發(fā)記錄添加到棧中
- pushState通過stateObject可以添加任意類型的數(shù)據(jù)記錄中碰凶,而hash只可添加短字符串
- pushState可額外設(shè)置title屬性供后續(xù)使用
history模式的問題
對(duì)于單頁應(yīng)用來說,理想的使用場景是僅在進(jìn)入應(yīng)用時(shí)加載index.html鹿驼,后續(xù)在的網(wǎng)絡(luò)操作通過ajax完成欲低,不會(huì)根據(jù)url重新請(qǐng)求頁面,但是如果用戶直接在地址欄中輸入并回車畜晰,瀏覽器重啟重新加載等特殊情況砾莱。
hash模式僅改變hash部分的內(nèi)容,而hash部分是不會(huì)包含在http請(qǐng)求中的(hash帶#):
http://oursite.com/#/user/id //如請(qǐng)求舷蟀,只會(huì)發(fā)送http://oursite.com/
所以hash模式下遇到根據(jù)url請(qǐng)求頁面不會(huì)有問題
而history模式則將url修改的就和正常請(qǐng)求后端的url一樣(history不帶#)
http://oursite.com/user/id
如果這種向后端發(fā)送請(qǐng)求的話恤磷,后端沒有配置對(duì)應(yīng)/user/id的get路由處理,會(huì)返回404錯(cuò)誤面哼。
官方推薦的解決辦法是在服務(wù)端增加一個(gè)覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態(tài)資源野宜,則應(yīng)該返回同一個(gè) index.html 頁面,這個(gè)頁面就是你 app 依賴的頁面魔策。同時(shí)這么做以后匈子,服務(wù)器就不再返回 404 錯(cuò)誤頁面,因?yàn)閷?duì)于所有路徑都會(huì)返回 index.html 文件闯袒。為了避免這種情況虎敦,在 Vue 應(yīng)用里面覆蓋所有的路由情況,然后在給出一個(gè) 404 頁面政敢∑溽悖或者,如果是用 Node.js 作后臺(tái)喷户,可以使用服務(wù)端的路由來匹配 URL唾那,當(dāng)沒有匹配到路由的時(shí)候返回 404,從而實(shí)現(xiàn) fallback褪尝。