Vue 3 中后台实战:我踩过的 10 个坑和最佳实践

前言

中后台系统,说简单也简单,无非就是增删改查、表格表单、权限菜单。但真做起来,坑一点都不少。

过去两年我一直在做 Vue 3 的中后台项目,从零搭过两套,也直接用过网上开源的框架。这篇文章不聊花哨的新特性,就聊中后台场景里最实际的问题:权限怎么做、表格怎么封装、请求怎么管、KeepAlive 怎么不踩坑、动态路由怎么刷新不白屏

每个点都会给出可落地的代码,希望能帮到同样在做中后台的你。

1. 动态路由 + 菜单权限,别等到刷新才白屏

中后台绕不开权限。常见做法是:登录后拿到用户权限,动态生成路由,再渲染菜单。

但很多人会踩一个坑:刷新页面后动态路由还没加,就跳到了 404

正确姿势是在全局守卫里处理:

javascript 复制代码
// router/permission.ts
import router from './index'
import { useUserStore } from '@/stores/user'
import { asyncRoutes } from './routes'

const WHITE_LIST = ['/login', '/404']

router.beforeEach(async (to) => {
  const userStore = useUserStore()

  if (WHITE_LIST.includes(to.path)) return true

  if (!userStore.token) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }

  // 已经加载过动态路由,直接放行
  if (userStore.routesLoaded) return true

  // 拉取用户信息 + 权限
  await userStore.fetchUserInfo()

  // 根据权限过滤出可访问路由
  const accessibleRoutes = filterRoutes(asyncRoutes, userStore.permissions)

  accessibleRoutes.forEach((route) => {
    router.addRoute(route)
  })

  // 关键:加一个兜底 404,必须在动态路由之后加
  router.addRoute({
    path: '/:pathMatch(.*)*',
    redirect: '/404',
  })

  userStore.routesLoaded = true

  // 重新导航,确保新加的路由生效
  return { ...to, replace: true }
})

几个关键点:

  • addRoute 之后要用 return { ...to, replace: true } 重新触发导航,否则可能匹配不到
  • 404 兜底路由一定要最后加,不然动态路由会被它拦截
  • routesLoaded 标记,避免每次导航都重复添加

2. 按钮级权限:用自定义指令,别到处写 v-if

菜单权限是路由级的,按钮权限就得细到操作级。常见做法是写一个 v-permission 指令。

typescript 复制代码
// directives/permission.ts
import type { Directive } from 'vue'
import { useUserStore } from '@/stores/user'

export const permission: Directive<HTMLElement, string[]> = {
  mounted(el, binding) {
    const userStore = useUserStore()
    const required = binding.value
    if (!required || required.length === 0) return

    const hasPermission = required.some((p) => userStore.permissions.includes(p))
    if (!hasPermission) {
      el.parentNode?.removeChild(el)
    }
  },
}

使用:

xml 复制代码
<template>
  <el-button v-permission="['user:add']" type="primary">新增</el-button>
  <el-button v-permission="['user:delete']" type="danger">删除</el-button>
</template>

为什么用指令而不是 v-if

  • 语义清晰,一眼能看出这是权限控制
  • 不需要在每个组件里都引入 store
  • 统一逻辑,改起来只改一个地方

如果权限是异步加载的,记得在权限就绪后再挂载组件,或者用 updated 钩子重新判断。

3. 请求封装:拦截器 + 统一错误处理 + 取消重复请求

中后台的请求量很大,不封装会写到崩溃。

typescript 复制代码
// utils/request.ts
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'

const pendingMap = new Map<string, AbortController>()

function getKey(config: any) {
  return `${config.method}&${config.url}&${JSON.stringify(config.params)}&${JSON.stringify(config.data)}`
}

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
})

request.interceptors.request.use((config) => {
  const userStore = useUserStore()

  // 取消重复请求
  const key = getKey(config)
  if (pendingMap.has(key)) {
    pendingMap.get(key)!.abort()
  }
  const controller = new AbortController()
  config.signal = controller.signal
  pendingMap.set(key, controller)

  // 带上 token
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }

  return config
})

request.interceptors.response.use(
  (response) => {
    const key = getKey(response.config)
    pendingMap.delete(key)

    const { code, data, message } = response.data

    if (code === 0) return data

    if (code === 401) {
      const userStore = useUserStore()
      userStore.logout()
      window.location.href = '/login'
      return Promise.reject(new Error('登录已过期'))
    }

    ElMessage.error(message || '请求失败')
    return Promise.reject(new Error(message))
  },
  (error) => {
    if (axios.isCancel(error)) return Promise.reject(error)

    const key = getKey(error.config ?? {})
    pendingMap.delete(key)

    ElMessage.error(error.message || '网络异常')
    return Promise.reject(error)
  }
)

