深入Vue-router最佳實踐

聽聞北歌有初心發表於2020-07-10

前言

最近再刷Vue周邊生態的官方文件,因為之前的學習都是看視訊配合著文件,但主要還是通過視訊學習,所以很多知識點都沒有了解,至從上次刷了Vuex的官方文件就體會到了通讀文件的好處,學習一門技術最好的還是去看它的官方文件,這樣對於這門技術你就會了解的比較透徹,知識點也比較全面,所以在刷完Vuex文件之後寫了篇《深入Vuex最佳實踐》,然後花了兩天(上班沒時間摸魚,都是晚上學習)的時間刷完了Vue-router官方文件,所以有了這篇文章,所以後續還會一篇關於Vue-cli相關的配置文章,所以整篇文章主要從實踐角度切入,可能不會有那麼多原始碼解析(有點標題黨的味道,哈哈~?),但也會涉及到核心功能的原始碼解讀

線上卑微,如果覺得這篇文章對你有幫助的話歡迎大家點個贊?
tip: 文章首發於掘金並做了排版美化推薦掘金閱讀體驗更好 戳我跳轉

簡介

Vue-router 是 Vue.js 官方的路由管理器。它和 Vue.js 的核心深度整合,讓構建單頁面應用變得易如反掌

先來了解兩點

  • 單頁面應用(SPA)
  • 路由管理器

單頁面應用

單頁面應用程式將所有的活動侷限於一個Web頁面中,僅在該Web頁面初始化時載入相應的HTML、JavaScript 和 CSS。一旦頁面載入完成了,SPA不會因為使用者的操作而進行頁面的重新載入或跳轉。取而代之的是利用 JavaScript 動態的變換HTML的內容,從而實現UI與使用者的互動。

路由管理器

這裡的路由管理器並不是我們並時生活中的硬體路由器,這裡的路由就是單頁應用(SPA)的路徑管理器,就是為了解決Vue.js開發單頁面應用不能進行連結跳轉,我們通過路徑的的方式來管理不同的頁面

瞭解Vue-router所解決的問題之後, 我們開始學習Vue-router在專案中常用的一些功能

  • 巢狀的路由/檢視表
  • 模組化的、基於元件的路由配置
  • 路由引數、查詢、萬用字元
  • 細粒度的導航控制

起步

在開始我們先體會下Vue-router的一些功能:

  • 動態路由匹配
  • 巢狀路由
  • 宣告式/程式設計式導航
  • 命名路由/命名檢視
  • 重定向和別名
  • 路由元件傳參

tip:本文章所有例項程式碼倉庫地址在文章最後有給出

動態路由匹配

router.js

import Vue from 'vue' // 引入Vue
import Router from 'vue-router' // 引入vue-router
import Home from '@/pages/Home' //引入根目錄下的Hello.vue元件
 
// Vue全域性使用Router
Vue.use(Router)

/*
	使用 Vue.js + Vue-router構建單頁面應用, 只要通過組合元件來組成我們的應用程式, 我們引入Vue-router,只要	將元件對映到路由,告訴Vue-router在那裡渲染它們
*/

let routes = [ // 配置路由,這裡是個陣列
  { // 每一個連結都是一個物件
    path: '/', // 連結路徑
    name: 'Home', // 路由名稱,
    component: Home // 對應的元件模板
  },
  // 動態路徑引數 以冒號開頭
  { path: '/user/:username', // 動態路由
    component: () => import('../pages/User1'), // 按需載入路由對應的元件, 需要下載polyfill相容ES6語法
  },
  {   // 多段路徑引數
    path: '/user/:id/post/:post_id', // 動態路由
    component: () => import('../pages/User2'), // 按需載入路由對應的元件, 需要下載polyfill相容ES6語法
  },
]

export default new Router({
  routes
})

User1

使用者訪問 /#/user/xxx的時候展示該元件

<template>
  <div class="User1">
    User1 - 單個路徑引數
  </div>
</template>

User2

使用者訪問 /#/user/xxx/post/xxx的時候展示該元件

<template>
  <div class="User2">
    User2 - 多段路徑引數路由
  </div>
