重写Vue-Router进阶

149 阅读7分钟

对官方文档的总结,官网关于routerLink的文档抽象,故自己重新总结

导航守卫

全局前置守卫

使用 router.beforeEach 注册一个全局前置守卫:

const router = createRouter({ ... })

router.beforeEach((to, from) => {
  // ...
  // 返回 false 以取消导航
  return false
})

守卫是异步解析执行,此时导航在所有守卫 resolve 完之前一直处于等待中

每个守卫方法接收两个参数:

可以返回的值如下:

  • false: 取消当前的导航。如果浏览器的 URL 改变了(可能是用户手动或者浏览器后退按钮),那么 URL 地址会重置到 from 路由对应的地址。
  • 一个路由地址: 通过一个路由地址重定向到一个不同的地址,如同调用 router.push(),且可以传入诸如 replace: true 或 name: 'home' 之类的选项。它会中断当前的导航,同时用相同的 from 创建一个新导航。

如果什么都没有,undefined 或返回 true则导航是有效的,并调用下一个导航守卫

以上所有都同 async 函数 和 Promise 工作方式一样:

router.beforeEach(async (to, from) => {
  // canUserAccess() 返回 `true` 或 `false`
  const canAccess = await canUserAccess(to)
  if (!canAccess) return '/login'
})

不建议使用next

全局后置钩子

你也可以注册全局后置钩子,然而和守卫不同的是,这些钩子不会接受 next 函数也不会改变导航本身:

js

router.afterEach((to, from) => {
  sendToAnalytics(to.fullPath)
})

在守卫内的全局注入

从 Vue 3.3 开始,你可以在导航守卫内使用 inject() 方法。这在注入像 pinia stores 这样的全局属性时很有用。在 app.provide() 中提供的所有内容都可以在 router.beforeEach()router.beforeResolve()router.afterEach() 内获取到:

// main.ts
const app = createApp(App)
app.provide('global', 'hello injections')

// router.ts or main.ts
router.beforeEach((to, from) => {
  const global = inject('global') // 'hello injections'
  // a pinia store
  const userStore = useAuthStore()
  // ...
})

路由独享的守卫

你可以直接在路由配置上定义 beforeEnter 守卫:

const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    beforeEnter: (to, from) => {
      // reject the navigation
      return false
    },
  },
]

组件内的守卫

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'

export default {
  setup() {
    // 与 beforeRouteLeave 相同,无法访问 `this`
    onBeforeRouteLeave((to, from) => {
      const answer = window.confirm(
        'Do you really want to leave? you have unsaved changes!'
      )
      // 取消导航并停留在同一页面上
      if (!answer) return false
    })

    const userData = ref()

    // 与 beforeRouteUpdate 相同,无法访问 `this`
    onBeforeRouteUpdate(async (to, from) => {
      //仅当 id 更改时才获取用户,例如仅 query 或 hash 值已更改
      if (to.params.id !== from.params.id) {
        userData.value = await fetchUser(to.params.id)
      }
    })
  },
}

完整的导航解析流程

  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 的回调函数,创建好的组件实例会作为回调函数的参数传入。

路由元信息

它可以在路由地址和导航守卫上都被访问到

const routes = [
  {
    path: '/posts',
    component: PostsLayout,
    children: [
      {
        path: ':id',
        component: PostsDetail
        // 任何人都可以阅读文章
        meta: { requiresAuth: false },
      }
    ]
  }
]

那么如何访问这个 meta 字段呢?

个路由匹配到的所有路由记录会暴露为 $route 对象(还有在导航守卫中的路由对象)的$route.matched 数组。我们需要遍历这个数组来检查路由记录中的 meta 字段,但是 Vue Router 还为你提供了一个 $route.meta 方法,它是一个非递归合并所有 meta 字段(从父字段到子字段)的方法,如果存在同名的属性,取最后一级的属性值

TypeScript

也可以继承来自 vue-router 中的 RouteMeta 来为 meta 字段添加类型:

// 这段可以直接添加到你的任何 `.ts` 文件中,例如 `router.ts`
// 也可以添加到一个 `.d.ts` 文件中。确保这个文件包含在
// 项目的 `tsconfig.json` 中的 "file" 字段内。
import 'vue-router'

// 为了确保这个文件被当作一个模块,添加至少一个 `export` 声明
export {}

