路由守卫
路由守卫用于控制导航行为,实现权限控制、登录拦截、数据预加载等功能。
概述
路由守卫主要分为三类,执行时机不同:
code
┌─────────────────────────────────────────────────────────────┐
│ 导航触发流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 触发导航 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ beforeRouteLeave │ ← 离开组件守卫 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ beforeEach │ ← 全局前置守卫 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ beforeRouteUpdate│ ← 复用组件守卫 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ beforeEnter │ ← 路由独享守卫 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ beforeRouteEnter │ ← 进入组件守卫 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ beforeResolve │ ← 全局解析守卫 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ afterEach │ ← 全局后置钩子 │
│ └─────────────────┘ │
│ │
│ 导航完成 │
│ │
└─────────────────────────────────────────────────────────────┘全局守卫
全局前置守卫 beforeEach
应用场景:登录验证、权限检查、页面访问控制
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [...]
})
// 登录验证
router.beforeEach((to, from) => {
const isAuthenticated = localStorage.getItem('token')
// 需要登录但未登录
if (to.meta.requiresAuth && !isAuthenticated) {
// 返回登录页,携带重定向地址
return {
path: '/login',
query: { redirect: to.fullPath }
}
}
// 已登录访问登录页
if (to.path === '/login' && isAuthenticated) {
return '/' // 重定向到首页
}
// 放行
return true
})返回值说明:
| 返回值 | 说明 |
|---|---|
true / undefined | 放行导航 |
false | 取消导航 |
路由地址 | 重定向到指定路由 |
全局解析守卫 beforeResolve
应用场景:在所有守卫完成后执行,适合做最终验证
js
router.beforeResolve(async (to) => {
// 确保所有路由守卫和异步组件都已解析
if (to.meta.requiresAuth) {
try {
await verifyToken() // 验证 token 有效性
} catch (error) {
return '/login'
}
}
})全局后置钩子 afterEach
应用场景:页面标题设置、进度条关闭、统计埋点
js
router.afterEach((to, from, failure) => {
// 导航失败时不执行
if (failure) return
// 设置页面标题
document.title = to.meta.title || '默认标题'
// 关闭进度条
NProgress.done()
// 页面统计
if (window._hmt) {
window._hmt.push(['_trackPageview', to.fullPath])
}
})路由独享守卫 beforeEnter
应用场景:特定路由的权限控制
js
const routes = [
{
path: '/admin',
component: Admin,
meta: { requiresAdmin: true },
beforeEnter: (to, from) => {
const userRole = getUserRole()
if (userRole !== 'admin') {
// 可返回 false 取消导航
return { name: 'Forbidden', params: { code: 403 } }
}
return true
}
},
{
path: '/dashboard',
component: Dashboard,
// 支持数组形式
beforeEnter: [checkAuth, checkPermission]
}
]
// 可复用的守卫函数
function checkAuth(to, from) {
if (!isAuthenticated()) {
return '/login'
}
}
function checkPermission(to, from) {
if (!hasPermission(to.meta.permission)) {
return '/403'
}
}组件内守卫
组合式 API 写法
Vue SFC
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
const hasUnsavedChanges = ref(false)
// 离开前确认(防止数据丢失)
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = window.confirm('有未保存的更改,确定离开吗?')
if (!answer) return false
}
return true
})
// 路由参数更新时重新获取数据
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
await fetchData(to.params.id)
}
return true
})
</script>选项式 API 写法
Vue SFC
<script>
export default {
data() {
return {
hasUnsavedChanges: false
}
},
// 离开前确认
beforeRouteLeave(to, from, next) {
if (this.hasUnsavedChanges) {
const answer = window.confirm('有未保存的更改,确定离开吗?')
if (answer) next()
else next(false)
} else {
next()
}
},
// 路由参数更新
async beforeRouteUpdate(to, from, next) {
if (to.params.id !== from.params.id) {
await this.fetchData(to.params.id)
}
next()
}
}
</script>完整示例:权限控制系统
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { guest: true }
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 白名单路由
const whiteList = ['/login', '/register', '/404', '/403']
// 全局前置守卫
router.beforeEach(async (to, from) => {
// 开启进度条
NProgress.start()
const userStore = useUserStore()
const hasToken = userStore.token
if (hasToken) {
// 已登录
if (to.path === '/login') {
// 已登录访问登录页,重定向到首页
return { path: '/' }
} else {
// 检查是否已获取用户信息
if (userStore.roles.length === 0) {
try {
await userStore.getUserInfo()
} catch (error) {
// 获取用户信息失败,清除 token
userStore.logout()
return `/login?redirect=${to.path}`
}
}
// 权限检查
if (to.meta.requiresAdmin && !userStore.isAdmin) {
return '/403'
}
return true
}
} else {
// 未登录
if (whiteList.includes(to.path) || to.meta.guest) {
return true
} else {
return `/login?redirect=${to.path}`
}
}
})
// 全局后置钩子
router.afterEach((to) => {
NProgress.done()
document.title = to.meta.title || 'My App'
})
export default router守卫参数详解
to 和 from 对象
ts
interface RouteLocationNormalized {
path: string // 解析后的路径
name: string | null // 路由名称
params: Record<string, string> // 路径参数
query: Record<string, string> // 查询参数
hash: string // URL hash
fullPath: string // 完整路径
matched: RouteRecord[] // 匹配的路由记录
meta: Record<string, any> // 路由元信息
redirectedFrom: string // 重定向来源
}使用示例
js
router.beforeEach((to, from) => {
// 访问路径参数
console.log(to.params.id)
// 访问查询参数
console.log(to.query.page)
// 访问路由元信息
if (to.meta.requiresAuth) {
// 需要登录
}
// 判断是否来自特定路由
if (from.name === 'Login') {
console.log('从登录页跳转')
}
// 判断是否是子路由
const parentRoute = to.matched.find(r => r.meta.requiresAuth)
})常见问题
1. 无限重定向循环
问题:守卫逻辑导致路由不断重定向
解决方案:
js
router.beforeEach((to, from) => {
// 添加条件判断,避免循环
if (to.path === '/login') return true
if (!isAuthenticated()) {
return '/login'
}
})2. 组件内守卫不触发
问题:onBeforeRouteUpdate 不触发
原因:相同路由参数变化时,组件会被复用
解决方案:
Vue SFC
<script setup>
import { watch } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
const route = useRoute()
// 方式1: 监听路由参数变化
watch(() => route.params.id, (newId) => {
fetchData(newId)
})
// 方式2: 使用 onBeforeRouteUpdate
onBeforeRouteUpdate((to) => {
fetchData(to.params.id)
return true
})
</script>3. 异步守卫处理
js
router.beforeEach(async (to, from) => {
// 异步操作
const isValid = await checkToken()
if (!isValid && to.meta.requiresAuth) {
return '/login'
}
return true
})最佳实践
1. 守卫逻辑抽离
js
// guards/auth.js
export function authGuard(to, from) {
if (to.meta.requiresAuth && !isAuthenticated()) {
return '/login'
}
}
export function permissionGuard(to, from) {
const requiredPermission = to.meta.permission
if (requiredPermission && !hasPermission(requiredPermission)) {
return '/403'
}
}
// router/index.js
import { authGuard, permissionGuard } from '@/guards'
router.beforeEach(authGuard)
router.beforeEach(permissionGuard)2. 使用路由元信息
js
const routes = [
{
path: '/admin',
component: Admin,
meta: {
requiresAuth: true,
title: '管理后台',
roles: ['admin'],
breadcrumb: ['首页', '管理']
}
}
]
// 守卫中使用
router.beforeEach((to) => {
// 设置标题
document.title = to.meta.title || '默认标题'
// 角色检查
const roles = to.meta.roles
if (roles && !roles.includes(currentUser.role)) {
return '/403'
}
})3. 进度条集成
js
// 使用 NProgress
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
router.beforeEach(() => {
NProgress.start()
})
router.afterEach(() => {
NProgress.done()
})下一步
- 路由懒加载 - 学习懒加载优化性能
路由懒加载
路由懒加载通过按需加载组件,显著优化应用首屏加载性能。
概述
code
┌────────────────────────────────────────────────────────────────┐
│ 路由加载方式对比 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 同步加载 (初始加载所有) 懒加载 (按需加载) │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ app.js (1MB) │ │ app.js (200KB) │ │
│ │ ┌─────────────────┐ │ │ ┌─────────────────┐ │ │
│ │ │ Home │ │ │ │ Home │ │ │
│ │ │ About │ │ │ └─────────────────┘ │ │
│ │ │ User │ │ │ │ │
│ │ │ Settings │ │ │ 按需加载: │ │
│ │ │ ... │ │ │ about.js (50KB) │ │
│ │ └─────────────────┘ │ │ user.js (80KB) │ │
│ └─────────────────────┘ │ settings.js (30KB) │ │
│ └─────────────────────┘ │
│ │
│ 首屏加载: 1MB 首屏加载: 200KB │
│ 首屏时间: 长 首屏时间: 短 │
│ │
└────────────────────────────────────────────────────────────────┘基本用法
静态导入 vs 动态导入
js
// 静态导入:打包到主包
import Home from '../views/Home.vue'
// 动态导入:懒加载,分离到独立 chunk
const Home = () => import('../views/Home.vue')路由配置
js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue')
},
{
path: '/about',
name: 'About',
component: () => import('../views/About.vue')
},
{
path: '/user/:id',
name: 'User',
component: () => import('../views/User.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})分组打包(代码分割)
Webpack 魔法注释
js
const routes = [
// 单个 chunk
{
path: '/about',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
},
// 分组打包:多个路由合并到同一个 chunk
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue'),
children: [
{
path: 'users',
component: () => import(/* webpackChunkName: "admin" */ '../views/AdminUsers.vue')
},
{
path: 'settings',
component: () => import(/* webpackChunkName: "admin" */ '../views/AdminSettings.vue')
}
]
}
]Vite 动态导入
js
// Vite 使用相同的语法
const routes = [
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue')
}
]
// Vite 也支持 glob 导入
const modules = import.meta.glob('../views/**/*.vue')
const routes = Object.entries(modules).map(([path, module]) => {
const name = path.match(/\/views\/(.*)\.vue$/)[1]
return {
path: `/${name.toLowerCase()}`,
component: module
}
})按功能模块分组
js
// 用户模块
const userRoutes = [
{
path: '/users',
component: () => import(/* webpackChunkName: "user" */ '@/views/users/List.vue')
},
{
path: '/users/:id',
component: () => import(/* webpackChunkName: "user" */ '@/views/users/Detail.vue')
},
{
path: '/users/:id/edit',
component: () => import(/* webpackChunkName: "user" */ '@/views/users/Edit.vue')
}
]
// 管理模块
const adminRoutes = [
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '@/views/admin/Layout.vue'),
children: [
{
path: 'dashboard',
component: () => import(/* webpackChunkName: "admin" */ '@/views/admin/Dashboard.vue')
},
{
path: 'settings',
component: () => import(/* webpackChunkName: "admin" */ '@/views/admin/Settings.vue')
}
]
}
]加载状态处理
使用异步组件
Vue SFC
<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import ErrorComponent from '@/components/ErrorComponent.vue'
// 带加载状态的异步组件
const AsyncComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorComponent,
delay: 200, // 延迟显示加载组件
timeout: 10000, // 超时时间
suspensible: false // 不使用 Suspense
})
</script>
<template>
<AsyncComponent />
</template>配合 router-view
Vue SFC
<template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>配合 Suspense
Vue SFC
<template>
<router-view v-slot="{ Component }">
<Suspense>
<template #default>
<component :is="Component" />
</template>
<template #fallback>
<div class="loading">
<LoadingSpinner />
<p>页面加载中...</p>
</div>
</template>
</Suspense>
</router-view>
</template>自定义加载组件
Vue SFC
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<div :key="route.path">
<component :is="Component" v-if="!loading" />
<LoadingSpinner v-else />
</div>
</transition>
</router-view>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const loading = ref(false)
router.beforeEach(() => {
loading.value = true
})
router.afterEach(() => {
loading.value = false
})
</script>预加载策略
路由级预加载
js
// 在特定路由预加载其他路由
router.beforeEach((to) => {
// 访问首页时预加载用户页面
if (to.name === 'Home') {
import('@/views/User.vue')
}
// 访问列表页时预加载详情页
if (to.name === 'UserList') {
import('@/views/UserDetail.vue')
}
})鼠标悬停预加载
Vue SFC
<template>
<router-link
to="/user/123"
@mouseenter="preloadUser"
>
用户详情
</router-link>
</template>
<script setup>
function preloadUser() {
// 鼠标悬停时预加载
import('@/views/User.vue')
}
</script>Webpack Prefetch
js
// Webpack 4+ 默认开启 prefetch
// 在页面空闲时预加载
const routes = [
{
path: '/user/:id',
component: () => import(
/* webpackChunkName: "user" */
/* webpackPrefetch: true */
'@/views/User.vue'
)
}
]
// 禁用 prefetch
// vue.config.js
module.exports = {
chainWebpack: config => {
config.plugins.delete('prefetch')
}
}错误处理
全局错误处理
js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [...]
})
// 捕获路由错误
router.onError((error) => {
console.error('路由错误:', error)
// 处理 chunk 加载失败
if (error.message.includes('Failed to fetch dynamically imported module')) {
// 刷新页面重新加载
window.location.reload()
}
})
export default router组件加载错误处理
Vue SFC
<script setup>
import { defineAsyncComponent, ref, onErrorCaptured } from 'vue'
const error = ref(null)
const AsyncComponent = defineAsyncComponent({
loader: () => import('./Component.vue'),
errorComponent: ErrorComponent,
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry() // 重试加载
} else {
fail() // 放弃加载
}
}
})
onErrorCaptured((e) => {
error.value = e
return false // 阻止错误继续传播
})
</script>
<template>
<div v-if="error" class="error">
加载失败: {{ error.message }}
<button @click="error = null">重试</button>
</div>
<AsyncComponent v-else />
</template>Chunk 加载失败重试
js
// utils/chunkRetry.js
const chunkRetry = (importFn, retries = 3, interval = 1000) => {
return new Promise((resolve, reject) => {
const attempt = (n) => {
importFn()
.then(resolve)
.catch((error) => {
if (n <= 0) {
reject(error)
return
}
setTimeout(() => attempt(n - 1), interval)
})
}
attempt(retries)
})
}
// 使用
const routes = [
{
path: '/about',
component: () => chunkRetry(() => import('@/views/About.vue'))
}
]性能优化
打包分析
js
// vue.config.js (Webpack)
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
module.exports = {
configureWebpack: {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false
})
]
}
}
// vite.config.js (Vite)
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [
visualizer({
open: true,
gzipSize: true
})
]
}Chunk 大小控制
js
// vue.config.js
module.exports = {
configureWebpack: {
optimization: {
splitChunks: {
chunks: 'all',
maxSize: 244 * 1024, // 244KB
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all'
}
}
}
}
}
}路由级别缓存
Vue SFC
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</router-view>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const cachedViews = computed(() => {
// 根据路由 meta 决定是否缓存
return route.meta.keepAlive ? [route.name] : []
})
</script>完整示例
优化后的路由配置
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
// 首页同步加载(首屏必需)
{
path: '/',
name: 'Home',
component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
},
// 用户模块(分组打包)
{
path: '/users',
name: 'UserList',
component: () => import(/* webpackChunkName: "user" */ '@/views/users/List.vue'),
meta: { keepAlive: true }
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import(/* webpackChunkName: "user" */ '@/views/users/Detail.vue'),
props: true
},
// 管理模块(分组打包)
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '@/layouts/AdminLayout.vue'),
children: [
{
path: '',
redirect: 'dashboard'
},
{
path: 'dashboard',
name: 'AdminDashboard',
component: () => import(/* webpackChunkName: "admin" */ '@/views/admin/Dashboard.vue')
},
{
path: 'users',
name: 'AdminUsers',
component: () => import(/* webpackChunkName: "admin" */ '@/views/admin/Users.vue')
}
]
},
// 404 页面
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 预加载策略
router.beforeEach((to) => {
// 访问列表页时预加载详情页
if (to.name === 'UserList') {
import(/* webpackChunkName: "user" */ '@/views/users/Detail.vue')
}
})
// 错误处理
router.onError((error) => {
if (error.message.includes('Failed to fetch dynamically imported module')) {
window.location.reload()
}
})
export default router主入口组件
Vue SFC
<!-- App.vue -->
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<keep-alive :include="cachedViews">
<suspense>
<template #default>
<component :is="Component" :key="route.path" />
</template>
<template #fallback>
<div class="loading">
<LoadingSpinner />
</div>
</template>
</suspense>
</keep-alive>
</transition>
</router-view>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
const route = useRoute()
const cachedViews = computed(() => {
const routes = route.matched.filter(r => r.meta?.keepAlive)
return routes.map(r => r.name)
})
</script>最佳实践
1. 首屏关键路由同步加载
js
// 首页等首屏必需的路由可以同步加载
import Home from '@/views/Home.vue'
const routes = [
{ path: '/', component: Home },
// 其他路由懒加载
{ path: '/about', component: () => import('@/views/About.vue') }
]2. 按功能模块分组
js
// 相关路由打包到同一个 chunk
const userRoutes = [
{ path: '/users', component: () => import(/* webpackChunkName: "user" */ '@/views/users/List.vue') },
{ path: '/users/:id', component: () => import(/* webpackChunkName: "user" */ '@/views/users/Detail.vue') }
]3. 合理使用缓存
js
const routes = [
{
path: '/products',
component: () => import('@/views/Products.vue'),
meta: { keepAlive: true } // 列表页适合缓存
},
{
path: '/products/:id',
component: () => import('@/views/ProductDetail.vue'),
meta: { keepAlive: false } // 详情页不缓存
}
]4. 监控加载性能
js
// 使用 Performance API 监控
router.afterEach((to) => {
if (window.performance) {
const timing = performance.getEntriesByName(to.path, 'navigation')
console.log(`页面 ${to.path} 加载时间: ${timing[0]?.duration}ms`)
}
})常见问题
1. 开发环境热更新失效
js
// vite.config.js
export default {
server: {
hmr: true // 确保开启 HMR
}
}2. 生产环境 chunk 加载失败
js
// 处理版本更新后的 chunk 加载失败
router.onError((error) => {
const pattern = /Loading chunk \d+ failed/
if (pattern.test(error.message)) {
window.location.reload()
}
})3. 过度拆分
js
// 不推荐:每个路由独立 chunk(请求过多)
const routes = [
{ path: '/a', component: () => import('@/views/a.vue') },
{ path: '/b', component: () => import('@/views/b.vue') },
{ path: '/c', component: () => import('@/views/c.vue') }
]
// 推荐:相关路由分组
const routes = [
{ path: '/a', component: () => import(/* webpackChunkName: "group1" */ '@/views/a.vue') },
{ path: '/b', component: () => import(/* webpackChunkName: "group1" */ '@/views/b.vue') },
{ path: '/c', component: () => import(/* webpackChunkName: "group1" */ '@/views/c.vue') }
]<br>
以下为深度补充内容,涵盖源码分析、性能优化和生产级实践。
导航守卫执行流程源码解析
导航管线(Pipeline)架构
Vue Router 4 的导航守卫系统基于 管线模式(Pipeline) 设计,核心思想是将所有守卫抽象为有序的守卫队列,按阶段依次执行。以下是简化实现:
ts
// 守卫管线核心类型定义
interface NavigationGuard {
(to: RouteLocationNormalized, from: RouteLocationNormalized):
NavigationGuardReturn | Promise<NavigationGuardReturn>
}
type NavigationGuardReturn =
| void // 无返回值 = 放行
| boolean // true=放行, false=取消
| RouteLocationRaw // 重定向到指定路由
// 守卫队列的各个阶段
enum GuardPhase {
Deactivated = 0, // beforeRouteLeave
GlobalBefore = 1, // beforeEach
Update = 2, // beforeRouteUpdate
Enter = 3, // beforeEnter(路由独享) + beforeRouteEnter(组件内)
BeforeResolve = 4, // beforeResolve
Resolved = 5, // 导航确认
AfterEach = 6 // afterEach (后置钩子,不参与管线)
}runGuardQueue 核心逻辑
runGuardQueue 是守卫执行的核心函数,它负责按顺序执行一个守卫数组,处理每个守卫的返回值:
ts
// 简化版 runGuardQueue 实现
async function runGuardQueue<T extends NavigationGuard>(
guards: T[],
to: RouteLocationNormalized,
from: RouteLocationNormalized
): Promise<NavigationGuardResult> {
for (const guard of guards) {
// 执行当前守卫
const result = await guard(to, from)
// 处理返回值
if (result === false) {
// 取消导航
throw new Error('Navigation cancelled')
}
if (result === true || result === undefined) {
// 放行,继续下一个守卫
continue
}
// result 是路径对象 —— 重定向
if (isRouteLocation(result)) {
return { type: 'redirect', location: result }
}
// result 是 Error 实例
if (result instanceof Error) {
throw result
}
}
return { type: 'pass' }
}
// 判断是否为路由地址
function isRouteLocation(value: unknown): boolean {
return (
typeof value === 'string' ||
(typeof value === 'object' && value !== null && ('path' in value || 'name' in value))
)
}完整的导航执行流程
以下简化代码展示了 Vue Router 4 中一次完整导航的执行过程:
ts
// 导航入口:navigate() 简化实现
async function navigate(
to: RouteLocationNormalized,
from: RouteLocationNormalized
): Promise<void | NavigationFailure> {
try {
// ===== 阶段 1:组件离开守卫 =====
// 获取离开的组件,执行 beforeRouteLeave
const leavingRecords = extractLeaveRecords(from.matched, to.matched)
for (const record of leavingRecords.reverse()) {
const leaveGuards = extractComponentsGuards(record, 'beforeRouteLeave')
await runGuardQueue(leaveGuards, to, from)
}
// ===== 阶段 2:全局前置守卫 beforeEach =====
await runGuardQueue(beforeEachGuards, to, from)
// ===== 阶段 3:复用组件中的 beforeRouteUpdate =====
const updatingRecords = extractUpdateRecords(from.matched, to.matched)
for (const record of updatingRecords) {
const updateGuards = extractComponentsGuards(record, 'beforeRouteUpdate')
await runGuardQueue(updateGuards, to, from)
}
// ===== 阶段 4:路由独享守卫 beforeEnter =====
const enterRecords = extractEnterRecords(from.matched, to.matched)
for (const record of enterRecords) {
if (record.beforeEnter) {
const enterGuards = Array.isArray(record.beforeEnter)
? record.beforeEnter
: [record.beforeEnter]
await runGuardQueue(enterGuards, to, from)
}
}
// ===== 阶段 5:组件内 beforeRouteEnter =====
for (const record of enterRecords) {
const enterGuards = extractComponentsGuards(record, 'beforeRouteEnter')
await runGuardQueue(enterGuards, to, from)
}
// ===== 阶段 6:全局解析守卫 beforeResolve =====
// 此时异步组件已解析完毕,适合做最终验证
await runGuardQueue(beforeResolveGuards, to, from)
// ===== 阶段 7:导航确认完成 =====
// 确认导航,更新 currentRoute
currentRoute.value = to
// ===== 阶段 8:全局后置钩子 afterEach =====
// afterEach 不参与管线控制,仅用于副作用
for (const hook of afterEachHooks) {
hook(to, from, undefined)
}
} catch (error) {
// afterEach 传递失败信息
for (const hook of afterEachHooks) {
hook(to, from, error)
}
// 处理导航重复
if (isNavigationFailure(error, NavigationFailureType.duplicated)) {
return error as NavigationFailure
}
// 处理导航取消
if (isNavigationFailure(error, NavigationFailureType.cancelled)) {
return error as NavigationFailure
}
throw error
}
}导航取消与重复检测原理
Vue Router 4 内置了三种导航失败类型,通过 Error 子类实现:
ts
// 导航失败类型
enum NavigationFailureType {
aborted = 4, // 被新的导航中止
cancelled = 8, // 被守卫返回 false 取消
duplicated = 16 // 导航到相同路由
}
// 检测函数
function isNavigationFailure(
error: unknown,
type?: NavigationFailureType
): boolean {
return (
error instanceof Error &&
'__navigationFailureType' in error &&
(type === undefined || (error as any).__navigationFailureType === type)
)
}
// 创建导航失败实例
function createNavigationFailure(
type: NavigationFailureType,
from: RouteLocationNormalized,
to: RouteLocationNormalized
): NavigationFailure {
const error = new Error(
type === NavigationFailureType.duplicated
? 'Navigation duplicated'
: type === NavigationFailureType.cancelled
? 'Navigation cancelled'
: 'Navigation aborted'
) as NavigationFailure
error.__navigationFailureType = type
error.from = from
error.to = to
return error
}守卫返回值处理完整逻辑
ts
// 守卫返回值在 navigate 中的处理编码
async function processGuardReturn(
result: unknown,
to: RouteLocationNormalized,
from: RouteLocationNormalized
): Promise<{ status: 'continue' | 'redirect' | 'abort'; location?: RouteLocationRaw }> {
// 返回值类型判断
if (result === false) {
// 取消导航:返回 false
throw createNavigationFailure(NavigationFailureType.cancelled, from, to)
}
if (result === true || result === undefined) {
// 放行导航
return { status: 'continue' }
}
if (result instanceof Error) {
// 错误处理
throw result
}
// 重定向:返回路由地址
const targetLocation = result as RouteLocationRaw
const resolved = router.resolve(targetLocation)
// 检测是否导航到相同路由
if (
resolved.path === to.path &&
deepEqual(resolved.params, to.params) &&
deepEqual(resolved.query, to.query)
) {
throw createNavigationFailure(NavigationFailureType.duplicated, from, to)
}
return { status: 'redirect', location: targetLocation }
}守卫执行时序图
code
时间线 →
用户触发导航: router.push('/user/123')
│
▼
[beforeRouteLeave] ← 离开组件守卫(按组件层级从子到父执行)
│ 组件A: 确认离开 → true
│ 组件B: 确认离开 → true
▼
[beforeEach] ← 全局前置守卫队列(按注册顺序执行)
│ guard1: 权限检查 → true
│ guard2: 日志记录 → true
│ guard3: 重定向? → true
▼
[beforeRouteUpdate] ← 复用组件守卫(路由变更但组件不变时)
▼
[beforeEnter] ← 路由独享守卫(支持数组,按序执行)
│ guard1 → true
│ guard2 → true
▼
[beforeRouteEnter] ← 进入组件守卫(按组件层级从父到子执行)
│ 组件C: 数据预加载 → true
│ 组件D: 权限检查 → true
▼
[beforeResolve] ← 全局解析守卫(此时异步组件已加载完毕)
│ guard: token 有效性验证 → true
▼
[导航确认] ← 更新 currentRoute ,触发 DOM 更新
▼
[afterEach] ← 全局后置钩子(不参与控制,纯副作用)
│ hook1: 页面标题更新
│ hook2: 进度条关闭
│ hook3: GA 埋点
▼
导航完成生产级权限系统设计
基于 RBAC 的完整路由权限方案
数据模型定义
ts
// types/permission.ts
export interface RoutePermission {
/** 路由唯一标识 */
code: string
/** 路由名称 */
name: string
/** 父级权限码(用于层级继承) */
parentCode?: string
/** 路由路径 */
path: string
/** 操作权限列表 */
actions?: string[]
}
export interface UserRole {
id: string
name: string
/** 角色拥有的路由权限码列表 */
routePermissions: string[]
/** 角色拥有的操作权限列表 */
actionPermissions: Record<string, string[]>
}
export interface PermissionState {
/** 可访问的路由列表 */
accessibleRoutes: RouteRecordRaw[]
/** 路由权限码集合(快速查找用) */
routeCodes: Set<string>
/** 按钮级别权限 */
actionPermissions: Map<string, Set<string>>
}路由权限匹配器
ts
// utils/permission/matcher.ts
import { RouteRecordRaw } from 'vue-router'
/**
* 根据用户权限过滤路由表
* 支持通配符匹配和层级继承
*/
export function filterRoutesByPermission(
routes: RouteRecordRaw[],
allowedCodes: Set<string>
): RouteRecordRaw[] {
const result: RouteRecordRaw[] = []
for (const route of routes) {
const routeCode = route.meta?.permissionCode as string | undefined
// 未设置权限码的路由:默认允许访问
if (!routeCode) {
const filtered: RouteRecordRaw = { ...route }
if (route.children) {
filtered.children = filterRoutesByPermission(route.children, allowedCodes)
}
result.push(filtered)
continue
}
// 检查权限:精确匹配 或 通配符匹配
const hasPermission = checkPermission(routeCode, allowedCodes)
if (hasPermission) {
const filtered: RouteRecordRaw = { ...route }
if (route.children) {
filtered.children = filterRoutesByPermission(route.children, allowedCodes)
}
result.push(filtered)
}
}
return result
}
function checkPermission(code: string, allowedCodes: Set<string>): boolean {
// 精确匹配
if (allowedCodes.has(code)) return true
// 通配符匹配(如 'admin:*' 匹配 'admin:users')
for (const allowed of allowedCodes) {
if (allowed.endsWith('*')) {
const prefix = allowed.slice(0, -1)
if (code.startsWith(prefix)) return true
}
}
return false
}动态路由权限(后端返回路由表)
ts
// stores/permission.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import { filterRoutesByPermission } from '@/utils/permission/matcher'
import { staticRoutes } from '@/router/routes'
interface ServerRoute {
id: string
path: string
name: string
component: string // 组件路径:'views/dashboard/index'
meta?: Record<string, unknown>
children?: ServerRoute[]
}
export const usePermissionStore = defineStore('permission', () => {
const serverRoutes = ref<ServerRoute[]>([])
const accessibleRoutes = ref<RouteRecordRaw[]>([])
const isRoutesReady = ref(false)
// 将后端路由转换为前端路由格式
function convertServerRoutes(serverRoutes: ServerRoute[]): RouteRecordRaw[] {
return serverRoutes.map(route => {
// 组件路径映射(使用 import.meta.glob 或手动映射)
const modules = import.meta.glob('@/views/**/*.vue')
const componentPath = `/src/views/${route.component}.vue`
const component = modules[componentPath]
if (!component) {
console.warn(`组件未找到: ${componentPath}`)
}
return {
path: route.path,
name: route.name,
component: component as any,
meta: route.meta,
children: route.children
? convertServerRoutes(route.children)
: undefined
}
})
}
// 生成最终可访问路由
async function generateRoutes(roleIds: string[]): Promise<RouteRecordRaw[]> {
// 从后端获取用户角色对应的路由权限
const serverRoutes = await fetchUserRoutes(roleIds)
// 转换为前端路由格式
const convertedRoutes = convertServerRoutes(serverRoutes)
// 合并静态路由和动态路由
const fullRoutes = [
...staticRoutes,
...convertedRoutes,
// 404 必须放在最后
{ path: '/:pathMatch(.*)*', redirect: '/404' }
]
accessibleRoutes.value = fullRoutes
isRoutesReady.value = true
return fullRoutes
}
// 权限变更后无缝刷新路由
async function refreshRoutes(roleIds: string[]): Promise<void> {
isRoutesReady.value = false
const newRoutes = await generateRoutes(roleIds)
// 移除旧动态路由
const router = useRouter()
router.getRoutes().forEach(route => {
if (route.meta?.isDynamic) {
router.removeRoute(route.name as string)
}
})
// 添加新动态路由
newRoutes
.filter(r => r.meta?.isDynamic)
.forEach(route => {
router.addRoute(route)
})
isRoutesReady.value = true
// 触发导航到当前路由,使新路由生效
// 如果当前路由不再有权限,重定向到 403
const currentRoute = router.currentRoute.value
if (!router.hasRoute(currentRoute.name as string)) {
router.replace('/403')
}
}
return {
accessibleRoutes,
isRoutesReady,
generateRoutes,
refreshRoutes
}
})
async function fetchUserRoutes(roleIds: string[]): Promise<ServerRoute[]> {
const response = await fetch('/api/user/routes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleIds })
})
return response.json()
}按钮级别权限控制
ts
// composables/usePermission.ts
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
export function usePermission() {
const userStore = useUserStore()
/** 检查路由权限 */
const hasRoute = (code: string): boolean => {
return userStore.routePermissions.has(code)
}
/** 检查按钮权限 */
const hasAction = (routeCode: string, action: string): boolean => {
const actions = userStore.actionPermissions.get(routeCode)
return actions?.has(action) ?? false
}
return { hasRoute, hasAction }
}Vue SFC
<!-- 权限指令:v-permission -->
<template>
<div>
<!-- 路由权限控制 -->
<router-link to="/admin" v-if="hasRoute('admin')">
管理后台
</router-link>
<!-- 按钮级别权限控制 -->
<button
v-if="hasAction('user:list', 'create')"
@click="handleCreate"
>
新建用户
</button>
<button
v-if="hasAction('user:list', 'delete')"
@click="handleDelete"
>
删除用户
</button>
</div>
</template>
<script setup lang="ts">
import { usePermission } from '@/composables/usePermission'
const { hasRoute, hasAction } = usePermission()
</script>自定义权限指令
ts
// directives/vPermission.ts
import type { Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/stores/user'
interface PermissionBinding {
code: string
action?: string
}
export const vPermission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<PermissionBinding>) {
const userStore = useUserStore()
const { code, action } = binding.value
let hasPermission = false
if (action) {
// 按钮级别权限
hasPermission = userStore.actionPermissions.get(code)?.has(action) ?? false
} else {
// 路由级别权限
hasPermission = userStore.routePermissions.has(code)
}
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
}
}多角色权限继承与合并策略
ts
// utils/permission/merge.ts
import type { UserRole, PermissionState } from '@/types/permission'
/**
* 多角色权限合并策略
* - 取并集:所有角色权限的集合
* - 权限冲突时采用"更宽松"策略
*/
export function mergeRolePermissions(roles: UserRole[]): PermissionState {
const routeCodes = new Set<string>()
const actionPermissions = new Map<string, Set<string>>()
for (const role of roles) {
// 合并路由权限(取并集)
for (const code of role.routePermissions) {
routeCodes.add(code)
}
// 合并操作权限(取并集)
for (const [routeCode, actions] of Object.entries(role.actionPermissions)) {
if (!actionPermissions.has(routeCode)) {
actionPermissions.set(routeCode, new Set())
}
const existingActions = actionPermissions.get(routeCode)!
for (const action of actions) {
existingActions.add(action)
}
}
}
return {
accessibleRoutes: [],
routeCodes,
actionPermissions
}
}
/**
* 权限继承策略
* 父级权限自动包含子级权限
* 例如:'admin' 权限自动包含 'admin:users' 和 'admin:settings'
*/
export function expandPermissionInheritance(
codes: Set<string>
): Set<string> {
const expanded = new Set(codes)
for (const code of codes) {
// 为每个权限码添加通配符子级
if (!code.includes('*')) {
expanded.add(`${code}:*`)
}
}
return expanded
}懒加载深度优化
Vite/Rollup 的代码分割原理
Vite 底层使用 Rollup 进行生产构建。当 Rollup 遇到 import() 动态导入时,会将其标记为异步入口,自动生成独立的 chunk 文件:
ts
// 动态导入:Rollup 将其识别为独立 chunk 入口
const User = () => import('@/views/User.vue')
// 输出 → dist/assets/User-[hash].js (独立 chunk)
// 多个 import() 指向同一模块路径 → 合并到同一个 chunk
const User = () => import('@/views/User.vue')
const UserDetail = () => import('@/views/UserDetail.vue')
// 输出 → dist/assets/User-[hash].js (包含 User.vue)
// 输出 → dist/assets/UserDetail-[hash].js (包含 UserDetail.vue)
// 魔法注释 chunk 分组 → 精确控制 chunk 归属
const Admin = () => import(/* chunk: "admin" */ '@/views/Admin.vue')
const AdminUsers = () => import(/* chunk: "admin" */ '@/views/AdminUsers.vue')
// 输出 → dist/assets/admin-[hash].js (包含 Admin.vue + AdminUsers.vue)Rollup 的 chunk 生成算法简化
ts
// Rollup 代码分割的核心逻辑简化
function generateChunks(modules: Module[]): Chunk[] {
const chunks: Map<string, Chunk> = new Map()
for (const mod of modules) {
const chunkName = mod.chunkNameHint || mod.id
const chunkKey = deterministicHash(chunkName)
if (!chunks.has(chunkKey)) {
chunks.set(chunkKey, {
fileName: `assets/${chunkName}-${chunkKey.slice(0, 8)}.js`,
modules: [],
dependencies: new Set()
})
}
const chunk = chunks.get(chunkKey)!
chunk.modules.push(mod)
// 分析模块依赖,建立 chunk 间依赖关系
for (const dep of mod.dynamicImports) {
chunk.dependencies.add(dep.chunkName)
}
}
return Array.from(chunks.values())
}Vite 构建配置优化
ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
// 手动分包策略
manualChunks: (id) => {
// 将 node_modules 中的大型库单独打包
if (id.includes('node_modules')) {
if (id.includes('echarts')) {
return 'vendor-echarts' // ~900KB → 独立 chunk
}
if (id.includes('lodash')) {
return 'vendor-lodash'
}
if (id.includes('element-plus')) {
return 'vendor-element'
}
return 'vendor' // 其他第三方库
}
// 按视图目录分组
if (id.includes('src/views/admin')) {
return 'admin'
}
if (id.includes('src/views/user')) {
return 'user'
}
}
}
},
// 控制 chunk 大小警告阈值
chunkSizeWarningLimit: 500 // KB
}
})预加载策略:prefetch vs preload
两种预加载机制对比
| 特性 | <link rel="prefetch"> | <link rel="preload"> |
|---|---|---|
| 加载时机 | 浏览器空闲时 | 页面加载时立即 |
| 优先级 | 最低 | 高 |
| 适用场景 | 下一个页面可能访问的资源 | 当前页面必需的资源 |
| 缓存 | 存入 HTTP 缓存 | 存入 HTTP 缓存 |
| 浏览器支持 | 良好 | 良好 |
html
<!-- prefetch:浏览器空闲时低优先级加载,适合下个页面 -->
<link rel="prefetch" href="/assets/admin-[hash].js" as="script">
<!-- preload:立即高优先级加载,适合当前页面即将使用的资源 -->
<link rel="preload" href="/assets/critical-[hash].js" as="script">Vite 中的预加载实现
ts
// router/preload.ts
import type { Router } from 'vue-router'
/**
* 智能预加载管理器
* 取代 Webpack 的 webpackPrefetch 魔法注释
*/
export class PreloadManager {
private router: Router
private loadedChunks = new Set<string>()
private observer: IntersectionObserver | null = null
constructor(router: Router) {
this.router = router
this.setupObserver()
}
/**
* 方式1:在父路由中手动预加载子路由
* 适用场景:列表页预加载详情页
*/
async preloadRelatedRoutes(currentRouteName: string): Promise<void> {
const preloadMap: Record<string, string[]> = {
'UserList': ['UserDetail', 'UserEdit'], // 用户列表 → 预加载详情/编辑
'ProductList': ['ProductDetail'], // 商品列表 → 预加载详情
'Dashboard': ['Reports', 'Settings'] // 仪表盘 → 预加载报表/设置
}
const targets = preloadMap[currentRouteName]
if (!targets) return
for (const routeName of targets) {
const route = this.router.resolve({ name: routeName })
const matched = route.matched[route.matched.length - 1]
if (matched?.components?.default) {
// 触发组件加载(实际是 import() 调用)
const component = matched.components.default
if (typeof component === 'function') {
component().catch(() => {
// 预加载失败静默处理,访问时再重试
})
}
}
}
}
/**
* 方式2:使用 prefetch 标签预加载
* 适用于明确的 chunk 地址
*/
prefetchChunk(chunkUrl: string): void {
if (this.loadedChunks.has(chunkUrl)) return
const link = document.createElement('link')
link.rel = 'prefetch'
link.as = 'script'
link.href = chunkUrl
document.head.appendChild(link)
this.loadedChunks.add(chunkUrl)
}
/**
* 方式3:使用 preload 高优先级加载
* 适用于当前页面即将使用的资源
*/
preloadChunk(chunkUrl: string): void {
if (this.loadedChunks.has(chunkUrl)) return
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'script'
link.href = chunkUrl
document.head.appendChild(link)
this.loadedChunks.add(chunkUrl)
}
private setupObserver(): void {
// 见下方 IntersectionObserver 预加载
}
}IntersectionObserver 可见性预加载
当路由链接进入视口时,自动预加载对应的 chunk:
ts
// composables/useVisiblePreload.ts
import { onMounted, onUnmounted, ref } from 'vue'
interface PreloadEntry {
el: HTMLElement
loader: () => Promise<unknown>
loaded: boolean
}
const preloadEntries = new Map<HTMLElement, PreloadEntry>()
/**
* 当链接滚入视口时自动预加载
* 性能提升:用户点击时组件已加载完毕,导航瞬间完成
*/
export function useVisiblePreload() {
let observer: IntersectionObserver | null = null
const registerPreload = (
el: HTMLElement,
loader: () => Promise<unknown>
): void => {
if (!observer) {
// 创建 IntersectionObserver
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const entryData = preloadEntries.get(entry.target as HTMLElement)
if (entryData && !entryData.loaded) {
entryData.loaded = true
entryData.loader().catch(() => {
// 预加载失败不影响后续访问
})
// 已触发预加载,取消观察
observer?.unobserve(entry.target)
}
}
}
},
{
// 提前 200px 开始预加载(用户即将滚动到)
rootMargin: '200px',
threshold: 0.01
}
)
}
const entry: PreloadEntry = { el, loader, loaded: false }
preloadEntries.set(el, entry)
observer.observe(el)
}
const unregisterPreload = (el: HTMLElement): void => {
preloadEntries.delete(el)
observer?.unobserve(el)
}
const cleanup = (): void => {
observer?.disconnect()
observer = null
preloadEntries.clear()
}
return { registerPreload, unregisterPreload, cleanup }
}Vue SFC
<!-- 使用示例:自动预加载链接 -->
<template>
<nav>
<router-link
v-for="link in links"
:key="link.to"
:to="link.to"
:ref="el => linkRefs[link.to] = el"
>
{{ link.label }}
</router-link>
</nav>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useVisiblePreload } from '@/composables/useVisiblePreload'
const { registerPreload, cleanup } = useVisiblePreload()
// 组件映射表
const preloadMap: Record<string, () => Promise<unknown>> = {
'/admin': () => import('@/views/Admin.vue'),
'/users': () => import('@/views/Users.vue'),
'/settings': () => import('@/views/Settings.vue')
}
onMounted(() => {
// 注册所有链接的预加载
document.querySelectorAll<HTMLElement>('[data-preload]').forEach(el => {
const route = el.dataset.preload!
const loader = preloadMap[route]
if (loader) {
registerPreload(el, loader)
}
})
})
onUnmounted(() => {
cleanup()
})
</script>网络状况自适应加载策略
ts
// utils/networkAdaptive.ts
/**
* 网络状况评估
* 使用 Navigator API 和性能测量判断网络质量
*/
export class NetworkAdapter {
private connection: NetworkInformation | null = null
private slowThreshold = 1000 // 慢网阈值(ms)
constructor() {
// 获取网络信息(Experimental API)
if ('connection' in navigator) {
this.connection = (navigator as any).connection
}
}
/** 检测是否为慢速网络 */
get isSlowConnection(): boolean {
if (!this.connection) return false
// 检查 effectiveType
if (this.connection.effectiveType === 'slow-2g' ||
this.connection.effectiveType === '2g') {
return true
}
// 检查 RTT(往返时间)
if (this.connection.rtt && this.connection.rtt > this.slowThreshold) {
return true
}
return false
}
/** 检测是否为流量节省模式 */
get isDataSaver(): boolean {
return this.connection?.saveData ?? false
}
/** 获取网络质量等级 */
get quality(): 'fast' | 'moderate' | 'slow' {
if (this.isSlowConnection) return 'slow'
if (this.connection?.effectiveType === '3g') return 'moderate'
return 'fast'
}
}
/**
* 自适应预加载策略
* - 快网:激进预加载(视口内 + 当前路由关联路由)
* - 中网:中等预加载(仅视口内链接)
* - 慢网/省流模式:禁用预加载,仅加载当前需要
*/
export class AdaptivePreloader {
private adapter = new NetworkAdapter()
private preloadManager: PreloadManager
constructor(preloadManager: PreloadManager) {
this.preloadManager = preloadManager
}
/** 根据网络状况决定预加载策略 */
executePreload(currentRouteName: string): void {
const quality = this.adapter.quality
switch (quality) {
case 'fast':
// 激进策略:预加载所有关联路由 + 视口内链接
this.preloadManager.preloadRelatedRoutes(currentRouteName)
// IntersectionObserver 已自动处理视口内链接
console.log('[Preload] 快网模式:激进预加载')
break
case 'moderate':
// 中等策略:仅预加载最可能访问的 1-2 个路由
this.preloadTopPriority(currentRouteName)
console.log('[Preload] 中网模式:保守预加载')
break
case 'slow':
// 慢网/省流:跳过预加载,仅加载当前必需
console.log('[Preload] 慢网/省流模式:跳过预加载')
break
}
}
private preloadTopPriority(routeName: string): void {
// 仅预加载最可能访问的顶级路由
const priorityMap: Record<string, string[]> = {
'UserList': ['UserDetail'],
'ProductList': ['ProductDetail'],
'Dashboard': ['Reports']
}
const targets = priorityMap[routeName]?.slice(0, 2) ?? []
for (const name of targets) {
const route = this.preloadManager['router'].resolve({ name })
// 触发预加载...
}
}
/** 监听网络变化,动态调整策略 */
onNetworkChange(callback: (quality: 'fast' | 'moderate' | 'slow') => void): void {
if (this.adapter['connection']) {
this.adapter['connection'].addEventListener('change', () => {
callback(this.adapter.quality)
})
}
}
}加载状态与错误边界
路由级别的 ErrorBoundary 实现
Vue SFC
<!-- components/RouteErrorBoundary.vue -->
<template>
<div v-if="error" class="route-error-boundary">
<div class="error-content">
<div class="error-code">{{ error.code || '500' }}</div>
<h3>{{ error.message || '页面加载失败' }}</h3>
<p class="error-detail">{{ error.detail }}</p>
<div class="error-actions">
<button @click="handleRetry" class="btn-primary">
重试
</button>
<button @click="handleGoHome" class="btn-secondary">
返回首页
</button>
<button @click="handleRefresh" class="btn-secondary">
刷新页面
</button>
</div>
</div>
</div>
<slot v-else />
</template>
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import { useRouter } from 'vue-router'
interface RouteError extends Error {
code?: number
detail?: string
canRetry?: boolean
}
const router = useRouter()
const error = ref<RouteError | null>(null)
// 捕获子组件同步和异步错误
onErrorCaptured((err: RouteError, instance, info) => {
error.value = {
...err,
code: err.code || 500,
detail: info || '未知错误',
canRetry: true
}
// 阻止错误继续冒泡
return false
})
function handleRetry(): void {
error.value = null
}
function handleGoHome(): void {
router.replace('/')
}
function handleRefresh(): void {
window.location.reload()
}
// 监听路由变化,清除错误状态
router.beforeEach(() => {
error.value = null
})
</script>
<style scoped>
.route-error-boundary {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
}
.error-content {
text-align: center;
max-width: 480px;
}
.error-code {
font-size: 4rem;
font-weight: 700;
color: #e0e0e0;
line-height: 1;
}
.error-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1.5rem;
}
</style>Vue SFC
<!-- App.vue 中集成 ErrorBoundary -->
<template>
<div id="app">
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<RouteErrorBoundary :key="route.path">
<component :is="Component" />
</RouteErrorBoundary>
</transition>
</router-view>
</div>
</template>Chunk 加载失败降级方案
ts
// utils/chunkFallback.ts
import type { Router } from 'vue-router'
interface ChunkFallbackOptions {
/** 最大重试次数 */
maxRetries?: number
/** 重试间隔(ms) */
retryInterval?: number
/** 是否启用缓存版本回退 */
enableCacheBackup?: boolean
/** 是否启用 IndexedDB 缓存 */
enableIndexedDB?: boolean
}
/**
* Chunk 加载失败降级系统
* 策略优先级:重试 → Service Worker 缓存 → IndexedDB 缓存 → 刷新页面
*/
export class ChunkFallback {
private router: Router
private options: Required<ChunkFallbackOptions>
private retryCount = new Map<string, number>()
constructor(router: Router, options: ChunkFallbackOptions = {}) {
this.router = router
this.options = {
maxRetries: 3,
retryInterval: 1000,
enableCacheBackup: true,
enableIndexedDB: false,
...options
}
this.setupErrorHandler()
}
private setupErrorHandler(): void {
this.router.onError(async (error: Error) => {
if (this.isChunkLoadError(error)) {
await this.handleChunkError(error)
}
})
}
private isChunkLoadError(error: Error): boolean {
const patterns = [
/Failed to fetch dynamically imported module/,
/Loading chunk \d+ failed/,
/Importing a module script failed/,
/error loading dynamically imported module/
]
return patterns.some(p => p.test(error.message))
}
private async handleChunkError(error: Error): Promise<void> {
const route = this.router.currentRoute.value
const routeKey = route.fullPath
const currentRetries = this.retryCount.get(routeKey) || 0
// 策略1: 重试加载
if (currentRetries < this.options.maxRetries) {
this.retryCount.set(routeKey, currentRetries + 1)
console.warn(`[ChunkFallback] 重试加载 (${currentRetries + 1}/${this.options.maxRetries}): ${routeKey}`)
await this.delay(this.options.retryInterval * (currentRetries + 1)) // 指数退避
// 重新导航到当前路由,触发重新加载
this.router.replace({ path: route.fullPath, query: { ...route.query, _retry: currentRetries + 1 } })
return
}
// 策略2: 尝试从 Service Worker 缓存加载
if (this.options.enableCacheBackup && 'serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration()
if (registration && registration.active) {
console.warn('[ChunkFallback] 尝试从 Service Worker 缓存加载')
this.router.replace({ path: route.fullPath, query: { ...route.query, _cache: 'sw' } })
return
}
}
// 策略3: 最终降级 - 刷新页面
console.error('[ChunkFallback] 所有重试失败,强制刷新页面')
this.retryCount.delete(routeKey)
// 显示降级提示
const confirmed = window.confirm('页面资源加载失败,可能由于版本更新。点击确定刷新页面获取最新版本。')
if (confirmed) {
window.location.reload()
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/** 重置路由的重试计数 */
resetRetryCount(routePath: string): void {
this.retryCount.delete(routePath)
}
}ts
// router/index.ts 中集成
import { createRouter } from 'vue-router'
import { ChunkFallback } from '@/utils/chunkFallback'
const router = createRouter({ /* ... */ })
// 初始化 Chunk 降级系统
new ChunkFallback(router, {
maxRetries: 3,
retryInterval: 1500,
enableCacheBackup: true
})
// 路由切换时清除重试计数
router.afterEach((to) => {
// 只在新路由加载成功时清除
// ChunkFallback 实例通过闭包或全局变量访问
})Suspense + 路由懒加载的完整错误处理方案
Vue SFC
<!-- App.vue -->
<template>
<div id="app">
<AppHeader />
<main class="main-content">
<router-view v-slot="{ Component, route }">
<transition name="page" mode="out-in">
<Suspense :key="route.path" @pending="onPending" @resolve="onResolve" @fallback="onFallback">
<!-- 默认插槽:加载成功后的内容 -->
<template #default>
<ErrorBoundary>
<component :is="Component" />
</ErrorBoundary>
</template>
<!-- 加载中插槽 -->
<template #fallback>
<LoadingState :route="route" />
</template>
</Suspense>
</transition>
</router-view>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import ErrorBoundary from '@/components/ErrorBoundary.vue'
import LoadingState from '@/components/LoadingState.vue'
const router = useRouter()
const isLoading = ref(false)
const loadError = ref<Error | null>(null)
function onPending(): void {
isLoading.value = true
loadError.value = null
}
function onResolve(): void {
isLoading.value = false
}
function onFallback(): void {
// Suspense 的 fallback 是正常行为(等待异步组件),
// 不是错误。这里只做状态记录
}
</script>Vue SFC
<!-- components/LoadingState.vue -->
<template>
<div class="loading-state">
<!-- 骨架屏 -->
<div v-if="showSkeleton" class="skeleton">
<div class="skeleton-header" />
<div class="skeleton-content">
<div class="skeleton-line" v-for="n in 4" :key="n"
:style="{ width: `${60 + Math.random() * 40}%` }" />
</div>
</div>
<!-- 进度条 -->
<div class="progress-bar" v-if="showProgress">
<div class="progress-bar-fill" :style="{ width: progress + '%' }" />
</div>
<!-- 加载提示 -->
<div class="loading-text">
<span class="spinner" />
<span>加载中...</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{ route: any }>()
const showSkeleton = ref(true)
const showProgress = ref(true)
const progress = ref(0)
let progressTimer: number | null = null
onMounted(() => {
// 模拟进度条(非精确,仅用于视觉反馈)
progressTimer = window.setInterval(() => {
if (progress.value < 90) {
progress.value += Math.random() * 15
}
}, 300)
})
onUnmounted(() => {
if (progressTimer) clearInterval(progressTimer)
})
</script>
<style scoped>
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
gap: 1.5rem;
}
.skeleton {
width: 100%;
max-width: 600px;
}
.skeleton-header {
height: 2rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 1rem;
}
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 0.75rem;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.progress-bar {
width: 200px;
height: 3px;
background: #eee;
border-radius: 2px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: #1890ff;
border-radius: 2px;
transition: width 0.3s ease;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #e0e0e0;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>进度条与骨架屏的配合方案
ts
// composables/useNavigationLoading.ts
import { ref, readonly } from 'vue'
import type { Router } from 'vue-router'
/**
* 导航加载状态管理
* 协调进度条、骨架屏、加载指示器的显示逻辑
*/
export function useNavigationLoading(router: Router) {
const isLoading = ref(false)
const loadingType = ref<'skeleton' | 'progress' | 'spinner' | 'none'>('none')
const estimatedProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
let loadingTimeout: ReturnType<typeof setTimeout> | null = null
function startLoading(type: 'skeleton' | 'progress' | 'spinner' = 'spinner'): void {
// 延迟显示加载状态(避免闪烁)
// 如果加载在 200ms 内完成,不显示加载指示器
loadingTimeout = setTimeout(() => {
isLoading.value = true
loadingType.value = type
// 启动模拟进度
if (type === 'progress') {
estimatedProgress.value = 0
progressTimer = setInterval(() => {
if (estimatedProgress.value < 85) {
// 模拟自然进度增长(越接近完成越慢)
estimatedProgress.value += (85 - estimatedProgress.value) * 0.1
}
}, 200)
}
}, 200)
}
function finishLoading(): void {
// 清除延迟定时器
if (loadingTimeout) {
clearTimeout(loadingTimeout)
loadingTimeout = null
}
// 完成进度条
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
estimatedProgress.value = 100
// 短暂延迟让进度条动画完成
setTimeout(() => {
isLoading.value = false
loadingType.value = 'none'
estimatedProgress.value = 0
}, 300)
}
function failLoading(error?: Error): void {
finishLoading()
// 错误状态由 ErrorBoundary 处理
}
// 注册路由事件
router.beforeEach(() => {
startLoading('progress')
})
router.afterEach(() => {
finishLoading()
})
router.onError((error) => {
failLoading(error)
})
return {
isLoading: readonly(isLoading),
loadingType: readonly(loadingType),
estimatedProgress: readonly(estimatedProgress)
}
}Vue Router 3 vs 4 守卫机制对比
API 核心变更
| 特性 | Vue Router 3 | Vue Router 4 |
|---|---|---|
| 守卫签名 | (to, from, next) | (to, from) |
| 放行方式 | next() | 返回 true 或 undefined |
| 取消导航 | next(false) | 返回 false |
| 重定向 | next('/login') | 返回 { path: '/login' } 或路由名 |
| 错误传递 | next(error) | throw error 或返回 Error |
| 异步守卫 | next() 在 then 中调用 | await / Promise 返回值 |
| 可选参数 | next() 必须调用 | 无参,返回 undefined = 放行 |
beforeRouteEnter | next(vm => ...) 访问实例 | 通过 next 回调不再可用,使用 setup 中的组合式 API |
next() 的移除原因
Vue Router 3 的 next() 回调模式存在以下问题:
- 容易忘记调用
next()-- 忘记调用导致导航永远挂起,且无报错提示 - 多次调用
next()-- 如果在守卫中多次调用next(),会导致不可预期行为 - 异步中
next()时序问题 -- 在异步操作完成后调用next()容易出错
ts
// Vue Router 3 的问题模式
router.beforeEach((to, from, next) => {
// 问题1:忘记调用 next()
if (to.path === '/admin') {
// 忘记 next() → 导航永久挂起!
return
}
// 问题2:多次调用 next()
next()
fetchUser().then(() => next('/home')) // 第二次调用 → 不可预期行为
// 问题3:异步时序错误
setTimeout(() => next(), 1000)
// 如果在这 1 秒内又触发了新导航,状态混乱
})
// Vue Router 4 的改进模式
router.beforeEach((to, from) => {
// 清晰的返回值语义,类型安全
if (to.path === '/admin') {
return false // 编译器会提醒你缺少返回值
}
// 无返回值 = 放行,无需显式调用
return true
})迁移清单
全局守卫迁移
ts
// ===== Vue Router 3 =====
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
next()
}
})
router.beforeResolve((to, from, next) => {
// 验证逻辑
next()
})
router.afterEach((to, from) => {
document.title = to.meta.title
})
// ===== Vue Router 4 =====
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// 返回 undefined = 放行
})
router.beforeResolve(async (to, from) => {
// 验证逻辑,返回 false 取消或返回路径重定向
const isValid = await verifyToken()
if (!isValid) return '/login'
})
router.afterEach((to, from, failure) => {
// 新增 failure 参数:导航失败时不为 undefined
if (failure) return
document.title = to.meta.title
})路由独享守卫迁移
ts
// ===== Vue Router 3 =====
const routes = [{
path: '/admin',
beforeEnter: (to, from, next) => {
if (hasPermission('admin')) next()
else next('/403')
}
}]
// ===== Vue Router 4 =====
const routes = [{
path: '/admin',
beforeEnter: (to, from) => {
if (hasPermission('admin')) return true
return '/403'
}
}]组件内守卫迁移
ts
// ===== Vue Router 3(选项式 API)=====
export default {
beforeRouteLeave(to, from, next) {
if (this.hasUnsaved) {
const answer = confirm('确定离开?')
next(answer)
} else {
next()
}
}
}
// ===== Vue Router 4(组合式 API)=====
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsaved.value) {
const answer = confirm('确定离开?')
return answer // 直接返回 boolean
}
return true
})迁移检查清单
code
□ 全局 beforeEach 守卫:移除 next 参数,使用返回值
□ 全局 beforeResolve 守卫:移除 next 参数,使用返回值
□ 全局 afterEach 钩子:新增第三个参数 failure
□ 路由独享 beforeEnter:移除 next 参数,使用返回值
□ 组件内 onBeforeRouteLeave:移除 next 参数,使用返回值
□ 组件内 onBeforeRouteUpdate:移除 next 参数,使用返回值
□ 异步守卫:从 next() 回调改为 await/return Promise
□ 错误处理:从 next(error) 改为 throw error
□ beforeRouteEnter 访问 vm:迁移到 setup 中使用组合式 API
□ 路由 meta 字段:确认类型声明与使用一致
□ 移除所有 next() 无参数调用(Vue Router 4 中自动忽略)
□ 测试:验证所有守卫的放行、取消、重定向行为下一步
- 路由进阶 - 学习高级功能