</template

那麼問題來了,我們怎麼知道使用者訪問的是那個動態引數路由呢?這個時候就要用到響應路由引數的變化

兩種方式:watch (監測變化) $route 物件, beforeRouteUpdate導航守衛

user1.vue增加下面程式碼

<template>
  <div class="User1">
    <!-- 通過router物件可以獲取到路由屬性, 這種方式耦合了,後面會講路由元件傳參的方式 -->
    User1 -{{$route.params.username}} 單個路徑引數
  </div>
</template>

<script>
export default {
  name: 'User1',
  // 偵聽route物件方式
  watch: {
    $route (to, from) {
      this.$message.success(`watch -> ${to.path}, ${from.path}`)
    },
    
  },
  // vue2.2引入的導航守衛,當路由引數發生變化呼叫
  beforeRouteUpdate (to, from, next) {
    this.$message.info(`導航守衛 -> ${to.path}, ${from.path}`)
    // 一定要呼叫next讓其繼續解析下一個管道中的路由元件
    next()
  }
}
</script>

演示

注意上面從ck->ks路由引數變化時兩種方式都監聽到了,我們可以在這兩個函式中做一些路由狀態變化時的操作

路由元件傳參

上面在<tempate>模板中通過$router.prarams.username方式獲取路由傳遞的引數已經於其對應路由形成高度耦合,限制了其靈活性, 我們可以通過props將元件和路由進行解耦

props傳遞路由元件引數有三種方式:

  • 布林模式
  • 物件模式
  • 函式模式

程式碼

router.js

import Vue from 'vue'
import Router from 'vue-router'
import home from '@/pages/Home'

Vue.use(Router)

let routes = [
  {
    path: '/',
    name: 'Home',
    component: home
  },
  {  // 動態路徑引數 以冒號開頭
    path: '/user1/:username', // 動態路由
    component: () => import('../pages/User1'),
    props: true  // 布林模式: 如果 props 被設定為 true,route.params 將會被設定為元件屬性。
  },
  { 
    path: '/user2', 
    component: () => import('../pages/User2'),
    props: {username: 'ck'} // 物件模式: 只有靜態路由才能有效, 並且引數是寫死的
  },
  {
    path: '/user3/:username', 
    component: () => import('../pages/User3'),
    // 返回了使用者url中的引數 比如 /user3?username='ck' => {username: 'ck} 最終還是以物件模式的方式返回引數
    props: (route) => ({username: route.query.username}) // 函式模式
  }
]

export default new Router({
  routes
})

User1

布林模式

<template>
  <div class="User1">
    User1 -{{username}} 
  </div>
</template>

<script>
export default {
  name: 'User1',
  props: ['username']  // 通過props獲取路由傳遞給對應元件的引數
}
</script>

User2

物件模式

<template>
  <div class="User2">
    User2 - {{username}} 
  </div>
</template>

<script>
export default {
  name: 'User2',
  props: ['username']  // 通過props獲取路由傳遞給對應元件的引數
}
</script>

User3

函式模式

<template>
  <div class="User3">
    User3 - {{username}}
  </div>
</template>

<script>
export default {
  name: 'User3',
  props: ['username']  // 通過props獲取路由傳遞給對應元件的引數
}
</script>

演示

從上面我們可以看出因為user2是靜態路由所以不支援動態引數而且其對應的路由元件傳遞過來的引數也是寫死的

巢狀路由

實際生活中的應用介面,通常由多層巢狀的元件組合而成。同樣地,URL 中各段動態路徑也按某種結構對應巢狀的各層元件,例如:

/user/ck/profile                     /user/ks/posts
+------------------+                  +-----------------+
| User             |                  | User            |
| +--------------+ |                  | +-------------+ |
| | Profile      | |  +------------>  | | Posts       | |
| |              | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+

router.js

import Vue from 'vue'
import Router from 'vue-router'
import home from '@/pages/Home'

Vue.use(Router)

