7張圖,從零實現一個簡易版Vue-Router,太通俗易懂了!

Sunshine_Lin發表於2021-12-14

前言

大家好,我是林三心,用最通俗易懂的話,講最難的知識點,相信大家在Vue專案中肯定都用過Vue-router,也就是路由。所以本文章我就不過多講解vue-router的基本講解了,我也不給你們講解vue-router的原始碼,我就帶大家從零開始,實現一個vue-router吧!!!

路由基本使用方法

平時我們們vue-router其實都用很多了,基本每個專案都會用它,因為Vue是單頁面應用,可以通過路由來實現切換元件,達到切換頁面的效果。我們們平時都是這麼用的,其實分為3步

  • 1、引入vue-router,並使用Vue.use(VueRouter)
  • 2、定義路由陣列,並將陣列傳入VueRouter例項,並將例項暴露出去
  • 3、將VueRouter例項引入到main.js,並註冊到根Vue例項上

    // src/router/index.js
    
    import Vue from 'vue'
    import VueRouter from 'vue-router'
    import home from '../components/home.vue'
    import hello from '../components/hello.vue'
    import homeChild1 from '../components/home-child1.vue'
    import homeChild2 from '../components/home-child2.vue'
    
    Vue.use(VueRouter) // 第一步
    
    const routes = [
      {
          path: '/home',
          component: home,
          children: [
              {
                  path: 'child1',
                  component: homeChild1
              },
              {
                  path: 'child2',
                  component: homeChild2
              }
          ]
      },
      {
          path: '/hello',
          component: hello,
          children: [
              {
                  path: 'child1',
                  component: helloChild1
              },
              {
                  path: 'child2',
                  component: helloChild2
              }
          ]
      },
    ]
    
    export default new VueRouter({
      routes // 第二步
    })
    
    // src/main.js
    import router from './router'
    
    new Vue({
    router,  // 第三步
    render: h => h(App)
    }).$mount('#app')
    

router-view和router-link的分佈

// src/App.vue

<template>
  <div id="app">
    <router-link to="/home">home的link</router-link>
    <span style="margin: 0 10px">|</span>
    <router-link to="/hello">hello的link</router-link>
    <router-view></router-view>
  </div>
</template>

// src/components/home.vue

<template>
    <div style="background: green">
        <div>home的內容哦嘿嘿</div>
        <router-link to="/home/child1">home兒子1</router-link>
        <span style="margin: 0 10px">|</span>
        <router-link to="/home/child2">home兒子2</router-link>
        <router-view></router-view>
    </div>
</template>

// src/components/hello.vue

<template>
    <div style="background: orange">
        <div>hello的內容哦嘿嘿</div>
        <router-link to="/hello/child1">hello兒子1</router-link>
        <span style="margin: 0 10px">|</span>
        <router-link to="/hello/child2">hello兒子2</router-link>
        <router-view></router-view>
    </div>
</template>

// src/components/home-child1.vue 另外三個子元件大同小異,區別在於文字以及背景顏色不一樣,就不寫出來了
<template>
    <div style="background: yellow">我是home的1兒子home-child1</div>
</template>

經過上面這3步,我們們能實現什麼效果呢?

  • 1、在網址處輸入對應path,就會展示對應元件
  • 2、可以在任何用到的元件裡訪問到$router和$router,並使用其身上的方法或屬性
  • 3、可以使用route-link元件進行路徑跳轉
  • 4、可以使用router-view元件進行路由對應內容展示

截圖2021-09-25 下午3.46.32.png

以下是達到的效果動圖

router2.gif

開搞!!!

VueRouter類

在src資料夾中,建立一個my-router.js

VueRouter類的options引數,其實就是new VueRouter(options)時傳入的這個引數物件,而install是一個方法,並且必須使VueRouter類擁有這個方法,為什麼呢?我們們下面會講的。

// src/my-router.js