export default request

实践建议:

  • 业务 code 和 HTTP status 分开处理
  • 401 统一跳登录,别每个页面自己判断
  • 重复请求取消,能避免快速切换 tab 时的数据错乱
  • 错误提示统一在拦截器里做,页面只管拿数据

4. 表格封装:中后台 80% 的代码都是表格

中后台最费时间的就是表格。建议抽一个 ProTable 组件,把分页、loading、列配置都封装进去。

xml 复制代码
<!-- components/ProTable/index.vue -->
<script setup lang="ts" generic="T extends Record<string, any>">
import { ref, watch } from 'vue'

interface Props {
  request: (params: any) => Promise<{ list: T[]; total: number }>
  columns: Array<{
    prop: string
    label: string
    width?: number
    slot?: string
  }>
  immediate?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  immediate: true,
})

const list = ref<T[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const searchParams = ref<Record<string, any>>({})

async function loadData() {
  loading.value = true
  try {
    const res = await props.request({
      page: page.value,
      pageSize: pageSize.value,
      ...searchParams.value,
    })
    list.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

function search(params: Record<string, any>) {
  searchParams.value = params
  page.value = 1
  loadData()
}

function reset() {
  searchParams.value = {}
  page.value = 1
  loadData()
}

if (props.immediate) loadData()

defineExpose({ loadData, search, reset })
</script>

封装的核心原则:

  • 分页、loading、请求这些通用逻辑收进组件
  • 列配置用 columns 驱动,特殊列用插槽
  • 暴露 loadDatasearchreset 给父组件
  • 不要过度封装,业务差异太大的场景允许直接写原生表格

5. 表单:动态表单 + 校验规则集中管理

中后台表单往往字段多、校验复杂。建议用配置驱动。

ts 复制代码
// config/userForm.ts
import type { FormItemRule } from 'element-plus'

export const userFormRules: Record<string, FormItemRule[]> = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 3, max: 20, message: '长度 3-20 个字符', trigger: 'blur' },
  ],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
  ],
  phone: [
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
  ],
}
vue 复制代码
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { FormInstance } from 'element-plus'
import { userFormRules } from '@/config/userForm'

const formRef = ref<FormInstance>()
const form = reactive({
  username: '',
  email: '',
  phone: '',
})

async function submit() {
  await formRef.value?.validate()
  // 提交逻辑
}
</script>

<template>
  <el-form ref="formRef" :model="form" :rules="userFormRules" label-width="80px">
    <el-form-item label="用户名" prop="username">
      <el-input v-model="form.username" />
    </el-form-item>
    <el-form-item label="邮箱" prop="email">
      <el-input v-model="form.email" />
    </el-form-item>
    <el-form-item label="手机号" prop="phone">
      <el-input v-model="form.phone" />
    </el-form-item>
  </el-form>
</template>

建议:

  • 校验规则抽到单独文件,方便复用和测试
  • 动态表单用 v-for + 配置数组渲染
  • 提交前统一 validate,不要每个字段单独判断

6. KeepAlive:缓存列表页,但别缓存详情页

中后台经常需要"从列表进详情,返回时列表状态还在"。用 KeepAlive 可以做到,但很容易踩坑。

vue 复制代码
<!-- App.vue 或 Layout.vue -->
<router-view v-slot="{ Component, route }">
  <keep-alive :include="cachedViews">
    <component :is="Component" :key="route.path" />
  </keep-alive>
</router-view>
ts 复制代码
// stores/tabs.ts
export const useTabsStore = defineStore('tabs', () => {
  const cachedViews = ref<string[]>([])

  function addCachedView(name: string) {
    if (!cachedViews.value.includes(name)) {
      cachedViews.value.push(name)
    }
  }

  function removeCachedView(name: string) {
    cachedViews.value = cachedViews.value.filter((v) => v !== name)
  }

  function clearCachedViews() {
    cachedViews.value = []
  }

  return { cachedViews, addCachedView, removeCachedView, clearCachedViews }
})

关键点:

  • include 匹配的是组件的 name ,不是路由 name<script setup> 里需要用 defineOptions({ name: 'UserList' }) 显式声明
  • 详情页通常不缓存,关闭 tab 时要记得 removeCachedView
  • 退出登录要 clearCachedViews,否则下一个用户可能看到上一个用户的数据
vue 复制代码
<script setup lang="ts">
defineOptions({ name: 'UserList' })
</script>

7. 面包屑和标签页:和路由联动

中后台一般都有顶部标签页(tabs)和面包屑。核心是监听路由变化,维护一个 tabs 列表。

ts 复制代码
// stores/tabs.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'

export interface TabItem {
  path: string
  title: string
  name: string
  closable: boolean
}

