vue-router原理剖析

Aine_潔發表於2018-05-26

vue-router的使用

頁面中所有的內容都是元件化的,只需要把路徑跟元件對應,在頁面中把元件渲染出來。

  1. 頁面實現:在vue-router中, 它定義了兩個標籤 和來對應點選和顯示部分。 就是定義頁面中點選的部分, 定義顯示部分。

  2. js中配置路由:首先要定義route,一條路由的實現,他是一個物件,由path和component組成。

    這裡的兩條路由,組成routes:

const routes = [
	{// 首頁
		path: '/',
		component: () => import('src/pages/home/index.vue'),
	},
	{// 首頁更多功能
		path: '/functionManage',
		component: () => import('src/pages/home/functionManage.vue'),
	},
]
複製程式碼
  1. 建立router對路由進行管理,它是由建構函式 new vueRouter() 建立,接受routes 引數。

    router.js檔案中

const router = new VueRouter({
	routes,
})
複製程式碼
  1. 配置完成後,把router 例項注入到 vue 根例項中。

    main.js檔案中

window.vm = new Vue({
	router,
})
複製程式碼

執行過程:當使用者點選 router-link 標籤時,會去尋找它的 to 屬性, 它的 to 屬性和 js 中配置的路徑{ path: '/home', component: Home} path 一一對應,從而找到了匹配的元件, 最後把元件渲染到 標籤所在的地方。

前端路由是通過改變URL,在不重新請求頁面的情況下,更新頁面檢視。

目前在瀏覽器環境中這一功能的實現主要有2種:

  • 利用URL中的hash;
  • 利用H5中history;

vue-router 是 vue.js 框架的路由外掛,它是通過 mode 這一引數控制路由的實現模式的。

const router = new VueRouter({
	// HTML5 history 模式
	mode: 'history',
	base: process.env.NODE_ENV === 'production' ? process.env.PROXY_PATH : '',
	routes,
})
複製程式碼

在入口檔案中需要例項化一個 VueRouter 的例項物件 ,然後將其傳入 Vue 例項的 options 中。

var VueRouter = function VueRouter (options) {
    if ( options === void 0 ) options = {};

    this.app = null;
    this.apps = [];
    this.options = options;
    this.beforeHooks = [];
    this.resolveHooks = [];
    this.afterHooks = [];
    // 建立 matcher 匹配函式
    this.matcher = createMatcher(options.routes || [], this);
    // 根據 mode 例項化具體的 History,預設為'hash'模式
    var mode = options.mode || 'hash';
    // 通過 supportsPushState 判斷瀏覽器是否支援'history'模式
    // 如果設定的是'history'但是如果瀏覽器不支援的話,'history'模式會退回到'hash'模式
    // fallback 是當瀏覽器不支援 history.pushState 控制路由是否應該回退到 hash 模式。預設值為 true。
    this.fallback = mode === 'history' && !supportsPushState &&   options.fallback !== false;
    if (this.fallback) {
        mode = 'hash';
    }
    if (!inBrowser) {
        // 不在瀏覽器環境下執行需強制為'abstract'模式
        mode = 'abstract';
    }
    this.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:
        {
            assert(false, ("invalid mode: " + mode));
        }
    }
};
複製程式碼
VueRouter.prototype.init = function init (app /* Vue component instance */) {
    
    ...
    
    var history = this.history;

    // 根據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;
        });
    });
};
複製程式碼

作為引數傳入的字串屬性mode只是一個標記,用來指示實際起作用的物件屬性history的實現類,兩者對應關係:

modehistory:
    'history': HTML5History;
    'hash': HashHistory;
    'abstract': AbstractHistory;
複製程式碼
  1. 在初始化對應的history之前,會對mode做一些校驗:若瀏覽器不支援HTML5History方式(通過supportsPushState變數判斷),則mode設為hash;若不是在瀏覽器環境下執行,則mode設為abstract;
  2. VueRouter類中的onReady(),push()等方法只是一個代理,實際是呼叫的具體history物件的對應方法,在init()方法中初始化時,也是根據history物件具體的類別執行不同操作