class VueRouter {
    constructor(options) {}
    init(app) {}
}

VueRouter.install = (Vue) => {}

export default VueRouter

install方法

為什麼必須定義一個install方法,並且把他賦予VueRouter呢?其實這跟Vue.use方法有關,大家還記得Vue是怎麼使用VueRouter的嗎?

import VueRouter from 'vue-router'

Vue.use(VueRouter) // 第一步

export default new VueRouter({ // 傳入的options
    routes // 第二步
})

import router from './router'

new Vue({
  router,  // 第三步
  render: h => h(App)
}).$mount('#app')

其實第二步和第三步很清楚,就是例項一個VueRouter物件,並且將這個VueRouter物件掛到根元件App上,那問題來了,第一步的Vue.use(VueRouter)是幹什麼用的呢?其實Vue.use(XXX),就是執行XXX上的install方法,也就是Vue.use(VueRouter) === VueRouter.install(),但是到了這,我們們是知道了install會執行,但是還是不知道install執行了是幹嘛的,有什麼用?

我們們知道VueRouter物件是被掛到根元件App上了,所以App是能直接使用VueRouter物件上的方法的,但是,我們們知道,我們們肯定是想每一個用到的元件都能使用VueRouter的方法,比如this.$router.push,但是現在只有App能用這些方法,咋辦呢?咋才能每個元件都能使用呢?這時install方法派上用場了,我們們先說說實現思路,再寫程式碼哈。

截圖2021-09-25 下午10.20.09.png

知識點:Vue.use(XXX)時,會執行XXX的install方法,並將Vue當做引數傳入install方法

// src/my-router.js

let _Vue
VueRouter.install = (Vue) => {
    _Vue = Vue
    // 使用Vue.mixin混入每一個元件
    Vue.mixin({
        // 在每一個元件的beforeCreate生命週期去執行
        beforeCreate() {
            if (this.$options.router) { // 如果是根元件
                // this 是 根元件本身
                this._routerRoot = this

                // this.$options.router就是掛在根元件上的VueRouter例項
                this.$router = this.$options.router

                // 執行VueRouter例項上的init方法,初始化
                this.$router.init(this)
            } else {
                // 非根元件,也要把父元件的_routerRoot儲存到自身身上
                this._routerRoot = this.$parent && this.$parent._routerRoot
                // 子元件也要掛上$router
                this.$router = this._routerRoot.$router
            }
        }
    })
}

createRouteMap方法

這個方法是幹嘛的呢?顧名思義,就是將傳進來的routes陣列轉成一個Map結構的資料結構,key是path,value是對應的元件資訊,至於為什麼要轉換呢?這個我們們下面會講。我們們先實現轉換。

截圖2021-09-25 下午10.47.42.png

// src/my-router.js

function createRouteMap(routes) {

    const pathList = []
    const pathMap = {}

    // 對傳進來的routes陣列進行遍歷處理
    routes.forEach(route => {
        addRouteRecord(route, pathList, pathMap)
    })

    console.log(pathList)
    // ["/home", "/home/child1", "/home/child2", "/hello", "/hello/child1"]
    console.log(pathMap)
    // {
    //     /hello: {path: xxx, component: xxx, parent: xxx },
    //     /hello/child1: {path: xxx, component: xxx, parent: xxx },
    //     /hello/child2: {path: xxx, component: xxx, parent: xxx },
    //     /home: {path: xxx, component: xxx, parent: xxx },
    //     /home/child1: {path: xxx, component: xxx, parent: xxx }
    // }


    // 將pathList與pathMap返回
    return {
        pathList,
        pathMap
    }
}

function addRouteRecord(route, pathList, pathMap, parent) {
    const path = parent ? `${parent.path}/${route.path}` : route.path
    const { component, children = null } = route
    const record = {
        path,
        component,
        parent
    }
    if (!pathMap[path]) {
        pathList.push(path)
        pathMap[path] = record
    }
    if (children) {
        // 如果有children,則遞迴執行addRouteRecord
        children.forEach(child => addRouteRecord(child, pathList, pathMap, record))
    }
}