declare module 'vue-router' {
  interface RouteMeta {
    // 是可选的
    isAdmin?: boolean
    // 每个路由都必须声明
    requiresAuth: boolean
  }
}

RouterView 插槽

<router-view v-slot="{ Component }">
  <transition>
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </transition>
</router-view>

模板引用

使用插槽可以让我们直接将模板引用放置在路由组件上:

<router-view v-slot="{ Component }">
  <component :is="Component" ref="mainContent" />
</router-view>

而如果我们将引用放在 <router-view> 上,那引用将会被 RouterView 的实例填充,而不是路由组件本身。

过渡动效

基于路由的动态过渡

也可以根据目标路由和当前路由之间的关系,动态地确定使用的过渡。使用和刚才非常相似的片段:

<!-- 使用动态过渡名称 -->
<router-view v-slot="{ Component, route }">
  <transition :name="route.meta.transition">
    <component :is="Component" />
  </transition>
</router-view>

我们可以添加一个 after navigation hook,根据路径的深度动态添加信息到 meta 字段。

router.afterEach((to, from) => {
  const toDepth = to.path.split('/').length
  const fromDepth = from.path.split('/').length
  to.meta.transition = toDepth < fromDepth ? 'slide-right' : 'slide-left'
})

目前发现在路由构子里修改meta成功,可以拿到,其他位置修改meta没有验证过

强制在复用的视图之间进行过渡

Vue 可能会自动复用看起来相似的组件,从而忽略了任何过渡。幸运的是,可以添加一个 key 属性来强制过渡。这也允许你在相同路由上使用不同的参数触发过渡:

<router-view v-slot="{ Component, route }">
  <transition name="fade">
    <component :is="Component" :key="route.path" />
  </transition>
</router-view>

滚动行为

使用前端路由,当切换到新路由时,想要页面滚到顶部,或者是保持原先的滚动位置,就像重新加载页面那样

注意: 这个功能只在支持 history.pushState 的浏览器中可用。

scrollBehavior 函数接收 to from 路由对象,如 Navigation Guards。第三个参数 savedPosition,只有当这是一个 popstate 导航时才可用(由浏览器的后退/前进按钮触发)。

该函数可以返回一个 ScrollToOptions 位置对象:

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    // 始终滚动到顶部
    return { top: 0 }
  },
})

你也可以通过 el 传递一个 CSS 选择器或一个 DOM 元素。在这种情况下,top 和 left 将被视为该元素的相对偏移量。

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    // 始终在元素 #main 上方滚动 10px
    return {
      // 也可以这么写
      // el: document.getElementById('main'),
      el: '#main',
      // 在元素上 10 像素
      top: 10,
    }
  },
})

如果返回一个 falsy 的值,或者是一个空对象,那么不会发生滚动。

返回 savedPosition,在按下 后退/前进 按钮时,就会像浏览器的原生表现那样:也就是定位到上次离开时的位置

js

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  },
})

如果你要模拟 “滚动到锚点” 的行为:

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    if (to.hash) {
      return {
        el: to.hash,
      }
    }
  },
})

如果你的浏览器支持滚动行为,你可以让它变得更流畅:

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',
      }
    }
  }
})

延迟滚动

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve({ left: 0, top: 0 })
      }, 500)
    })
  },
})

把组件按组分块(懒加载)

使用 webpack

有时候我们想把某个路由下的所有组件都打包在同个异步块 (chunk) 中。只需要使用命名 chunk,一个特殊的注释语法来提供 chunk name (需要 Webpack > 2.4):

js

const UserDetails = () =>
  import(/* webpackChunkName: "group-user" */ './UserDetails.vue')
const UserDashboard = () =>
  import(/* webpackChunkName: "group-user" */ './UserDashboard.vue')
const UserProfileEdit = () =>
  import(/* webpackChunkName: "group-user" */ './UserProfileEdit.vue')

webpack 会将任何一个异步模块与相同的块名称组合到相同的异步块中。

等待导航结果

router.push('/my-profile')
this.isMenuOpen = false

但是这样做会马上关闭菜单,因为 导航是异步的,我们需要 await router.push 返回的 promise :

await router.push('/my-profile')
this.isMenuOpen = false

检测导航故障

