☰
Vue3 + Element Plus 后台管理系统实战:架构、权限与避坑指南
2026/9/29 1:23:46 网站建设 项目流程

1. 项目缘起与整体设计思路

1.1 为什么我最后选了 Vue3 + Element Plus 这套组合

后台管理系统这个品类,说实话已经被做烂了,但每年还是会有一批新项目要起。我前后带过四五个中后台项目,从最早的 jQuery + Bootstrap,到后来的 Vue2 + Element UI,再到现在的 Vue3 + Element Plus,每次技术选型都会被问同一个问题:图什么?

先说结论。Vue3 + Element Plus 这套组合,在当前时间点上,是中小型中后台项目性价比最高的一条路。理由不是"Vue3 性能好"这种空话,而是几个具体到能算账的点。

第一个是组合式 API 对逻辑复用的改造。Vue2 时代我们用 mixin 抽公共逻辑,一个页面引三四个 mixin,变量从哪来的根本查不到,改一个方法要全局搜。换成 Vue3 的setup加上自定义组合函数之后,一个列表页的分页、搜索、加载状态、重置逻辑能打包成一个useTable,哪个页面用了、传了什么参数,一眼看得清楚。我实测过一个 30 多个页面的后台,重构前后代码量从 8000 多行降到 6000 出头,而且新人接手时理解成本降得特别明显。

第二个是Element Plus 的组件覆盖度。后台系统 90% 的界面就是表格、表单、弹窗、日期选择、上传这几样东西反复拼。Element Plus 把这几个核心组件做得足够厚,el-table支持多级表头、固定列、树形数据、懒加载,el-form的校验规则覆盖了从必填到自定义异步校验的全场景。这意味着你不需要为了一个复杂表单去引第三方库,减少依赖就是减少维护成本。

第三个是构建工具链的成熟。Vite 现在已经不是"尝鲜"阶段了,冷启动一个中型项目两三秒,热更新基本是保存即生效。对比早期 Vue CLI 用 webpack 那种改一行等五秒的体验,开发时的耐心消耗完全不是一个量级。

当然也有取舍。Vue3 的响应式用 Proxy 实现,对 IE 是完全不支持的,如果你的甲方还在要求兼容 IE11,那这套方案直接出局。另外 Element Plus 的默认样式偏"朴素",想要那种设计感强的后台,要么用它的 CSS 变量系统深度定制,要么就干脆换 Naive UI 这类更现代感的库。我个人的判断标准是:团队里有 2 年以上 Vue 经验的人、项目周期在 3 个月以内、不需要兼容老浏览器,这三条同时满足,闭眼选这套。

1.2 一套后台系统到底要拆成哪几块

很多新手一上来就写登录页,写着写着发现路由守不住、权限没地方放、请求拦截器到处复制。根子在于没先把架构分层想清楚。我这套项目的分层是这样的:

基础设施层,包括 Vite 配置、环境变量、路径别名、代理配置、代码规范工具。这层不写业务,但决定了后面所有代码写得顺不顺。

通用能力层,封装 axios 实例、统一响应处理、token 存取、全局状态(用 Pinia)、路由实例。这层的目标是让业务层"拿来就用",不用关心底层细节。

布局与框架层,侧边栏、顶栏、标签页、内容区、面包屑。这层是可复用的外壳,业务页面都挂在里面。

业务页面层,具体到每个模块的列表、详情、新增、编辑。

我见过太多项目把 token 逻辑写在每个请求里、把权限判断写死在菜单组件里,后期加一个角色就要改十几个文件。分层不是为了好看,是为了改动时能被定位。你改权限规则,就只动通用能力层那一个文件,业务层完全不用碰。

1.3 目录结构怎么排才不闹心

下面是我用了几个项目之后固定下来的目录结构,你可以直接抄:

src/ ├── api/ # 所有接口定义,按模块拆分 │ ├── user.js │ └── role.js ├── assets/ # 静态资源,会被构建处理 ├── components/ # 全局公共组件 │ └── SvgIcon/ ├── composables/ # 组合式函数,逻辑复用核心 │ ├── useTable.js │ └── usePermission.js ├── layout/ # 布局外壳 │ ├── index.vue │ └── components/ ├── router/ # 路由与守卫 │ ├── index.js │ └── guard.js ├── stores/ # Pinia 状态 │ ├── user.js │ └── app.js ├── styles/ # 全局样式与变量 ├── utils/ # 纯函数工具 │ ├── request.js │ └── auth.js └── views/ # 业务页面,与路由一一对应

关键点是api和views的对应关系。我的习惯是路由路径、页面目录、接口文件三者同名,比如路由是/system/user,那页面在views/system/user/index.vue,接口在api/system/user.js。这样别人问你"用户管理在哪",答案只有一个。命名一致性是团队协作里被严重低估的东西。

关于composables这个目录,我要单独强调一下。这是 Vue3 相对 Vue2 最大的结构性变化,把可复用的响应式逻辑单独抽出来。它不是"工具函数",工具函数是纯输入输出,而 composable 内部可以有ref、computed、生命周期钩子。理解这一点,你才算真正用上了 Vue3。

2. 环境搭建与工程化配置的关键细节

2.1 从零初始化一个不埋雷的工程

初始化这一步看着简单,但很多坑是在这里埋下的。第一件事:确认 Node 版本。Vite 5 要求 Node 18 以上,Vite 6 要求 Node 18.18 以上。用node -v看一眼,版本不对先升。Windows 上如果同时装了多个 Node,建议用nvm-windows管理,切换起来比手动改环境变量省事太多。

创建项目直接用它官方的脚手架:

npm create vue@latest my-admin

然后它会交互式问你要不要 TypeScript、要不要 Pinia、要不要 Router、要不要 ESLint 和 Prettier。我的建议是:

  • Pinia 和 Router 一定要选,后面必然要用,手动装还得配。
  • TypeScript 看团队。如果团队 TS 经验足就上,类型提示在大型项目里能省很多 debug 时间;如果团队全是新手,强上 TS 会变成any满天飞,反而更乱。我个人的折中是:新项目用 TS,老项目迁移用 JS。
  • ESLint + Prettier 一定要选。多人协作没有统一规范,代码 review 会变成格式争吵。

创建完之后进目录装依赖,然后跑起来看看能不能正常访问。

2.2 Vite 配置里必须改的几处

默认生成的vite.config.js是能跑,但基本不能用于实战。我一般会改这几个地方,每一处都有明确的理由。

路径别名。默认导入组件要写../../../components/xxx,层级一多眼睛都花。配个别名之后@/components/xxx就搞定:

import { fileURLToPath, URL } from 'node:url' export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })

这里为什么要用fileURLToPath而不是直接用path.resolve(__dirname, 'src')?因为在 ES Module 环境下__dirname是拿不到的,用 URL 转换是更稳妥的写法,也不会在某些打包场景下出错。

开发服务器代理。本地开发时前端跑在 5173,后端跑在 8080,直接请求会撞跨域。代理配置是这样:

server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }

changeOrigin: true的作用是修改请求头里的 Host,让后端认为请求来自它自己的域名,绕过同源检查。这个参数忘了加,后端有时会拒绝请求,而且报错信息很不直观。

打包优化。默认打包所有依赖进一个大 chunk,首屏加载会很难看。把体积大的库单独拆出来:

build: { rollupOptions: { output: { manualChunks: { 'element-plus': ['element-plus'], 'echarts': ['echarts'], 'vendor': ['vue', 'vue-router', 'pinia'] } } } }

这么拆的好处是,业务代码改动时,vendor和element-plus这两个大 chunk 的 hash 不变,用户浏览器缓存能命中,只有业务 chunk 需要重新下载。一个真实项目里这个优化能把二次访问的加载时间从 2 秒多降到 400 毫秒左右。

2.3 Element Plus 怎么引入才不拖慢首屏

Element Plus 支持全量引入和按需引入两种方式。全量引入代码简单,但会把整个库几百个组件都打进包里,压缩后大概几百 KB,首屏直接受影响。