let routes = [
  {
    path: '/',
    name: 'Home',
    component: home,
  },
  {
    path: '/user/:username', // 動態路由
    name: 'User',
    component: () => import('../components/User'),
    children: [
      {
       // 當 '/user/:username/profile' 匹配成功, UserProfile 會被渲染在 User 的 <router-view> 中
        path: 'profile', // 可以匹配 /user/ks/profile
        name: 'Profile',
        component: () => import('../components/Profile')
      },
      {
        path: '/user/:usrname/posts', // 這樣也可以匹配 /user/ks/posts, 但其實是將其匹配為了根元件的/user:username動態元件下的 posts
        name: 'Posts',
        component: () => import('../components/Posts')
      },
      {
        path: '',
        name: 'UserHome',
        // 當 /user/:username 匹配成功,比如 /user/ks || /user/ck
        // UserHome 會被渲染在 User 的 <router-view> 中
        component: () => import('../components/UserHome')
      },
    ]
  },
  {
    path: '/footer',
    name: 'Foo',
    component: () => import('../components/Footer')
  }
]

export default new Router({
  routes
})

演示

宣告式/程式設計式導航

宣告式 程式設計式
<router-link :to="..." replace> router.replace(...)
<template>
  <div class="home">
       <!-- 宣告式 -->
    <router-link
  	  to="footer"
      tag="button"
    >
        to footer
    </router-link>  
      
    <!-- 程式設計式 -->
    <button @click="$router.push('footer')">字串-寫路由路徑的方式</button>
    <button @click="$router.push({path: '/footer'})">物件-寫路徑的方式</button>
    <button @click="$router.push({name: 'Foo', params: {'userId': '123'}})">name和params - 寫路由名稱攜帶引數的方式</button>
    <button @click="$router.push({path: '/footer', query: {'userId': '456'}})">queyr和path - 寫路由路徑攜帶引數的方式</button>
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  name: 'home',
  data () {
    return {
    }
  },
  methods: {
  }
}
</script>
<style>
button {
  display: block;
}
</style>

  • router.push(location, onComplete?, onAbort?)
  • router.replace(location, onComplete?, onAbort?)

這兩種的方式一樣, 唯一區別在於 push會產生路由的歷史記錄, 而repalce不會產生, 這對於window中的history是一致的

<!-- router.go方法 -->
<template>
     <button @click="goForward">go(1)-前進一步</button>
    <button @click="goBack">go(-1)-後退一步</button>
    <button @click="gogogo">go(100)-前進一白步</button>   
 </template>

<script>
 export default {
    name: 'home'
  	methods: {
        goForward () {
          // 從歷史路由中前進一步相當於 window.history.forward
          this.$router.go(1);
        },
        goBack () {
              // 從歷史路由中後退一步相當於 window.history.back
              this.$router.go(-1);
        },
        gogogo () {
              // 歷史路由中沒有100步, 就啥也不幹
              this.$router.go(100);
        }
    }  
 }
</script>

演示

命名路由/命名檢視/重定向和別名

router.js

import Vue from 'vue'
import Router from 'vue-router'
import UserSettings from '@/pages/UserSettings'

Vue.use(Router)

let routes = [
  {
    path: '/',
    redirect: '/settings' // 重定向
  },
  {
    path: '/settings',
    name: 'Settings', // 命名路由
    alias: '/a', // 取別名,當url中訪問 /a -> 也是訪問的 settings元件但是路由匹配的是/a, 就相當於使用者訪問 /a一樣
    // 你也可以在頂級路由就配置命名檢視
    component: UserSettings,
    children: [
      {
        path: 'emails',
        component: () => import('../pages/UserEmails')
      }, 
      {
        path: 'profile',
        components: {
          default: () => import('../pages/UserProfile'),
          helper: () => import('../pages/UserProfilePreview')
        }
      }
    ]
  }
]

export default new Router({
  routes
})

UserSetttings

<template>
  <div class="UserSettings">
    <h1>User Settings</h1>
    <NavBar/>
    <router-view/>
    <!-- 命名檢視 -->
    <router-view name="helper"/>  
  </div>
</template>

<script>
import NavBar from '../components/NavBar'
export default {
  name: 'UserSettings',
  components: {
    NavBar
  }
}
</script>

