{T}

路由守卫

路由守卫用于控制导航行为,实现权限控制、登录拦截、数据预加载等功能。

概述

路由守卫主要分为三类,执行时机不同:

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 3Vue Router 4
守卫签名(to, from, next)(to, from)
放行方式next()返回 trueundefined
取消导航next(false)返回 false
重定向next('/login')返回 { path: '/login' } 或路由名
错误传递next(error)throw error 或返回 Error
异步守卫next() 在 then 中调用await / Promise 返回值
可选参数next() 必须调用无参,返回 undefined = 放行
beforeRouteEnternext(vm => ...) 访问实例通过 next 回调不再可用,使用 setup 中的组合式 API

next() 的移除原因

Vue Router 3 的 next() 回调模式存在以下问题:

  1. 容易忘记调用 next() -- 忘记调用导致导航永远挂起,且无报错提示
  2. 多次调用 next() -- 如果在守卫中多次调用 next(),会导致不可预期行为
  3. 异步中 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 中自动忽略)
□ 测试:验证所有守卫的放行、取消、重定向行为

下一步