HashHistory

  • hash雖然出現在url中,但不會被包括在http請求中,它是用來指導瀏覽器動作的,對伺服器端沒影響,因此,改變hash不會重新載入頁面。

  • 可以為hash的改變新增監聽事件:

    window.addEventListener("hashchange",funcRef,false)

  • 每一次改變hash(window.location.hash),都會在瀏覽器訪問歷史中增加一個記錄。

function HashHistory (router, base, fallback) {
    History$$1.call(this, router, base);
    // 如果是從history模式降級來的,需要做降級檢查
    if (fallback && checkFallback(this.base)) {
        // 如果降級且做了降級處理,則返回
        return
    }
    ensureSlash();
}

function checkFallback (base) {
    // 得到除去base的真正的 location 值
    var location = getLocation(base);
    if (!/^\/#/.test(location)) {
        // 如果此時地址不是以 /# 開頭的
        // 需要做一次降級處理,降為 hash 模式下應有的 /# 開頭
        window.location.replace(
            cleanPath(base + '/#' + location)
        );
    return true
    }
}

function ensureSlash () {
    // 得到 hash 值
    var path = getHash();
    // 如果是以 / 開頭的,直接返回即可
    if (path.charAt(0) === '/') {
        return true
    }
    // 不是的話,需要手動保證一次 替換 hash 值
    replaceHash('/' + path);
    return false
}

function getHash () {
    // 因為相容性的問題,這裡沒有直接使用 window.location.hash
    // 因為 Firefox decode hash 值
    var href = window.location.href;
    var index = href.indexOf('#');
    return index === -1 ? '' : href.slice(index + 1)
}
複製程式碼

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);
};
複製程式碼

transitionTo()方法是用來處理路由變化中的基礎邏輯的,push()方法最主要的是對window的hash進行了直接賦值:

function pushHash (path) {
    window.location.hash = path
}
複製程式碼

hash的改變會自動新增到瀏覽器的訪問歷史記錄中。 那麼檢視的更新是怎麼實現的呢,看下 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);
        ...
    });
};

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.prototype.listen = function listen (cb) {
    this.cb = cb;
};
複製程式碼

可以看到,當路由變化時,呼叫this.cb方法,而this.cb方法是通過History.listen(cb)進行設定的,在init()中對其進行了設定:

Vue作為漸進式的前端框架,本身的元件定義中應該是沒有有關路由內建屬性_route,如果元件中要有這個屬性,應該是在外掛載入的地方,即VueRouter的install()方法中混入Vue物件的,install.js的原始碼:

function install (Vue) {
  
  ...

  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);
    }
  });
}
複製程式碼

通過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()方法不同之處在於,它並不是將新路由新增到瀏覽器訪問歷史棧頂,而是替換掉當前的路由:

HashHistory.prototype.replace = function replace (location, onComplete, onAbort) {
    var this$1 = this;

    var ref = this;
    var fromRoute = ref.current;
    this.transitionTo(location, function (route) {
      replaceHash(route.fullPath);
      handleScroll(this$1.router, route, fromRoute, false);
      onComplete && onComplete(route);
    }, onAbort);
};
  