通過上面的學習相信大家已經撐握了Vue-router在專案中所常用的功能,下面我們開始學習Vue-router的導航守衛

進階

導航守衛

“導航”表示路由正在發生改變。記住引數或查詢的改變並不會觸發進入/離開的導航守衛。你可以通過觀察 $route 物件響應路由引數的變化來應對這些變化,或使用 beforeRouteUpdate 的元件內守衛。

全域性的守衛

  • 全域性前置守衛 (router.beforeEach)
  • 全域性解析守衛 (router.breforeResolve)
  • 全域性後置鉤子 (router.afterEach) 注:這個鉤子中不存在next

路由獨享的守衛

你可以在路由配置上直接定義 beforeEnter 守衛:

const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      beforeEnter: (to, from, next) => {
        // to -> 要跳轉過去的路由資訊
        // from -> 當前的路由資訊
        // next() => 一個函式,表示解析下一個管道中的路由記錄
      }
    }
  ]
})

元件內的守衛

最後,你可以在路由元件內直接定義以下路由導航守衛:

  • beforeRouteEnter
  • beforeRouteUpdate (2.2 新增)
  • beforeRouteLeave
const Foo = {
  template: `...`,
  beforeRouteEnter (to, from, next) {
    // 在渲染該元件的對應路由被 confirm 前呼叫
    // 不!能!獲取元件例項 `this`
    // 因為當守衛執行前,元件例項還沒被建立
  },
  beforeRouteUpdate (to, from, next) {
    // 在當前路由改變,但是該元件被複用時呼叫
    // 舉例來說,對於一個帶有動態引數的路徑 /foo/:id,在 /foo/1 和 /foo/2 之間跳轉的時候,
    // 由於會渲染同樣的 Foo 元件,因此元件例項會被複用。而這個鉤子就會在這個情況下被呼叫。
    // 可以訪問元件例項 `this`
  },
  beforeRouteLeave (to, from, next) {
    // 導航離開該元件的對應路由時呼叫
    // 可以訪問元件例項 `this`
  }
}

beforeRouteEnter 守衛 不能 訪問 this,因為守衛在導航確認前被呼叫,因此即將登場的新元件還沒被建立。不過,你可以通過傳一個回撥給 next來訪問元件例項。在導航被確認的時候執行回撥,並且把元件例項作為回撥方法的引數。

beforeRouteEnter (to, from, next) {
  next(vm => {
    // 在例項建立好之後會呼叫next傳遞過去的回撥並且將例項當做引數傳遞進來,所以通過 `vm` 可以訪問元件例項
  })
}

注意 beforeRouteEnter 是支援給 next 傳遞迴調的唯一守衛。對於 beforeRouteUpdatebeforeRouteLeave 來說,this 已經可用了,所以不支援傳遞迴調,因為沒有必要了。

beforeRouteUpdate (to, from, next) {
  // just use `this`
  this.name = to.params.name
  next()
}

這個離開守衛通常用來禁止使用者在還未儲存修改前突然離開。該導航可以通過 next(false) 來取消。

beforeRouteLeave (to, from, next) {
  const answer = window.confirm('Do you really want to leave? you have unsaved changes!')
  if (answer) {
    next()
  } else {
    next(false)
  }
}

實踐

上面講了那麼多相信大家也是懵懵的,這些路由呼叫的時機是怎麼樣的,順序又是怎麼樣的,下面我們按照官方給的解釋實踐一下

完整的導航解析流程

  1. 導航被觸發。
  2. 在失活的元件裡呼叫 beforeRouteLeave 守衛。
  3. 呼叫全域性的 beforeEach 守衛。
  4. 在重用的元件裡呼叫 beforeRouteUpdate 守衛 (2.2+)。
  5. 在路由配置裡呼叫 beforeEnter
  6. 解析非同步路由元件。
  7. 在被啟用的元件裡呼叫 beforeRouteEnter
  8. 呼叫全域性的 beforeResolve 守衛 (2.5+)。
  9. 導航被確認。
  10. 呼叫全域性的 afterEach 鉤子。
  11. 觸發 DOM 更新。
  12. 用建立好的例項呼叫 beforeRouteEnter 守衛中傳給 next 的回撥函式。

