vue-router 源碼閱讀 - 文件結構與註冊機制

前端路由是我們前端開發日常開發中經常碰到的概念,作爲自己思考的輸出,本人水平有限,歡迎留言討論~

目標 vue-rouer 版本:3.0.2

vue-router源碼註釋:vue-router-analysis

聲明:文章中源碼的語法都使用 Flow,並且源碼根據需要都有刪節(爲了不被迷糊 @_@),如果要看完整版的請進入上面的 github地址 ~

本文是系列文章,鏈接見底部 ~

0. 前備知識

  • Flow
  • ES6語法
  • 設計模式 - 外觀模式
  • HTML5 History Api

如果你對這些還沒有了解的話,可以看一下本文末尾的推介閱讀。

1. 文件結構

首先我們來看看文件結構:

.
├── build                    // 打包相關配置
├── scripts                    // 構建相關
├── dist                    // 構建後文件目錄
├── docs                    // 項目文檔
├── docs-gitbook            // gitbook配置
├── examples                // 示例代碼,調試的時候使用
├── flow                    // Flow 聲明
├── src                        // 源碼目錄
│   ├── components             // 公共組件
│   ├── history                // 路由類實現
│   ├── util                // 相關工具庫
│   ├── create-matcher.js    // 根據傳入的配置對象創建路由映射表
│   ├── create-route-map.js    // 根據routes配置對象創建路由映射表 
│   ├── index.js            // 主入口
│   └── install.js            // VueRouter裝載入口
├── test                    // 測試文件
└── types                    // TypeScript 聲明

我們主要關注的就是 src 中的內容。

2. 入口文件

2.1 rollup 出口與入口

按照慣例,首先從 package.json 看起,這裏有兩個命令值得注意一下:

{
    "scripts": {
        "dev:dist": "rollup -wm -c build/rollup.dev.config.js",
        "build": "node build/build.js"
  }
}

dev:dist 用配置文件 rollup.dev.config.js 生成 dist 目錄下方便開發調試相關生成文件,對應於下面的配置環境 development

build 是用 node 運行 build/build.js 生成正式的文件,包括 es6commonjsIIFE 方式的導出文件和壓縮之後的導出文件;

這兩種方式都是使用 build/configs.js 這個配置文件來生成的,其中有一段語義化比較不錯的代碼挺有意思,跟 Vue 的配置生成文件比較類似:

// vue-router/build/configs.js

module.exports = [{                     // 打包出口
    file: resolve('dist/vue-router.js'),
    format: 'umd',
    env: 'development'
  },{
    file: resolve('dist/vue-router.min.js'),
    format: 'umd',
    env: 'production'
  },{
    file: resolve('dist/vue-router.common.js'),
    format: 'cjs'
  },{
    file: resolve('dist/vue-router.esm.js'),
    format: 'es'
  }
].map(genConfig)

function genConfig (opts) {
  const config = {
    input: {
      input: resolve('src/index.js'),     // 打包入口
      plugins: [...]
    },
    output: {
      file: opts.file,
      format: opts.format,
      banner,
      name: 'VueRouter'
    }
  }
  return config
}

可以清晰的看到 rollup 打包的出口和入口,入口是 src/index.js 文件,而出口就是上面那部分的配置,env 是開發/生產環境標記,format 爲編譯輸出的方式:

  • es: ES Modules,使用ES6的模板語法輸出
  • cjs: CommonJs Module,遵循CommonJs Module規範的文件輸出
  • umd: 支持外鏈規範的文件輸出,此文件可以直接使用script標籤,其實也就是 IIFE 的方式

那麼正式輸出是使用 build 方式,我們可以從 src/index.js 看起

// src/index.js

import { install } from './install'

export default class VueRouter { ... }

VueRouter.install = install

首先這個文件導出了一個類 VueRouter,這個就是我們在 Vue 項目中引入 vue-router 的時候 Vue.use(VueRouter) 所用到的,而 Vue.use 的主要作用就是找註冊插件上的 install 方法並執行,往下看最後一行,從一個 install.js 文件中導出的 install 被賦給了 VueRouter.install,這就是 Vue.use 中執行所用到的 install 方法。

2.2 Vue.use

可以簡單看一下 Vue 中 Vue.use 這個方法是如何實現的:

// vue/src/core/global-api/use.js

export function initUse (Vue: GlobalAPI) {
  Vue.use = function (plugin: Function | Object) {
    // ... 省略一些判重操作
    const args = toArray(arguments, 1)
    args.unshift(this)            // 注意這個this,是vue對象
    if (typeof plugin.install === 'function') {
      plugin.install.apply(plugin, args)
    }
    return this
  }
}