按需引入推荐用unplugin-vue-components和unplugin-auto-import这两个插件,配置之后你在模板里用<el-table>,插件自动识别并只引入用到的组件样式:

import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })

配置完要重启 dev server 才生效。有个坑要提醒:自动引入的 API(比如ElMessage)如果插件没正确识别,运行时会报ElMessage is not defined,这时候检查一下插件版本和 Vite 版本是否匹配。另外ElMessage、ElMessageBox这类命令式调用的组件的样式,有时需要手动在入口文件引入一次,因为插件对它们样式的自动处理不完全。

注意:按需引入不是万能的,它省的是"没用到"的组件,你真正用到的组件该多大还是多大。如果项目用到了 Element Plus 里几乎全部组件(这种情况很少),全量引入反而更省事。

关于热词里很多人问的 "vue ui 框架对比 element plus",简单说一句:后台管理密集使用表格和表单的场景,Element Plus 的文档和社区案例是最厚的,遇到问题搜得到答案;Naive UI 类型友好、主题定制强,但生态相对薄;Ant Design Vue 组件丰富,风格偏企业级。没有绝对好坏,看你团队熟悉哪个。

3. 核心骨架:路由、状态与请求层的落地

3.1 路由设计:静态路由和动态路由要分开

后台系统的路由有两类。一类是登录页、404 页这种谁都能访问的,叫静态路由,直接写在router/index.js里。另一类是登录后根据用户权限动态生成的,叫动态路由。

分开写的原因很实际:如果所有路由都写死,那低权限用户虽然看不到某个菜单,但手动输 URL 照样能进页面,这是安全问题。动态路由的逻辑是,登录拿到用户信息后,从后端拿这个用户能访问的菜单列表,前端根据列表过滤出路由,再用router.addRoute动态添加。

// router/index.js 静态部分 const constantRoutes = [ { path: '/login', component: () => import('@/views/login/index.vue') }, { path: '/404', component: () => import('@/views/error/404.vue') } ] // 登录后动态添加 function addDynamicRoutes(menuList) { menuList.forEach(menu => { if (menu.component) { router.addRoute('Layout', { path: menu.path, name: menu.name, component: import(`@/views/${menu.component}.vue`), meta: { title: menu.title, icon: menu.icon } }) } }) }

这里addRoute的第一个参数'Layout'是父路由的 name,表示把这个路由挂到 Layout 下面。踩过的坑是:如果你挂载的路由 name 和已有的重名,Vue Router 会覆盖不报错,导致有些页面莫名消失。所以动态路由的 name 一定要保证唯一,我一般用路径转驼峰来生成。

3.2 路由守卫:判断顺序不能乱

守卫是整个权限体系的闸门。逻辑顺序错了会出现死循环或者白屏:

router.beforeEach(async (to, from, next) => { const userStore = useUserStore() const token = userStore.token if (!token) { // 没 token,除了白名单都赶去登录 if (whiteList.includes(to.path)) { next() } else { next(`/login?redirect=${to.path}`) } return } // 有 token 但没用户信息,说明是刷新页面,重新拉一次 if (!userStore.userInfo) { try { await userStore.getUserInfo() next({ ...to, replace: true }) } catch (e) { await userStore.logout() next('/login') } return } next() })

关键点是那句next({ ...to, replace: true })。刷新页面时动态路由还没加上,直接next()会走到 404。重新触发一次导航,这次路由已经生成好了,就能正确匹配。这个写法是官方推荐的,但很多人第一次做都会卡在这。

还有个细节:whiteList白名单里必须包含/login,否则未登录访问登录页会被重定向到登录页,无限循环。

3.3 Pinia 状态管理怎么切分

Pinia 比 Vuex 舒服太多,没有 mutation 那层了,直接在 action 里改 state。但怎么切 store 是有讲究的,切得太粗会变得像个全局变量垃圾场,切得太细又会到处互相引用。

我的切分原则是按领域切,而不是按功能切。常见的有这几个:

  • user:token、用户信息、权限列表
  • app:侧边栏折叠状态、设备类型、主题色
  • tabs:标签页列表、当前激活项(如果需要多标签)

重点说user,它是权限体系的核心:

export const useUserStore = defineStore('user', { state: () => ({ token: getToken() || '', userInfo: null, roles: [], permissions: [] }), actions: { async login(loginForm) { const res = await loginApi(loginForm) this.token = res.data.token setToken(res.data.token) }, async getUserInfo() { const res = await getUserInfoApi() this.userInfo = res.data.user this.roles = res.data.roles this.permissions = res.data.permissions } } })

为什么 token 要同时存在 state 和 localStorage?state 里是为了响应式,localStorage 是为了刷新页面不丢。两者要同步,登出的时候都要清掉。我见过只清了一个导致刷新后还是登录态的 bug,排查半天。

3.4 请求层封装:一次写好,受益全程

axios 封装是最值得花心思的地方。封得好,业务里写请求就一行;封不好,每个请求都要重复写 token、重复处理错误。

先创建实例,配好基础路径和超时。然后是请求拦截器,主要做两件事:带上 token、处理重复请求。带 token 是标配:

service.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }, error => Promise.reject(error))

重复请求这块我要多讲两句。用户手快连点两次提交按钮,就发出两个一样的请求,可能造成重复下单、重复新增。做法是维护一个 pending 请求的 Map,请求发出去前用method + url + 参数生成 key,已存在就取消前一个。这个功能不是必须的,但涉及资金、库存这种敏感操作时一定要加,配合按钮的 loading 状态双保险。

响应拦截器更关键,它决定了业务层怎么写:

service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { // token 过期,清掉并跳登录 userStore.logout() router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { // 网络层错误,超时、断网等 ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )

注意区分两种错误:业务错误(HTTP 200 但 code 不是成功值,比如用户名密码错)和网络错误(HTTP 4xx/5xx、超时)。前者在第一个回调里处理,后者在第二个。很多新手只写了第一个回调,导致服务器 500 时页面一片安静,用户完全不知道发生了什么。

处理完这些,业务层调用就干净了:const res = await getUserList(params),然后直接用res.data。这就是封装的价值。

4. 实战落地:从登录到增删改查全流程

4.1 登录页要处理的几个非表面问题

登录页看着就是个表单,但有几个点是新手的重灾区。

表单校验。用el-form的 rules,密码这类字段加上长度和格式限制。写法就不展开,重点提醒:rules 里每个字段对应的trigger要选对,输入框用blur,选择器用change。trigger 写错了会出现"填了还提示必填"的情况。

登录按钮防抖。前面说的重复请求防护是一层,按钮自身的 loading 是另一层。在handleLogin开头把 loading 置 true,请求结束后无论成功失败都置回 false,用finally。

记住密码。热门搜索里 "vue3 登录页面 点线动态的背景" 说明很多人想给登录页加视觉效果。用 canvas 画动态点线背景不算难,但注意性能:粒子数量控制在 60 到 100 个,用requestAnimationFrame驱动,离开页面时cancelAnimationFrame把循环停掉,否则在登录页停留久了会一直占用 CPU。

登录成功后的跳转有个坑:如果 URL 里带了redirect参数,要跳回那个地址,而不是固定跳首页。否则用户从某个深层页面被踢到登录,登录完回到首页还要自己找回去,体验很差。

4.2 列表页:分页、搜索、加载的组合拳

列表页是后台系统里重复率最高的东西,值得抽成useTable:

import { ref, reactive, onMounted } from 'vue' export function useTable(apiFn, initParams = {}) { const loading = ref(false) const list = ref([]) const total = ref(0) const query = reactive({ page: 1, pageSize: 10, ...initParams }) async function fetchData() { loading.value = true try { const res = await apiFn(query) list.value = res.data.list total.value = res.data.total } finally { loading.value = false } } function handleSearch() { query.page = 1 // 搜索时重置到第一页,这个很重要 fetchData() } function handleReset() { Object.assign(query, { page: 1, pageSize: 10, ...initParams }) fetchData() } onMounted(fetchData) return { loading, list, total, query, fetchData, handleSearch, handleReset } }

两个细节值得展开。第一,handleSearch里把page重置为 1。如果不重置,你在第 5 页按搜索,搜出来结果只有 2 页,就会显示空白。这个 bug 非常常见。第二,loading用try/finally保证。如果写在成功回调里,一旦请求报错 loading 永远是 true,表格一直转圈。

表格本身用el-table,字段多的时候给关键列加fixed="right"固定操作列,宽度用min-width而不是width,让列能自适应。

4.3 表单新增与编辑:复用一套代码

新增和编辑用同一个表单弹窗是惯例。区别在于编辑时要先回填数据。我的做法是用一个dialogType变量区分:

function handleAdd() { dialogType.value = 'add' formRef.value?.resetFields() // 先重置校验状态 Object.assign(form, getDefaultForm()) dialogVisible.value = true } async function handleEdit(row) { dialogType.value = 'edit' dialogVisible.value = true // 先打开弹窗再请求,避免弹窗卡顿 const res = await getDetailApi(row.id) Object.assign(form, res.data) }

这里有个顺序讲究:编辑时先打开弹窗再拉详情,让用户立刻看到响应;如果用await等数据回来才开弹窗,网络慢时会有明显延迟感。

还有resetFields这个坑。它的作用是重置表单校验状态和字段值,但它只对在 el-form 上声明的 model 字段有效,而且要在表单渲染完成后调用。如果你在弹窗还没渲染的时候调,会报错或无效。所以我一般用formRef.value?.resetFields()加可选链,且确认dialogVisible已经为 true。

4.4 删除操作:二次确认不能少

删除是最危险的操作,一定要二次确认:

async function handleDelete(row) { try { await ElMessageBox.confirm(`确定删除「${row.name}」吗?`, '提示', { type: 'warning' }) } catch { return // 用户点取消,直接返回 } await deleteApi(row.id) ElMessage.success('删除成功') fetchData() }

ElMessageBox.confirm在用户点取消时会reject,所以要 catch 住,不然后台会报未捕获的 Promise 错误。删除后要刷新列表,但更友好的做法是判断当前页删完后是否为空,如果空了就往前翻一页再刷新。这个细节能让用户少一次"删完发现页面空白"的困惑。

批量操作要用el-table的selection-change事件收集选中项,注意row-key必须设置且唯一,否则选中状态在数据更新后会错乱。

5. 常见问题排查与避坑实录

5.1 开发环境类问题速查

下面这张表是我和团队踩过的坑里,最高频的一部分,直接对照排查:

现象大概率原因处理方式
启动报 Node 版本错误Node 低于 18升级 Node,用 nvm 管理多版本
组件自动引入失败插件未生效重启 dev server,检查插件与 Vite 版本
代理请求 404后端路径没配对检查 rewrite 规则是否正确去掉前缀
修改样式不生效scoped 样式隔离用:deep()穿透子组件样式
HMR 热更新失效文件大小写不一致检查 import 路径大小写与文件名完全一致

特别说下Windows 上大小写问题。Windows 文件系统不区分大小写,所以import Foo from './foo.vue'即使实际文件是Foo.vue也能跑。但一旦部署到 Linux 服务器,构建直接失败。建议在 VS Code 里开启files.insensitive相关提示,或者干脆养成严格大小写的习惯。

还有热词里提到的 "vue3 项目在 edge 浏览器中无法关闭最小化按钮",这类问题一般是某些第三方库操作了浏览器原生 UI 导致的,属于特定环境问题,排查时先确认是不是引入了某个 UI 库或浏览器扩展所致。

5.2 请求相关的典型问题

token 过期后页面不停弹登录框。原因是响应拦截器里发现 401 直接跳登录,但页面上可能有多个并发请求同时返回 401,就会触发多次跳转。解决办法是加一个标志位,第一个 401 的时候标记正在跳转,后续 401 直接忽略:

let isRelogin = false if (res.code === 401 && !isRelogin) { isRelogin = true userStore.logout().finally(() => { isRelogin = false router.push('/login') }) }

上传文件时 Content-Type 不对。用el-upload配合自定义请求时,如果手动设了Content-Type: application/json,文件就传不上去。正确做法是用FormData,并让浏览器自动带上 multipart 的 boundary,不要手动设 Content-Type。

GET 请求传数组参数丢失。axios 默认对数组的序列化可能不符合后端预期。如果后端要ids=1&ids=2,需要配置paramsSerializer或者用URLSearchParams手动拼。

5.3 权限控制的常见误区

只藏了菜单没管路由。前面说过,菜单隐藏不代表路由不可达。真正的权限控制要三层:菜单不显示、路由不注册、按钮不渲染。按钮级权限可以用自定义指令:

// v-permission 指令 app.directive('permission', { mounted(el, binding) { const userStore = useUserStore() if (!userStore.permissions.includes(binding.value)) { el.parentNode?.removeChild(el) } } })

用的时候<el-button v-permission="'user:delete'">删除</el-button>。注意用的是removeChild而不是display: none,因为隐藏的按钮理论上还能通过控制台触发点击,删掉更彻底。当然真正的安全还是要后端再校验一次,前端权限只是防误操作和提升体验,绝对不能作为安全边界。

动态路由刷新丢失。这是最经典的坑,前面路由守卫那里已经给了方案,核心就是刷新后重新拉一次菜单并生成路由,且用next({ ...to, replace: true })重新触发导航。

5.4 性能与体验优化心得

表格大数据量卡顿。一页 100 条以上、列又多的表格会明显卡。第一选择是后端分页,别一次拉几千条;如果确实要展示大量行,用el-table-v2虚拟滚动,只渲染可视区域,几千行也丝滑。

图标全量引入导致包变大。Element Plus 的图标如果用import * as Icons全量注册,会显著增加体积。建议按需引入,或者用unplugin-icons自动处理。

路由懒加载别忘了写。所有业务页面都用() => import()动态导入,Vite 会自动分包,首屏只加载当前页面。如果全写成同步 import,首屏会把所有页面都拉下来,白屏时间翻倍。

提醒:性能优化一定要有数据支撑再动手。先跑一次构建看 chunk 体积,用浏览器的 Performance 面板看真实瓶颈,再决定优化哪块。凭感觉优化往往做了无用功。

5.5 我从几个项目里攒下的实操建议

最后分享几条不成体系但很值钱的经验。

环境变量要区分清楚。.env.development和.env.production里的接口地址要区分开,且变量名必须以VITE_开头 Vite 才会暴露给客户端。曾经有个项目把生产地址写进了开发配置,打包后直接请求了生产库,幸好及时发现。涉及敏感信息的东西绝对不要写在前端环境变量里,因为打包后是明文可见的。

提交前一定要跑一次 lint 和构建。本地开发时 Vite 不做类型检查和严格的 lint,很多错误要到构建才暴露。装个husky加lint-staged,提交时自动跑,能拦住一大批低级错误。

给团队写一份路由与接口的约定文档。不用长,一页纸说清楚命名规范、目录对应关系、接口返回格式。这份文档能省下后面无数次口头解释,尤其是团队有新人的时候。

贵在一致,不在最优。技术选型上纠结半天用哪个 UI 库、哪个状态管理,对项目成败的影响远小于"全项目风格是否统一"。一套不那么完美但贯彻到底的规范,胜过每个人各显神通。我带过的项目里出问题最多的,从来不是技术选型选错,而是同一件事有五种写法。

关于后续扩展,这套骨架往上加东西其实很顺:需要可视化大屏就接 ECharts,需要流程图就接 LogicFlow,需要导出打印就接打印组件。因为基础层封得干净,加新能力基本不动老代码。这也是为什么我一开始强调要把基础设施层和通用能力层做扎实,前面多花的半天时间,后面每个新功能都在还本付息。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询