如果导航被阻止,导致用户停留在同一个页面上,由 router.push 返回的 Promise 的解析值将是 Navigation Failure。否则,它将是一个 falsy 值(通常是 undefined)。这样我们就可以区分我们导航是否离开了当前位置

 const navigationResult = await router.push('/quotaManage/myQuota')
  
 console.log(isNavigationFailure(navigationResult, NavigationFailureType.aborted))

Navigation Failure 是带有一些额外属性的 Error 实例,这些属性为我们提供了足够的信息,让我们知道哪些导航被阻止了以及为什么被阻止了。要检查导航结果的性质,请使用 isNavigationFailure 函数:

NavigationFailureType里的类型包括

  • aborted 中断的导航是因为导航守卫返回 false 会调用了 next(false) 而导致失败的导航。
  • cancelled 如果之前发起的导航请求还未完成,而新的导航请求已经开始,那么之前的导航请求就会被取消,比如,在等待导航守卫的过程中又调用了 router.push
  • duplicated 导航被阻止,因为我们已经在目标位置了。

如果你忽略第二个参数: isNavigationFailure(failure),那么就只会检查这个 failure 是不是一个 Navigation Failure

导航故障的属性

所有的导航失败都会暴露 to 和 from 属性,以反映失败导航的当前位置和目标位置:

// 正在尝试访问 admin 页面
router.push('/admin').then(failure => {
  if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
    failure.to.path // '/admin'
    failure.from.path // '/'
  }
})

动态路由

动态路由主要通过两个函数实现。router.addRoute() 和 router.removeRoute()

如果新增加的路由与当前位置相匹配,就需要你用 router.push() 或 router.replace() 来手动导航,才能显示该新路由。

想象一下,只有一个路由的以下路由:

const router = createRouter({
  history: createWebHistory(),
  routes: [{ path: '/:articleName', component: Article }],
})

进入任何页面,/about/store,或者 /3-tricks-to-improve-your-routing-code 最终都会呈现 Article 组件。如果我们在 /about 上添加一个新的路由:

router.addRoute({ path: '/about', component: About })

页面仍然会显示 Article 组件,我们需要手动调用 router.replace() 来改变当前的位置,并覆盖我们原来的位置(而不是添加一个新的路由,最后在我们的历史中两次出现在同一个位置):

router.addRoute({ path: '/about', component: About })
// 我们也可以使用 this.$route 或 route = useRoute() (在 setup 中)
router.replace(router.currentRoute.value.fullPath)

在导航守卫中添加路由

如果你决定在导航守卫内部添加或删除路由,你不应该调用 router.replace(),而是通过返回新的位置来触发重定向:

router.beforeEach(to => {
  if (!hasNecessaryRoute(to)) {
    router.addRoute(generateRoute(to))
    // 触发重定向
    return to.fullPath
  }
})

上面的例子有两个假设:第一,新添加的路由记录将与 to 位置相匹配,实际上导致与我们试图访问的位置不同。第二,hasNecessaryRoute() 在添加新的路由后返回 false,以避免无限重定向。

因为是在重定向中,所以我们是在替换将要跳转的导航,实际上行为就像之前的例子一样。而在实际场景中,添加路由的行为更有可能发生在导航守卫之外,例如,当一个视图组件挂载时,它会注册新的路由。

删除路由

有几个不同的方法来删除现有的路由:

  • 通过添加一个名称冲突的路由。如果添加与现有途径名称相同的途径,会先删除路由,再添加路由:

    js

    router.addRoute({ path: '/about', name: 'about', component: About })
    // 这将会删除之前已经添加的路由,因为他们具有相同的名字且名字必须是唯一的
    router.addRoute({ path: '/other', name: 'about', component: Other })
    
  • 通过调用 router.addRoute() 返回的回调:

    const removeRoute = router.addRoute(routeRecord)
    removeRoute() // 删除路由如果存在的话
    

    当路由没有名称时,这很有用。

  • 通过使用 router.removeRoute() 按名称删除路由:

    router.addRoute({ path: '/about', name: 'about', component: About })
    // 删除路由
    router.removeRoute('about')
    

    需要注意的是,如果你想使用这个功能,但又想避免名字的冲突,可以在路由中使用 Symbol 作为名字。

当路由被删除时,所有的别名和子路由也会被同时删除

添加嵌套路由

要将嵌套路由添加到现有的路由中,可以将路由的 name 作为第一个参数传递给 router.addRoute(),这将有效地添加路由,就像通过 children 添加的一样:

