Vue3 动态路由踩坑记

Vue3 动态路由踩坑记:页面始终显示固定页面(首页内容)的排查之路

一、问题背景

最近在开发一个基于 Vue 3 + Element Plus + Koa 后端的航天管理系统时,遇到了一个令人困扰的问题:无论点击哪个菜单,页面始终显示固定页面,也就是我写的首页内容。

作为一个有经验的前端开发者,我以为这只是一个简单的路由配置问题,但没想到这个问题竟然牵扯出了多个层面的问题。下面我将详细记录整个排查过程,既是项目复盘,也希望能帮到遇到同类问题的开发者。

二、问题现象

核心现象非常典型,具体表现为:

plaintext 复制代码
点击菜单 "用户管理" → URL 变为 /system/user → 页面显示 "我是首页内容"
点击菜单 "运载火箭" → URL 变为 /space/rocket → 页面依然显示 "我是首页内容"

关键特征:

  • 路由地址正确变化,说明路由跳转逻辑正常;

  • 页面内容始终不变,始终显示我写的首页内容,说明路由匹配成功,但对应组件未正确渲染;

  • 控制台无明显报错,排查难度增加。

三、项目基础结构

plaintext 复制代码
src/
├── router/
│   ├── index.js        # 基础路由 + 路由守卫
│   └── dynamicRoutes.js # 动态路由注册逻辑
├── layout/
│   └── index.vue       # 公共布局(侧边栏 + 主内容区)
├── views/
│   ├── Home.vue        # 首页组件(我写的首页内容)
│   ├── Login.vue       # 登录页组件
│   ├── system/         # 系统管理相关页面
│   ├── space/          # 航天器相关页面
│   └── 其他功能模块页面
├── store/
│   └── index.js        # 用户状态管理(登录、角色等)
└── 其他配置文件

server/
└── routes/
    └── menu.js         # 后端菜单接口,返回动态菜单数据

四、排查过程(从易到难,逐步定位)

第一步:检查基础路由配置

首先排查最基础的静态路由配置,查看 src/router/index\.js

javascript 复制代码
// src/router/index.js (主路由配置)
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layout/index.vue' // 公共布局组件
import { useUserStore } from '@/store/index.js'

// 基础路由
const baseRoutes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue')
  },
  {
    path: '/',
    component: Layout, // 全局唯一布局
    name: 'HomeLayout', // 关键:给父路由命名,用于动态路由挂载
    redirect: '/home',
    children: [
      {
        path: 'home',
        name: 'Home',
        component: () => import('@/views/Home.vue'), // 对应我写的首页内容
        meta: { requiresAuth: true }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: baseRoutes
})

// 路由守卫:权限拦截
router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  console.log('=== 路由守卫调试 ===')
  console.log('要去的位置:', to.path)
  console.log('用户角色:', userStore.roles)
  console.log('路由元信息:', to.meta)
  
  if (to.path === '/login') {
    next()
    return
  }

  // 检查是否登录
  if (!userStore.isLoggedIn) {
    next('/login')
    return
  }

  // 检查路由权限
  if (to.meta.requiresAuth) {
    // 如果路由需要特定角色
    if (to.meta.roles && to.meta.roles.length > 0) {
      const hasRole = to.meta.roles.some(role => userStore.roles.includes(role))
      if (!hasRole) {
        next('/403') // 无权限页面(可自行实现)
        return
      }
    }
  }

  next()
})

export default router

排查结论:基础路由配置无问题,我写的首页内容能正常显示,登录页跳转正常,路由守卫逻辑严谨,排除基础路由导致的问题。

第二步:检查动态路由注册(核心坑 1)

接下来排查动态路由注册逻辑,查看 src/router/dynamicRoutes\.js 最初的写法:

javascript 复制代码
// 最初的错误写法 ❌
router.addRoute({
  path: child.path,           // 例如:/system/user
  component: Layout,          // 错误:重复创建 Layout 组件
  children: [...]
})

问题原因:

动态路由被注册为 独立的顶层路由 ,而非挂载到已有的 HomeLayout 父路由下,导致系统中存在多个独立的 Layout 组件,路由匹配混乱,子页面无法嵌入主布局,最终 fallback 到我写的首页内容。

修复方案:将动态路由挂载到已有的 HomeLayout 父路由下(通过父路由 name 挂载):