function replaceHash (path) {
    const i = window.location.href.indexOf('#')
    // 直接呼叫 replace 強制替換 以避免產生“多餘”的歷史記錄
    // 主要是使用者初次跳入 且hash值不是以 / 開頭的時候直接替換
    // 其餘時候和push沒啥區別 瀏覽器總是記錄hash記錄
    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元件的邏輯程式碼中直接呼叫的,除此之外在瀏覽器中,使用者還可以直接在瀏覽器位址列中輸入改變路由,因此還需要監聽瀏覽器位址列中路由的變化 ,並具有與通過程式碼呼叫相同的響應行為,在HashHistory中這一功能通過setupListeners監聽hashchange實現:

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.pushState()和history.replaceState()方法,他們分別可以新增和修改歷史記錄條目。這些方法通常與window.onpopstate配合使用。

window.history.pushState(stateObject,title,url)
window.history,replaceState(stateObject,title,url)
複製程式碼
  • stateObject:當瀏覽器跳轉到新的狀態時,將觸發popState事件,該事件將攜帶這個stateObject引數的副本
  • title:所新增記錄的標題
  • url:所新增記錄的url(可選的)

pushState和replaceState兩種方法的共同特點:當呼叫他們修改瀏覽器歷史棧後,雖然當前url改變了,但瀏覽器不會立即傳送請求該url,這就為單頁應用前端路由,更新檢視但不重新請求頁面提供了基礎。

export function pushState (url?: string, replace?: boolean) {
    saveScrollPosition()
    // 加了 try...catch 是因為 Safari 有呼叫 pushState 100 次限制
    // 一旦達到就會丟擲 DOM Exception 18 錯誤
    const history = window.history
    try {
        if (replace) {
            // replace 的話 key 還是當前的 key 沒必要生成新的
            history.replaceState({ key: _key }, '', url)
        } else {
            // 重新生成 key
            _key = genKey()
            // 帶入新的 key 值
            history.pushState({ key: _key }, '', url)
        }
    } catch (e) {
        // 達到限制了 則重新指定新的地址
        window.location[replace ? 'replace' : 'assign'](url)
    }
}

// 直接呼叫 pushState 傳入 replace 為 true
export function replaceState (url?: string) {
    pushState(url, true)
}
複製程式碼

程式碼結構以及更新檢視的邏輯與hash模式基本類似,只不過將對window.location.hash()直接進行賦值window.location.replace()改為了呼叫history.pushState()和history.replaceState()方法。

在HTML5History中新增對修改瀏覽器位址列URL的監聽popstate是直接在建構函式中執行的:

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)
      }
    })
  })
}
複製程式碼

以上就是'hash'和'history'兩種模式,都是通過瀏覽器介面實現的。

兩種模式比較

一般的需求場景中,hash模式與history模式是差不多的,根據MDN的介紹,呼叫history.pushState()相比於直接修改hash主要有以下優勢:

  • pushState設定的新url可以是與當前url同源的任意url,而hash只可修改#後面的部分,故只可設定與當前同文件的url
  • pushState設定的新url可以與當前url一模一樣,這樣也會把記錄新增到棧中,而hash設定的新值必須與原來不一樣才會觸發記錄新增到棧中
  • pushState通過stateObject可以新增任意型別的資料記錄中,而hash只可新增短字串
  • pushState可額外設定title屬性供後續使用

AbstractHistory

'abstract'模式,不涉及和瀏覽器地址的相關記錄,流程跟'HashHistory'是一樣的,其原理是通過陣列模擬瀏覽器歷史記錄棧的功能

// 對於 go 的模擬
    go (n: number) {
        // 新的歷史記錄位置
        const targetIndex = this.index + n
        // 超出返回了
        if (targetIndex < 0 || targetIndex >= this.stack.length) {
            return
        }
        // 取得新的 route 物件
        // 因為是和瀏覽器無關的 這裡得到的一定是已經訪問過的
        const route = this.stack[targetIndex]
        // 所以這裡直接呼叫 confirmTransition 了
        // 而不是呼叫 transitionTo 還要走一遍 match 邏輯
        this.confirmTransition(route, () => {
            // 更新
            this.index = targetIndex
            this.updateRoute(route)
        })
    }
複製程式碼

history模式的問題

hash模式僅改變hash部分的內容,而hash部分是不會包含在http請求中的(hash帶#):

http://oursite.com/#/user/id //如請求,只會傳送http://oursite.com/

所以hash模式下遇到根據url請求頁面不會有問題

而history模式則將url修改的就和正常請求後端的url一樣(history不帶#)

http://oursite.com/user/id

如果這種向後端傳送請求的話,後端沒有配置對應/user/id的get路由處理,會返回404錯誤。

官方推薦的解決辦法是在服務端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態資源,則應該返回同一個 index.html 頁面,這個頁面就是你 app 依賴的頁面。同時這麼做以後,伺服器就不再返回 404 錯誤頁面,因為對於所有路徑都會返回 index.html 檔案。為了避免這種情況,在 Vue 應用裡面覆蓋所有的路由情況,然後在給出一個 404 頁面。或者,如果是用 Node.js 作後臺,可以使用服務端的路由來匹配 URL,當沒有匹配到路由的時候返回 404,從而實現 fallback。

相關文章