源碼相關的文章確實不好寫杂数,一個是每個人基礎功不一樣拍柒,我覺得說的清楚的東西可能對到別人依舊含糊惊来,一個是對一些邏輯的理解也未必就敢說百分百正確地技,最后是真想拆分一步步的關鍵代碼都不好拆炫狱。如果有這種文章經(jīng)驗的作者歡迎交流态鳖。
本文實現(xiàn)的是vue-router v3.4.8版主要核心功能
準備工作
自行實現(xiàn)的vue畢竟是閹割版的篙议,所以這里下載vue/cli
來進行router與后面的vuex開發(fā)。執(zhí)行命令npm i @vue/cli -D
進行安裝(這里怎么安裝都可以擎宝,具體看個人)
由于不是全局安裝郁妈,所以使用vue命令會報錯,需要自行配置環(huán)境變量绍申,參考傳送門:非全局vue-cli
安裝完成噩咪,執(zhí)行vue create 你的項目工程名稱
,一路想怎么選就看個人了极阅,我選的自定義然后按照自己習慣配置胃碾。
去到工程目錄,執(zhí)行npm run serve
啟動服務
vue-router兩種模式簡介
單頁應用也叫spa應用筋搏,路徑切換不刷新頁面仆百,但可以重新渲染組件
vue-router是一個構造函數(shù),前端路由實現(xiàn)奔脐,有兩種模式:hash模式與history模式
hash鏈接上會帶有#號俄周,但是兼容性好,不同路徑可展示不同頁面組件髓迎,基于location.hash
history與一般的鏈接無異峦朗,但鏈接是模擬出來的,并非真實鏈接排龄,因此直接進入會404波势,需要后臺配置(本地開發(fā)不需要考慮,因為使用了history-fallback插件)橄维,基于window.history.pushState
初始化結構目錄
src目錄下新建vue-router文件夾尺铣,創(chuàng)建index.js
與install.js
來替換node_modules中的vue-router
將src/router/index.js
中引用的vue-router替換為自行創(chuàng)建的vue-routerimport VueRouter from '@/vue-router'
vue.use
vue使用插件的方式是使用vue.use
,vue.use
會自動執(zhí)行插件的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實例福澡,其實現(xiàn)主要是靠mixin,向beforeCreate
生命周期注入
我們知道初始化vue會產(chǎn)生兩個實例驹马,一個是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需要生成一份配置表,用于匹配路徑來決定使用什么組件效拭,還可以支持動態(tài)加載路由addRoute
// vue-router/index.js
+ import createMather from './createMather'
export default class VueRouter {
constructor (options) {
// 根據(jù)用戶的配置生成一個映射表暂吉,跳轉時,根據(jù)路徑找到對應的組件來進行渲染
// 創(chuàng)建匹配器后缎患,核心的方法就是匹配
// 但用戶可能還會動態(tài)的添加路由(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) // 根據(jù)用戶的路由配置創(chuàng)建一個映射表
// 動態(tài)添加路由權限
function addRoutes (routes) {
createRouteMap(routes, pathMap) // 實現(xiàn)動態(tài)路由
}
// 根據(jù)提供的路徑匹配路由
function match (path) {
// 先占個坑
}
return {
addRoutes,
match
}
}
生成路由映射表慕的,根據(jù)用戶傳入的routes生成一份路由相對應的映射表,后續(xù)通過該映射表就可以快速知道使用的參數(shù)插件等等
// vue-router/create-route-map.js
// 生成路由映射表挤渔,支持動態(tài)加載路由
export default function createRouteMap (routes, oldPathMap) {
// 一個參數(shù)是初始化肮街,兩個參數(shù)是動態(tài)添加路由
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模式實現(xiàn)
前面說了router
有兩種模式绕辖,一種時hash,另一種時history鸟整,hash與history路徑變化是不一致的引镊。所以需要分開處理,而兩者又都有一樣的部分操作篮条,所以可以通過三個類來進行不同處理
History
主要負責跳轉弟头,渲染等,因為這些事情不管使用哪一種模式都是一致的涉茧,HashHistory
和H5History
都繼承于該類
// history/base.js
export default class History {
constructor (router) {
this.router = router
}
// 根據(jù)路徑進行組件渲染赴恨,數(shù)據(jù)變化更新視圖
transitionTo (location, onComplete) { // 默認會先執(zhí)行一次
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用于監(jiān)聽瀏覽器歷史記錄變化惑淳,hash變化也會觸發(fā)popstate
window.addEventListener('popstate', () => {
// 根據(jù)當前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) {
+ // 根據(jù)當前的mode,創(chuàng)建不同的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
+ // 初始化后肚医,需要先根據(jù)路徑做一次匹配绢馍,后續(xù)根據(jù)hash值變化再次匹配
+ const history = this.history // history的實例
+ const setupListener = () => {
+ history.setupListener() // 掛載監(jiān)聽向瓷,監(jiān)聽hash值變化
+ }
+ // 跳轉到哪里,getCurrentLocation為私有舰涌,因為 hash 與 history 處理不一致
+ history.transitionTo(history.getCurrentLocation(), setupListener)
}
}
VueRouter.install = install
根據(jù)跳轉路徑猖任,匹配及產(chǎn)生對應路由記錄
目前跳轉時,history并不知道發(fā)生了什么事瓷耙,也不知道應該使用什么記錄朱躺。因此需要根據(jù)跳轉路徑獲取對應的路由記錄。路由記錄需要從子頁面到父頁面都產(chǎn)生出來搁痛,需要使用matcher
進行匹配室琢,產(chǎn)生對應的所有路由記錄
// history/base.js
// 根據(jù)路徑,返回該路徑所需的所有記錄
+ 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屬性變化成響應式的盈滴,后續(xù)current變化會更新視圖
+ this.current = createRoute(null, {
+ path: '/'
+ })
}
// 根據(jù)路徑進行組件渲染,數(shù)據(jù)變化更新視圖
transitionTo (location, onComplete) { // 默認會先執(zhí)行一次
// 根據(jù)跳轉的路徑轿钠,獲取匹配的記錄
const route = this.router.match(location)
+ this.current = route
// 由于由響應式變換的是_route(install中進行的響應式定義)巢钓,而更改的是this.current,無法觸發(fā)響應式
+ /**
+ * vueRoute用于提供給用戶直接使用疗垛,vueRoute中又需要對歷史記錄進行操作
+ * 跳轉的時候又是由歷史記錄所觸發(fā)症汹,需要通知變更vue._route,而現(xiàn)在變更的是歷史記錄中的current
+ * 需要將自身變更后匹配到的路由返回給vueRouter贷腕,這里也不能直接使用 install 導出的 _vue
+ * 是因為考慮到有可能實例化了多個Vue背镇,這個時候的_Vue是最后實例化的Vue,并非對應vueRouter所使用的Vue實例
+ * 通過listen去執(zhí)行vueRouter綁定的函數(shù)泽裳,vueRouter中有當前Vue實例瞒斩,就能將當前匹配到的路由賦值給Vue._route,這樣就能觸發(fā)響應式變化
+ */
+ 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) {
// 根據(jù)提供的路徑匹配路由
+ function match (path) {
+ const record = pathMap[path]
+
+ return createRoute(record, {
+ path
+ })
}
}
定義響應式及掛載屬性胸囱,注冊組件
history中已經(jīng)可以根據(jù)路由變化產(chǎn)生對應的路由記錄(createRoute
),但是用戶操作的是vue.set來進行亿鲜。前面實現(xiàn)的
vue核心中允蜈,有一個
defineReactive方法用于定義響應式,因此插件中是直接通過使用
Vue.util.defineReactive`來定義成響應式的
export function install (Vue, options) {
+ // 如果已經(jīng)注冊過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)
}
創(chuàng)建這兩個組件
// 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函數(shù)褐奥,寫起來太痛苦
// return h(this.tag, {}, this.$slots.default)
}
}
// components/view.js
export default {
name: 'router-view',
render (h) {
return h()
}
}
RouteView實現(xiàn)
routerView負責的工作咖耘,就是通過當前路徑,渲染對應的組件
routerView的渲染方式為functional撬码,無狀態(tài) (沒有響應式數(shù)據(jù))儿倒,也沒有實例 (沒有 this 上下文),傳送門:(函數(shù)式組件)[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 => 執(zhí)行渲染函數(shù)驼鹅,出入的data為標記過routerView(其實就是原本的data加上一個routerView標識)=> 來到about.vue頁面微谓,發(fā)現(xiàn)里面寫了一個routerView => routerViewComponent.data.routerView = true => parent.$vnode.data.routerView(就是app.vue頁面的router-view組件,上一個步驟已經(jīng)掛上一個routerView標識) => 進入depth++ => 取出匹配結果為(/about/aa)=> 執(zhí)行渲染 => 然后就是各種實例化結束的生命周期等
// component/view.js
export default {
functional: true, // 函數(shù)式組件输钩,可以節(jié)省性能豺型,但沒有實例與沒有響應式變化
name: 'RouterView',
render (h, { data, parent }) {
const route = parent.$route // 會做依賴收集了
let depth = 0
const records = route.matched
data.routerView = true // 渲染router-view時標記它是一個router-view,這樣如果子組件中繼續(xù)調用router-view买乃,不至于會死循環(huán)
// 二級節(jié)點触创,看之前渲染過幾個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() } // 匹配不到为牍,返回一個空白節(jié)點
return h(record.component, data) // 渲染一個組件哼绑,函數(shù)式寫法為:h(component),這里就是去渲染組件
}
}
history實現(xiàn)
history觀測的是瀏覽器的前進后退碉咆,不同于hash抖韩,跳轉的時候window.history.pushState
并不會觸發(fā)popstate
(因為該api是歷史管理,并不會觀測路徑變化)疫铜,所以需要手動執(zhí)行跳轉茂浮,再去調用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', () => {
// 監(jiān)聽路徑變化(瀏覽器的前進后退)進行跳轉
this.transitionTo(this.getCurrentLocation())
})
}
push (location) {
this.transitionTo(location, () => {
// 采用 H5 的 API 跳轉,這里的切換不會觸發(fā) popstate次和,所以不能像hash一樣微王,需要放到回調中來處理
window.history.pushState({}, null, location)
})
}
}
hook實現(xiàn)
導航具體的觸發(fā)流程,建議閱讀官方文檔财剖,傳送門: 完整的導航解析流程幌羞,根據(jù)面試造火箭特性寸谜,父子組件生命周期渲染流程經(jīng)常提問,或許以后會出現(xiàn)導航解析流程
vueRouter有一個方法属桦,beforeEach(全局前置守衛(wèi))熊痴,實際項目中被用來做一些權限判斷(攔截器),簡單的理解聂宾,就是類似于Koa的中間件(比如本人前面Koa的文章使用koa-jwt對用戶登錄權限判斷)
多次使用依次執(zhí)行果善,實質就是個迭代器
使用示例代碼:
router.beforeEach((to, from, next) => {
setTimeout(() => {
console.log(1)
next()
}, 1000)
})
router.beforeEach((to, from, next) => {
setTimeout(() => {
next()
}, 1000)
})
具體實現(xiàn)代碼
+ function runQueue (queue, interator, cb) {
+ function next (index) {
+ if (index >= queue.length) {
+ return cb() // 一個鉤子都沒有,或者鉤子全部執(zhí)行完畢系谐,直接調用cb完成渲染即可
+ } else {
+ const hook = queue[index]
+ interator(hook, () => next(index + 1))
+ }
+ }
+
+ next(0)
+ }
export default class History {
// 根據(jù)路徑進行組件渲染巾陕,數(shù)據(jù)變化更新視圖
transitionTo (location, onComplete) { // 默認會先執(zhí)行一次
// 根據(jù)跳轉的路徑,獲取匹配的記錄
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止喷,無法觸發(fā)響應式
// vueRoute用于提供給用戶直接使用馆类,vueRoute中又需要對歷史記錄進行操作
// 跳轉的時候又是由歷史記錄所觸發(fā),需要通知變更vue._route弹谁,而現(xiàn)在變更的是歷史記錄中的current
// 需要將自身變更后匹配到的路由返回給vueRouter乾巧,這里不能直接使用 install導出的_vue
// 是因為考慮到有可能實例化了多個Vue,這個時候的_Vue是最后實例化的Vue预愤,并非對應vueRouter所使用的Vue實例
// 通過listen去執(zhí)行vueRouter綁定的函數(shù)沟于,vueRouter中有當前Vue實例,就能將當前匹配到的路由賦值給Vue._route植康,這樣就能觸發(fā)響應式變化
this.cb && this.cb(route) // 第一次cb不存在旷太,還未進行綁定回調,cb調用觸發(fā)視圖更新
onComplete && onComplete() // cb調用hash值變化會再次調用transitionTo
+ })
}
listen (cb) {
this.cb = cb
}
}