export default createRouteMap

路由模式

路由有三種模式

  • 1、hash模式,最常用的模式
  • 2、history模式,需要後端配合的模式
  • 3、abstract模式,非瀏覽器環境的模式

而且模式怎麼設定呢?是這麼設定的,通過options的mode欄位傳進去

export default new VueRouter({
    mode: 'hash' // 設定模式
    routes
})

而如果不傳的話,預設是hash模式,也是我們平時開發中用的最多的模式,所以本章節就只實現hash模式

// src/my-router.js

import HashHistory from "./hashHistory"

class VueRouter {
    constructor(options) {
        
        this.options = options
        
        // 如果不傳mode,預設為hash
        this.mode = options.mode || 'hash'

        // 判斷模式是哪種
        switch (this.mode) {
            case 'hash':
                this.history = new HashHistory(this)
                break
            case 'history':
                // this.history = new HTML5History(this, options.base)
                break
            case 'abstract':

        }
    }
    init(app) { }
}

HashHistory

在src資料夾下建立hashHistory.js

其實hash模式的原理就是,監聽瀏覽器url中hash值的變化,並切換對應的元件

class HashHistory {
    constructor(router) {

        // 將傳進來的VueRouter例項儲存
        this.router = router

        // 如果url沒有 # ,自動填充 /#/ 
        ensureSlash()
        
        // 監聽hash變化
        this.setupHashLister()
    }
    // 監聽hash的變化
    setupHashLister() {
        window.addEventListener('hashchange', () => {
            // 傳入當前url的hash,並觸發跳轉
            this.transitionTo(window.location.hash.slice(1))
        })
    }

    // 跳轉路由時觸發的函式
    transitionTo(location) {
        console.log(location) // 每次hash變化都會觸發,可以自己在瀏覽器修改試試
        // 比如 http://localhost:8080/#/home/child1 最新hash就是 /home/child1
    }
}

// 如果瀏覽器url上沒有#,則自動補充/#/
function ensureSlash() {
    if (window.location.hash) {
        return
    }
    window.location.hash = '/'
}

// 這個先不講,後面會用到
function createRoute(record, location) {
    const res = []
    if (record) {
        while (record) {
            res.unshift(record)
            record = record.parent
        }
    }
    return {
        ...location,
        matched: res
    }
}
export default HashHistory

createMmatcher方法

上面講了,每次hash修改,都能獲取到最新的hash值,但是這不是我們們的最終目的,我們們最終目的是根據hash變化渲染不同的元件頁面,那怎麼辦呢?

還記得之前createRouteMap方法嗎?我們們將routes陣列轉成了Map資料結構,有了那個Map,我們們就可以根據hash值去獲取對應的元件並進行渲染

截圖2021-09-26 下午9.26.44.png

但是這樣真的可以嗎?其實是不行的,如果按照上面的方法,當hash為/home/child1時,只會渲染home-child1.vue這一個元件,但這樣肯定是不行的,當hash為/home/child1時,肯定是渲染home.vuehome-child1.vue這兩個元件

截圖2021-09-26 下午9.30.57.png

所以我們們得寫一個方法,來查詢hash對應哪些元件,這個方法就是createMmatcher

// src/my-router.js

class VueRouter {
    
    // ....原先程式碼

    // 根據hash變化獲取對應的所有元件
    createMathcer(location) {
    
        // 獲取 pathMap
        const { pathMap } = createRouteMap(this.options.routes)

        const record = pathMap[location]
        const local = {
            path: location
        }
        if (record) {
            return createRoute(record, local)
        }
        return createRoute(null, local)
    }
}

// ...原先程式碼

