“更新視圖但不重新請求頁面”是前端路由原理的核心之一,目前在瀏覽器環境中這一功能的實現主要有兩種方式:
-
利用URL中的hash(“#”)
-
利用History interface在 HTML5中新增的方法
vue-router是Vue.js框架的路由插件,下面我們從它的源碼入手,邊看代碼邊看原理,由淺入深觀摩vue-router是如何通過這兩種方式實現前端路由的。
模式參數
在vue-router中是通過mode這一參數控制路由的實現模式的:
const router = new VueRouter({ mode: 'history', routes: [...] })
vue-router的實際源碼如下:
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: $`) } } } 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對象的方法
VueRouter.prototype.beforeEach = function beforeEach (fn) {
return registerHook(this.beforeHooks, fn)
};
VueRouter.prototype.beforeResolve = function beforeResolve (fn) {
return registerHook(this.resolveHooks, fn)
};
VueRouter.prototype.afterEach = function afterEach (fn) {
return registerHook(this.afterHooks, fn)
};
VueRouter.prototype.onReady = function onReady (cb, errorCb) {
this.history.onReady(cb, errorCb);
};
VueRouter.prototype.onError = function onError (errorCb) {
this.history.onError(errorCb);
};
VueRouter.prototype.push = function push (location, onComplete, onAbort) {
this.history.push(location, onComplete, onAbort);
};
VueRouter.prototype.replace = function replace (location, onComplete, onAbort) {
this.history.replace(location, onComplete, onAbort);
};
VueRouter.prototype.go = function go (n) {
this.history.go(n);
};
VueRouter.prototype.back = function back () {
this.go(-1);
};
VueRouter.prototype.forward = function forward () {
this.go(1);
};
從上面代碼可以看出:
-
作為參數傳入的字符串屬性mode只是一個標記,用來指示實際起作用的對象屬性history的實現類,兩者對應關系如下:
mode的對應如右——history:'HTML5History;'hash':'HashHistory'; abstract:'AbstractHistory'; -
在初始化對應的history之前,會對mode做一些校驗:若瀏覽器不支持HTML5History方式(通過supportsPushState變量判斷),則mode強制設為'hash';若不是在瀏覽器環境下運行,則mode強制設為'abstract'
-
VueRouter類中的onReady(), push()等方法只是一個代理,實際是調用的具體history對象的對應方法,在init()方法中初始化時,也是根據history對象具體的類別執行不同操作
在瀏覽器環境下的兩種方式,分別就是在HTML5History,HashHistory兩個類中實現的。History中定義的是公用和基礎的方法,直接看會一頭霧水,我們先從HTML5History,HashHistory兩個類中看着親切的push(), replace()方法的說起。
HashHistory
hash(“#”)符號的本來作用是加在URL中指示網頁中的位置:
http://www.example.com/index.html#print
#符號本身以及它后面的字符稱之為hash,可通過window.location.hash屬性讀取。它具有如下特點:
-
hash雖然出現在URL中,但不會被包括在HTTP請求中。它是用來指導瀏覽器動作的,對服務器端完全無用,因此,改變hash不會重新加載頁面
-
可以為hash的改變添加監聽事件:
window.addEventListener("hashchange", funcRef, false) -
每一次改變hash(window.location.hash),都會在瀏覽器的訪問歷史中增加一個記錄
利用hash的以上特點,就可以來實現前端路由“更新視圖但不重新請求頁面”的功能了。

頁面URL如下:

輸出如下:
HashHistory.push()
我們來看HashHistory中的push()方法:
HashHistory.prototype.push = function push (location, onComplete, onAbort) { var this$1 = this; var ref = this; var fromRoute = ref.current; this.transitionTo(location, function (route) { pushHash(route.fullPath); handleScroll(this$1.router, route, fromRoute, false); onComplete && onComplete(route); }, onAbort); }; // 對window的hash進行直接賦值 function pushHash (path) { if (supportsPushState) { pushState(getUrl(path)); } else { window.location.hash = path; } }
transitionTo()方法是父類中定義的是用來處理路由變化中的基礎邏輯的,push()方法最主要的是對window的hash進行了直接賦值:
pushHash(route.fullPath);
hash的改變會自動添加到瀏覽器的訪問歷史記錄中。
那么視圖的更新是怎么實現的呢,我們來看父類History中transitionTo()方法的這么一段:
History.prototype.transitionTo = function transitionTo (location, onComplete, onAbort) { var this$1 = this; var route = this.router.match(location, this.current); this.confirmTransition(route, function () { this$1.updateRoute(route); onComplete && onComplete(route); this$1.ensureURL(); // fire ready cbs once if (!this$1.ready) { this$1.ready = true; this$1.readyCbs.forEach(function (cb) { cb(route); }); } }, function (err) { if (onAbort) { onAbort(err); } if (err && !this$1.ready) { this$1.ready = true; this$1.readyErrorCbs.forEach(function (cb) { cb(err); }); } }); }; History.prototype.confirmTransition = function confirmTransition (route, onComplete, onAbort) { var this$1 = this; var current = this.current; var abort = function (err) { if (isError(err)) { if (this$1.errorCbs.length) { this$1.errorCbs.forEach(function (cb) { cb(err); }); } else { warn(false, 'uncaught error during route navigation:'); console.error(err); } } onAbort && onAbort(err); }; if ( isSameRoute(route, current) && // in the case the route map has been dynamically appended to route.matched.length === current.matched.length ) { this.ensureURL(); return abort() } var ref = resolveQueue(this.current.matched, route.matched); var updated = ref.updated; var deactivated = ref.deactivated; var activated = ref.activated; var queue = [].concat( // in-component leave guards extractLeaveGuards(deactivated), // global before hooks this.router.beforeHooks, // in-component update hooks extractUpdateHooks(updated), // in-config enter guards activated.map(function (m) { return m.beforeEnter; }), // async components resolveAsyncComponents(activated) ); this.pending = route; var iterator = function (hook, next) { if (this$1.pending !== route) { return abort() } try { hook(route, current, function (to) { if (to === false || isError(to)) { // next(false) -> abort navigation, ensure current URL this$1.ensureURL(true); abort(to); } else if ( typeof to === 'string' || (typeof to === 'object' && ( typeof to.path === 'string' || typeof to.name === 'string' )) ) { // next('/') or next({ path: '/' }) -> redirect abort(); if (typeof to === 'object' && to.replace) { this$1.replace(to); } else { this$1.push(to); } } else { // confirm transition and pass on the value next(to); } }); } catch (e) { abort(e); } }; runQueue(queue, iterator, function () { var postEnterCbs = []; var isValid = function () { return this$1.current === route; }; // wait until async components are resolved before // extracting in-component enter guards var enterGuards = extractEnterGuards(activated, postEnterCbs, isValid); var queue = enterGuards.concat(this$1.router.resolveHooks); runQueue(queue, iterator, function () { if (this$1.pending !== route) { return abort() } this$1.pending = null; onComplete(route); if (this$1.router.app) { this$1.router.app.$nextTick(function () { postEnterCbs.forEach(function (cb) { cb(); }); }); } }); }); }; History.prototype.updateRoute = function updateRoute (route) { var prev = this.current; this.current = route; this.cb && this.cb(route); this.router.afterHooks.forEach(function (hook) { hook && hook(route, prev); }); };
可以看到,當路由變化時,調用了History中的this.cb方法,而this.cb方法是通過History.listen(cb)進行設置的。回到VueRouter類定義中,找到了在init()方法中對其進行了設置:
VueRouter.prototype.init = function init (app /* Vue component instance */) { var this$1 = this; process.env.NODE_ENV !== 'production' && assert( install.installed, "not installed. Make sure to call `Vue.use(VueRouter)` " + "before creating root instance." ); this.apps.push(app); // main app already initialized. if (this.app) { return } this.app = app; var history = this.history; if (history instanceof HTML5History) { history.transitionTo(history.getCurrentLocation()); } else if (history instanceof HashHistory) { var setupHashListener = function () { history.setupListeners(); }; history.transitionTo( history.getCurrentLocation(), setupHashListener, setupHashListener ); } history.listen(function (route) { this$1.apps.forEach(function (app) { app._route = route; }); }); };
根據注釋,app為Vue組件實例,但我們知道Vue作為漸進式的前端框架,本身的組件定義中應該是沒有有關路由內置屬性_route,如果組件中要有這個屬性,應該是在插件加載的地方,即VueRouter的install()方法中混合入Vue對象的,查看install.js源碼,有如下一段:
function install (Vue) { if (install.installed && _Vue === Vue) { return } install.installed = true; _Vue = Vue; var isDef = function (v) { return v !== undefined; }; var registerInstance = function (vm, callVal) { var i = vm.$options._parentVnode; if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) { i(vm, callVal); } }; Vue.mixin({ beforeCreate: function beforeCreate () { if (isDef(this.$options.router)) { this._routerRoot = this; this._router = this.$options.router; this._router.init(this); Vue.util.defineReactive(this, '_route', this._router.history.current); } else { this._routerRoot = (this.$parent && this.$parent._routerRoot) || this; } registerInstance(this, this); }, destroyed: function destroyed () { registerInstance(this); } }); Object.defineProperty(Vue.prototype, '$router', { get: function get () { return this._routerRoot._router } }); Object.defineProperty(Vue.prototype, '$route', { get: function get () { return this._routerRoot._route } }); Vue.component('router-view', View); Vue.component('router-link', Link); var strats = Vue.config.optionMergeStrategies; // use the same hook merging strategy for route hooks strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created; }
通過Vue.mixin()方法,全局注冊一個混合,影響注冊之后所有創建的每個 Vue 實例,該混合在beforeCreate鈎子中通過Vue.util.defineReactive()定義了響應式的_route屬性。所謂響應式屬性,即當_route值改變時,會自動調用Vue實例的render()方法,更新視圖。
總結一下,從設置路由改變到視圖更新的流程如下:
$router.push() -->; HashHistory.push() -->; History.transitionTo() -->; History.updateRoute() -->; vm.render()