router.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '@/pages/Home'
import {message} from 'ant-design-vue'

Vue.use(VueRouter)

let routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/index',
    name: 'Index',
    component: () => import('../pages/Index'),
  },
  {
    path: '/user/:id',
    name: 'User',
    props: true,
    component: () => import('../pages/User'),
    beforeEnter: (to, from, next) => {
      message.success(`路由獨享守衛[beforeEnter] -> 從${from.path} 到 ${to.path}`);
      next()
    }
  }
]

let router = new VueRouter({
  routes
})
router.beforeEach((to, from, next) => {
  message.success(`全域性前置守衛[beforeEach] -> 從${from.path} 到 ${to.path}`, 5)
  next();
})

router.beforeResolve((to, from, next) => {
  message.success(`全域性解析守衛[beforeResolve] -> 從${from.path} 到 ${to.path}`, 5)
  next();
})

router.afterEach((to, from) =>  {
  // 鉤子沒有next, 也不會改變導航本身
  message.success(`全域性後置鉤子[afterEach] -> 從${from.path} 到 ${to.path}`, 5)
})


export default router

Home.vue

<template>
  <div class="Home">
    <div class="title">Home</div>
    <a-button type="primary" @click="toIndexHanlder">
      to Index
    </a-button>
  </div>
</template>

<script>
export default {
  name: 'Home',
  beforeRouteLeave(to, from, next) {
    this.$message.success(`元件內守衛[leave] -> 從${from.path} 到 ${to.path}`, 5);
    next();
  },
  methods: {
    toIndexHanlder() {
      this.$router.push({ path: '/index' });
    },
  },
};
</script>

Index.vue

<template>
  <div class="Index">
    <div class="title">Index</div>
    <a-button class="my-btn" type="primary" @click="BackHanlder">
      返回
    </a-button>
    <a-button class="my-btn" type="primary" @click="toUserHanlder">
      toUser
    </a-button>
  </div>
</template>

<script>
export default {
  name: 'Index',
  beforeRouteLeave (to, from, next) {
    console.log(to);
    next()
  },
  methods: {
    BackHanlder () {
      this.$router.go(-1)
    },
    toUserHanlder () {
      this.$router.push({path: 'user/ck'})
    }
  }
}
</script>

User.vue

<template>
  <div class="User">
    <div class="title">User - {{id}}</div>
    <a-button class="my-btn" type="primary" @click="toUserJump">
      跳轉動態路由
    </a-button>
  </div>
</template>

<script>
export default {
  name: 'User',
  data () {
    return {
      names: ['a', 'b', 'c', 'd', 'e', 'f'], // 隨機路由
      curNameIndex: 0,
      lastNameIndex: 0,
    }
  },
  props: ['id'],
  beforeRouteUpdate (to, from, next) {
    this.$message.success(`元件內的守衛[beforeRouteUpdate] -> 從${from.path} 到 ${to.path}`, 5)
    next()
  },
  beforeRouteEnter (to, from, next) {
    // 不能獲取this, 因為當守衛執行前,元件例項還沒被建立, 
    // 但是在這個守衛中next支援傳遞迴調, 在例項建立完畢後呼叫 
    next((vm) => {
      // vm => 建立好的Vue例項
      vm.$message.success(`元件內的守衛[beforeRouteEnter] -> 從${from.path} 到 ${to.path}`, 5)
    })
  },
  methods: {
    // 獲取隨機路由的方法
   getRandomArbitrary (min, max) {
        this.curNameIndex = Math.round(Math.random() * (max - min) + min);
        return this.curNameIndex === this.lastNameIndex 
        ? this.getRandomArbitrary(min, max) 
        : this.curNameIndex;
    },
    toUserJump () {
      this.getRandomArbitrary(0, this.names.length -1)
      this.lastNameIndex = this.curNameIndex;
      this.$router.push({path: `/user/${this.names[this.curNameIndex]}`})
    }
  }
}
</script>

演示

上面動圖可能過於快了, 將其截圖下來每一步做下分析

上面標的數子是對應官方給的順序

