「進階篇」Vue Router 核心原理解析

我不是大熊哦發表於2022-04-05

前言

此篇為進階篇,希望讀者有 Vue.js,Vue Router 的使用經驗,並對 Vue.js 核心原理有簡單瞭解;

不會大篇幅手撕原始碼,會貼最核心的原始碼,對應的官方倉庫原始碼地址會放到超上,可以配合著看;

對應的原始碼版本是 3.5.3,也就是 Vue.js 2.x 對應的 Vue Router 最新版本;

Vue Router 是標準寫法,為了簡單,下面會簡稱 router。

本文將用以下問題為線索展開講 router 的原理:

  1. $router 和 $route 哪來的
  2. router 怎樣知道要渲染哪個元件
  3. this.$router.push 呼叫了什麼原生 API
  4. router-view 渲染的檢視是怎樣被更新的
  5. router 怎樣知道要切換檢視的

文末有總結大圖

以下是本文使用的簡單例子:

image.png

// main.js
import Vue from 'vue'
import App from './App'
import router from './router'

new Vue({
  el: '#app',
  // 掛載 Vue Router 例項
  router,
  components: { App },
  template: '<App/>'
})

// router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import About from '@/components/About'
import Home1 from '@/components/Home1'

// 使用 Vue Router 外掛
Vue.use(Router)
// 建立 Vue Router 例項
export default new Router({
  routes: [
    {
      path: '/',
      redirect: '/home'
    },
    {
      path: '/home',
      name: 'Home',
      component: Home,
      children: [
        {
          path: 'home1',
          name: 'Home1',
          component: Home1
        }
      ]
    },
    {
      path: '/about',
      name: 'About',
      component: About
    }
  ]
})
// App.vue
<template>
  <div id="app">
    <router-link to="/home">Go to Home</router-link>
    <router-link to="/about">Go to About</router-link>
    <router-link to="/home/home1">Go to Home1</router-link>
    <router-view/>
  </div>
</template>
<script>
export default {
  name: 'App'
}
</script>

頁面表現舉例:

Xnip2022-04-04_15-14-26.jpg

$router 和 $route 哪來的

我們在元件裡使用 this.$router 去跳轉路由、使用 this.$route 獲取當前路由資訊或監聽路由變化,那它們是從哪裡來的?答案是路由註冊

路由註冊

Xnip2022-04-04_15-17-16.jpg

路由註冊發生在 Vue.use 時,而 use 的就是 router 在 index.js 暴露的 VueRouter 類:

// demo程式碼:
import Router from 'vue-router'

// 使用 Vue Router 外掛
Vue.use(Router)
// router 的 index.js
import { install } from './install'

// VueRouter 類
export default class VueRouter {

}
VueRouter.install = install

// install.js
export function install (Vue) {
  // 全域性混入鉤子函式
  Vue.mixin({
    beforeCreate () {
      // 有router配置項,代表是根元件,設定根router
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        this._router = this.$options.router
      } else {
	// 非根元件,通過其父元件訪問,一層層直到根元件
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
    },
  })
  // Vue 原型上增加 $router 和 $route
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })
  // 全域性註冊了 router-view 元件和 router-link 元件
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)
}

所以 this.$router,this.$route 就是在註冊路由時混入了全域性的 beforeCreate 鉤子,鉤子裡進行了 Vue 原型的擴充。

同時也清楚了 router-view 和 router-link 的來源。

VueRouter 類

Xnip2022-04-04_15-17-25.jpg

我們先看最核心部分

export default class VueRouter {
  constructor (options) {
    // 確定路由模式,瀏覽器環境預設是 hash,Node.js環境預設是abstract
    let mode = options.mode || 'hash'
    this.fallback =
      mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode

    // 根據模式例項化不同的 history 來管理路由
    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
  }
}

constructor 裡重要的兩個事情:1. 確定路由模式,2. 根據模式建立 History 例項。

Xnip2022-04-04_13-48-40.jpg

如上,history 類有 base 基類,不同模式有對應的 abstract 類、hash 類、html5 類,繼承於 base 類,history 例項處理路由切換、路由跳轉等等事情。

init

VueRouter 的 init 發生在剛才說的 beforeCreate 鉤子裡

// beforeCreate 鉤子裡呼叫了 init
this._router.init(this)

// VueRouter類的 init 例項方法
init(app) {
  // 儲存 router 例項
  this.app = app
  const history = this.history
  if (history instanceof HTML5History || history instanceof HashHistory) {
    const setupListeners = routeOrError => {
      // 待揭祕
      history.setupListeners()
    }
    // 路由切換
    history.transitionTo(
      history.getCurrentLocation(),
      setupListeners,
      setupListeners
    )
  }
}

init 裡最主要處理了 history.transitionTo,transitionTo 有呼叫了 setupListeners,先有個印象即可。

router 怎樣知道要渲染哪個元件

使用者傳入路由配置後,router 是怎樣知道要渲染哪個元件的,答案是 Matcher

Matcher

Xnip2022-04-04_15-17-30.jpg

Matcher 是匹配器,處理路由匹配,建立 matcher 發生在 VueRouter 類的建構函式裡

this.matcher = createMatcher(options.routes || [], this)

// create-matcher.js
export function createMatcher(routes, router){
  // 建立對映表
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  // 根據我們要跳轉的路由匹配到元件,比如 this.$router.push('/about')
  function match() {

  }
}

createRouteMap

createRouteMap 負責建立路由對映表

export function createRouteMap(routes, oldPathList, oldPathMap, oldNameMap){
  const pathList: Array<string> = oldPathList || []
  const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
  const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)

  ...
	
  return {
    pathList,
    pathMap,
    nameMap
  }
}

