本文旨在介紹vue-router
的實現思路,並動手實現一個簡化版的vue-router
。我們先來看一下一般專案中對vue-router
最基本的一個使用,可以看到,這裡定義了四個路由元件,我們只要在根vue
例項中注入該router
物件就可以使用了.
import VueRouter from 'vue-router';
import Home from '@/components/Home';
import A from '@/components/A';
import B from '@/components/B'
import C from '@/components/C'
Vue.use(VueRouter)
export default new VueRouter.Router({
// mode: 'history',
routes: [
{
path: '/',
component: Home
},
{
path: '/a',
component: A
},
{
path: '/b',
component: B
},
{
path: '/c',
component: C
}
]
})
複製程式碼
vue-router
提供兩個全域性元件,router-view
和router-link
,前者是用於路由元件的佔位,後者用於點選時跳轉到指定路由。此外元件內部可以通過this.$router.push
,this.$rouer.replace
等api實現路由跳轉。本文將實現上述兩個全域性元件以及push
和replace
兩個api,呼叫的時候支援params
傳參,並且支援hash
和history
兩種模式,忽略其餘api、巢狀路由、非同步路由、abstract
路由以及導航守衛等高階功能的實現,這樣有助於理解vue-router
的核心原理。本文的最終程式碼不建議在生產環境使用,只做一個學習用途,下面我們就來一步步實現它。
install實現
任何一個vue
外掛都要實現一個install
方法,通過Vue.use
呼叫外掛的時候就是在呼叫外掛的install
方法,那麼路由的install
要做哪些事情呢?首先我們知道 我們會用new
關鍵字生成一個router
例項,就像前面的程式碼例項一樣,然後將其掛載到根vue
例項上,那麼作為一個全域性路由,我們當然需要在各個元件中都可以拿到這個router
例項。另外我們使用了全域性元件router-view
和router-link
,由於install
會接收到Vue
建構函式作為實參,方便我們呼叫Vue.component
來註冊全域性元件。因此,在install
中主要就做兩件事,給各個元件都掛載router
例項,以及實現router-view
和router-link
兩個全域性元件。下面是程式碼:
const install = (Vue) => {
if (this._Vue) {
return;
};
Vue.mixin({
beforeCreate() {
if (this.$options && this.$options.router) {
this._routerRoot = this;
this._router = this.$options.router;
Vue.util.defineReactive(this, '_routeHistory', this._router.history)
} else {
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
}
Object.defineProperty(this, '$router', {
get() {
return this._routerRoot._router;
}
})
Object.defineProperty(this, '$route', {
get() {
return {
current: this._routerRoot._routeHistory.current,
...this._routerRoot._router.route
};
}
})
}
});
Vue.component('router-view', {
render(h) { ... }
})
Vue.component('router-link', {
props: {
to: String,
tag: String,
},
render(h) { ... }
})
this._Vue = Vue;
}
複製程式碼
這裡的this
代表的就是vue-router
物件,它有兩個屬性暴露出來供外界呼叫,一個是install
,一個是Router
建構函式,這樣可以保證外掛的正確安裝以及路由例項化。我們先忽略Router
建構函式,來看install
,上面程式碼中的this._Vue
是個開始沒有定義的屬性,他的目的是防止多次安裝。我們使用Vue.mixin
對每個元件的beforeCreate
鉤子做全域性混入,目的是讓每個元件例項共享router
例項,即通過this.$router
拿到路由例項,通過this.$route
拿到路由狀態。需要重點關注的是這行程式碼:
Vue.util.defineReactive(this, '_routeHistory', this._router.history)
複製程式碼
這行程式碼利用vue
的響應式原理,對根vue
例項註冊了一個_routeHistory
屬性,指向路由例項的history
物件,這樣history
也變成了響應式的。因此一旦路由的history
發生變化,用到這個值的元件就會觸發render
函式重新渲染,這裡的元件就是router-view
。從這裡可以窺察到vue-router
實現的一個基本思路。上述的程式碼中對於兩個全域性元件的render
函式的實現,因為會依賴於router
物件,我們先放一放,稍後再來實現它們,下面我們分析一下Router
建構函式。
Router建構函式
經過剛才的分析,我們知道router
例項需要有一個history
物件,需要一個儲存當前路由狀態的物件route
,另外很顯然還需要接受路由配置表routes
,根據routes
需要一個路由對映表routerMap
來實現元件搜尋,還需要一個變數mode
判斷是什麼模式下的路由,需要實現push
和replace
兩個api,程式碼如下:
const Router = function (options) {
this.routes = options.routes; // 存放路由配置
this.mode = options.mode || 'hash';
this.route = Object.create(null), // 生成路由狀態
this.routerMap = createMap(this.routes) // 生成路由表
this.history = new RouterHistory(); // 例項化路由歷史物件
this.init(); // 初始化
}
Router.prototype.push = (options) => { ... }
Router.prototype.replace = (options) => { ... }
Router.prototype.init = () => { ... }
複製程式碼
我們看一下路由表routerMap
的實現,由於不考慮巢狀等其他情況,實現很簡單,如下:
const createMap = (routes) => {
let resMap = Object.create(null);
routes.forEach(route => {
resMap[route['path']] = route['component'];
})
return resMap;
}
複製程式碼
RouterHistory
的實現也很簡單,根據前面分析,我們只需要一個current
屬性就可以,如下:
const RouterHistory = function (mode) {
this.current = null;
}
複製程式碼
有了路由表和history
,router-view
的實現就很容易了,如下:
Vue.component('router-view', {
render(h) {
let routerMap = this._self.$router.routerMap;
return h(routerMap[this._self.$route.current])
}
})
複製程式碼
這裡的this
是一個renderProxy
例項,他有一個屬性_self
可以拿到當前的元件例項,進而訪問到routerMap
,可以看到路由例項history
的current
本質上就是我們配置的路由表中的path
。
接下來我們看一下Router
要做哪些初始化工作。對於hash
路由而言,url上hash
值的改變不會引起頁面重新整理,但是可以觸發一個hashchange
事件。由於路由history.current
初始為null
,因此匹配不到任何一個路由,所以會導致頁面重新整理載入不出任何路由元件。基於這兩點,在init
方法中,我們需要實現對頁面載入完成的監聽,以及hash
變化的監聽。對於history
路由,為了實現瀏覽器前進後退時準確渲染對應元件,還要監聽一個popstate
事件。程式碼如下:
Router.prototype.init = function () {
if (this.mode === 'hash') {
fixHash()
window.addEventListener('hashchange', () => {
this.history.current = getHash();
})
window.addEventListener('load', () => {
this.history.current = getHash();
})
}
if (this.mode === 'history') {
removeHash(this);
window.addEventListener('load', () => {
this.history.current = location.pathname;
})
window.addEventListener('popstate', (e) => {
if (e.state) {
this.history.current = e.state.path;
}
})
}
}
複製程式碼
當啟用hash
模式的時候,我們要檢測url上是否存在hash
值,沒有的話強制賦值一個預設path
,hash
路由時會根據hash
值作為key
來查詢路由表。fixHash
和getHash
實現如下:
const fixHash = () => {
if (!location.hash) {
location.hash = '/';
}
}
const getHash = () => {
return location.hash.slice(1) || '/';
}
複製程式碼
這樣在重新整理頁面和hash
改變的時候,current
可以得到賦值和更新,頁面能根據hash
值準確渲染路由。history
模式也是一樣的道理,只是它通過location.pathname
作為key
搜尋路由元件,另外history
模式需要去除url上可能存在的hash
,removeHash
實現如下:
const removeHash = (route) => {
let url = location.href.split('#')[1]
if (url) {
route.current = url;
history.replaceState({}, null, url)
}
}
複製程式碼
我們可以看到當瀏覽器後退的時候,history
模式會觸發popstate
事件,這個時候是通過state
狀態去獲取path
的,那麼state
狀態從哪裡來呢,答案是從window.history
物件的pushState
和replaceState
而來,這兩個方法正好可以用來實現router
的push
方法和replace
方法,我們看一下這裡它們的實現:
Router.prototype.push = function (options) {
this.history.current = options.path;
if (this.mode === 'history') {
history.pushState({
path: options.path
}, null, options.path);
} else if (this.mode === 'hash') {
location.hash = options.path;
}
this.route.params = {
...options.params
}
}
Router.prototype.replace = function (options) {
this.history.current = options.path;
if (this.mode === 'history') {
history.replaceState({
path: options.path
}, null, options.path);
} else if (this.mode === 'hash') {
location.replace(`#${options.path}`)
}
this.route.params = {
...options.params
}
}
複製程式碼
pushState
和replaceState
能夠實現改變url的值但不引起頁面重新整理,從而不會導致新請求發生,pushState
會生成一條歷史記錄而replaceState
不會,後者只是替換當前url。在這兩個方法執行的時候將path
存入state
,這就使得popstate
觸發的時候可以拿到路徑從而觸發元件渲染了。我們在元件內按照如下方式呼叫,會將params
寫入router
例項的route
屬性中,從而在跳轉後的元件B
內通過this.$route.params
可以訪問到傳參。
this.$router.push({
path: '/b',
params: {
id: 55
}
});
複製程式碼
router-link實現
router-view
的實現很簡單,前面已經說過。最後,我們來看一下router-link
的實現,先放上程式碼:
Vue.component('router-link', {
props: {
to: String,
tag: String,
},
render(h) {
let mode = this._self.$router.mode;
let tag = this.tag || 'a';
let routerHistory = this._self.$router.history;
return h(tag, {
attrs: tag === 'a' ? {
href: mode === 'hash' ? '#' + this.to : this.to,
} : {},
on: {
click: (e) => {
if (this.to === routerHistory.current) {
e.preventDefault();
return;
}
routerHistory.current = this.to;
switch (mode) {
case 'hash':
if (tag === 'a') return;
location.hash = this.to;
break;
case 'history':
history.pushState({
path: this.to
}, null, this.to);
break;
default:
}
e.preventDefault();
}
},
style: {
cursor: 'pointer'
}
}, this.$slots.default)
}
})
複製程式碼
router-link
可以接受兩個屬性,to
表示要跳轉的路由路徑,tag
表示router-link
要渲染的標籤名,預設a
為標籤。如果是a
標籤,我們為其新增一個href
屬性。我們給標籤繫結click
事件,如果檢測到本次跳轉為當前路由的話什麼都不做直接返回,並且阻止預設行為,否則根據to
更換路由。hash
模式下並且是a
標籤時候可以直接利用瀏覽器的預設行為完成url上hash
的替換,否者重新為location.hash
賦值。history
模式下則利用pushState
去更新url。
以上實現就是一個簡單的vue-router,完整程式碼參見vue-router-simple。