從Home.vue跳轉到Index.vue觸發的路由守衛

    1. 點選按鈕導航被觸發
    1. 在失活的元件守衛中(Home.vue)呼叫的beforeRouterLeave, 表示離開該元件
    1. 呼叫全域性前置守衛beforeEach, 從route物件中可以獲取我們跳轉前和跳轉後的路由資訊
    1. 路由解析完畢呼叫

上面標的數子是對應官方給的順序

Index.vue跳轉到user/ck觸發的路由守衛

    1. 呼叫全域性前置守衛beforeEach
    1. 在路由配置(User.vue)中呼叫befreEnter
    1. 呼叫全域性的 afterEach 鉤子。
    1. 用建立好的例項呼叫 beforeRouteEnter 守衛中傳給 next 的回撥函式並且將建立好的例項傳遞進去了

user/ck跳轉到user/c觸發的路由守衛

    1. 因為這個元件是動態路由, 在/user/ck -> user/c重用同一個元件所以觸發beforeRoteUpdate

案列

該案列涉及到到了

  • 動態修改路由元資訊,修改文件標題
  • 基於路由的動態過渡
  • 基於導航守衛對使用者登入狀態進行攔截
  • 對於沒有定義的元件投統一返回404頁面
  • 使用路由的離開守衛判對於使用者跳轉登入頁面進行確認

戳我去GitHub倉庫地址,歡迎大家點個Start?

原始碼解讀

檢視官方vue-router 原始碼地址

vue-router 實現原理

vue-router 例項化時會初始化 this.history,傳入不同 mode 會對應不同的 history,下面來看下程式碼

constructor (options: RouterOptions = {}) {
    this.mode = mode // 不傳mode, 預設就是hash
    
    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}`)
        }
    }
}
// => 上面通過HashHistory初始化之後會得到其例項,我們呼叫的一些 push、replace、go都是this.history上的方法

這裡以 HashHistory 為例,vue-router 的 push 方法實現如下:

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.push(location, resolve, reject)
      })
    } else {
      this.history.push(location, onComplete, onAbort)
    }
}
// 在呼叫push方法之後呼叫了this.history的push方法

HashHistory 具體實現了 push 方法:

function pushHash (path) {
  if (supportsPushState) {
    pushState(getUrl(path))
  } else {
    window.location.hash = path // 本質上還是通過對window.location.hash方法進行的封裝
  }
}

對路由的監聽通過 hash 相應的事件監聽實現:

window.addEventListener(
  supportsPushState ? 'popstate' : 'hashchange',
  () => {
    const current = this.current
    if (!ensureSlash()) {
      return
    }
    this.transitionTo(getHash(), route => {
      if (supportsScroll) {
        handleScroll(this.router, route, current, true)
      }
      if (!supportsPushState) {
        replaceHash(route.fullPath)
      }
    })
  }
) 

// 對於路由的監聽也是通過監聽window物件提供的 popstate、hashchange兩個事件對於hash的監聽來做出不同的響應

所以,Vue-router最核心的也是通過History來例項相應的功能,而History是由傳遞進去的mode決定,不同的History呼叫的底層方法不一樣,但底層都是通過window.location提供的一些方法進行例項,比如hash改變就是通過hashchange這個事件監聽來支援的,所以Vue-router本質上就是對於原生事件的封裝

除此之外,vue-router 還提供了兩個元件:

Vue.component('RouterView', View)
Vue.component('RouterLink', Link)
// => 所以我們就可以在全域性上使用 <router-view> <router-link> 這兩個內建元件

寫在最後

因為是是實踐文,所以這整篇文章都是通過程式碼的方式來講的,對於一些概念性和基礎性語法的東西講的比較少。如果 這篇文章對你有幫助請點個贊?

看完兩件小事

如果你覺得我的文章對你挺有幫助,我想請你幫我兩個小忙:

  1. 關注我的 GitHub 博文,讓我們成為長期關係
  2. 關注公眾號「前端自學驛站」,所有文章、資料第一時間首發公眾號,公眾號後臺回覆「教程」 免費領取我精心整理的前端視訊教程

img