Vue3+Vite2从零搭建通用后台管理系统:工程化与鉴权实战

发布时间:2026/9/15 9:05:41
Vue3+Vite2从零搭建通用后台管理系统:工程化与鉴权实战 后台管理系统大概是前端开发里重复率最高的一类项目了但真正从零手写过的和一直靠开箱即用模板起家的写出来的东西完全是两个层次。这个系列我打算用 Vue3 Vite2.0 的组合从一个空目录开始逐步落地一套通用后台管理系统覆盖工程化搭建、动态路由、权限控制、前后端鉴权、通用组件封装这些全栈开发的主线问题。这篇是上篇先把地基打牢环境、工程结构、请求链路、路由权限模型、后端鉴权接口以及主布局的拆分方式。适合正在入门 Vue3或者用过 Vue2 后台模板但没自己动手搭过整套工程的人参考。1. 先统一认知这个后台管理系统的边界与模块清单1.1 通用后台反复出现的能力模块我刚入行那会儿后台管理系统给我留下的印象就是“页面套页面”左边一个菜单右边一个表格点开是表单提交后刷新列表。做了几个项目之后发现这些系统的共性远比差异多核心能力基本固定在下面这几块登录与登出账号密码换 tokentoken 驱动后续所有请求。布局框架侧边菜单、顶栏、面包屑、内容区再加一个多标签页基本是后台标配。权限控制不同角色登录后菜单和路由不一样接口越权也要兜底。业务页模板列表页搜索、分页、表格操作、表单页新增、编辑、校验、详情页。通用交互文件上传、富文本、字典翻译、状态标签、批量操作。这些东西单独拆开都不难但组合在一起且要支撑业务长期迭代时工程结构的优劣就会明显拉开差距。很多后台项目后期难维护不是因为某个页面逻辑复杂而是从一开始就没有把通用能力和业务代码分开。1.2 本次技术栈选型与为什么这么选这个系列定的技术栈是 Vue3 Vite2.0配套 Vue Router 4、Pinia、Axios、Element Plus后端用 Node.js 的 Express 提供接口。选 Vue3 不用多说组合式 API 带来的逻辑复用能力对后台系统里的分页逻辑、表单联动、权限判断这些高频场景帮助很大。ref、reactive、computed这套响应式心智模型一旦习惯写业务比 Options API 更直接。选 Vite2.0核心原因是开发体验。后台管理系统文件多、依赖多用 Webpack 启动冷启动动辄几十秒改一个文件热更新等两三秒这种手感我相信做过的人都有体会。Vite 基于原生 ES Module冷启动和 HMR 都是毫秒级。Pinia 替代 Vuex因为它去掉了很多 Vuex 的样板代码天然支持组合式写法且和 Vue3 的类型推断配合更好。Express 做后端是因为整个系列的侧重点在前端工程化后端只承担登录鉴权和菜单配置的支撑不需要把 NestJS 的分层架构再铺一层进来。1.3 本系列的分工上篇只做地基还有一个必须放在最前面说清楚的边界这是一篇系列文章的上篇。我见过太多人一上来就做具体页面结果页面做完发现登录鉴权没有、路由守卫没有、请求封装没有全部写在一个组件里后面推倒重来。这个系列的上篇只做地基环境准备、目录设计、请求链路、路由权限模型、后端鉴权接口、布局骨架搭建。动态路由和菜单权限在第五章会讲完整方案但具体业务页面用户管理、角色管理、字典管理这些放在下篇展开。2. Vite 2.0 的工程化价值与环境搭建实录2.1 为什么偏要用 Vite 2.0它解决了什么痛点Vite 2.0 是 2021 年发布的里程碑版本它把开发服务器和构建工具两件事分得很清楚。开发阶段Vite 直接利用浏览器原生 ESModule启动时只做依赖预构建不打包整个业务代码。所以项目再大冷启动也是秒开改一行保存在浏览器里几乎瞬间反馈。这背后用的是 esbuildGo 语言写的打包器比传统 JS 打包器快一个量级。生产构建阶段仍然用 Rollup保证产物做 Tree Shaking、按需导入、代码分割这些能力是完整的。所以 Vite2 不是“只能跑开发”而是开发和构建两套分工明确开发用 esbuild 追求快构建用 Rollup 追求体积和兼容性。和现在的新版 Vite 相比2.0 的 API 和配置方式基本一致。如果你现在安装的是 Vite 5/6本系列的配置文件也照样能看懂只有创建命令略有差别这个我在 2.3 节说明。2.2 Node 版本检查与依赖安装的坑先检查 Node 环境。Vite2 官方要求 Node.js 版本不低于 12.2.0但我实际用下来的经验是不要卡在最低版本建议直接用 14.x LTS 或 16.x。原因是 Vite 依赖预构建过程中对 ESM 的处理逻辑在低版本 Node 上容易出兼容问题表现就是启动时报各种ERR_UNSUPPORTED_NODE_MODULES或者 esbuild 安装失败。如果你机器上同时有多个 Node 版本用 nvm 管理nvm install 14.21.3 nvm use 14.21.3 node -v这里有一个很隐蔽的坑用 nvm 把 Node 切到 14 之后全局 npm 缓存可能还残留高版本 Node 的全局包。建议先执行npm cache clean --force再装依赖。依赖安装慢是另一个高频问题。国内环境建议先配好镜像源npm config set registry https://registry.npmmirror.com如果你项目里准备用 Sass注意一定不要装 node-sass。node-sass 的下载二进制文件在老版本 Node 上是出了名的折磨。Vite 环境下用sassDart Sass就可以npm install -D sass2.3 创建项目与 package.json 解读Vite2 时期的创建命令是npm init vitejs/app my-admin -- --template vue注意Vite 从 3.x 开始把命令改成了npm create vitelatest my-admin -- --template vue新版 create 出来的项目结构与本篇基本一致但插件的版本会更新配置写法没有本质变化。命令执行后会生成一个干净的 Vue3 模板依赖大致如下{ dependencies: { vue: ^3.2.25, vue-router: ^4.0.12, pinia: ^2.0.11, axios: ^0.26.0, element-plus: ^2.1.0 }, devDependencies: { vitejs/plugin-vue: ^2.1.0, vite: ^2.9.15, sass: ^1.49.0 } }这里要说一个版本细节如果你是照着 Vite2 模板安装出来的Vue 版本可能是 3.0.x那时script setup还是实验性功能。我建议把 Vue 升级到^3.2再使用script setup否则会碰到语法支持不稳定的问题。在后续章节的代码里我都会默认使用script setup。装完依赖后先跑一次npm run dev看到Local: http://localhost:3000就算环境通了。Vite2 默认端口是 3000如果你和我一样本地跑了很多服务直接在vite.config.js里改server.port即可。3. 目录结构、环境变量与 axios 请求链路的搭建3.1 src 目录的设计原则后台管理系统的目录规划我常用的原则是先按职责分大层再按业务域分模块。src/ ├── api/ # 接口请求定义按业务模块拆分文件 ├── assets/ # 图片、字体等静态资源 ├── components/ # 全局通用组件如分页、上传、字典标签 ├── composables/ # 组合式函数如 useTable、useForm ├── layouts/ # 主布局组件 ├── router/ # 路由表与路由守卫 ├── store/ # Pinia 状态管理 ├── styles/ # 全局样式与 SCSS 变量 ├── utils/ # 工具函数、请求封装 ├── views/ # 页面组件按业务域分子目录 ├── App.vue └── main.jsapi目录很多人会忽略觉得“直接在页面里调 axios 不就行了”。但后台系统的接口复用率很高同一个getUserList可能列表页用、选择器用、导出功能也用。把接口定义收敛到api目录页面只负责传入参数拿结果后续接口路径调整只需要改一个文件。3.2 别名配置vite.config.js 与 jsconfig/tsconfig 双处同步为了让/能指向src目录需要在两个地方配置。第一处是 Vite 配置import { fileURLToPath, URL } from node:url import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } })注意这里我用的是fileURLToPath(new URL(./src, import.meta.url))不是path.resolve(__dirname, src)。主要原因在于 Vite 内部会把配置文件按 ESM 处理而__dirname在 ESM 环境里不存在用fileURLToPath更稳。第二处是 IDE 智能提示。如果项目没有用到 TypeScript也要添加一个jsconfig.json{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } }, include: [src/**/*], exclude: [node_modules, dist] }这个文件不配置的话编辑器里/utils/request这类导入路径不会跳转也不会自动补全。3.3 环境变量文件的组织与 BaseURL 注入后台系统至少要区分开发和生产两套环境变量。在项目根目录创建.env.development和.env.production# .env.development VITE_APP_TITLE通用后台管理系统 VITE_API_BASE_URL/api VITE_MOCK_SWITCHfalse# .env.production VITE_APP_TITLE通用后台管理系统 VITE_API_BASE_URL/api VITE_MOCK_SWITCHfalseVite 只会把带VITE_前缀的变量注入到import.meta.env。这么做是有意的隔离设计——防止把 Node 环境里的秘密变量全量暴露给浏览器端。在代码里读取const baseURL import.meta.env.VITE_API_BASE_URL这里有一个容易踩的坑.env文件修改后必须重启 dev server否则import.meta.env不会刷新。很多人在.env.development里改了端口或 BaseURL刷新页面发现没生效就是这个原因。3.4 拦截器设计登录态注入与统一错误处理axios 封装是整个前端请求链路的枢纽。我的设计是三个职责注入 token、拆包返回数据、统一处理错误。import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( (response) { const res response.data if (res.code 0) { return res.data } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message || 请求失败)) }, (error) { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) } ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default service后端约定的响应包裹结构是{ code, data, message }code 0表示成功。这样业务代码里调接口时直接拿到data不需要每个页面都if (res.code 0)判断一次。401 处理放在响应拦截器的error分支里原因是后端鉴权失败返回的 HTTP 状态码通常是 401axios 会把它当作请求异常进入 error 分支。这里要做的是清掉本地 token然后带redirect参数跳登录页登录成功后用户可以回到之前想访问的页面。4. 让加载速度跑起来的产物细节路由设计、Pinia 与按需 UI4.1 路由表的最小模型静态路由动态路由后台系统路由不可能一次性注册完。普通用户没有权限访问的页面如果路由表里直接存在用户输入 URL 也能打开等于没做控制。所以我采用双路由模型静态路由登录页、404、主布局壳。动态路由需要登录后根据用户权限动态追加的路由表。// src/router/index.js import { createRouter, createWebHistory } from vue-router export const constantRoutes [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { title: 登录 } }, { path: /, name: Layout, component: () import(/layouts/index.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 首页 } } ] }, { path: /:pathMatch(.*)*, name: NotFound, component: () import(/views/error/404.vue), meta: { title: 404 } } ] const router createRouter({ history: createWebHistory(), routes: constantRoutes }) export default router注意这里所有页面组件都用了动态导入() import()这样 Vite 会按路由自动分包首屏只加载当前页面需要的 JS 文件。后台系统页面多不做路由级分包的话首屏体积会非常难看。4.2 Pinia 为什么比 Vuex 更合适这个项目项目里登录态、用户信息、菜单权限都是全局共享的必须有一个全局状态容器。Pinia 相比 Vuex 4 最直观的改进是省掉了mutations层。Vuex 里改状态要走actions-mutations两跳Pinia 中直接调用 action 或直接给 state 赋值就行。后台系统里最常见的场景就是登录后把 user 信息写进 storePinia 的写法更接近普通对象操作。// src/store/user.js import { defineStore } from pinia import { loginApi, getUserInfoApi } from /api/user import { constantRoutes } from /router export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , name: , roles: [], menus: [] }), actions: { async login(loginForm) { const data await loginApi(loginForm) this.token data.token localStorage.setItem(token, data.token) }, async fetchUserInfo() { const data await getUserInfoApi() this.name data.name this.roles data.roles this.menus data.menus return data }, logout() { this.token this.name this.roles [] this.menus [] localStorage.removeItem(token) } } })在组件或者路由守卫中使用的时候有一个注意点在 Pinia 中没有被组件模板直接使用到的 state一定要通过 storeToRefs 取出来才能保持响应性。比如路由守卫里要判断userStore.token直接在守卫函数里读值是没问题的因为守卫本身就是依赖函数执行时的快照但如果在组件里用const { token } userStore解构出来的 token 不会跟随 store 更新。正确写法import { storeToRefs } from pinia const userStore useUserStore() const { token } storeToRefs(userStore)4.3 组件库按需加载配置Element Plus 是后台系统的常用 UI 库但全量引入会让首屏体积多出几百 KB。Vite2 时代就流行按需导入这里用unplugin-vue-components自动注册组件// vite.config.js import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), Components({ resolvers: [ElementPlusResolver()] }) ] })这样在模板里写了el-table构建时插件会找到 Element Plus 对应组件的源码自动 import体积只包含实际用到的组件。需要注意按需导入后ElMessage、ElMessageBox 这类函数式调用不能直接用全局组件自动注册解决需要手动引入样式import { ElMessage, ElMessageBox } from element-plus import element-plus/es/components/message/style/css import element-plus/es/components/message-box/style/css5. 全栈开发的前后端握手Express 鉴权接口与 Vite Proxy5.1 后端服务的目录与接口设计全栈开发意味着要同时写前端 Vue 和后端服务。项目根目录下新建server目录单独作为后端服务运行。server/ ├── index.js # Express 入口 ├── routes/ │ ├── auth.js # 登录/登出 │ └── user.js # 用户信息/菜单权限 ├── data/ # mock 数据后续可替换为 MySQL └── package.json后端接口设计尽量和前面 axios 封装里的响应结构一致统一返回{ code, data, message }。这样前后端约定在一开始就稳定下来避免后端返回{ status: 200 }前端却按code 0判断的错位问题。5.2 JWT 签发的完整流程登录接口用 JWT 做无状态鉴权。后端用jsonwebtoken签发 token前端后续请求在 Authorization 头带回来后端中间件校验身份。先安装依赖cd server npm init -y npm install express jsonwebtoken cors登录接口核心代码const express require(express) const jwt require(jsonwebtoken) const cors require(cors) const app express() app.use(cors()) app.use(express.json()) const SECRET_KEY process.env.SECRET_KEY || dev-secret app.post(/api/login, (req, res) { const { username, password } req.body if (username admin password 123456) { const token jwt.sign( { username, role: admin }, SECRET_KEY, { expiresIn: 8h } ) res.json({ code: 0, data: { token, username } }) } else { res.status(401).json({ code: 1, message: 账号或密码错误 }) } })这里的SECRET_KEY在正式项目中绝不写死在代码里应从环境变量读取。JWT 本身自带过期时间expiresIn: 8h前端 401 拦截后跳转登录页前端无需再维护一套过期逻辑。但 JWT 有个现实问题服务端签发之后在过期前没法主动让它失效用户被踢下线或者改密码后旧 token 仍然有效。对这个系列的通用后台来说单点登录和强制下线机制先不做实际生产环境中如果需要可以再加一层 token 黑名单机制。5.3 获取用户信息与菜单权限接口登录成功只是第一步前端还需要知道当前用户能看哪些菜单、能访问哪些路由。用户信息接口返回用户基本资料和菜单列表app.get(/api/user/info, (req, res) { const token req.headers.authorization?.replace(Bearer , ) try { const payload jwt.verify(token, SECRET_KEY) if (payload.username admin) { res.json({ code: 0, data: { name: 管理员, roles: [admin], menus: [ { path: /dashboard, title: 首页, icon: HomeFilled }, { path: /system, title: 系统管理, icon: Setting, children: [ { path: /system/user, title: 用户管理 }, { path: /system/role, title: 角色管理 } ] } ] } }) } else { res.status(403).json({ code: 1, message: 无权访问 }) } } catch (err) { res.status(401).json({ code: 1, message: token 无效或已过期 }) } })这套接口返回的是“菜单配置”前端拿它做两件事一是动态添加路由二是渲染侧边菜单。菜单和路由在后台系统里可以共用同一份数据模型因为大多数菜单项点进去就是一个页面路由。5.4 前端路由守卫与动态路由生成接口有了接下来是前端路由守卫的核心逻辑。需求是未登录只能去登录页已登录访问登录页要重定向到首页刷新页面时要从后端重新拉用户信息并动态加路由。router.beforeEach(async (to) { const userStore useUserStore() if (!userStore.token) { if (to.path /login) return true return /login?redirect${encodeURIComponent(to.fullPath)} } if (to.path /login) return / // 已登录但用户信息为空说明是刷新页面需要拉取用户信息 if (!userStore.menus.length) { await userStore.fetchUserInfo() // 动态注册路由 const menus userStore.menus const accessRoutes generateAccessRoutes(menus) accessRoutes.forEach((route) { router.addRoute(route) }) // 重新进入当前导航让新注册的路由生效 return { ...to, replace: true } } return true })generateAccessRoutes这块放在下篇详细写因为它牵扯到后端返回的菜单数据如何转换为 Vue Router 可用的路由记录以及按钮权限级别怎么处理。这里只看关键跳转逻辑动态路由注册完毕后必须return { ...to, replace: true }让 Vue Router 放弃当前导航重新走一遍到目标地址。不这样做的话用户访问/system/user时由于路由在导航开始前还没有注册会被匹配到 404。5.5 Vite Proxy 解决本地联调跨域前端跑在 3000 端口后端跑在 3001 端口直接请求必然跨域。开发环境最佳方式是 Vite Proxy让浏览器只感知到同源请求// vite.config.js server: { port: 3000, proxy: { /api: { target: http://localhost:3001, changeOrigin: true } } }这样前端请求/api/loginVite 开发服务器会转发到http://localhost:3001/api/login浏览器的地址栏还是http://localhost:3000/api/login没有跨域问题。代理配置里有一个细节changeOrigin: true必须写。后端如果根据请求头里的 Host 判断来源比如生成全链接地址时不加这个字段拿到的还是前端域名可能导致回调地址错误。生产环境不需要 Vite直接用 Nginx 做反向代理。把前端构建产物放到静态目录/api路径代理到 Node 服务。这个我在后续如果有部署相关的文章再展开但核心代理逻辑和 Vite Proxy 是相通的。6. 主布局 Layout 的拆分策略与通用组件边界6.1 后台布局常见的三块骨架做后台管理系统最忌讳把布局写进每个页面。正确做法是抽一个主布局组件所有页面都作为子路由渲染在内容区里。主布局src/layouts/index.vue拆成三个核心部分------------------------------- | Header: 折叠按钮、面包屑、用户 | ------------------------------ | Sidebar| AppMain | | 菜单 | 嵌套路由渲染区域 | | | | ------------------------------AppMain部分用 Vue Router 的RouterView渲染嵌套路由template div classapp-wrapper Sidebar / div classmain-container Header / tabs-bar / main classapp-main RouterView v-slot{ Component } transition namefade-transform modeout-in component :isComponent / /transition /RouterView /main /div /div /template侧边菜单的数据源不写死从 Pinia 的 menus 中读取。这样权限接口返回什么菜单界面就渲染什么菜单前端不需要维护第二份菜单配置。后端返回的菜单字段结构就是 5.3 节那种嵌套结构。6.2 通用业务组件的抽象边界布局之上后台系统还有一类高频产出列表页的 ProTable、搜索条件的 SearchForm、表单弹窗的 DialogForm。这些组件如果要拆需要先想清楚边界不然骨架阶段拆得太细后面业务变化会反复返工。我的建议是骨架阶段只确定这些组件存在的目录位置和接口约定不要急着实现完整逻辑。比如 ProTable 组件确定它接收哪些 props请求接口、列配置、分页参数、搜索表单字段。具体表格列怎么渲染、分页组件怎么封装放到下篇做具体页面时再落代码。这样做的原因很实际通用组件一定是多个页面沉淀出来的不是靠想象设计出来的。写一个页面就抽象组件容易导致组件为了“通用”堆砌大量毫无用处的配置项最后连自己都看不懂。6.3 下篇展望与一致性问题到这一步一个后台管理系统的地基层已经出来了项目能启动请求链路通了登录鉴权闭环跑通后端能发 token前端路由守卫能根据菜单动态加路由布局壳子已经搭好。下篇我会把这些能力落到具体业务上系统管理模块的用户管理、角色管理、菜单管理三个页面以及 ProTable、SearchForm 这类通用组件的二次封装还有按钮权限指令v-permission的实现。在继续写业务之前有一个建议值得讨论把统一约定先固化下来。比如所有接口都用{ code, data, message }包裹所有分页参数固定叫pageNum和pageSize所有时间字段统一用时间戳还是字符串。这些问题如果在骨架阶段不确定后面几十个页面做起来会有改不完的细节。在我自己维护这个项目的过程中感受最深的一点是后台管理系统能不能长期平稳维护不是看某几个页面写得多漂亮而是看公共层的约定是否从一开始就稳定。上面的代码全部是实际运行验证过的如果你照着搭的时候在某个步骤卡住优先检查版本匹配Vue 是否升到 3.2vitejs/plugin-vue是否匹配 Vite2Node 是否在 14 以上。这三个版本齐了这个地基基本不会出幺蛾子。