源碼相關的文章確實不好寫,一個是每個人基礎功不一樣,我覺得說的清楚的東西可能對到別人依舊含糊,一個是對一些邏輯的理解也未必就敢說百分百正確,最后是真想拆分一步步的關鍵代碼都不好拆。如果有這種文章經驗的作者歡迎交流。
本文實現的是vue-router v3.4.8版主要核心功能
準備工作
自行實現的vue畢竟是閹割版的,所以這里下載vue/cli
來進行router與后面的vuex開發。執行命令npm i @vue/cli -D
進行安裝(這里怎么安裝都可以,具體看個人)
由于不是全局安裝,所以使用vue命令會報錯,需要自行配置環境變量,參考傳送門:非全局vue-cli
安裝完成,執行vue create 你的項目工程名稱
,一路想怎么選就看個人了,我選的自定義然后按照自己習慣配置。
去到工程目錄,執行npm run serve
啟動服務
vue-router兩種模式簡介
單頁應用也叫spa應用,路徑切換不刷新頁面,但可以重新渲染組件
vue-router是一個構造函數,前端路由實現,有兩種模式:hash模式與history模式
hash鏈接上會帶有#號,但是兼容性好,不同路徑可展示不同頁面組件,基于location.hash
history與一般的鏈接無異,但鏈接是模擬出來的,并非真實鏈接,因此直接進入會404,需要后臺配置(本地開發不需要考慮,因為使用了history-fallback插件),基于window.history.pushState
初始化結構目錄
src目錄下新建vue-router文件夾,創建index.js
與install.js
來替換node_modules中的vue-router
將src/router/index.js
中引用的vue-router替換為自行創建的vue-routerimport VueRouter from '@/vue-router'
vue.use
vue使用插件的方式是使用vue.use
,vue.use
會自動執行插件的install方法,這樣做的好處是插件需要依賴于vue
,但如果插件中指定了某個vue
版本,而用戶下載使用的版本與插件的版本不一致時,就會導致沖突。所以通過vue,將用戶使用的vue傳入組件中,就能保證用戶使用的vue與插件使用的vue是完全一致的(意思就是你插件中直接使用import Vue from 'vue'
的話,那么這個vue是不是就有可能跟用戶使用的不一致了)
代碼示例:
Vue.use = function (plugin, options) {
plugin.install(this, options)
}
Vue.use(VueRouter)
VueRouter與install
// vue-router/index.js
// 拿到的是變量_Vue,所以Vue.use時就可以拿到Vue
import { install, _Vue } from './install'
export default class VueRouter {
constructor (options) {}
}
VueRouter.install = install
我們在任何組件中都可以通過vue.router
來獲取到router實例,其實現主要是靠mixin,向beforeCreate
生命周期注入
我們知道初始化vue會產生兩個實例,一個是new Vue
,一個是實例化app的vue組件
而我們路由只會在實例化Vue時注入,子組件中(上圖為app)中是不會有該router實例的
new Vue({
name: 'Root',
router,
render: h => h(App)
}).$mount('#app')
因此可以通過判斷$options
中是否有router
來鑒別是否為vue
實例,否則證明是子組件,子組件通過$parent
來獲取router
實例
// vue-router/install.js
// 需要將install方法單獨的進行拆分
export let _Vue
export function install (Vue, options) {
_Vue = Vue
// 將當前的根實例提供的router屬性共享給所有子組件
Vue.mixin({
beforeCreate () {
// 獲取到每個子組件的實例,給實例添加屬性
if (this.$options.router) {
this._routerRoot = this
} else {
this._routerRoot = this.$parent && this.$parent._routerRoot
}
}
})
}
createRouteMap
vue-router需要生成一份配置表,用于匹配路徑來決定使用什么組件,還可以支持動態加載路由addRoute
// vue-router/index.js
+ import createMather from './createMather'
export default class VueRouter {
constructor (options) {
// 根據用戶的配置生成一個映射表,跳轉時,根據路徑找到對應的組件來進行渲染
// 創建匹配器后,核心的方法就是匹配
// 但用戶可能還會動態的添加路由(match/addRoutes)
this.mather = createMather(options.routes || [])
}
// 路由初始化
init (app) { // app就是根實例 new Vue
}
}
// vue-router/createMather.js
import createRouteMap from './create-route-map'
export default function createMather (routes) {
const { pathMap } = createRouteMap(routes) // 根據用戶的路由配置創建一個映射表
// 動態添加路由權限
function addRoutes (routes) {
createRouteMap(routes, pathMap) // 實現動態路由
}
// 根據提供的路徑匹配路由
function match (path) {
// 先占個坑
}
return {
addRoutes,
match
}
}
生成路由映射表,根據用戶傳入的routes生成一份路由相對應的映射表,后續通過該映射表就可以快速知道使用的參數插件等等
// vue-router/create-route-map.js
// 生成路由映射表,支持動態加載路由
export default function createRouteMap (routes, oldPathMap) {
// 一個參數是初始化,兩個參數是動態添加路由
const pathMap = oldPathMap || {}
routes.forEach(route => {
addRouteRecord(route, pathMap, null)
})
return pathMap
}
// 填充路由,生成路由對象
function addRouteRecord (route, pathMap, parent) { // pathMap = {路徑: 記錄}
// 要判斷兒子的路徑不是以 / 開頭的,否則不拼接父路徑
const path = route.path.startsWith('/') ? route.path : parent ? parent.path + '/' + route.path : route.path
const record = {
path,
parent, // 父記錄
component: route.component,
name: route.name,
props: route.props,
params: route.params || {},
meta: route.meta
}
// 判斷是否存在路由記錄,沒有則添加
if (!pathMap[path]) {
pathMap[path] = record
}
if (route.children) {
// 遞歸,沒有孩子就停止遍歷
route.children.forEach(childRoute => {
addRouteRecord(childRoute, pathMap, record)
})
}
}
不同模式處理,hash模式實現
前面說了router
有兩種模式,一種時hash,另一種時history,hash與history路徑變化是不一致的。所以需要分開處理,而兩者又都有一樣的部分操作,所以可以通過三個類來進行不同處理
History
主要負責跳轉,渲染等,因為這些事情不管使用哪一種模式都是一致的,HashHistory
和H5History
都繼承于該類
// history/base.js
export default class History {
constructor (router) {
this.router = router
}
// 根據路徑進行組件渲染,數據變化更新視圖
transitionTo (location, onComplete) { // 默認會先執行一次
onComplete && onComplete() // onComplete調用hash值變化會再次調用transitionTo
}
}
// history/hash.js
import History from './base'
// 判斷鏈接是否帶有hash,沒有則添加#/,否則不添加
function ensureSlash () {
if (window.location.hash) { return }
window.location.hash = '/' // url如果不帶hash,自動添加 #/
}
function getHash () {
return window.location.hash.slice(1)
}
export default class HashHistory extends History {
constructor (router) {
super(router)
// 默認hash模式需要加 #/
ensureSlash()
}
setupListener () {
// 好陌生,查了一下事件居然有這么多:https://www.runoob.com/jsref/dom-obj-event.html
// hashchange性能不如popstate,popstate用于監聽瀏覽器歷史記錄變化,hash變化也會觸發popstate
window.addEventListener('popstate', () => {
// 根據當前hash值,去匹配對應的組件
this.transitionTo(getHash())
})
}
getCurrentLocation () {
return getHash()
}
}
// history/history.js
import History from './base'
// 沒按照源碼 HTML5History,指的是瀏覽器跳轉
export default class BrowserHistory extends History {
constructor (router) {
console.log('history mode')
super(router)
}
getCurrentLocation () {
return window.location.pathname
}
}
有了上面不同的實例后,就能在初始化時實例化不同歷史實例
import { install, _Vue } from './install'
import createMather from './createMather'
import HashHistory from './history/hash'
import BrowserHistory from './history/history'
export default class VueRouter {
constructor (options) {
+ // 根據當前的mode,創建不同的history管理策略
+ switch (options.mode) {
+ case 'hash':
+ this.history = new HashHistory(this)
+ break
+ case 'history':
+ this.history = new BrowserHistory(this)
+ break
+ }
}
// 路由初始化
init (app) { // app就是根實例 new Vue
+ // 初始化后,需要先根據路徑做一次匹配,后續根據hash值變化再次匹配
+ const history = this.history // history的實例
+ const setupListener = () => {
+ history.setupListener() // 掛載監聽,監聽hash值變化
+ }
+ // 跳轉到哪里,getCurrentLocation為私有,因為 hash 與 history 處理不一致
+ history.transitionTo(history.getCurrentLocation(), setupListener)
}
}
VueRouter.install = install
根據跳轉路徑,匹配及產生對應路由記錄
目前跳轉時,history并不知道發生了什么事,也不知道應該使用什么記錄。因此需要根據跳轉路徑獲取對應的路由記錄。路由記錄需要從子頁面到父頁面都產生出來,需要使用matcher
進行匹配,產生對應的所有路由記錄
// history/base.js
// 根據路徑,返回該路徑所需的所有記錄
+ export function createRoute (record, location) {
+ const res = []
+
+ if (record) {
+ while (record) { // 二級菜單及N級菜單,將對應的菜單一個個往棧中加
+ res.unshift(record)
+ record = record.parent
+ }
+ }
+
+ return {
+ ...location,
+ matched: res
+ }
+ }
export default class History {
constructor (router) {
this.router = router
+ // 最終核心需要將current屬性變化成響應式的,后續current變化會更新視圖
+ this.current = createRoute(null, {
+ path: '/'
+ })
}
// 根據路徑進行組件渲染,數據變化更新視圖
transitionTo (location, onComplete) { // 默認會先執行一次
// 根據跳轉的路徑,獲取匹配的記錄
const route = this.router.match(location)
+ this.current = route
// 由于由響應式變換的是_route(install中進行的響應式定義),而更改的是this.current,無法觸發響應式
+ /**
+ * vueRoute用于提供給用戶直接使用,vueRoute中又需要對歷史記錄進行操作
+ * 跳轉的時候又是由歷史記錄所觸發,需要通知變更vue._route,而現在變更的是歷史記錄中的current
+ * 需要將自身變更后匹配到的路由返回給vueRouter,這里也不能直接使用 install 導出的 _vue
+ * 是因為考慮到有可能實例化了多個Vue,這個時候的_Vue是最后實例化的Vue,并非對應vueRouter所使用的Vue實例
+ * 通過listen去執行vueRouter綁定的函數,vueRouter中有當前Vue實例,就能將當前匹配到的路由賦值給Vue._route,這樣就能觸發響應式變化
+ */
+ this.cb && this.cb(route) // 第一次cb不存在,還未進行綁定回調
onComplete && onComplete() // cb調用hash值變化會再次調用transitionTo
}
+ listen (cb) {
+ this.cb = cb
+ }
}
match填坑
+ import { createRoute } from './history/base.js'
// 匹配器
export default function createMather (routes) {
// 根據提供的路徑匹配路由
+ function match (path) {
+ const record = pathMap[path]
+
+ return createRoute(record, {
+ path
+ })
}
}
定義響應式及掛載屬性,注冊組件
history中已經可以根據路由變化產生對應的路由記錄(createRoute
),但是用戶操作的是vue.set
來進行。前面實現的
vue核心中,有一個
defineReactive方法用于定義響應式,因此插件中是直接通過使用
Vue.util.defineReactive`來定義成響應式的
export function install (Vue, options) {
+ // 如果已經注冊過router并且是同一個Vue實例,直接返回
+ if (install.installed && _Vue === Vue) { return }
+ install.installed = true
+ _Vue = Vue
// 將當前的根實例提供的router屬性共享給所有子組件
Vue.mixin({
beforeCreate () {
// 獲取到每個子組件的實例,給實例添加屬性
if (this.$options.router) {
// code...
+ // 使用 Vue 的工具類方法定義成響應式的,真實項目需要使用 $set,這里沒法用是因為Vue還未實例化
+ Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
// code...
}
}
})
我們需要使用vue-router
時,是通過vue.$route
和vue.$router
來訪問路由對象及獲取當前路由對應屬性的,插件中是將這兩個屬性掛載原型上并進行劫持
vue-router中還提供兩個組件,用于跳轉與渲染視圖:RouteLink
和RouteView
+ import RouteLink from './components/link'
+ import RouteView from './components/view'
export function install (Vue, options) {
// code...
+ // 讓用戶可以直接使用 vue.$route 和 $router
+ Object.defineProperty(Vue.prototype, '$route', {
+ get () {
+ return this._routerRoot._route // current對象里面的所有屬性
+ }
+ })
+
+ Object.defineProperty(Vue.prototype, '$router', {
+ get () {
+ return this._routerRoot._router // addRoute match 方法等
+ }
+ })
+
+ // 注冊所需組件
+ Vue.component('router-link', RouteLink)
+ Vue.component('router-view', RouteView)
}
創建這兩個組件
// components/link.js
export default {
name: 'router-link',
props: {
to: {
type: String,
required: true
},
tag: {
type: String,
default: 'a'
}
},
render (h) {
// jsx,但不同于react的jsx需要寫死標簽,vue中可以寫變量標簽
const tag = this.tag
return <tag onClick={() => {
this.$router.push(this.to)
}}>{this.$slots.default}</tag>
// 等價的render函數,寫起來太痛苦
// return h(this.tag, {}, this.$slots.default)
}
}
// components/view.js
export default {
name: 'router-view',
render (h) {
return h()
}
}
RouteView實現
routerView負責的工作,就是通過當前路徑,渲染對應的組件
routerView的渲染方式為functional,無狀態 (沒有響應式數據),也沒有實例 (沒有 this 上下文),傳送門:(函數式組件)[https://cn.vuejs.org/v2/guide/render-function.html#%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BB%84%E4%BB%B6]
vnode 表示的是組件本身是長啥樣的
_vnode 表示的是組件真實渲染出來的結果是啥樣的
<my></my> // $vnode => {type: {name: 'vue-component-id-my'}, data: {...}, children: undefind}
// _vnode => {type: 'div', dataL {...} children: undefined, el: div}
假設頁面中有兩個router-view,一個為App.vue
中寫的router-view
,一個為about
頁面中的router-view
,當前路徑為/about/aa
,簡單的描述這一整個過程:
此時匹配出的matched:[/about, /about/aa]
此時的Vue文件中調用router-view的順序:[App.vue/router-view,About.vue/router-view]
app.vue => routerView => routerViewComponent.data.routerView = true => parent.$vnode.data.routerView為undefined,不進入depth++ => 取出record為 /about => 執行渲染函數,出入的data為標記過routerView(其實就是原本的data加上一個routerView標識)=> 來到about.vue頁面,發現里面寫了一個routerView => routerViewComponent.data.routerView = true => parent.$vnode.data.routerView(就是app.vue頁面的router-view組件,上一個步驟已經掛上一個routerView標識) => 進入depth++ => 取出匹配結果為(/about/aa)=> 執行渲染 => 然后就是各種實例化結束的生命周期等
// component/view.js
export default {
functional: true, // 函數式組件,可以節省性能,但沒有實例與沒有響應式變化
name: 'RouterView',
render (h, { data, parent }) {
const route = parent.$route // 會做依賴收集了
let depth = 0
const records = route.matched
data.routerView = true // 渲染router-view時標記它是一個router-view,這樣如果子組件中繼續調用router-view,不至于會死循環
// 二級節點,看之前渲染過幾個router-view
while (parent) {
// 由于 $vnode 與 _vnode 命名太相像,vue3中將 _vnode 命名未 subtree
if (parent.$vnode && parent.$vnode.data.routerView) {
depth++
}
parent = parent.$parent
}
const record = records[depth]
if (!record) { return h() } // 匹配不到,返回一個空白節點
return h(record.component, data) // 渲染一個組件,函數式寫法為:h(component),這里就是去渲染組件
}
}
history實現
history觀測的是瀏覽器的前進后退,不同于hash,跳轉的時候window.history.pushState
并不會觸發popstate
(因為該api是歷史管理,并不會觀測路徑變化),所以需要手動執行跳轉,再去調用pushState
import History from './base'
export default class BrowserHistory extends History {
constructor (router) {
console.log('history mode')
super(router)
}
getCurrentLocation () {
return window.location.pathname
}
setupListener () {
window.addEventListener('popstate', () => {
// 監聽路徑變化(瀏覽器的前進后退)進行跳轉
this.transitionTo(this.getCurrentLocation())
})
}
push (location) {
this.transitionTo(location, () => {
// 采用 H5 的 API 跳轉,這里的切換不會觸發 popstate,所以不能像hash一樣,需要放到回調中來處理
window.history.pushState({}, null, location)
})
}
}
hook實現
導航具體的觸發流程,建議閱讀官方文檔,傳送門: 完整的導航解析流程,根據面試造火箭特性,父子組件生命周期渲染流程經常提問,或許以后會出現導航解析流程
vueRouter有一個方法,beforeEach(全局前置守衛),實際項目中被用來做一些權限判斷(攔截器),簡單的理解,就是類似于Koa的中間件(比如本人前面Koa的文章使用koa-jwt對用戶登錄權限判斷)
多次使用依次執行,實質就是個迭代器
使用示例代碼:
router.beforeEach((to, from, next) => {
setTimeout(() => {
console.log(1)
next()
}, 1000)
})
router.beforeEach((to, from, next) => {
setTimeout(() => {
next()
}, 1000)
})
具體實現代碼
+ function runQueue (queue, interator, cb) {
+ function next (index) {
+ if (index >= queue.length) {
+ return cb() // 一個鉤子都沒有,或者鉤子全部執行完畢,直接調用cb完成渲染即可
+ } else {
+ const hook = queue[index]
+ interator(hook, () => next(index + 1))
+ }
+ }
+
+ next(0)
+ }
export default class History {
// 根據路徑進行組件渲染,數據變化更新視圖
transitionTo (location, onComplete) { // 默認會先執行一次
// 根據跳轉的路徑,獲取匹配的記錄
const route = this.router.match(location)
+ const queue = [].concat(this.router.beforeEachHooks)
+ // 迭代器
+ const interator = (hook, cb) => { // 這里如果用function來聲明,this則為undefined,因為構建后是嚴格模式
+ hook(route, this.current, cb) // to, from, next
+ }
+ runQueue(queue, interator, () => {
this.current = route
// 由于由響應式變換的是_route(install中進行的響應式定義),而更改的是this.current,無法觸發響應式
// vueRoute用于提供給用戶直接使用,vueRoute中又需要對歷史記錄進行操作
// 跳轉的時候又是由歷史記錄所觸發,需要通知變更vue._route,而現在變更的是歷史記錄中的current
// 需要將自身變更后匹配到的路由返回給vueRouter,這里不能直接使用 install導出的_vue
// 是因為考慮到有可能實例化了多個Vue,這個時候的_Vue是最后實例化的Vue,并非對應vueRouter所使用的Vue實例
// 通過listen去執行vueRouter綁定的函數,vueRouter中有當前Vue實例,就能將當前匹配到的路由賦值給Vue._route,這樣就能觸發響應式變化
this.cb && this.cb(route) // 第一次cb不存在,還未進行綁定回調,cb調用觸發視圖更新
onComplete && onComplete() // cb調用hash值變化會再次調用transitionTo
+ })
}
listen (cb) {
this.cb = cb
}
}