當無情面試官問 vue-next-router 帶來了哪些變化?

Leiy發表於2020-05-11

前言

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

本文基於的原始碼版本是 vue-next-router alpha.10,為了與 Vue 2.0 中的 Vue Router 區分,下文將 vue-router v3.1.6 稱為 vue2-router

本文旨在幫助更多人對新版本 Router 有一個初步的瞭解,如果文中有誤導大家的地方,歡迎留言指正。

重大改進

此次 Vue 的重大改進隨之而來帶來了 Vue Router 的一系列改進,現階段(alpha.10)相比 vue2-router 的主要變化,總結如下:

1. 構建選項 mode

由原來的 mode: "history" 更改為 history: createWebHistory()。(設定其他 mode 也是同樣的方式)。

// vue2-router
const router = new VueRouter({
  mode: 'history',
  ...
})

// vue-next-router
import { createRouter, createWebHistory } from 'vue-next-router'
const router = createRouter({
  history: createWebHistory(),
  ...
})

2. 構建選項 base

傳給 createWebHistory()(和其他模式) 的第一個引數作為 base

//vue2-router
const router = new VueRouter({
  base: __dirname,
})

// vue-next-router
import { createRouter, createWebHistory } from 'vue-next-router'
const router = createRouter({
  history: createWebHistory('/'),
  ...
})