上面可以看到 Vue.use 這個方法就是執行待註冊插件上的 install 方法,並將這個插件實例保存起來。值得注意的是 install 方法執行時的第一個參數是通過 unshift 推入的 this,因此 install 執行時可以拿到 Vue 對象。

對應上一小節,這裏的 plugin.install 就是 VueRouter.install

3. 路由註冊

3.1 install

接之前,看一下 install.js 裏面是如何進行路由插件的註冊:

// vue-router/src/install.js

/* vue-router 的註冊過程 Vue.use(VueRouter) */
export function install(Vue) {
  _Vue = Vue    // 這樣拿到 Vue 不會因爲 import 帶來的打包體積增加
  
  const isDef = v => v !== undefined
  
  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode // 至少存在一個 VueComponent 時, _parentVnode 屬性才存在
    // registerRouteInstance 在 src/components/view.js
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }
  
  // new Vue 時或者創建新組件時,在 beforeCreate 鉤子中調用
  Vue.mixin({
    beforeCreate() {
      if (isDef(this.$options.router)) {  // 組件是否存在$options.router,該對象只在根組件上有
        this._routerRoot = this           // 這裏的this是根vue實例
        this._router = this.$options.router      // VueRouter實例
        this._router.init(this)
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {                            // 組件實例纔會進入,通過$parent一級級獲取_routerRoot
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      registerInstance(this, this)
    },
    destroyed() {
      registerInstance(this)
    }
  })
  
  // 所有實例中 this.$router 等同於訪問 this._routerRoot._router
  Object.defineProperty(Vue.prototype, '$router', {
    get() { return this._routerRoot._router }
  })
  
  // 所有實例中 this.$route 等同於訪問 this._routerRoot._route
  Object.defineProperty(Vue.prototype, '$route', {
    get() { return this._routerRoot._route }
  })
  
  Vue.component('RouterView', View)     // 註冊公共組件 router-view
  Vue.component('RouterLink', Link)     // 註冊公共組件 router-link
  
  const strats = Vue.config.optionMergeStrategies
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

install 方法主要分爲幾個部分:

  1. 通過 Vue.mixinbeforeCreatedestroyed 的時候將一些路由方法掛載到每個 vue 實例中
  2. 給每個 vue 實例中掛載路由對象以保證在 methods 等地方可以通過 this.$routerthis.$route 訪問到相關信息
  3. 註冊公共組件 router-viewrouter-link
  4. 註冊路由的生命週期函數

Vue.mixin 將定義的兩個鉤子在組件 extend 的時候合併到該組件的 options 中,從而註冊到每個組件實例。看看 beforeCreate,一開始訪問了一個 this.$options.router 這個是 Vue 項目裏面 app.js 中的 new Vue({ router }) 這裏傳入的這個 router,當然也只有在 new Vue 這時纔會傳入 router,也就是說 this.$options.router 只有根實例上纔有。這個傳入 router 到底是什麼呢,我們看看它的使用方式就知道了:

const router = new VueRouter({
  mode: 'hash',
  routes: [{ path: '/', component: Home },
        { path: '/foo', component: Foo },
        { path: '/bar', component: Bar }]
})

new Vue({
  router,
  template: `<div id="app"></div>`
}).$mount('#app')

可以看到這個 this.$options.router 也就是 Vue 實例中的 this._route 其實就是 VueRouter 的實例。

剩下的一頓眼花繚亂的操作,是爲了在每個 Vue 組件實例中都可以通過 _routerRoot 訪問根 Vue 實例,其上的 _route_router 被賦到 Vue 的原型上,這樣每個 Vue 的實例中都可以通過 this.$routethis.$router 訪問到掛載在根實例 _routerRoot 上的 _route_router,後面用 Vue 上的響應式化方法 defineReactive 來將 _route 響應式化,另外在根組件上用 this._router.init() 進行了初始化操作。

隨便找個 Vue 組件,打印一下其上的 _routerRoot

可以看到這是 Vue 的根組件。

3.2 VueRouter

在之前我們已經看過 src/index.js 了,這裏來詳細看一下 VueRouter 這個類

// vue-router/src/index.js

export default class VueRouter {  
  constructor(options: RouterOptions = {}) { }
  
  /* install 方法會調用 init 來初始化 */
  init(app: any /* Vue組件實例 */) { }
  
  /* createMatcher 方法返回的 match 方法 */
  match(raw: RawLocation, current?: Route, redirectedFrom?: Location) { }
  
  /* 當前路由對象 */
  get currentRoute() { }
  
  /* 註冊 beforeHooks 事件 */
  beforeEach(fn: Function): Function { }
  
  /* 註冊 resolveHooks 事件 */
  beforeResolve(fn: Function): Function { }
  
  /* 註冊 afterHooks 事件 */
  afterEach(fn: Function): Function { }
  
  /* onReady 事件 */
  onReady(cb: Function, errorCb?: Function) { }
  
  /* onError 事件 */
  onError(errorCb: Function) { }
  
  /* 調用 transitionTo 跳轉路由 */
  push(location: RawLocation, onComplete?: Function, onAbort?: Function) { }
  
  /* 調用 transitionTo 跳轉路由 */
  replace(location: RawLocation, onComplete?: Function, onAbort?: Function) { }
  
  /* 跳轉到指定歷史記錄 */
  go(n: number) { }
  
  /* 後退 */
  back() { }
  
  /* 前進 */
  forward() { }
  
  /* 獲取路由匹配的組件 */
  getMatchedComponents(to?: RawLocation | Route) { }
  
  /* 根據路由對象返回瀏覽器路徑等信息 */
  resolve(to: RawLocation, current?: Route, append?: boolean) { }
  
  /* 動態添加路由 */
  addRoutes(routes: Array<RouteConfig>) { }
}

VueRouter 類中除了一坨實例方法之外,主要關注的是它的構造函數和初始化方法 init

首先看看構造函數,其中的 mode 代表路由創建的模式,由用戶配置與應用場景決定,主要有三種 History、Hash、Abstract,前兩種我們已經很熟悉了,Abstract 代表非瀏覽器環境,比如 Node、weex 等;this.history 主要是路由的具體實例。實現如下:

// vue-router/src/index.js

export default class VueRouter {  
  constructor(options: RouterOptions = {}) {
    let mode = options.mode || 'hash'       // 路由匹配方式,默認爲hash
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) { mode = 'hash' }    // 如果不支持history則退化爲hash
    if (!inBrowser) { mode = 'abstract' }   // 非瀏覽器環境強制abstract,比如node中
    this.mode = mode
    
    switch (mode) {         // 外觀模式
      case 'history':       // history 方式
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':          // hash 方式
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':      // abstract 方式
        this.history = new AbstractHistory(this, options.base)
        break
      default: ...
    }
  }
}

init 初始化方法是在 install 時的 Vue.mixin 所註冊的 beforeCreate 鉤子中調用的,可以翻上去看看;調用方式是 this._router.init(this),因爲是在 Vue.mixin 裏調用,所以這個 this 是當前的 Vue 實例。另外初始化方法需要負責從任一個路徑跳轉到項目中時的路由初始化,以 Hash 模式爲例,此時還沒有對相關事件進行綁定,因此在第一次執行的時候就要進行事件綁定與 popstatehashchange 事件觸發,然後手動觸發一次路由跳轉。實現如下:

// vue-router/src/index.js

export default class VueRouter {  
  /* install 方法會調用 init 來初始化 */
  init(app: any /* Vue組件實例 */) {
    const history = this.history
    
    if (history instanceof HTML5History) {
      // 調用 history 實例的 transitionTo 方法
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) { 
      const setupHashListener = () => {
          history.setupListeners()      // 設置 popstate/hashchange 事件監聽
      }
      history.transitionTo(             // 調用 history 實例的 transitionTo 方法
          history.getCurrentLocation(), // 瀏覽器 window 地址的 hash 值
          setupHashListener,            // 成功回調
          setupHashListener             // 失敗回調
      )
    }
  }
}

除此之外,VueRouter 還有很多實例方法,用來實現各種功能的,剩下的將在系列文章分享 ~


本文是系列文章,隨後會更新後面的部分,共同進步~

  1. vue-router 源碼閱讀 - 文件結構與註冊機制

網上的帖子大多深淺不一,甚至有些前後矛盾,在下的文章都是學習過程中的總結,如果發現錯誤,歡迎留言指出~

推介閱讀:

  1. H5 History Api - MDN
  2. ECMAScript 6 入門 - 阮一峯
  3. JS 靜態類型檢查工具 Flow - SegmentFault 思否
  4. JS 外觀模式 - SegmentFault 思否
  5. 前端路由跳轉基本原理 - 掘金

參考:

  1. Vue.js 技術揭祕
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章