手寫Vue2核心(七):vue-router實現

源碼相關的文章確實不好寫,一個是每個人基礎功不一樣,我覺得說的清楚的東西可能對到別人依舊含糊,一個是對一些邏輯的理解也未必就敢說百分百正確,最后是真想拆分一步步的關鍵代碼都不好拆。如果有這種文章經驗的作者歡迎交流。

本文實現的是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.jsinstall.js來替換node_modules中的vue-router
src/router/index.js中引用的vue-router替換為自行創建的vue-routerimport VueRouter from '@/vue-router'

vue.use

vue使用插件的方式是使用vue.usevue.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

而我們路由只會在實例化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主要負責跳轉,渲染等,因為這些事情不管使用哪一種模式都是一致的,HashHistoryH5History都繼承于該類

// 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.route并不是響應式的。總不能用戶路由跳轉之后還得調一個方法才能產生頁面渲染,數據變了則視圖更新,需要vue的響應式,但插件中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.$routevue.$router來訪問路由對象及獲取當前路由對應屬性的,插件中是將這兩個屬性掛載原型上并進行劫持
vue-router中還提供兩個組件,用于跳轉與渲染視圖:RouteLinkRouteView

+ 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 的區別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
    }
}
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容