首页 / 资讯中心 / 文章详情

Vue3+Vite+TS+Pinia后台管理系统工程化实践与避坑指南

Vue3+Vite+TS+Pinia后台管理系统工程化实践与避坑指南 ★ FEATURED ARTICLE
简介面向Vue3前端开发者的一份后台管理系统源码包完整整合了Vite、TypeScript、Ant Design Vue与Pinia这套企业级中后台的主流技术栈覆盖从开发调试到生产构建的完整工程配置。项目以实际业务场景为骨架集中展示Composition API逻辑复用、Vite自动生成路由、Pinia模块化状态管理、Keep-Alive页面缓存、Suspense异步组件加载等关键实践并通过TypeScript类型约束强化代码安全可直接用于学习参考或二次开发。压缩包共172个文件含74个Vue组件、63个TypeScript逻辑与类型文件、9个JSON配置另有Less样式、TSX入口、Lint规则、测试脚本等配套内容整体大小约232KB目录结构清晰便于按业务模块快速查找。已有1194人学习下载尤其适合有一定Vue基础、希望系统掌握前端工程化落地细节的开发者无论是系统学习还是作为中后台项目起步脚手架都具有很高参考价值。1. 这套后台管理系统技术栈到底在解决什么问题Vue3、Vite、TypeScript、Ant Design Vue、Pinia 这套后台管理系统技术组合拆开看每个词都不陌生但拼在一起项目难度的重心就从「写页面」转移到了「工程约束」上。一个典型的后台管理系统一半以上的工作量并不是界面而是登录鉴权、路由守卫、请求封装、权限按钮这些横切逻辑。这套组合的价值在于用类型把横切逻辑收住Vite 保证启动和构建速度Pinia 让全局状态有迹可循TypeScript 让接口返回值和 store 字段都有据可查。适合正在做中后台项目、想从 Vue2 JS 迁移到 Vue3 TS 的开发者和团队。拿到这类项目包第一件事不该是急着看页面而是先把工程配置读明白。2. 为什么是 Vue3 Vite TS Ant Design Vue Pinia选型逻辑与版本红线做后台管理系统技术选型最怕的不是「不会用」而是「版本不配套」。Vue3 和 Vite 是官方确认的标准搭配Pinia 是 Vue 官方钦定的状态管理方案Ant Design Vue 是成熟的中后台组件库。这一套组合不是简单的功能叠加而是每一层都解决了上一层的痛点Vue3 的组合式 API 让代码聚合Vite 解决编译速度TypeScript 解决协作维护Pinia 解决响应式状态Ant Design Vue 解决重复造轮子。下面把每层拆开讲清楚。2.1 Vue3 与 Vite替代 Webpack 的配套组合后台管理系统页面数量多路由动辄几十个组件更是上百个。如果还在用 Webpack冷启动和热更新都要等几秒甚至十几秒开发体验非常难受。Vite 的开发服务器基于浏览器原生 ES Module 按需编译冷启动几乎秒开改一个组件只刷新那一个模块热更新速度明显快。这是 Vite 在这套组合里存在的核心原因。Vue3 的script setup写法也非常适合后台管理这类业务密集型项目。以前用 Options API一个业务功能要拆到 data、methods、computed 三个区块来回看用组合式 API 之后同一个功能的变量和函数写在一起逻辑聚合度高。配合自定义 composable像表格分页、表单校验、弹窗开关这类高频逻辑都能抽成可复用函数而不是复制粘贴代码块。这里有一条红线Vue3 项目不要再用 Vue CLI 起步。Vue CLI 对 Vue3 的支持一直比较被动而且它的 dev server 本质还是 Webpack速度瓶颈绕不开。Vite 配套 Vue3 是当前的主流做法vitejs/plugin-vue 是官方维护的插件。版本上需要注意Vite 5 及更新的版本要求 Node 18 以上低版本 Node 会在启动阶段直接报错这个坑后面避坑章再细说。2.2 TypeScript 在后台管理系统里的真实收益后台管理系统本质上是数据管理系统用户数据、订单数据、配置数据前端拿到什么、展示什么、提交什么数据流非常固定。TypeScript 在这个场景下的价值不像在复杂业务组件里那么花哨但它能解决最实际的维护问题——接口返回字段写错、类型漏判、字段改名后没同步。最大的收益在协作场景。一个项目由多个开发者维护A 同学定义好的接口类型B 同学在页面上拿到返回值后编辑器自动补全字段名写错直接红线报错。这比上线后才发现「这个字段为什么一直是 undefined」要效率高得多。类型声明前期会有工作量但后台管理的列表页、表单页、详情页都是重复模式类型定义一次可以复用整个项目。我的建议是types 目录先建api 层先收口。不要一上来就到处写 any至少把登录、用户信息、列表接口这三类的类型定义好。等到项目中期你就会发现所有 any 都会变成维护时的黑匣子改了接口文档还要去代码里猜哪里漏改。越早收口后期越省力。2.3 Ant Design Vue 与 Pinia组件库和状态管理的选型细节Ant Design Vue 的 3.x 版本正式支持 Vue3。选择它的原因很简单后台管理系统需要的布局、表格、表单、弹窗、树选择、日期选择这类组件它是全的而且交互细节比较成熟。搭配 unplugin-vue-components 可以实现组件按需自动导入打包体积不会被整包组件库拖垮。如果你的团队之前用过 Ant Design 系迁移成本还会更低。Pinia 是相对 Vuex 更推荐的选择。它去掉了 mutations可以直接修改 state代码量减少一半模块之间是扁平的不再有 Vuex 的 module 嵌套和命名空间问题最重要的是它的 TypeScript 推导非常好store 里定义了什么类型页面里使用时就自动有类型提示。用 Pinia 管理登录用户信息、权限码、菜单状态这类全局数据结构非常清晰。还需要补充一个标题里没提但后台管理系统离不开的角色Vue Router 4。这套组合没有路由守卫是不可能称为「后台管理系统」的登录态校验、权限过滤、菜单生成全部挂在路由上。后面第四章会专门讲路由守卫和 Pinia store 配合的写法和细节。3. 把项目包跑起来依赖安装、Vite 配置与目录结构拿到这类项目 zip最常见的第一反应是直接npm install然后npm run dev。但如果是几年前的包大概率会装完就跑不起来。我的习惯是先把 package.json 和 vite.config.ts 读一遍确认版本配套关系再动手装依赖。下面按顺序拆解。3.1 package.json 与依赖版本对照先弄清楚装了什么解压后第一件事打开 package.json。重点关注两个位置scripts 里的命令以及 dependencies 和 devDependencies 的划分。后台管理项目的依赖结构通常会长的像下面这样{ scripts: { dev: vite, build: vue-tsc vite build, preview: vite preview, type-check: vue-tsc --noEmit }, dependencies: { vue: ^3.4.0, vue-router: ^4.2.0, pinia: ^2.1.0, ant-design-vue: ^3.0.0 }, devDependencies: { vitejs/plugin-vue: ^5.0.0, typescript: ^5.3.0, vite: ^5.0.0, vue-tsc: ^1.8.0, unplugin-auto-import: ^0.17.0, unplugin-vue-components: ^0.26.0, types/node: ^20.0.0 } }注意版本号只是示例以你实际拿到的 zip 为准但结构和分层逻辑是统一的。运行时依赖放 dependencies构建期工具放 devDependencies。特别说明build脚本里的vue-tsc它会在打包前做一次完整的类型检查如果项目里有类型错误构建会直接失败。这是这套组合和纯 JS 项目最大的差异——类型错误不是警告而是阻断。确认版本后用node -v检查本机 Node 版本。如果发现 Vite 版本和 Node 不匹配先升级 Node 再装依赖不然后面报错会非常难排查。安装命令就是常规的npm install如果发现装完后启动报错优先看是不是 lock 文件与当前 Node 版本不兼容。常见做法是删掉 node_modules 和 lock 文件重新安装或者让 npm 重新解析依赖树。这算是这类项目包最常见的起步障碍之一。3.2 vite.config.ts别名、代理与按需引入vite.config.ts 是这套组合里最值得逐行读的文件。它至少承担三个职责配置别名、配置开发环境代理、配置组件自动导入。下面是一份常见且可复用的配置import { fileURLToPath, URL } from node:url import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { AntDesignVueResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ imports: [vue, vue-router, pinia], dts: src/types/auto-imports.d.ts }), Components({ resolvers: [AntDesignVueResolver()], dts: src/types/components.d.ts }) ], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } }, build: { chunkSizeWarningLimit: 1500 } })这个文件里有几个细节值得展开。第一是别名的写法用fileURLToPath(new URL(./src, import.meta.url))是 ESM 下的标准做法不依赖__dirname也省去了配置types/node的麻烦。第二是代理的 rewrite如果后端接口已经带/api前缀rewrite 会把前缀去掉如果不带前缀就不要写 rewrite否则会把路径改没。这块很容易配错导致请求 404。第三是组件自动导入。AntDesignVueResolver()会让模板里直接使用的a-table、a-form等组件自动按需引入样式也自动带进来。AutoImport 则负责把ref、computed、useRouter这类 API 自动注入页面里直接写就能用。这两个插件会在 src/types 下生成 dts 文件记得提交到 Git不要放进 .gitignore否则类型提示会丢失。3.3 src 目录怎么拆分views、stores、api、types后台管理系统的目录结构直接决定这个项目能维护多久。下面是一个在这类项目里比较常见、也比较稳的拆分方式目录职责说明src/views页面组件按业务模块建子目录每个页面一个文件夹src/storesPinia 状态模块user、permission、app 等全局状态src/api接口请求函数按业务模块拆分文件统一导出src/types全局类型定义接口返回类型、路由 meta 类型、公共类型src/router路由配置与守卫index.ts 注册路由permission.ts 写守卫src/components通用业务组件按组件名拆文件尽量无状态src/utils工具函数请求实例、日期格式化、权限指令等src/layouts布局组件侧边栏、顶栏、主内容区组成的框架这个拆分的好处是每一层职责单一页面不直接写 axiosstore 不关心视图api 层只处理请求和类型。在类型收口方面env.d.ts 也要单独维护用来扩展 Vite 的环境变量类型/// reference typesvite/client / interface ImportMetaEnv { readonly VITE_API_BASE_URL: string readonly VITE_APP_TITLE: string } interface ImportMeta { readonly env: ImportMetaEnv }对应地项目根目录会有 .env.development 和 .env.production 两个文件分别定义不同环境下的VITE_API_BASE_URL。运行时通过import.meta.env.VITE_API_BASE_URL读取这样开发环境和生产环境切接口只需要改环境变量文件不需要动代码。这个习惯能让「环境切换」这件事彻底告别手改。4. 核心模块怎么写路由守卫、Pinia 状态管理与 axios 封装后台管理系统能不能用看三个模块路由守卫、用户状态、请求封装。这三个模块是横切逻辑的核心也是这类项目包最值得研究的地方。下面按常见的主流写法逐步拆解。4.1 登录态与动态路由router.beforeEach 怎么用路由守卫的核心目标只有一个不允许未登录用户进入需要鉴权的页面。在 vue-router 4 里守卫既可以写在路由注册之后的单独文件里也可以直接在 router/index.ts 中注册。我一般会把守卫逻辑单独抽到 src/router/permission.ts方便维护。先看路由注册// src/router/index.ts import { createRouter, createWebHistory } from vue-router import { routes } from ./routes const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) export default router再看守卫的逻辑。vue-router 4 里的守卫函数可以返回true、false或者一个路由地址对象不再依赖之前 Vue Router 3 的next()回调式写法。这会让代码明显更简洁也更不容易出「next 调用多次」这种老问题。登录权限和用户信息初始化的完整代码如下// src/router/permission.ts import router from ./index import { useUserStore } from /stores/user router.beforeEach(async (to) { const userStore useUserStore() // 到登录页直接放行 if (to.name login) { return true } // 没有 token跳转登录页并记录来源路径 if (!userStore.token) { return { name: login, query: { redirect: to.fullPath } } } // 有 token 但还没有用户信息先拉取用户信息再继续 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() } catch { userStore.logout() return { name: login } } } return true })这段逻辑需要注意的是await userStore.fetchUserInfo()之后还用return true可以让页面跳转在用户信息拿到之后再发生。否则会出现页面先渲染、用户昵称和权限码还没回来的闪空窗。另外如果这里不做userInfo判断每次刷新浏览器都会重复拉一遍用户信息加上这个判断就能只在首次进入时拉取一次。如果需要做按钮级权限后续可以扩展为用户信息返回后根据权限码过滤路由表再通过router.addRoute动态注册。基础版先保证登录链路完整动态路由属于权限控制里更复杂的一层需要跟菜单表、后端权限接口一起设计不必一开始就堆上去。4.2 Pinia 状态模块用户信息与权限的拆分方式Pinia 的 store 建议按业务领域拆而不是按页面拆。用户信息、登录状态、退出登录这些逻辑放一个 user store权限码、菜单折叠这类放 app 或 permission store。拆分粒度可以看一个模块被多少页面共享共享多就独立成 store。用户 store 是这套组合里最核心的状态模块下面用 setup 风格写法演示// src/stores/user.ts import { defineStore } from pinia import { ref } from vue import { login as loginApi, fetchUserInfo } from /api/auth import type { LoginParams, UserInfo } from /types/user export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const userInfo refUserInfo | null(null) async function login(payload: LoginParams) { const { token: newToken } await loginApi(payload) token.value newToken localStorage.setItem(token, newToken) } async function fetchUser() { userInfo.value await fetchUserInfo() } function logout() { token.value userInfo.value null localStorage.removeItem(token) } return { token, userInfo, login, fetchUser: fetchUser, logout } })注意 token 持久化这里用的是 localStorage刷新页面后 token 恢复但内存里的 userInfo 是空的所以第一段守卫逻辑里才会有!userStore.userInfo的补拉判断。这是这套组合里「刷新后登录态不丢」的关键。在页面组件里使用 store 时有一个高频细节不要直接对 store 实例做解构那会丢失响应式。正确做法是配合storeToRefsimport { storeToRefs } from pinia import { useUserStore } from /stores/user const userStore useUserStore() const { userInfo, token } storeToRefs(userStore)按钮权限如果要做成指令可以在 src/utils/permission.ts 里注册一个自定义指令// src/utils/permission.ts import type { Directive } from vue import { usePermissionStore } from /stores/permission export const permission: Directive { mounted(el: HTMLElement, binding) { const permissionStore usePermissionStore() const required binding.value as string const hasPermission permissionStore.codes.includes(required) if (!hasPermission) { el.parentNode?.removeChild(el) } } }然后模板里用button v-permissionuser:create新增/button按钮没有权限码时直接不渲染。这个方案实现成本低又不依赖第三方库适合后台管理系统的权限按钮场景。常见做法是把指令在 main.ts 里app.directive(permission, permission)注册后续所有页面直接可用。4.3 axios 实例封装请求拦截、响应拦截与类型注入axios 封装是后端联调的核心也是最容易写出「又臭又长」代码的部分。贴一份在这个项目里常见且可复用的实现// src/utils/request.ts import axios from axios import { message } from ant-design-vue import { useUserStore } from /stores/user import router from /router const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) // 请求拦截自动带 token request.interceptors.request.use((config) { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截统一处理业务码和 401 request.interceptors.response.use( (response) { const res response.data // 业务码非 0 视为业务错误 if (res.code ! 0) { message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { if (error.response?.status 401) { const userStore useUserStore() userStore.logout() router.push({ name: login }) } else { message.error(error.message || 网络异常) } return Promise.reject(error) } ) export default request这段封装里有两个设计值得说明。第一响应拦截器里把res.data直接返回业务层拿到的就是真正的数据部分不需要在每个接口里都做解包。相应的api 函数的返回类型也只描述数据本身的类型。第二业务码判断用的code ! 0具体判断逻辑要看后端约定有的是0有的是200还有的是00000这个字段必须跟后端对齐后才能形成正式约定。API 层的类型注入才是这套组合相比 JS 项目的最大优势。假设有一个用户列表接口类型和请求函数这样写// src/api/user.ts import request from /utils/request import type { PageResult, UserItem, UserQuery } from /types/user export function getUserList(params: UserQuery) { return request.getPageResultUserItem(/user/list, { params }) }PageResultT是一个泛型类型意思是「分页包裹的数据结构里面列表项的类型是 T」。使用的时候getUserList返回的数据自动有list和total字段提示UserItem上的字段也能自动补全。如果后端改了字段名编辑器会直接在类型使用处标红这是 TS 在后台管理系统里最实的价值。5. 避坑记录版本冲突、类型报错与部署后白屏的 5 个常见坑这套组合的坑不在业务代码而在版本配套、类型声明、部署环境这三类位置。下面按「现象 → 原因 → 解决」的格式整理 5 个高频问题都是真实场景里踩过、也查过很久的经验。5.1 ant-design-vue 版本错配页面空白、组件不渲染现象项目启动后没有任何报错但页面空白控制台打印ant-design-vue相关组件没有注册或者安装依赖时报错 peerDependencies 冲突。原因ant-design-vue 2.x 是匹配 Vue2 的版本3.x 才匹配 Vue3。如果 package.json 里写的是ant-design-vue: ^2.2.0装出来的组件库和 Vue3 的响应式系统不兼容组件注册后渲染不出来。解决npm uninstall ant-design-vue然后安装 3.x 最新稳定版npm install ant-design-vue^3.0.0。装完后检查 main.ts 里的注册方式按需自动导入场景下不需要手动app.use(Antd)如果两者混用会出现组件重复注册的警告。5.2 Node 与 Vite 版本不匹配启动即崩溃现象执行npm run dev后终端直接抛Error: Cannot find module rollup或者类似The CJS build of Vites Node API is deprecated的报错npm install 过程本身也可能有编译失败。原因新版本 Vite5.x 及之后要求 Node 18 及以上低版本 Node 的模块解析机制和 Vite 内部依赖不兼容。很多老项目包在旧的 Node 环境下安装装出来的依赖树不符合新版本要求。解决先用node -v确认版本如果低于 18升级 Node 环境。升级后删除node_modules和package-lock.json重新执行npm install。如果公司内网有 npm 私服还要确认私服上是否缓存了旧版本的 Vite 包这个也可能导致解析到错误版本。5.3 Pinia 解构丢响应式页面数据改了不更新现象从 store 里解构出来的变量在 store 中更新后页面视图没有变化重新进入页面才显示最新值。原因const { userInfo } useUserStore()这种解构拿走的是普通值而不是响应式引用。Pinia 的 store 是基于 reactive 实现的直接用解构会丢失 Proxy 包装。解决使用storeToRefs做解构。import { storeToRefs } from pinia const userStore useUserStore() const { userInfo, token } storeToRefs(userStore)只有 state 层的数据需要storeToRefsstore 里的 actions 方法直接解构没有问题因为方法本身不依赖响应式劫持。5.4 SPA history 路由部署后刷新 404现象本地开发一切正常nginx 部署后访问首页正常但点击路由跳转后再刷新页面报 404。原因vue-router 4 默认使用createWebHistory路由路径不是真实文件路径。nginx 收到/user/list这样的请求后去站点根目录找这个路径对应的文件找不到就返回 404。只有前端通过 history API 拦截跳转时才不会发真实请求。解决在 nginx 配置里把找不到的路径全部回退到 index.html。location / { try_files $uri $uri/ /index.html; }这里需要注意try_files的回退会让所有 404 都落到 index.html后端接口如果有独立 location 匹配要写在前面路由回退的 location 只兜前端页面路径。5.5 tsconfig 的 paths 和 Vite 的 alias 不一致类型报错但运行正常现象代码里用/stores/user导入时Vite 启动正常、页面也正常但 TypeScript 报「Cannot find module /stores/user」的红线。原因Vite 的 alias 只是在构建时把路径替换成真实路径TypeScript 的类型解析走的是 tsconfig.json 里的paths配置。两边没同步时运行时没问题类型检查过不去。尤其是vue-tsc参与的 build 流程会在打包阶段直接失败。解决确认 tsconfig.json 里有对应配置{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }baseUrl必须存在paths里的路径是基于baseUrl解析的。改完后重启 IDE让 TS Server 重新加载配置否则编辑器里仍然会残留旧报错。6. 进阶给表格页做一套泛型封装把类型红利吃满后台管理系统里最高频的页面是表格查询页。找一个又长又全的功能页面你会发现列表、筛选、分页的代码占了很大篇幅。把这块收口成泛型封装是 TypeScript 在这套组合里最有说服力的应用。先定义一个通用的列表接口返回类型放在 src/types/page.tsexport interface PageResultT { list: T[] total: number page: number pageSize: number }然后封装一个组合式函数useTableList接收请求函数和初始查询参数import { ref, onMounted } from vue export function useTableListT, Q extends Recordstring, any( api: (params: Q) PromisePageResultT, initialParams: Q ) { const loading ref(false) const list refT[]([]) const total ref(0) const query refQ({ ...initialParams }) async function load() { loading.value true try { const data await api({ ...query.value }) list.value data.list total.value data.total } finally { loading.value false } } function reset() { query.value { ...initialParams } load() } onMounted(load) return { loading, list, total, query, load, reset } }页面里的用法就变得极其干净const { loading, list, total, query, load, reset } useTableList(getUserList, { page: 1, pageSize: 10, keyword: })模板里直接绑定list、total到表格查询条件变化时重新调load。getUserList的函数签名决定了list自动是UserItem[]类型表格的 columns 里字段名写错编辑器立刻标红。这套封装的价值不在于少写代码而在于把「接口返回的数据一定是某种类型」这个约定固化到整个页面层。我早期做这类项目时图快在列表页里用 any 接接口数据当时觉得省事三个月后维护同一个模块后端改了字段名我对着十几个页面一个个找哪个地方用了旧字段。从那以后再也不敢让 any 出现在 api 层和 store 层。这套泛型封装就是那次翻车后沉淀下来的习惯。如果你正在改造或仿写这个技术栈的后台管理系统不妨从表格页开始先尝到类型推导的甜头再把同样的约束推广到表单、详情和弹窗。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站