export const useTabsStore = defineStore('tabs', () => {
  const tabs = ref<TabItem[]>([])
  const activeTab = ref('')

  function addTab(route: RouteLocationNormalized) {
    const path = route.path
    if (tabs.value.some((t) => t.path === path)) {
      activeTab.value = path
      return
    }

    tabs.value.push({
      path,
      title: (route.meta.title as string) || '未命名',
      name: (route.name as string) || '',
      closable: path !== '/dashboard',
    })
    activeTab.value = path
  }

  function removeTab(path: string) {
    const index = tabs.value.findIndex((t) => t.path === path)
    if (index === -1) return

    tabs.value.splice(index, 1)

    if (activeTab.value === path) {
      const next = tabs.value[index] || tabs.value[index - 1]
      activeTab.value = next?.path ?? '/dashboard'
    }
  }

  return { tabs, activeTab, addTab, removeTab }
})

在布局组件里监听:

ts 复制代码
watch(
  () => route.path,
  () => {
    if (route.meta.title) {
      tabsStore.addTab(route)
    }
  },
  { immediate: true }
)

注意:

  • 关闭当前 tab 时要跳到相邻 tab,别直接跳首页
  • 首页/工作台通常不可关闭
  • tab 和 KeepAlive 的 cachedViews 要同步维护

8. 环境变量和代理:别把后端地址写死在代码里

中后台一般有 dev / test / prod 多套环境。

bash 复制代码
# .env.development
VITE_API_BASE_URL=/api
VITE_APP_TITLE=XXX 管理系统(开发)

# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=XXX 管理系统
ts 复制代码
// vite.config.ts
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd())

  return {
    server: {
      proxy: {
        '/api': {
          target: env.VITE_PROXY_TARGET,
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/api/, ''),
        },
      },
    },
  }
})

建议:

  • 只有 VITE_ 前缀的变量才会暴露给客户端
  • 敏感信息不要放前端环境变量里
  • 代理只在开发环境用,生产走 nginx 或网关

9. 大表格性能:虚拟滚动 + 分页 + 按需渲染

中后台经常遇到几千行甚至上万行的表格,直接渲染会卡死。

几个手段:

  1. 分页:最直接,后端分页,每页 20-50 条
  2. 虚拟滚动 :必须一次展示大量数据时用,比如 el-table-v2vue-virtual-scroller
  3. 按需渲染 :展开行、详情弹窗里的表格,用 v-if 懒加载
  4. 避免在表格里写复杂计算:提前在数据层算好
vue 复制代码
<script setup lang="ts">
import { shallowRef } from 'vue'

// 大列表用 shallowRef,避免深度响应式开销
const bigList = shallowRef<Row[]>([])

async function loadBigList() {
  const res = await fetch('/api/big-list')
  bigList.value = await res.json()
}
</script>

原则:

  • 能分页就分页,虚拟滚动是最后手段
  • 大数据用 shallowRef / markRaw
  • 表格列不要放太多 formatter,能预处理就预处理

10. 构建优化:分包 + CDN + 按需引入

中后台打包体积容易失控,几个实用手段:

ts 复制代码
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vue: ['vue', 'vue-router', 'pinia'],
          elementPlus: ['element-plus'],
          echarts: ['echarts'],
          utils: ['lodash-es', 'dayjs'],
        },
      },
    },
    chunkSizeWarningLimit: 1500,
  },
})

其他建议:

  • UI 库按需引入,别全量注册
  • ECharts 按需引入,只注册用到的图表和组件
  • 大的第三方库考虑 CDN 外链
  • 开启 gzip / brotli 压缩
  • 路由懒加载,首屏只加载必要 chunk
ts 复制代码
// 按需引入 Element Plus
import { ElButton, ElTable, ElForm } from 'element-plus'

写在最后

作者是在一个小公司,就我一个前端,大部分东西只能靠自己研究,踩坑也是自己爬,记录一些觉得有用的东西,如果有不对的地方,欢迎大家指正。

相关推荐
江华森1 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端
江华森1 小时前
HTTPS协议详解——SSL/TLS握手、证书与加密通信
前端
江华森1 小时前
从一个HTTP请求看网络分层原理:基于华为云ECS的真实抓包实战
前端
江华森1 小时前
TCP协议详解——三次握手、四次挥手与连接状态
前端
甲维斯1 小时前
《钢铁洪流》官网搞定,纯AI制作,Opus5操刀!
前端·人工智能·游戏开发
szephyr2 小时前
WebSocket 实战:心跳、断线重连、鉴权,一次讲清
前端·websocket·node.js·长连接·实时通信
默_笙2 小时前
🚋 从流水线到地铁网:为什么复杂 AI 都要拆成多 Agent(上)——LangGraph 基础入门
前端·javascript
科技苑2 小时前
前后端分离与微服务架构如何协同?
前端·后端·前端框架