router.addRoute({ name: 'admin', path: '/admin', component: Admin })
router.addRoute('admin', { path: 'settings', component: AdminSettings })

这等效于:

router.addRoute({
  name: 'admin',
  path: '/admin',
  component: Admin,
  children: [{ path: 'settings', component: AdminSettings }],
})

查看现有路由

Vue Router 提供了两个功能来查看现有的路由:

扩展 RouterLink

RouterLink 提供了什么

  props: {
    to: {
      type: [String, Object] as PropType<RouteLocationRaw>,
      required: true,
    },
    replace: Boolean,
    activeClass: String,
    // inactiveClass: String,
    exactActiveClass: String,
    custom: Boolean,
    ariaCurrentValue: {
      type: String as PropType<RouterLinkProps['ariaCurrentValue']>,
      default: 'page',
    },
  },

属性

activeClass

• 可选 activeClassstring

链接在匹配当前路由时被应用到 class。


ariaCurrentValue

• 可选 ariaCurrentValue"location" | "time" | "page" | "step" | "date" | "true" | "false"

链接在匹配当前路由时传入 aria-current attribute 的值。

Default Value

'page'


custom

• 可选 customboolean

RouterLink 是否应该将其内容包裹在一个 a 标签里。用于通过 v-slot 创建自定义 RouterLink。


exactActiveClass

• 可选 exactActiveClassstring

链接在严格匹配当前路由时被应用到 class。


replace

• 可选 replaceboolean

调用 router.replace 以替换 router.push


to

• toRouteLocationRaw

当点击该链接时应该进入的路由地址。

route-link到底还是一个a标签

提供了一个插槽

 const link = reactive(useLink(props))
  
 return () => {
      const children = slots.default && slots.default(link)
      return props.custom
        ? children
        : h(
            'a',
            {
              'aria-current': link.isExactActive
                ? props.ariaCurrentValue
                : null,
              href: link.href,
              // this would override user added attrs but Vue will still add
              // the listener, so we end up triggering both
              onClick: link.navigate,
              class: elClass.value,
            },
            children
          )
    }

如果设置了custom,将会取插槽的内容。并且这是一个作用域插槽。它提供useLink返回的参数。

  return {
    route,// 解析出来的路由对象
    href: computed(() => route.value.href),// 用在链接里的 href
    isActive,// 布尔类型的 ref 标识链接是否匹配当前路由
    isExactActive,// 布尔类型的 ref 标识链接是否严格匹配当前路由
    navigate,// 导航至该链接的函数
  }

唯一不好理解的是navigate,它接受一个MouseEvent,它的作用是触发了这个事件将导航到组件接受的props中的to属性。

  function navigate(
    e: MouseEvent = {} as MouseEvent
  ): Promise<void | NavigationFailure> {
    if (guardEvent(e)) {
      return router[unref(props.replace) ? 'replace' : 'push'](
        unref(props.to)
        // avoid uncaught errors are they are logged anyway
      ).catch(noop)
    }
    return Promise.resolve()
  }

示例,点击这个按钮会跳到path为/的位置,这便于二次封装


<el-button type="primary" @click="query(e)">跳转</el-button>
     
const props = defineProps({
  to: {
    type: String, // 属性类型为字符串
    default: '/' // 默认值为 '/'
  }
})
     
navigate(e)

接着可以进行router-link的封装,现在让新组件额外支持跳转外部链接

<template>
  <a v-if="isExternalLink" v-bind="$attrs" :href="to" target="_blank">
  </a>
  <router-link
    v-else
    v-bind="$props"
    custom
    v-slot="{ isActive, href, navigate }"
  >
    <a
      v-bind="$attrs"
      :href="href"
      @click="navigate"
      :class="isActive ? activeClass : inactiveClass"
    >
    </a>
  </router-link>
</template>

<script>
import { RouterLink } from 'vue-router'

export default {
  name: 'AppLink',
  inheritAttrs: false,

  props: {
    // 如果使用 TypeScript,请添加 @ts-ignore
    ...RouterLink.props,
    inactiveClass: String,
  },

  computed: {
    isExternalLink() {
      return typeof this.to === 'string' && this.to.startsWith('http')
    },
  },
}
</script>

这样新组件支持接收routerlink的所有props.并在此基础上进行了拓展。我们可以自由控制组件,对应内部的跳转只需要利用useLink,甚至不需要routerLink。