javascript 复制代码
// 正确写法 ✅
router.addRoute('HomeLayout', {
  path: routePath,
  name: routePath.replace(/\//g, '-'),
  component: () => import(...),
  meta: child.meta
})

第三步:检查组件路径拼接(核心坑 2)

修复路由挂载方式后,问题依然存在,打开控制台发现 Vite 报错:

plaintext 复制代码
[plugin:vite:vue] src/views/views/space/rocket.vue At least one <template> or <script> is required

问题原因:组件路径拼接错误,使用相对路径导致路径重复:

javascript 复制代码
// 错误写法 ❌
const importPath = `../views${child.path}.vue`
// child.path = '/space/rocket'
// 最终解析结果:../views/space/rocket.vue → 实际路径为 src/views/views/space/rocket.vue(重复 views)

修复方案:使用项目根目录绝对路径,避免路径解析错误:

javascript 复制代码
// 正确写法 ✅
const importPath = `/src/views${child.path}.vue`

第四步:检查 Vite 动态 import 别名解析(核心坑 3)

尝试使用项目中常用的 @ 别名简化路径,发现动态 import 中别名无法生效:

javascript 复制代码
// 错误写法 ❌ Vite 无法解析动态路径中的 @ 别名
import(`@/views${child.path}.vue`)

// 正确写法 ✅ 使用绝对路径
import(`/src/views${child.path}.vue`)

问题原因:Vite 的别名解析的在静态 import 中能正常工作,但在动态 import 中,由于路径是动态拼接的,无法被 Vite 静态分析,导致别名解析失败,组件加载失败,最终显示我写的首页内容。

解决方案:动态 import 一律使用绝对路径 /src/views/xxx,放弃 @ 别名。

第五步:检查登录后动态路由重新加载(核心坑 4)

修复路径问题后,页面依然不切换,控制台出现 401 错误:

plaintext 复制代码
401 Unauthorized - 请求菜单数据被拒绝

问题原因:动态路由最初在应用启动时就加载,但此时用户尚未登录,没有 Token,请求后端菜单接口被拒绝,导致动态路由未成功注册,路由匹配时只能匹配到我写的首页内容。

修复方案:在登录成功后,拿到 Token 再重新初始化加载动态路由:

javascript 复制代码
// Login.vue 登录逻辑(关键代码)
const handleLogin = async () => {
  try {
    await loginFormRef.value.validate()
    loading.value = true

    // 调用登录接口
    const response = await axios.post('http://localhost:3000/api/auth/login', {
      username: loginForm.username,
      password: loginForm.password
    })

    if (response.data.code === 200) {
      // 登录成功,保存用户信息到 store
      userStore.login(response.data.data)
      
      // 设置 axios 默认请求头(携带 Token)
      axios.defaults.headers.common['Authorization'] = `Bearer ${response.data.data.token}`
      
      // 关键:登录后重新加载动态路由
      await initDynamicRoutes(router)
      
      ElMessage.success(response.data.message)
      router.push('/')
    } else {
      ElMessage.error(response.data.message)
    }
  } catch (error) {
    console.error('登录失败:', error)
    const message = error.response?.data?.message || '登录失败,请重试'
    ElMessage.error(message)
  } finally {
    loading.value = false
  }
}

第六步:清理不存在的页面(隐性坑)

最后,排查发现后端返回的菜单数据中,有部分菜单项对应的前端页面并不存在(例如:空间站、卫星等页面),导致动态 import 加载组件失败,路由匹配降级到我写的首页内容。

修复方案:注释掉后端菜单中不存在的菜单项,保证后端菜单路径与前端 views 目录下的文件一一对应:

javascript 复制代码
// server/routes/menu.js - 注释掉不存在的页面
{
  key: '航天器',
  icon: '🚀',
  children: [
    { name: '运载火箭', path: '/space/rocket', meta: { ... } },
    { name: '载人飞船', path: '/space/spaceship', meta: { ... } },
    // { name: '空间站', path: '/space/station', meta: { ... } }, // 不存在,注释
    // { name: '卫星', path: '/space/satellite', meta: { ... } }, // 不存在,注释
  ]
}

五、问题总结(一张表看懂所有坑)

问题现象 根本原因 解决方案
路由跳转页面不变,始终显示我写的首页内容 动态路由注册为顶层路由,重复创建 Layout 将动态路由挂载到父路由 HomeLayout 下
Vite 报错,组件路径重复(views/views) 相对路径拼接错误,导致路径解析异常 使用 /src/views 绝对路径拼接组件路径
动态 import 别名 @ 失效 Vite 无法静态分析动态路径中的别名 放弃别名,统一使用绝对路径导入
加载动态路由接口 401 应用初始化时未登录,无 Token 无法请求菜单 登录成功后再重新加载动态路由
组件加载失败,路由降级到我写的首页内容 后端菜单与前端实际页面文件不匹配 清理无效菜单项,保持路径一致

六、最终完整可运行源码

1. 动态路由注册 src/router/dynamicRoutes.js

javascript 复制代码
import axios from 'axios'
import Layout from '@/layout/index.vue'

export async function initDynamicRoutes(router) {
  console.log('🚀 开始加载动态路由...')
  
  try {
    const token = localStorage.getItem('token')
    console.log('🔑 token:', token ? '存在' : '不存在')
    
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
    }

    console.log('📡 正在请求菜单数据...')
    const res = await axios.get('http://localhost:3000/api/menu/list')
    const menuList = res.data.data
    console.log('📋 获取到菜单数据:', JSON.stringify(menuList, null, 2))

    let count = 0
    menuList.forEach(group => {
      group.children.forEach(child => {
        if (child.path === '/') return

        // 组件路径:绝对路径,避免解析错误
        const importPath = `/src/views${child.path}.vue`
        console.log('📦 组件路径:', importPath)

        // 路由路径(移除开头的 /,避免路由层级错误)
        const routePath = child.path.startsWith('/') ? child.path.slice(1) : child.path
        console.log('🛤️ 路由路径:', routePath)

        // 注册动态路由(挂载到 HomeLayout 父路由下)
        router.addRoute('HomeLayout', {
          path: routePath,
          name: routePath.replace(/\//g, '-'), // 路由 name 去斜杠,避免冲突
          component: () => import(/* @vite-ignore */ importPath).catch(() => {
            console.warn(`⚠️ 组件不存在: ${importPath}`)
            return import('/src/views/Home.vue') // 降级到我写的首页内容
          }),
          meta: child.meta // 继承菜单的权限元信息
        })

        count++
        console.log(`✅ 已注册路由 ${count}:`, routePath)
      })
    })

    const allRoutes = router.getRoutes()
    console.log('📊 所有注册的路由:', allRoutes.map(r => ({ path: r.path, name: r.name, parentName: r.parentName })))
    console.log('✅ 所有动态路由加载完成,共注册', count, '个路由')
    
  } catch (err) {
    console.error('❌ 路由加载失败', err.message || err)
    if (err.response?.status === 401) {
      // 401 清除 Token,跳转登录页
      localStorage.removeItem('token')
      localStorage.removeItem('userInfo')
    }
  }
}

2. 登录页完整代码 Login.vue

vue 复制代码
<template>
  <div class="login-container">
    <div class="login-form">
      <h2 class="login-title">航天管理系统</h2>
      <el-form
        ref="loginFormRef"
        :model="loginForm"
        :rules="loginRules"
        label-width="0px"
        class="login-form-content"
      >
        <el-form-item prop="username">
          <el-input
            v-model="loginForm.username"
            placeholder="请输入用户名"
            size="large"
            prefix-icon="User"
          />
        </el-form-item>
        <el-form-item prop="password">
          <el-input
            v-model="loginForm.password"
            type="password"
            placeholder="请输入密码"
            size="large"
            prefix-icon="Lock"
          />
        </el-form-item>
        <el-form-item prop="role">
          <el-select
            v-model="loginForm.role"
            placeholder="选择角色"
            size="large"
            style="width: 100%"
          >
            <el-option label="普通用户" value="user" />
            <el-option label="管理员" value="admin" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button
            type="primary"
            size="large"
            style="width: 100%"
            :loading="loading"
            @click="handleLogin"
          >
            登录
          </el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/index.js'
import { initDynamicRoutes } from '@/router/dynamicRoutes.js'
import axios from 'axios'

const router = useRouter()
const userStore = useUserStore()

const loginFormRef = ref()
const loading = ref(false)

const loginForm = reactive({
  username: '',
  password: '',
  role: ''
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' }
  ],
  role: [
    { required: true, message: '请选择角色', trigger: 'change' }
  ]
}

const handleLogin = async () => {
  try {
    await loginFormRef.value.validate()
    loading.value = true

    // 调用真实的登录API
    const response = await axios.post('http://localhost:3000/api/auth/login', {
      username: loginForm.username,
      password: loginForm.password
    })
    
    console.log('=== 登录返回数据 ===')
    console.log('完整响应:', response.data)
    console.log('用户数据:', response.data.data)
    
    if (response.data.code === 200) {
      // 登录成功,保存用户信息到store
      userStore.login(response.data.data)
      
      // 设置axios默认header(携带Token)
      axios.defaults.headers.common['Authorization'] = `Bearer ${response.data.data.token}`
      
      // 关键:重新加载动态路由
      await initDynamicRoutes(router)
      
      ElMessage.success(response.data.message)
      router.push('/')
    } else {
      ElMessage.error(response.data.message)
    }
  } catch (error) {
    console.error('登录失败:', error)
    const message = error.response?.data?.message || '登录失败,请重试'
    ElMessage.error(message)
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-container {
  min-height: 100vh;
  background: linear-gradient(135deg, rgba(8, 19, 47, 0.85), rgba(2, 12, 31, 0.65)), url('/images/1.jpeg') no-repeat center center;
  background-size: cover;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  padding: 24px;
}

.login-container::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.14), transparent 30%), radial-gradient(circle at bottom right, rgba(255, 255, 255, 0.08), transparent 25%);
  z-index: 1;
}