function createRoute(record, location) {
    const res = []
    if (record) {
        while (record) {
            res.unshift(record)
            record = record.parent
        }
    }
    return {
        ...location,
        matched: res
    }
}
// src/hashHistory.js

class HashHistory {
    
    // ...原先程式碼

    // 跳轉路由時觸發的函式
    transitionTo(location) {
        console.log(location)
        
        // 找出所有對應元件,router是VueRouter例項,createMathcer在其身上
        let route = this.router.createMathcer(location)

        console.log(route)
    }
}

截圖2021-09-26 下午9.51.01.png

這只是保證了hash變化的時候能找出對應的所有元件來,但是有一點我們忽略了,那就是我們如果手動重新整理頁面的話,是不會觸發hashchange事件的,也就是找不出元件來,那咋辦呢?重新整理頁面肯定會使路由重新初始化,我們們只需要在初始化函式init上一開始執行一次原地跳轉就行。

// src/my-router.js

class VueRouter {

    // ...原先程式碼
    
    init(app) {
        // 初始化時執行一次,保證重新整理能渲染
        this.history.transitionTo(window.location.hash.slice(1))
    }

    // ...原先程式碼
}

響應式的hash改變

上面我們們實現了根據hash值找出所有需要渲染的元件,但最後的渲染環節卻還沒實現,不過不急,實現渲染之前,我們們先把一件事給完成了,那就是要讓hash值改變這件事變成一件響應式的事,為什麼呢?我們們剛剛每次hash變化是能拿到最新的元件合集,但是沒用啊,Vue的元件重新渲染只能通過某個資料的響應式變化來觸發。所以我們們得搞個變數來儲存這個元件合集,並且這個變數需要是響應式的才行,這個變數就是$route,注意要跟$router區別開來哦!!!但是這個$route需要用兩個中介變數來獲取,分別是current和_route

這裡可能會有點繞,還望大家有點耐心。我已經把複雜的程式碼最簡單化展示了。
// src/hashHistory.js

class HashHistory {
    constructor(router) {

        // ...原先程式碼

        // 一開始給current賦值初始值
        this.current = createRoute(null, {
            path: '/'
        })

    }
    
    // ...原先程式碼

    // 跳轉路由時觸發的函式
    transitionTo(location) {
        // ...原先程式碼

        // hash更新時給current賦真實值
        this.current = route
    }
    // 監聽回撥
    listen(cb) {
        this.cb = cb
    }
}
// src/my-router.js

class VueRouter {

    // ...原先程式碼
    
    init(app) {
        // 把回撥傳進去,確保每次current更改都能順便更改_route觸發響應式
        this.history.listen((route) => app._route = route)
        
        // 初始化時執行一次,保證重新整理能渲染
        this.history.transitionTo(window.location.hash.slice(1))
    }

    // ...原先程式碼
}

VueRouter.install = (Vue) => {
    _Vue = Vue
    // 使用Vue.mixin混入每一個元件
    Vue.mixin({
        // 在每一個元件的beforeCreate生命週期去執行
        beforeCreate() {
            if (this.$options.router) { // 如果是根元件

                // ...原先程式碼
                
                // 相當於存在_routerRoot上,並且呼叫Vue的defineReactive方法進行響應式處理
                Vue.util.defineReactive(this, '_route', this.$router.history.current)
            } else {
                // ...原先程式碼
            }


        }
    })
    
    // 訪問$route相當於訪問_route
    Object.defineProperty(Vue.prototype, '$route', {
        get() {
            return this._routerRoot._route
        }
    })
}

router-view元件渲染

其實元件渲染關鍵在於<router-view>元件,我們們可以自己實現一個<my-view>

src下建立view.js,老規矩,先說說思路,再實現程式碼

截圖2021-09-26 下午11.07.10.png

// src/view.js