4. 捕獲所有路由 ( /* ) 時,現在必須使用帶有自定義正規表示式的引數進行定義:/:catchAll(.*)。

// vue2-router
const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: '/user/:a*' },
  ],
})


// vue-next-router
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/user/:a:catchAll(.*)', component: component },
  ],
})

當路由為 /user/a/b 時,捕獲到的 params{"a": "a", "catchAll": "/b"}

5. router.matchrouter.resolve 合併在一起為 router.resolve,但簽名略有不同。

// vue2-router
...
resolve ( to: RawLocation, current?: Route, append?: boolean) {
  ...
  return {
    location,
    route,
    href,
    normalizedTo: location,
    resolved: route
  }
}

// vue-next-router
function resolve(
    rawLocation: Readonly<RouteLocationRaw>,
    currentLocation?: Readonly<RouteLocationNormalizedLoaded>
  ): RouteLocation & { href: string } {
  ...
  let matchedRoute = matcher.resolve(matcherLocation, currentLocation)
  ...
  return {
    fullPath,
    hash,
    query: normalizeQuery(rawLocation.query),
    ...matchedRoute,
    redirectedFrom: undefined,
    href: routerHistory.base + fullPath,
  }
}

6. 刪除 router.getMatchedComponents,可以從 router.currentRoute.value.matched 中獲取。

router.getMatchedComponents 返回目標位置或是當前路由匹配的元件陣列 (是陣列的定義/構造類,不是例項)。通常在服務端渲染的資料預載入時使用。
[{
  aliasOf: undefined
  beforeEnter: undefined
  children: []
  components: {default: {…}, other: {…}}
  instances: {default: null, other: Proxy}
  leaveGuards: []
  meta: {}
  name: undefined
  path: "/"
  props: ƒ (to)
  updateGuards: []
}]

7. 如果使用 <transition>,則可能需要等待 router 準備就緒才能掛載應用程式。

app.use(router)
// Note: on Server Side, you need to manually push the initial location
router.isReady().then(() => app.mount('#app'))

一般情況下,正常掛載也是可以使用 <transition> 的,但是現在導航都是非同步的,如果在路由初始化時有路由守衛,則在 resolve 之前會出現一個初始渲染的過渡,就像給 <transiton> 提供一個 appear 一樣。

8. 在服務端渲染 (SSR) 中,需要使用一個三目運算子手動傳遞合適的 mode

let history = isServer ? createMemoryHistory() : createWebHistory()
let router = createRouter({ routes, history })
// on server only
router.push(req.url) // request url
router.isReady().then(() => {
  // resolve the request
})

9. push 或者 resolve 一個不存在的命名路由時,將會引發錯誤,而不是導航到根路由 "/" 並且不顯示任何內容。

vue2-router 中,當 push 一個不存在的命名路由時,路由會導航到根路由 "/" 下,並且不會渲染任何內容。

const router = new VueRouter({
  mode: 'history',
  routes: [{ path: '/', name: 'foo', component: Foo }]
}
this.$router.push({name: 'baz'})

瀏覽器控制檯只會提示如下警告,並且 url 會跳轉到根路由 / 下。

vue-next-router 中,同樣做法會引發錯誤。

const router = createRouter({
  history: routerHistory(),
  routes: [{ path: '/', name: 'foo', component: Foo }]
})
...
import { useRouter } from 'vue-next-router'
...
const router = userRouter()
router.push({name: 'baz'})) // 這段程式碼會報錯

Active-RFCS

以下內容的改進來自 active-rfcsactive 就是已經討論通過並且正在實施的特性)。

  • 0021-router-link-scoped-slot
  • 0022-router-merge-meta-routelocation
  • 0028-router-active-link
  • 0029-router-dynamic-routing
  • 0033-router-navigation-failures - 本文略

router-link-scoped-slot

這個 rfc 主要提議及改進如下:

  • 刪除 tag prop - 使用作用域插槽代替
  • 刪除 event prop - 使用作用域插槽代替
  • 增加 scoped-slot API
  • 停止自動將 click 事件分配給內部錨點
  • 新增 custom prop 以完全支援自定義的 router-link 渲染

在 vue2-router 中,想要將 <roter-link> 渲染成某種標籤,例如 <button>,需要這麼做:

<router-link to="/" tag="button">按鈕</router-link>
!-- 渲染結果 -->
<button>按鈕</button>

根據此次 rfc,以後可能需要這樣做:

<router-link to="/" custom v-slot="{ navigate, isActive, isExactActive }">
  <button role="link" @click="navigate" :class="{ active: isActive, 'exact-active': isExactActive }">
    按鈕
  </button>
<router-link>
!-- 渲染結果 -->
<button role="link">按鈕</button>

更多詳細的介紹請看這個 rfc

router-active-link

這個 rfc 改進的緣由是 gayhub 上名為 zamakkat 的大哥提出來的,他的 issues 主要內容是,有一個巢狀元件,像這樣:

Foo (links to /pages/foo)
|-- Bar (links to /pages/foo/bar)

需求:需要突出顯示當前選中的頁面(並且只能突出顯示一項)。

  • 如果使用者開啟 /pages/foo,則僅 Foo 高亮顯示。
  • 如果使用者開啟 /pages/foo/bar,則僅 Bar 應高亮顯示。

但是,Bar 頁面也有分頁,選擇第二頁時,會導航到 /pages/foo/bar?page=2vue2-router 預設情況下,路由匹配規則是「包含匹配」。也就是說,當前的路徑是 /pages 開頭的,那麼 <router-link to="/pages/*"> 都會被設定 CSS 類名。

在這個示例中,如果使用「精確匹配模式」(exact: true),則精確匹配將匹配 /pages/foo/bar,不會匹配 /pages/foo/bar?page=2 因為它在比較中包括查詢引數 ?page=2,所以當選擇第二頁面時,Bar 就不高亮顯示了。

所以無論是「精確匹配」還是「包含匹配」都不能滿足此需求。

為了解決上述問題和其他邊界情況,此次改進使得 router-link-active 應用方式更嚴謹,處理此問題的核心:

// 確認路由 isActive 的行為
function includesParams(
  outer: RouteLocation['params'],
  inner: RouteLocation['params']
): boolean {
  for (let key in inner) {
    let innerValue = inner[key]
    let outerValue = outer[key]
    if (typeof innerValue === 'string') {
      if (innerValue !== outerValue) return false
    } else {
      if (
        !Array.isArray(outerValue) ||
        outerValue.length !== innerValue.length ||
        innerValue.some((value, i) => value !== outerValue[i])
      )
        return false
    }
  }
  return true
}

詳情請參見這個 rfc

router-merge-meta-routelocation

vue2-router中,在處理巢狀路由時,meta 僅包含匹配位置的 route meta 資訊。 看個栗子:

{
  path: '/parent',
  meta: { nested: true },
  children: [
    { path: 'foo', meta: { nested: true } },
    { path: 'bar' }
  ]
}

在導航到 /parent/bar 時,只會顯示當前路由對應的 meta 資訊為 {},不會顯示父級的 meta 資訊。

meta: {}

所以在這種情況下,需要通過 to.matched.some() 檢查 meta 欄位是否存在,而進行下一步邏輯。

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.nested))
    next('/login')
  else next()
})

因此為了避免使用額外的 to.matched.some, 這個 rfc 提議,將父子路由中的 meta 進行第一層合併(同 Object.assing())。如果再遇到上述巢狀路由時,將可以直接通過 to.meta 獲取資訊。

router.beforeEach((to, from, next) => {
  if (to.meta.nested) next('/login')
  else next()
})

更多詳細介紹請看這個 rfc

router-dynamic-routing

這個 rfc 的主要內容是,允許給 Router 新增和刪除(單個)路由規則。

  • router.addRoute(route: RouteRecord) - 新增路由規則
  • router.removeRoute(name: string | symbol) - 刪除路由規則
  • router.hasRoute(name: string | symbol): boolean - 檢查路由是否存在
  • router.getRoutes(): RouteRecord[] - 獲取當前路由規則的列表

相比 vue2-router 刪除了動態新增多個路由規則的 router.addRoutes API。

在 Vue 2.0 中,給路由動態新增多個路由規則時,需要這麼做:

router.addRoutes(  
 [  
   { path: '/d', component: Home },  
   { path: '/b', component: Home }  
 ]  
)

而在 Vue 3.0 中,需要使用 router.addRoute() 單個新增記錄,並且還可以使用更豐富的 API:

router.addRoute({  
 path: '/new-route',  
 name: 'NewRoute',  
 component: NewRoute  
})  
​  
// 給現有路由新增子路由  
router.addRoute('ParentRoute', {  
 path: 'new-route',  
 name: 'NewRoute',  
 component: NewRoute  
})  
// 根據路由名稱刪除路由  
router.removeRoute('NewRoute')  
​  
// 獲得路由的所有記錄  
const routeRecords \= router.getRoutes()

關於 RfCS 上提出的改進,這裡就介紹這麼多,想了解更多的話,請移步到 active-rfcs

走進原始碼

相比 vue2-routerES6-class 的寫法 vue-next-routerfunction-to-function 的編寫更易讀也更容易維護。

Router 的 install

暴露的 Vue 元件解析入口相對來說更清晰,開發外掛時定義的 install 也簡化了許多。

我們現看下 vue2-router 原始碼中 install 方法的定義:

import View from './components/view'
import Link from './components/link'
export let _Vue
export function install (Vue) {
  // 當 install 方法被同一個外掛多次呼叫,外掛將只會被安裝一次。
  if (install.installed && _Vue === Vue) return
  install.installed = true
  _Vue = Vue
  const isDef = v => v !== undefined
  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }
  // 將 router 全域性註冊混入,影響註冊之後所有建立的每個 Vue 例項
  Vue.mixin({
    beforeCreate () {
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        this._router = this.$options.router
        this._router.init(this)
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      // 註冊例項,將 this 傳入
      registerInstance(this, this)
    },
    destroyed () {
      registerInstance(this)
    }
  })
  // 將 $router 繫結的 vue 原型物件上
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

  // 將 $route 手動繫結到 vue 原型物件上
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })
  // 註冊全域性元件 RouterView、RouterLink
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)

  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

我們可以看到,在 2.0 中,Router 提供的 install() 方法中更觸碰底層,需要用到選項的私有方法 _parentVnode(),還會用的 Vue.mixin() 進行全域性混入,之後會手動將 $router$route 繫結到 Vue 的原型物件上。

VueRouter.install = install
VueRouter.version = '__VERSION__'

// 以 src 方法匯入
if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}

做了這麼多事情之後,然後會在定義 VueRouter 類的檔案中,將 install() 方法繫結到 VueRouter 的靜態屬性 install 上,以符合外掛的標準。

安裝 Vue.js 外掛。如果外掛是一個物件,必須提供 install 方法。如果外掛是一個函式,它會被作為 install 方法。install 方法呼叫時,會將 Vue 作為引數傳入。

我們可以看到,在 2.0 中開發一個外掛需要做的事情很多,install 要處理很多事情,這對不瞭解 Vue 的童鞋,會變得很困難。

說了這麼多,那麼 vue-next-router 中暴露的 install 是什麼樣的呢? applyRouterPlugin() 方法就是處理 install() 全部邏輯的地方,請看原始碼:

import { App, ComputedRef, reactive, computed } from 'vue'
import { Router } from './router'
import { RouterLink } from './RouterLink'
import { RouterView } from './RouterView'

export function applyRouterPlugin(app: App, router: Router) {
  // 全域性註冊元件 RouterLink、RouterView
  app.component('RouterLink', RouterLink)
  app.component('RouterView', RouterView)
  //省略部分程式碼
  // 注入 Router 例項,原始碼其他地方會用到
  app.provide(routerKey, router)
  app.provide(routeLocationKey, reactive(reactiveRoute))
}

基於 3.0 使用 composition API 時,沒有 this 也沒有混入,外掛將充分利用 provideinject 對外暴露一個組合函式即可,當然,沒了 this 之後也有不好的地方,看這裡

provideinject 這對選項需要一起使用,以允許一個祖先元件向其所有子孫後代注入一個依賴,不論元件層次有多深,並在起上下游關係成立的時間裡始終生效。

再來看下 vue-next-routerinstall() 是什麼樣的:

export function createRouter(options: RouterOptions): Router {
  // 省略大部分程式碼
  const router: Router = {
    currentRoute,
    addRoute,
    removeRoute,
    hasRoute,
    history: routerHistory,
    ...
    // install
    install(app: App) {
      applyRouterPlugin(app, this)
    },
  }
  return router
}

很簡單,在 vue-next-router 提供的 install() 方法中呼叫 applyRouterPlugin 將 Vue 和 Router 作為引數傳入。

最後在應用程式中使用 Router 時,只需要匯入 createRouter 然後顯示呼叫 use() 方法,傳入 Vue,就可以在程式中正常使用了。

import { createRouter, createWebHistory } from 'vue-next-router'
const router = createRouter({
  history: createWebHistory(),
  strict: true,
  routes: [
    { path: '/home', redirect: '/' }
})

const app = createApp(App)
app.use(router)

沒有全域性 $router$route

我們知道在 vue2-router 中,通過在 Vue 根例項的 router 配置傳入 router 例項,下面這些屬性成員會被注入到每個子元件。

  • this.$router - router 例項。
  • this.$route - 當前啟用的路由資訊物件。

但是 3.0 中,沒有 this,也就不存在在 this.$router | $route 這樣的屬性,那麼在 3.0 中應該如何使用這些屬性呢?

我們首先看下原始碼暴露的 api 的地方:

// useApi.ts
import { inject } from 'vue'
import { routerKey, routeLocationKey } from './injectionSymbols'
import { Router } from './router'
import { RouteLocationNormalizedLoaded } from './types'

// 匯出 useRouter
export function useRouter(): Router {
  // 注入 router Router (key 與 上文的 provide 對應)
  return inject(routerKey)!
}
// 匯入 useRoute
export function useRoute(): RouteLocationNormalizedLoaded {
  // 注入 路由物件資訊 (key 與 上文的 provide 對應)
  return inject(routeLocationKey)!
}

原始碼中,useRouteruseRoute 通過 inject 注入物件例項,並以單個函式的方式暴露出去。

在應用程式中只需要通過命名匯入的方式匯入即可使用。

import { useRoute, useRouter } from 'vue-next-router'
...
setup() {
  const route = useRoute()
  const router = useRouter()
  ...
  // router -> this.$router
  // route > this.$route
  router.push('/foo')
  console.log(route) // 路由物件資訊
}

除了可以命名匯入 useRouteruseRoute 之外,還可暴露出很多函式,以更好的支援 tree-shaking(期待新版本的釋出吧)。

NavigationFailureType
RouterLink
RouterView
createMemoryHistory
createRouter
createWebHashHistory
createWebHistory
onBeforeRouteLeave
onBeforeRouteUpdate
parseQuery
stringifyQuery
useLink
useRoute
useRouter
...

最後

我想,就介紹這麼多吧,上文介紹到的只是改進的一部分,感覺還有很多很多東西需要我們去了解和掌握,新版本給我們帶來了更靈活的程式設計,讓我們共同期待 vue 3.0 到到來吧。

參考:

相關文章