.login-form {
  position: relative;
  z-index: 2;
  width: 360px;
  max-width: 100%;
  padding: 12px 38px 22px 38px;
  background: rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(16px);
  border-radius: 24px;
  border: 1px solid rgba(255, 255, 255, 0.5);
  box-shadow: 0 28px 90px rgba(0, 0, 0, 0.22);
}

.login-title {
  text-align: center;
  margin-bottom: 23px;
  color: #1f2a3c;
  font-size: 26px;
  font-weight: 700;
}

.login-form-content {
  max-width: 100%;
}

.el-form-item {
  margin-bottom: 18px;
}

.el-input__inner,
.el-select .el-input__inner {
  background: rgba(255, 255, 255, 0.95);
  border: 1px solid rgba(31, 42, 60, 0.16);
  border-radius: 10px;
  box-shadow: inset 0 1px 2px rgba(31, 42, 60, 0.06);
}

.el-input__inner:focus,
.el-select .el-input__inner:focus {
  border-color: #409eff;
}

.el-input__inner:hover,
.el-select .el-input__inner:hover {
  border-color: rgba(31, 42, 60, 0.24);
}

.el-button {
  border-radius: 10px;
  font-weight: 600;
  background: #409eff;
  border-color: #409eff;
}

.el-button:hover {
  background: #3a8de0;
}
</style>

