
Vue3 加 Element Plus 这套组合我在四个后台管理系统里完整用过最短的一个项目从建仓到上线十一天最长的一个维护了两年多。同样是后台管理系统有的越写越顺有的写到第二十张页面就开始推倒重来差距基本不在业务复杂度而在最开始那两个小时里做的那几个决定——工程怎么初始化、路由权限放在哪一层、组件库怎么引入、请求层怎么封。这篇文章就把这几轮项目里验证过的流程拆开从环境配置到一张标准 CRUD 页面落地再到处上线前必须过的体积和缓存两关一步步说清楚每一步顺带解释为什么这么选。全文基于 Vite Vue3 TypeScript Pinia Element Plus 这条主流技术栈读完之后你应该能直接照着搭出一套能长期维护的后台骨架。1. 从零起步Vite 建 Vue3 工程时那些没人告诉你的选择后台管理系统的技术选型其实没有太多想象空间真正容易出问题的是初始化那几分钟。我见过不少团队上来就npm create vuelatest一路回车结果装完发现没有 TypeScript、没有 Pinia回头再补配置反而比一开始就规划清楚更费时间。这一节把建仓阶段需要拍板的几件事一次性说清楚。1.1 用 create-vue 还是手搓 Vite 配置先给结论除非你要接入已有的构建体系否则直接用官方脚手架create-vue不要手搓。原因很简单后台管理系统是个典型的重配置项目路由、状态管理、代码规范、环境变量这几块都跑不掉create-vue在交互式创建时就能把这些勾选到位。npm create vuelatest admin-system # 依次勾选 # TypeScript / JSX(可选) / Vue Router / Pinia / ESLint / Prettier选完之后重点看两件事。一是vite.config.ts里有没有帮你配好别名指向src没有的话手动补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)) } } })二是确认构建工具确实是 Vite 而不是 Webpack。后台管理系统这种动辄上百个路由、几十个依赖的场景Vite 的冷启动和热更新优势是实打实的——它开发阶段不做全量打包而是基于浏览器原生 ESM 按需加载改一个.vue文件通常几百毫秒就能看到效果。用 Webpack 的话项目规模上去以后热更新等三五秒是常态一天下来浪费的时间很可观。什么时候还留着 Webpack只有一种情况你的后台需要嵌进一个已经在跑的老微前端体系且那套体系的构建插件强依赖 Webpack。除此之外新后台一律 Vite。1.2 Node 版本和依赖版本的三条红线这一块踩坑的人最多尤其是那句uncaught syntaxerror: invalid or unexpected token十个里有八个是 Node 版本或者依赖装重了导致的。第一条红线是Node 版本。Vite 5 要求 Node 18 及以上Vite 6 同样Vite 7 已经要求到 20.19 或 22.12。我个人的建议是统一锁 20 的 LTS 版本用nvm或volta管理并且在package.json里写清楚{ engines: { node: 20.0.0 } }看着不起眼但团队里只要有一个人的 Node 是 16构建时冒出来的报错能让你查一下午。第二条红线是包管理器要统一。这条听着像废话但pnpm、npm、yarn混用导致幽灵依赖的场景我见过太多次。后台项目依赖多pnpm 的软链接机制在节省磁盘和提速上确实好代价是它对没在自己package.json声明却直接 import的行为零容忍——这其实是好事能逼你把依赖写清楚。选定一个之后把 lock 文件提交进仓库再在根目录加个packageManager字段锁死版本。第三条红线是Element Plus 和 Vue 的版本对应关系。Element Plus 2.x 系列要求 Vue 3.2 以上而defineOptions这类宏要到 Vue 3.3 才支持。如果你在script setup里想给组件命名却发现用不了defineOptions八成是 Vue 版本低了。我的习惯是建仓后先跑一遍npm ls vue element-plus把实际解析到的版本列出来而不是只看package.json里写的范围号。1.3 目录结构怎么分才不用三个月后重构后台系统的目录结构只有一个原则按职责分别按技术分得太细。我见过把components拆成base、business、layout、common八层的结果新来的同事找组件得先问人。常用的分层是这样目录放什么判断标准src/api接口请求函数一个业务模块一个文件src/router路由表和守卫静态路由、动态路由分开src/storePinia 模块按状态域划分不按页面src/layout后台整体框架侧边栏、顶栏、标签页src/views页面组件目录结构尽量和路由树对齐src/components全局可复用组件至少被两个页面用到才提上来src/utils纯函数工具不依赖 Vue 实例views的目录结构和路由树对齐这一点特别重要。比如路由是/system/user页面就放src/views/system/user/index.vue中间层级用文件夹。这样做的好处是过半年回来找一个页面你不用先翻路由表凭路径直觉就能点到。另外提一个容易忽略的点不要过早抽全局组件。很多教程一上来就让把所有表格、表单封装成通用组件结果业务一变就发现封装层比业务代码还难改。我自己的做法是同一个模式出现第三次的时候再抽前两次就在页面里写。2. 路由骨架与权限后台系统真正的地基后台管理和普通前台项目最大的区别就是它天生带着一套菜单 权限 登录态的结构。这套东西设计得好后面加页面就是复制粘贴设计得不好每加一个页面都得改三处配置。这一节把路由层的完整链路捋一遍。2.1 静态路由和动态路由的分界线路由一定要分成两份这是所有后台项目的共识静态路由constantRoutes不需要登录就能访问的比如登录页、404、重定向页。动态路由asyncRoutes需要根据用户权限动态挂载的比如系统管理、报表、业务模块。// src/router/routes.ts export const constantRoutes: RouteRecordRaw[] [ { path: /login, component: () import(/views/login/index.vue) }, { path: /404, component: () import(/views/error/404.vue) }, { path: /, redirect: /dashboard } ] export const asyncRoutes: RouteRecordRaw[] [ { path: /system, component: () import(/layout/index.vue), redirect: /system/user, meta: { title: 系统管理, icon: Setting }, children: [ { path: user, name: SystemUser, component: () import(/views/system/user/index.vue), meta: { title: 用户管理, roles: [admin] } } ] } ]这里meta字段是整个后台系统的信息中枢我一般会放这么几个title菜单显示文字和标签页标题icon菜单图标Element Plus 的图标组件名hidden是否在侧边栏隐藏roles哪些角色能看keepAlive页面是否需要缓存affix是否固定在标签页上比如首页字段定义清楚之后路由表本身就是一份配置侧边栏、面包屑、标签页、权限校验全都从这份配置里读数据不用再各自维护一份。2.2 登录态校验与路由守卫的完整链路守卫逻辑是后台项目最容易写出死循环的地方。完整链路我一般这么写router.beforeEach(async (to) { const userStore useUserStore() const whiteList [/login, /404] if (userStore.token) { if (to.path /login) { return { path: / } } // 已经拿到用户信息直接放行 if (userStore.userInfo.roles?.length) { return true } // 第一次进来先拉用户信息再挂动态路由 try { await userStore.getUserInfo() const routes await userStore.generateRoutes() routes.forEach(r router.addRoute(r)) // 关键replace 重新匹配一次 return { ...to, replace: true } } catch (e) { await userStore.logout() return { path: /login } } } else { return whiteList.includes(to.path) ? true : { path: /login } } })重点解释这一句return { ...to, replace: true }。很多人addRoute之后直接next()然后发现刷新页面或者直接输网址进来会白屏。原因是当守卫第一次执行时动态路由还没挂上Vue Router 已经把to解析成了 404你addRoute之后如果不重新走一次导航路由表虽然更新了但当前这次导航用的是旧结果。replace: true是防止用户点浏览器返回时又退回到登录页。还有一个常见坑是守卫里的判断顺序。如果你把userStore.userInfo.roles?.length这个判断放到拉取用户信息之后那每次刷新页面都会重新拉一次接口。判断顺序错了接口调用次数会翻好几倍页面加载速度直接受影响。2.3 侧边菜单如何从路由表长出来菜单组件本质就是一个递归组件。核心逻辑是遍历路由表有children且可见的子项多于一个就渲染el-sub-menu只有一个子项且没有别的选择就直接把这一层拍平成单个菜单项。!-- src/layout/components/SidebarItem.vue -- template template v-if!item.meta?.hidden el-sub-menu v-ifshowChildren :indexresolvePath(item.path) template #title el-iconcomponent :isitem.meta?.icon //el-icon span{{ item.meta?.title }}/span /template sidebar-item v-forchild in item.children :keychild.path :itemchild :base-pathresolvePath(item.path) / /el-sub-menu el-menu-item v-else :indexresolvePath(onlyChild.path) el-iconcomponent :isonlyChild.meta?.icon ?? item.meta?.icon //el-icon template #title{{ onlyChild.meta?.title }}/template /el-menu-item /template /template几个实操细节值得说el-menu上要开router模式index直接给完整路径点击就能跳转不用自己写select。default-active绑route.path刷新后菜单能自动高亮到当前页。但如果路径带了 query 参数route.path是干净的没这个问题。图标从meta.icon里取字符串配合 Element Plus 图标全局注册用component :isiconName /动态渲染。全局注册图标这一步不做的话模板里写字符串是渲染不出来的。3. Element Plus 接入按需引入、主题与中文包的坑组件库接入看起来只是npm install加一行app.use但真正上线前体积、主题、函数式组件样式这三块几乎每个人都会栽一次。这一节按接入顺序讲。3.1 自动导入方案和手动引入的取舍全量引入写起来最省事import ElementPlus from element-plus import element-plus/dist/index.css app.use(ElementPlus)代价是打包体积。全量引入时哪怕你只用了一个按钮整个组件库的样式和逻辑都会进包gzip 之后通常多出两三百 KB。后台系统对首屏没前台那么敏感但两三百 KB 换来的是每次冷启动都慢一拍不划算。主流做法是用unplugin-vue-components和unplugin-auto-import做自动按需引入// vite.config.ts 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: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })配好之后模板里直接写el-button不用 import插件会按需引入对应组件和样式。这里有个必须记住的例外ElMessage、ElMessageBox、ElNotification、ElLoading这几个是函数式调用的unplugin-vue-components管不到它们的样式。表现就是弹出来的提示框没有背景色、没有圆角像裸奔一样。解决办法有两种我推荐第一种// 1. 手动补样式直观改起来清楚 import element-plus/theme-chalk/el-message.css import element-plus/theme-chalk/el-message-box.css第二种是配ElementPlusResolver({ importStyle: sass })走 Sass 变量路线配合下面的主题定制一起用。走 Sass 路线时要注意SCSS 编译会比纯 CSS 慢不少项目大了之后冷启动会明显变慢取舍看你自己。至于中文语言包Element Plus 的日期选择器、分页、表格空状态默认是英文。在入口里配一下import zhCn from element-plus/es/locale/lang/zh-cn app.use(ElementPlus, { locale: zhCn })注意按需引入的时候如果没用app.use(ElementPlus)全量注册语言包要通过ElConfigProvider组件包在根组件外层这是两种引入方式下配置位置不一样的地方。3.2 覆盖主题色时样式不生效的三种原因改主题色是每个后台项目的刚需因为公司有品牌色。标准做法是覆盖 Element Plus 的 SCSS 变量/* src/styles/element/index.scss */ forward element-plus/theme-chalk/src/common/var.scss with ( $colors: ( primary: (base: #3a7afe) ) );然后在vite.config.ts里让所有 SCSS 文件自动带上这个css: { preprocessorOptions: { scss: { additionalData: use /styles/element/index.scss as *; } } }这段配置看着简单但失败概率极高我总结下来就三个原因第一additionalData里的路径写错了。别名在 SCSS 里不一定能被解析稳妥的做法是用相对路径或者配resolve.alias之后再配合~。配置改完一定要重启 dev server热更新不吃这个改动。第二同时引入了全量 CSS。如果你main.ts里还留着import element-plus/dist/index.css那它和你的变量覆盖会打架谁后进来谁生效表现就是主题色时好时坏。走 Sass 覆盖路线时这行必须删掉。第三Sass 版本不对。forward ... with (...)这个语法是 Dart Sass 的模块系统老版本的node-sass不支持会直接报错。检查一下依赖确保用的是sass而不是node-sass。顺带说下深色模式。Element Plus 2.2 之后官方提供了深色主题的 CSS 变量文件切换方式就是在html上加classdarkimport element-plus/theme-chalk/dark/css-vars.css // 切换 document.documentElement.classList.toggle(dark)需要注意你自己写的页面颜色不能硬编码成#fff这种要用 CSS 变量或者用 Element Plus 提供的--el-bg-color这类变量否则切深色时组件黑了、你的页面还是白的非常割裂。3.3 表格、表单、弹窗三大件的二次封装后台系统里百分之七十的代码都在这三件套上。我的封装原则是封装配置不封装结构。表格封装成ProTable把分页、loading、空状态、列渲染这几件重复事收进去列定义用配置数组pro-table :datalist :loadingloading :totaltotal v-model:pagequery.page v-model:sizequery.size changefetchList el-table-column propusername label用户名 / el-table-column propstatus label状态 template #default{ row } el-tag :typerow.status 1 ? success : info {{ row.status 1 ? 启用 : 禁用 }} /el-tag /template /el-table-column /pro-table注意我这里用插槽而不是把列定义全变成 JSON 配置。纯 JSON 配置的表格看起来很优雅但一旦遇到自定义列状态标签、操作按钮、进度条配置就爆炸式增长还不如直接写模板直观。折中方案是列配置只放简单文本列复杂列用插槽。弹窗封装的关键是暴露方法而不是只暴露 props。用defineExpose暴露一个open(row?)方法父组件通过 ref 调用// 子组件 const visible ref(false) const formRef ref() async function open(row?: UserItem) { visible.value true await nextTick() if (row) { Object.assign(form, row) isEdit.value true } else { formRef.value?.resetFields() isEdit.value false } } defineExpose({ open })await nextTick()这一步不能省。因为弹窗是懒加载的visible变 true 之后表单 DOM 才渲染出来这时候调resetFields才有对象。顺序写反了表现就是——第一次打开编辑弹窗表单里还残留着上一次新增时的数据。4. 请求层与状态管理Axios 与 Pinia 怎么配合才不打架请求层和状态管理这两块是后台项目的血管和神经。写得糙一点也能跑但项目一大重复代码和状态混乱就会集中爆发。4.1 Axios 实例的拦截器设计先建一个统一的实例绝对不要在页面里裸用axios.get// src/utils/request.ts import axios from axios import { useUserStore } from /store/user const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) service.interceptors.response.use( response { const { code, data, message } response.data if (code 200) return data // 业务错误提示但不 reject看团队约定 ElMessage.error(message || 请求失败) return Promise.reject(new Error(message)) }, error { // HTTP 层错误 handleHttpError(error) return Promise.reject(error) } ) export default service几个我踩过的点timeout要设。后台有些报表查询接口很慢不设超时的话用户会一直转圈设了之后至少能报个错。15 秒是个比较平衡的值报表类接口可以单独在调用时覆盖。环境变量用import.meta.env.VITE_前缀。Vite 只暴露带这个前缀的变量这是为了防止你误把数据库密码这种敏感信息打进前端包。我一般配三个文件.env公共、.env.development开发、.env.production生产。响应拦截里要不要统一 reject是个团队决策。我倾向于业务失败也 reject这样页面里的await能直接走 catch逻辑清楚。如果你的代码风格是 try/catch 满天飞会显得乱那就在拦截里消化掉错误、只返回成功数据页面里就不用管错误了。关键是团队统一别一半页面 reject 一半页面不 reject。4.2 Token 过期与并发请求重试这个场景很典型用户页面放了五个接口在并发跑恰好这时候 token 过期了五个请求全返回 401结果弹了五个登录已过期的提示用户体验极差。处理方式是加一个标志位保证同一时间只弹一次let isRefreshing false let requestsQueue: Array(token: string) void [] async function handle401(error) { const config error.config if (isRefreshing) { // 正在刷新把请求挂起等新 token 回来再重发 return new Promise(resolve { requestsQueue.push(token { config.headers.Authorization Bearer ${token} resolve(service(config)) }) }) } isRefreshing true try { const userStore useUserStore() const newToken await userStore.refreshToken() requestsQueue.forEach(cb cb(newToken)) requestsQueue [] config.headers.Authorization Bearer ${newToken} return service(config) } catch (e) { userStore.logout() location.href /login } finally { isRefreshing false } }如果你用的是Bearer之外的鉴权方式思路一样就是第一个请求去刷新剩下的排队等。队列一定要记得清空不然会把已经过期的回调一直留着后面再触发 401 会造成重复请求。还有一个更省事的做法干脆不做自动刷新401 就跳登录页让用户重新登。这在安全要求高的后台里反而更合适因为长时间无操作本来就该重新认证。选哪种看你的产品定位别两种混着来。4.3 Pinia 分模块与持久化Pinia 分模块按状态域分不按页面分。后台常见的就四个user用户信息、权限、token、app侧边栏折叠、主题、语言、permission动态路由、tagsView标签页列表。别再多了。持久化用pinia-plugin-persistedstateexport const useUserStore defineStore(user, () { const token ref() const userInfo refUserInfo({}) return { token, userInfo } }, { persist: { key: admin-user, paths: [token] // 只持久化 token用户信息每次重新拉 } })这里paths的取舍很重要。token必须持久化否则刷新页面就掉登录。但userInfo我建议不持久化因为它可能包含了权限变更、昵称修改这些会变的数据从本地缓存读出来可能是过期的。刷新页面重新拉一次接口几十毫秒的事换来的是数据一定准确。还要强调一句不要把列表数据往 store 里塞。很多新手会把表格数据、分页参数都放进 store结果页面之间互相污染关掉再打开还是旧数据。列表数据属于页面级状态用ref放在组件里就够了store 只放跨页面共享的东西。5. 一个标准 CRUD 页面的完整落地路径前面都是地基这一节把地基上真正要盖的房子——一个标准的增删改查页面——从头到尾走一遍。后台里这样的页面能有二三十个写好第一个剩下的就是复制改字段。5.1 查询表单与分页表格的联动查询条件和分页参数我习惯放一个reactive对象里因为它们本质是一组查询状态const query reactive({ page: 1, size: 10, keyword: , status: undefined as number | undefined }) const list refUserItem[]([]) const total ref(0) const loading ref(false) async function fetchList() { loading.value true try { const res await getUserList(query) list.value res.records total.value res.total } finally { loading.value false } } function handleSearch() { query.page 1 // 关键查新条件必须回到第一页 fetchList() } function handleReset() { Object.assign(query, { page: 1, size: 10, keyword: , status: undefined }) fetchList() }handleSearch里那句query.page 1是必须的。不写的话会出现我在第三页搜一个只有两条结果的词结果是空的这种问题因为第三页没有数据。搜索重置页码是产品逻辑上的硬要求不是可选项。loading用try/finally保证关闭别在 catch 里关不然接口报错时 loading 会一直转。至于分页组件如果表格封装了就把分页一起收进去或者单独放el-pagination v-model:current-pagequery.page v-model:page-sizequery.size :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper size-changehandleSearch current-changefetchList /注意size-change要调handleSearch重置页码而不是fetchList因为改了每页条数之后原来的页码可能已经越界。5.2 新增和编辑弹窗的复用策略新增和编辑的表单字段通常一模一样区别只是有没有初始值和提交时调哪个接口。所以一定要复用同一个弹窗组件靠一个isEdit标志区分。前面讲过的defineExpose加open(row?)就是这个思路。再补充两个细节。第一表单验证规则里编辑态的某些字段可能需要禁用。比如用户名编辑时不能改那就用:disabledisEdit处理规则本身不用变。第二提交成功后的刷新时机。我的做法是新增成功后重新拉第一页编辑成功后保持当前页刷新。因为新增的数据一般排在列表最前面而编辑的数据还在原地跳页会让用户失去位置感。async function handleSubmit() { await formRef.value.validate() if (isEdit.value) { await updateUser(form.id, form) } else { await addUser(form) query.page 1 } ElMessage.success(isEdit.value ? 修改成功 : 新增成功) visible.value false emit(success) }5.3 批量操作与二次确认批量删除这类操作两个要素必须齐选中状态管理和二次确认。el-table selection-changehandleSelectionChange el-table-column typeselection width55 / ... /el-table el-button typedanger :disabledselectedIds.length 0 clickhandleBatchDelete 批量删除/el-button没有选中任何行时按钮禁用这个细节能避免用户点了之后发现没反应以为系统卡了。二次确认用ElMessageBoxasync function handleBatchDelete() { try { await ElMessageBox.confirm( 确定删除选中的 ${selectedIds.value.length} 条数据吗删除后不可恢复。, 删除确认, { type: warning, confirmButtonText: 确定删除, cancelButtonText: 取消 } ) await deleteUsers(selectedIds.value) ElMessage.success(删除成功) fetchList() } catch { // 用户点了取消什么都不做 } }ElMessageBox.confirm在用户取消时是 reject 的所以必须try/catch否则控制台会飘一个未捕获的 promise rejection 报错。这个错无害但看着心烦而且有些团队的 CI 会把它当问题。另一个经验是确认文案要写具体。写确定删除吗和写确定删除选中的 5 条数据吗删除后不可恢复用户的心理负担是完全不一样的后者能有效降低误操作率。6. 打包体积、首屏与路由缓存上线前必须过的几道关功能写完只是及格能不能上线还得看体积、首屏和缓存这几关。这一节全是上线前必查项。6.1 按需引入之后还要不要手动分包按需引入解决的是用不到的组件不进包但用得到的那些尤其是 Element Plus 和 Vue 本身还是会在同一个主体积里。首屏要等整个包下载完才能渲染所以还需要分包。build: { rollupOptions: { output: { manualChunks: { vue: [vue, vue-router, pinia], element: [element-plus], echarts: [echarts] } } } }这样拆之后第三方库的包可以被浏览器长期缓存你每次发版只更新业务代码那个小包用户第二次访问会快很多。想搞清楚到底哪个包大装个分析工具npm i -D rollup-plugin-visualizer配进vite.config.ts之后构建会生成一张体积分布图谁占比大一眼就能看出来。我遇到过好几次是某个人随手import _ from lodash全量引入一个包就占了 70KB改成lodash-es按需引入就下来了。服务端能配 gzip 或 brotli 的话一定要配压缩后体积通常能降到原来的三分之一。6.2 keep-alive 缓存的正确姿势后台里用户经常列表点进详情再返回列表如果列表不缓存返回时查询条件和滚动位置全丢体验很差。缓存的机制是在布局层用keep-aliverouter-view v-slot{ Component, route } keep-alive :includecachedViews component :isComponent :keyroute.path / /keep-alive /router-viewcachedViews是一个字符串数组元素是组件的 name不是路由的 path。而script setup写的组件默认没有 name所以include一直匹配不上缓存看起来完全没生效。解决办法是用defineOptions显式声明script setup langts defineOptions({ name: SystemUser }) /script组件 name 最好和路由的name保持一致这样从路由表生成cachedViews的时候直接取route.name就行。配套的还有标签页tabs。用户关掉某个标签时要把对应的 name 从cachedViews里移除否则缓存会越攒越多内存占用一直涨。这个逻辑放在tagsViewstore 里关闭标签的动作里同步删。6.3 深浅色和样式覆盖的收尾最后是样式层面的收尾主要两件事深浅色适配和覆盖 Element Plus 组件样式。深浅色前面提过了核心是不要硬编码颜色。我一般会在全局样式里定义一组业务变量跟随 Element Plus 的变量走:root { --app-card-bg: var(--el-bg-color); --app-border: var(--el-border-color-lighter); } html.dark { --app-card-bg: var(--el-bg-color-overlay); }这样切主题时你自己写的卡片、分割线会跟着一起变。覆盖组件样式时最常见的问题是style scoped里写了样式但不生效。原因是 scoped 会给元素加属性选择器而 Element Plus 组件内部生成的 DOM 没有这个属性。正确写法是用深度选择器style scoped :deep(.el-table__header th) { background-color: var(--app-card-bg); } /style如果遇到:deep也覆盖不掉的情况八成是优先级问题。Element Plus 有些组件的样式带了较高的选择器权重这时候要么在全局样式里写不带 scoped要么用!important尽量少用。还有一种更干净的做法是给组件加一个自定义类名然后用嵌套选择器提高优先级比!important好维护。另外提一个容易被忽略的细节表格、弹窗在深色模式下的背景。它们默认走的是--el-bg-color-overlay而不是--el-bg-color如果你手动改过全局背景色一定要确认这两个变量都有正确值否则会出现深色模式下弹窗还是白的情况。我在实际维护这套后台的两年多里最省时间的习惯其实是——把每一个踩过的坑都写进项目 README 的已知问题里。像函数式组件样式丢失动态路由刷新白屏keep-alive 不生效的三种原因这些新人接手时看一遍就能绕过去比让他自己查半天车要划算得多。后台系统这种东西技术难度真不算高难的是把所有细碎的经验沉淀下来让第二个人、第三个人接手时不用重头再踩一遍。