const myView = {
    functional: true,
    render(h, { parent, data }) {
        const { matched } = parent.$route

        data.routerView = true // 標識此元件為router-view
        let depth = 0 // 深度索引

        while(parent) {
            // 如果有父元件且父元件為router-view 說明索引需要加1
            if (parent.$vnode && parent.$vnode.data.routerView) {
                depth++
            }
            parent = parent.$parent
        }
        const record = matched[depth]

        if (!record) {
            return h()
        }

        const component = record.component

        // 使用render的h函式進行渲染元件
        return h(component, data)

    }
}
export default myView

router-link跳轉

其實他的本質就是個a標籤而已

src下建立link.js

const myLink = {
    props: {
        to: {
            type: String,
            required: true,
        },
    },
    // 渲染
    render(h) {

        // 使用render的h函式渲染
        return h(
            // 標籤名
            'a',
            // 標籤屬性
            {
                domProps: {
                    href: '#' + this.to,
                },
            },
            // 插槽內容
            [this.$slots.default]
        )
    },
}

export default myLink

最終效果

最後把router/index.js裡的引入改一下

import VueRouter from '../Router-source/index2'

然後把所有router-view和router-link全都替換成my-view和my-link

效果

router2.gif

結語

如果你覺得此文對你有一丁點幫助,點個贊,鼓勵一下林三心哈哈。或者可以加入我的摸魚群
想進學習群,摸魚群,請點選這裡[摸魚](
https://juejin.cn/pin/6969565...),我會定時直播模擬面試,答疑解惑

image.png

完整程式碼

/src/my-router.js

import HashHistory from "./hashHistory"
class VueRouter {
    constructor(options) {

        this.options = options

        // 如果不傳mode,預設為hash
        this.mode = options.mode || 'hash'

        // 判斷模式是哪種
        switch (this.mode) {
            case 'hash':
                this.history = new HashHistory(this)
                break
            case 'history':
                // this.history = new HTML5History(this, options.base)
                break
            case 'abstract':

        }
    }
    init(app) {
        this.history.listen((route) => app._route = route)

        // 初始化時執行一次,保證重新整理能渲染
        this.history.transitionTo(window.location.hash.slice(1))
    }

    // 根據hash變化獲取對應的所有元件
    createMathcer(location) {
        const { pathMap } = createRouteMap(this.options.routes)

        const record = pathMap[location]
        const local = {
            path: location
        }
        if (record) {
            return createRoute(record, local)
        }
        return createRoute(null, local)
    }
}

let _Vue
VueRouter.install = (Vue) => {
    _Vue = Vue
    // 使用Vue.mixin混入每一個元件
    Vue.mixin({
        // 在每一個元件的beforeCreate生命週期去執行
        beforeCreate() {
            if (this.$options.router) { // 如果是根元件
                // this 是 根元件本身
                this._routerRoot = this

                // this.$options.router就是掛在根元件上的VueRouter例項
                this.$router = this.$options.router

                // 執行VueRouter例項上的init方法,初始化
                this.$router.init(this)

                // 相當於存在_routerRoot上,並且呼叫Vue的defineReactive方法進行響應式處理
                Vue.util.defineReactive(this, '_route', this.$router.history.current)
            } else {
                // 非根元件,也要把父元件的_routerRoot儲存到自身身上
                this._routerRoot = this.$parent && this.$parent._routerRoot
                // 子元件也要掛上$router
                this.$router = this._routerRoot.$router
            }
        }
    })
    Object.defineProperty(Vue.prototype, '$route', {
        get() {
            return this._routerRoot._route
        }
    })
}

function createRouteMap(routes) {

    const pathList = []
    const pathMap = {}

    // 對傳進來的routes陣列進行遍歷處理
    routes.forEach(route => {
        addRouteRecord(route, pathList, pathMap)
    })

    console.log(pathList)
    // ["/home", "/home/child1", "/home/child2", "/hello", "/hello/child1"]
    console.log(pathMap)
    // {
    //     /hello: {path: xxx, component: xxx, parent: xxx },
    //     /hello/child1: {path: xxx, component: xxx, parent: xxx },
    //     /hello/child2: {path: xxx, component: xxx, parent: xxx },
    //     /home: {path: xxx, component: xxx, parent: xxx },
    //     /home/child1: {path: xxx, component: xxx, parent: xxx }
    // }


    // 將pathList與pathMap返回
    return {
        pathList,
        pathMap
    }
}

function addRouteRecord(route, pathList, pathMap, parent) {
    // 拼接path
    const path = parent ? `${parent.path}/${route.path}` : route.path
    const { component, children = null } = route
    const record = {
        path,
        component,
        parent
    }
    if (!pathMap[path]) {
        pathList.push(path)
        pathMap[path] = record
    }
    if (children) {
        // 如果有children,則遞迴執行addRouteRecord
        children.forEach(child => addRouteRecord(child, pathList, pathMap, record))
    }
}

function createRoute(record, location) {
    const res = []
    if (record) {
        while (record) {
            res.unshift(record)
            record = record.parent
        }
    }
    return {
        ...location,
        matched: res
    }
}
export default VueRouter

src/hashHistory.js

class HashHistory {
    constructor(router) {

        // 將傳進來的VueRouter例項儲存
        this.router = router

        // 一開始給current賦值初始值
        this.current = createRoute(null, {
            path: '/'
        })

        // 如果url沒有 # ,自動填充 /#/ 
        ensureSlash()

        // 監聽hash變化
        this.setupHashLister()
    }
    // 監聽hash的變化
    setupHashLister() {
        window.addEventListener('hashchange', () => {
            // 傳入當前url的hash
            this.transitionTo(window.location.hash.slice(1))
        })
    }

    // 跳轉路由時觸發的函式
    transitionTo(location) {
        console.log(location)
        
        // 找出所有對應元件
        let route = this.router.createMathcer(location)

        console.log(route)

        // hash更新時給current賦真實值
        this.current = route
        // 同時更新_route
        this.cb && this.cb(route)
    }
    // 監聽回撥
    listen(cb) {
        this.cb = cb
    }
}

// 如果瀏覽器url上沒有#,則自動補充/#/
function ensureSlash() {
    if (window.location.hash) {
        return
    }
    window.location.hash = '/'
}

export function createRoute(record, location) {
    const res = []
    if (record) {
        while (record) {
            res.unshift(record)
            record = record.parent
        }
    }
    return {
        ...location,
        matched: res
    }
}

export default HashHistory

src/view.js

const myView = {
    functional: true,
    render(h, { parent, data }) {
        const { matched } = parent.$route

        data.routerView = true // 標識此元件為router-view
        let depth = 0 // 深度索引

        while(parent) {
            // 如果有父元件且父元件為router-view 說明索引需要加1
            if (parent.$vnode && parent.$vnode.data.routerView) {
                depth++
            }
            parent = parent.$parent
        }
        const record = matched[depth]

        if (!record) {
            return h()
        }

        const component = record.component

        // 使用render的h函式進行渲染元件
        return h(component, data)

    }
}
export default myView

src/link.js

const myLink = {
    props: {
        to: {
            type: String,
            required: true,
        },
    },
    // 渲染
    render(h) {

        // 使用render的h函式渲染
        return h(
            // 標籤名
            'a',
            // 標籤屬性
            {
                domProps: {
                    href: '#' + this.to,
                },
            },
            // 插槽內容
            [this.$slots.default]
        )
    },
}

export default myLink

結語

有人可能覺得沒必要,但是嚴格要求自己其實是很有必要的,平時嚴格要求自己,才能做到每到一個公司都能更好的做到向下相容難度。

如果你覺得此文對你有一丁點幫助,點個贊,鼓勵一下林三心哈哈。

如果你想一起學習前端或者摸魚,那你可以加我,加入我的摸魚學習群

image.png

相關文章