其中的處理細節先不用關注,列印一下例子裡的路由對映表就很清楚有什麼內容了:

Xnip2022-04-04_09-54-02.jpg

pathList【path 列表】、pathMap【path 到 RouteRecord 的對映】、nameMap【name 到RouteRecord 的對映】,有了路由對映表之後想定位到 RouteRecord 就很容易了

其中 router 一些資料結構如下:原始碼

Xnip2022-04-04_10-09-15.jpg

match 方法

match 方法就是從剛才生成的路由對映表裡面取出 RouterRecord

// create-matcher.js
function match(raw, currentRoute, redirectedFrom){
    const location = normalizeLocation(raw, currentRoute, false, router)
    const { name } = location

    if (name) {
       // name 的情況
       ...
    } else if (location.path) {
       // path 的情況
       ...
    }
}

this.$router.push 呼叫了什麼原生 API

this.$router.push 用於跳轉路由,內部呼叫的是 transitionTo 做路由切換,

在 hash 模式的原始碼,在 history 模式的原始碼

Xnip2022-04-04_15-17-38.jpg

以 hash 模式為例

// history/hash.js
// push 方法
push (location, onComplete, onAbort) {
    // transitionTo 做路由切換,在裡面呼叫了剛才的 matcher 的 match 方法匹配路由
    // transitionTo 第2個和第3個引數是回撥函式
    this.transitionTo(
      location,
      route => {
        pushHash(route.fullPath)
        onComplete && onComplete(route)
      },
      onAbort
    )
}
// 更新 url,如果支援 h5 的 pushState api,就使用 pushState 的方式,
// 否則設定 window.location.hash
function pushHash (path) {
  if (supportsPushState) {
    pushState(getUrl(path))
  } else {
    window.location.hash = path
  }
}

function getUrl (path) {
  const href = window.location.href
  const i = href.indexOf('#')
  const base = i >= 0 ? href.slice(0, i) : href
  return `${base}#${path}`
}

history 模式就是呼叫 pushState 方法

pushState 方法

原始碼

export function pushState (url, replace) {
  // 獲取 window.history
  const history = window.history
  try {
    if (replace) {
      const stateCopy = extend({}, history.state)
      stateCopy.key = getStateKey()
      // 呼叫 replaceState
      history.replaceState(stateCopy, '', url)
    } else {
      // 呼叫 pushState
      history.pushState({ key: setStateKey(genStateKey()) }, '', url)
    }
  } catch (e) {
    ...
  }
}

router-view 渲染的檢視是怎樣被更新的

Xnip2022-04-04_15-17-44.jpg

router-view 用於渲染傳入路由配置對應的元件

export default {
	name: 'RouterView',
	functional: true,
	render(_, { props, children, parent, data }) {
		...
    // 標識
    data.routerView = true
    // 通過 depth 由 router-view 元件向上遍歷直到根元件,
    // 遇到其他的 router-view 元件則路由深度+1 
    // 用 depth 幫助找到對應的 RouterRecord
    let depth = 0
    while (parent && parent._routerRoot !== parent) {
      const vnodeData = parent.$vnode ? parent.$vnode.data : {}
      if (vnodeData.routerView) {
        depth++
      }
      parent = parent.$parent
    }
    data.routerViewDepth = depth
        // 獲取匹配的元件
        const route = parent.$route
        const matched = route.matched[depth]
        const component = matched && matched.components[name]

        ...
        // 渲染對應的元件
        const h = parent.$createElement
        return h(component, data, children)
   }
}

比如例子中的二級路由 home1

Xnip2022-04-04_15-14-26.jpg

因為是二級路由,所以深度 depth 是 1,找到如下圖的 home1 元件

Xnip2022-04-04_11-59-13.jpg

更新

那麼每次路由切換之後,怎樣觸發了渲染新檢視呢?

每次 transitionTo 完成後會執行新增的回撥函式,回撥函式裡更新了當前路由資訊

在 VueRouter 的 init 方法裡註冊了回撥:

history.listen(route => {
  this.apps.forEach(app => {
		// 更新當前路由資訊 _route
    app._route = route
  })
})

而在元件的 beforeCreate 鉤子裡把 _route 變成了響應式的,在 router-view 的 render 函式裡訪問到了 parent.$route,也就是訪問到了 _route,

所以一旦 _route 改變了,就觸發了 router-view 元件的重新渲染

// 把 _route 變成響應式的
Vue.util.defineReactive(this, '_route', this._router.history.current)

router 怎樣知道要切換檢視的

到現在我們已經清楚了 router 是怎樣切換檢視的,那當我們點選瀏覽器的後退按鈕、前進按鈕的時候是怎樣觸發檢視切換的呢?

答案是 VueRouter 在 init 的時候做了事件監聽 setupListeners

setupListeners

Xnip2022-04-04_15-17-51.jpg

popstate 事件:在做出瀏覽器動作時,才會觸發該事件,呼叫 window.history.pushState 或 replaceState 不會觸發,文件

hashchange 事件:hash 變化時觸發

核心原理總結

Xnip2022-04-04_15-18-14.jpg

本文從5個問題出發,解析了 Vue Router 的核心原理,而其它分支比如導航守衛是如何實現的等等可以自己去了解,先了解了核心原理再看其他部分也是水到渠成。

本身前端路由的實現並不複雜,Vue Router 更多的是考慮怎樣和 Vue.js 的核心能力結合起來,應用到 Vue.js 生態中去。

對 Vue Router 的原理有哪一部分想和我聊聊的,可以在評論區留言

相關文章