3. 后端菜单接口 server/routes/menu.js

javascript 复制代码
// server/routes/menu.js - 菜单路由相关
const Router = require('koa-router')

const router = new Router()

// 完整的菜单数据
const allMenus = [
  {
    key: '首页',
    icon: '🏠',
    children: [
      { name: '首页', path: '/', meta: { title: '首页', requiresAuth: false } }
    ]
  },
  {
    key: '航天器',
    icon: '🚀',
    children: [
      { name: '运载火箭', path: '/space/rocket', meta: { title: '运载火箭', requiresAuth: true, roles: ['user', 'admin'] } },
      { name: '载人飞船', path: '/space/spaceship', meta: { title: '载人飞船', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '空间站', path: '/space/station', meta: { title: '空间站', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '卫星', path: '/space/satellite', meta: { title: '卫星', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '探测器', path: '/space/probe', meta: { title: '深空探测器', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '发射任务',
    icon: '🛸',
    children: [
      { name: '任务规划', path: '/mission/plan', meta: { title: '任务规划', requiresAuth: true, roles: ['user', 'admin'] } },
      { name: '发射记录', path: '/mission/record', meta: { title: '发射记录', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '在轨任务', path: '/mission/orbit', meta: { title: '在轨任务', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '返回任务', path: '/mission/return', meta: { title: '返回任务', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '测控通信',
    icon: '📡',
    children: [
      { name: '地面测控', path: '/tracking/ground', meta: { title: '地面测控', requiresAuth: true, roles: ['user', 'admin'] } },
      { name: '航天测控网', path: '/tracking/network', meta: { title: '航天测控网', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '数据传输', path: '/tracking/data', meta: { title: '数据传输', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '深空通信', path: '/tracking/deepspace', meta: { title: '深空通信', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '航天员',
    icon: '👨‍🚀',
    children: [
      { name: '航天员列表', path: '/astronaut/list', meta: { title: '航天员列表', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '训练计划', path: '/astronaut/training', meta: { title: '训练计划', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '出舱活动', path: '/astronaut/eva', meta: { title: '出舱活动', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '生活保障', path: '/astronaut/life', meta: { title: '生活保障', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '科学实验',
    icon: '🔬',
    children: [
      { name: '微重力实验', path: '/experiment/gravity', meta: { title: '微重力实验', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '生命科学', path: '/experiment/life', meta: { title: '生命科学', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '材料科学', path: '/experiment/material', meta: { title: '材料科学', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '天文观测', path: '/experiment/astronomy', meta: { title: '天文观测', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '系统管理',
    icon: '⚙️',
    children: [
      { name: '用户管理', path: '/system/user', meta: { title: '用户管理', requiresAuth: true, roles: ['admin'] } },
      { name: '角色管理', path: '/system/role', meta: { title: '角色管理', requiresAuth: true, roles: ['admin'] } },
      { name: '菜单管理', path: '/system/menu', meta: { title: '菜单管理', requiresAuth: true, roles: ['admin'] } },
      // { name: '权限管理', path: '/system/permission', meta: { title: '权限管理', requiresAuth: true, roles: ['admin'] } },
      { name: '岗位管理', path: '/system/position', meta: { title: '岗位管理', requiresAuth: true, roles: ['admin'] } }
    ]
  }
]

// 根据用户权限动态返回菜单
router.get('/api/menu/list', async (ctx) => {
  // 从认证中间件获取用户信息(需自行实现认证中间件)
  const userRoles = ctx.state.user?.roles || []

  // 过滤菜单:根据用户角色
  const filteredMenus = allMenus.map(group => {
    const filteredChildren = group.children.filter(child => {
      const meta = child.meta
      // 不需要认证的菜单(如首页)
      if (!meta.requiresAuth) return true
      // 需要认证但没有角色限制的菜单
      if (!meta.roles) return false
      // 检查用户是否有权限访问
      return meta.roles.some(role => userRoles.includes(role))
    })

    // 如果分组下有子菜单,则保留分组;否则过滤掉
    return filteredChildren.length > 0 ? {
      ...group,
      children: filteredChildren
    } : null
  }).filter(Boolean)

  ctx.body = {
    code: 200,
    message: 'success',
    data: filteredMenus
  }
})

module.exports = {
  router,
  allMenus
}

七、经验教训与避坑要点

  1. 路由结构要统一:所有功能页面应共享同一个 Layout 组件,动态路由必须挂载到父路由下,避免重复创建 Layout,导致路由匹配混乱,最终始终显示我写的首页内容。

  2. 动态 import 注意事项 :Vite 环境下,动态 import 无法解析@ 别名,使用绝对路径 /src/views/xxx 更可靠,避免路径解析错误,防止组件加载失败后显示首页内容。

  3. 权限相关动态路由:动态路由依赖用户 Token 和菜单接口,必须在登录成功、拿到 Token 后再重新加载,不能在应用初始化时加载,否则动态路由注册失败,会一直显示我写的首页内容。

  4. 数据与文件要同步 :后端返回的菜单数据,必须与前端 views 目录下的实际页面文件一一对应,避免出现"菜单存在但页面不存在"的情况,防止组件加载失败降级到首页内容。

  5. 排查技巧:遇到"URL 变、页面不变,始终显示我写的首页内容"的问题,优先排查 3 点:① 动态路由挂载是否正确;② 组件导入路径是否正确;③ 动态路由是否成功注册。

八、结束语

这个看似简单的"页面始终显示我写的首页内容"问题,牵扯到路由配置、动态导入、权限校验、前后端数据同步等多个层面,也让我深刻体会到:前端开发中,细节决定成败。

如果你也遇到了类似的动态路由问题,希望这篇文章能帮你快速定位问题、解决问题,少走弯路。

欢迎在评论区交流探讨,共同进步!🚀

(加油啦~zyy)

相关推荐
SurgeJS2 小时前
Vue Rex: 一个更简单的 Vue 3 请求库
前端
前端那点事2 小时前
Vue十万条数据渲染无卡顿!3种工业级方案(附可复制代码+避坑指南)
前端·vue.js
tenggouwa3 小时前
16GB Mac 同时开 3 个 Cursor 拯救我的mac
前端·后端
天天打码3 小时前
从 Rolldown 到 Oxc:前端工具链正在全面 Rust 化
开发语言·前端·rust
zubylon3 小时前
前端 RAG:把文档检索接到聊天页
前端·人工智能·算法
犹豫的果冻布丁3 小时前
OpenSpec 完全中文教程:AI 规范驱动开发入门与实战
前端·后端
Beginner x_u3 小时前
前端八股整理总索引|JS/TS、HTML/CSS、Vue、浏览器、工程化与手写题
前端·javascript·html
Cobyte3 小时前
10.响应式系统演进:通过位运算优化动态依赖收集(Vue3.2)
前端·javascript·vue.js
IT_陈寒3 小时前
Java的HashMap竟然不是线程安全的?刚在生产环境踩了坑
前端·人工智能·后端