Vue2后台管理实战:从列表页到权限与性能优化的完整踩坑记录

发布时间:2026/9/8 6:54:27
Vue2后台管理实战:从列表页到权限与性能优化的完整踩坑记录 【Vue2项目】人力资源后台管理项目这个系列写到第三篇前面已经完成了登录鉴权、路由守卫、Layout布局和基础组件封装项目从能启动进入了能干活的阶段。这一篇我的重点是员工管理模块包括员工花名册列表、部门筛选、新增编辑表单这些核心业务。原本以为后面就是照着接口堆页面实际写下来才发现后台管理系统里大量的重复请求代码、表单组件一些反直觉的交互、缓存页面之后的内存上涨每一个都值得单独拿出来记录。如果你正在用Vue2 Element UI做类似的内部系统这篇里关于axios请求层二次封装、el-select全选联动异常排查、keep-alive缓存后的内存泄漏定位、动态菜单与按钮权限落地、构建产物体积可视化优化基本就是一个人从页面能跑走到项目能上线这段路上最常碰到的几个坎。我会把排查思路和最终实现代码都写清楚不绕弯子。1. 员工花名册页面从查询区到分页区的完整组装员工管理在人力系统里最核心的页面就是花名册本质上是一个数据密集型列表页顶部筛选条件中间表格底部翻页。Vue2 Element UI做这类页面非常成熟但成熟不代表可以随手写模块关系没理清楚一个列表页能堆出两三百行模板代码后面改一个字段都得翻半天。1.1 需求梳理与页面结构拆分先把需求列一下做类似系统可以直接参考查询区姓名、工号、部门下拉、入职日期范围、在职状态工具栏新增员工、批量导出、批量删除表格工号、姓名、性别、部门、岗位、手机号、入职时间、工作状态、操作列分页显示总条数支持每页条数切换页面结构我拆成了三个模块div classemployee-page search-form :queryquery searchhandleSearch resethandleReset / div classtable-wrapper div classtool-bar el-button typeprimary clickopenAddDialog新增员工/el-button /div el-table v-loadingloading :datatableData selection-changehandleSelectionChange !-- 列定义 -- /el-table pagination :totaltotal :page.syncquery.page :limit.syncquery.limit / /div /div这里有个取舍问题search-form是单独抽成组件还是写在页面里我刚开始做的时候觉得搜索区每个页面都不一样抽组件反而麻烦就在页面里直接堆。结果部门管理、岗位管理的列表页写完之后发现搜索区的结构高度相似都是几个输入框加一个查询/重置按钮区别只是字段不同。后来统一抽了一个search-form组件用配置项渲染维护成本低很多。但我仍然不建议第一版就抽象因为需求还没稳定过早抽象容易把组件的props和events设计得很别扭。等两三个页面沉淀出规律后再抽顺理成章。1.2 查询条件、表格与分页的参数联动查询参数我用一个对象维护不推荐每个条件单独定义变量否则组查询参数的时候要写很多key。data() { return { query: { name: , deptId: null, status: , dateRange: [], page: 1, limit: 10 }, tableData: [], total: 0, loading: false }; }, methods: { async fetchList() { this.loading true; try { const params { ...this.query }; if (params.dateRange params.dateRange.length 2) { params.startDate params.dateRange[0]; params.endDate params.dateRange[1]; } delete params.dateRange; const result await getEmployeeList(params); this.tableData result.list; this.total result.total; } finally { this.loading false; } }, handleSearch() { this.query.page 1; this.fetchList(); }, handleReset() { this.query { ...defaultQuery }; this.fetchList(); } }这里有两个很容易忽略的点。第一搜索之后page必须重置为1否则你停在10页的位置搜索条件收紧后总共只有3条数据列表会被打到空页面用户会以为系统出bug了。第二日期范围提交时要拆成startDate和endDate两个字段很多后端不愿意接收数组前端把参数整理成他们习惯的格式能省掉大量沟通成本。reset的时候用Object.assign浅拷贝一个defaultQuery避免query里残留多余的字段。1.3 表格列、分页与字典管理的几个细节表格列我用fixed属性固定住操作列和工号列部门一多横向滚动时操作按钮不会跟着滚出去。手机号和身份证这类字段如果后端返回的是数字串超过15位会出现精度丢失接口层要做好格式化或者在列模板里用formatter。分页组件我把el-pagination包了一层把page和limit用.sync双向绑定父组件就不用单独监听current-change了。如果项目里多个列表页这个包装组件的收益非常明显。还有一个建议列表页里很多数据字典性别、工作状态这类不要硬编码在页面里我在src/dict/index.js里统一维护。表格里显示中文用的formatter可以复用导出功能也可以复用同一套字典映射。这是后台管理项目里性价比很高的一步抽象越早做越省事。2. 请求层的第二次重构axios拦截器与业务状态码处理员工列表页写完功能是能跑了但代码里每一处接口调用都要手动带token、手动catch错误、手动弹message。继续写部门管理、考勤管理的话同样的代码要复制七八份。所以在开始写新增/编辑表单之前我先把请求层做了一次重构。2.1 在写表单之前我为什么停下来重写请求层当时的现状是每个api函数里都自己从localStorage取token拼到header写得很啰嗦遇到401响应没有统一跳转逻辑页面直接白屏用户不知道怎么恢复接口报错时有的地方用Message.error有的地方直接console.log用户完全不知道发生了什么每次请求都要重复写loading状态代码大量重复这属于典型的功能都通但基本不可维护。后台管理系统的接口数量一多这种问题的危害会指数级放大。比如后端某天下线了一个接口前端所有调用它的函数都要一个个去检查错误提示是否正确这不现实。2.2 拦截器的具体实现与几个关键边界在src/utils/request.js里重新封装一个axios实例import axios from axios import { Message, MessageBox } from element-ui import { getToken, clearToken } from /utils/auth const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) service.interceptors.request.use(config { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } return config }, error { return Promise.reject(error) }) service.interceptors.response.use(response { const res response.data if (res.code ! 200) { if (res.code 401) { clearToken() MessageBox.confirm(登录状态已过期请重新登录, 提示, { confirmButtonText: 重新登录, showCancelButton: false }).then(() { window.location.href /login }).catch(() {}) return Promise.reject(new Error(unauthorized)) } Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message || Error)) } return res.data }, error { Message.error(error.message || 网络异常) return Promise.reject(error) }) export default service几个值得说明的点拦截器返回res.data而不是response这样接口层拿到的是后端业务数据的本体不用每个api函数再写一层res.data。代价是如果后端有部分接口的数据结构不同要做好兼容否则会出现undefined。我这边和后端约定所有接口统一返回{ code, message, data }结构。401用一个明显的MessageBox不用Message弹一下就没的提示。MessageBox会拦截用户操作强制选择重新登录。跳登录页我用window.location.href而没有直接引入router因为request.js如果import router而router又依赖store很可能形成循环依赖。用window.location.href虽然会刷新整个页面但对登录失效这个场景来说刷新反而是最干净的处理。2.3 接口层文件怎么组织页面才能保持干净有了request实例之后按业务模块建api文件// src/api/employee.js import request from /utils/request export function getEmployeeList(params) { return request({ url: /employee/list, method: get, params }) } export function addEmployee(data) { return request({ url: /employee, method: post, data }) } export function updateEmployee(data) { return request({ url: /employee/${data.id}, method: put, data }) } export function deleteEmployee(id) { return request({ url: /employee/${id}, method: delete }) }组件里只用引函数不再关心header、错误提示这些事。这层抽象做完后后面几个模块的开发速度明显上来了。也有人喜欢在每个接口上加loading状态我不建议这么做多个请求并发时loading变量互相覆盖状态不可控。loading的控制权应当留在页面层接口层保持纯粹。3. 部门多选的el-select陷阱全部选择后其他也选上员工新增/编辑表单里有一个负责部门的多选部门分两级。产品经理要一个快捷操作用户可以选择某个具体的部门也可以一键选择全部部门。这个需求听起来平平无奇结果做的时候出现了那个经典问题——全部选择后其他也选上。3.1 需求场景与第一次错误实现表单里el-select的初始写法el-form-item label负责部门 propdeptIds el-select v-modelform.deptIds multiple filterable placeholder请选择负责部门 changehandleDeptChange el-option label全部部门 valueALL / el-option v-fordept in deptOptions :keydept.id :labeldept.name :valuedept.id / /el-select /el-form-item我的第一反应是既然要全选那就在change事件里把所有部门id都塞进数组。handleDeptChange(val) { if (val.includes(ALL)) { this.form.deptIds deptOptions.map(d d.id) } }问题马上来了点击全部部门后下拉面板里全部部门和每一个子部门项同时变成选中状态。用户只选了一个全部标签结果三四十个部门全被勾上了。哪怕不打开下拉面板输入框里也堆了一长串标签。更麻烦的是这种实现是静态的部门列表后来新增了一个部门之前选中的数组里不会自动包含它全选状态会悄悄过期。3.2 根因全选不等于把子项塞进数组问题本质不是el-select的bug而是实现思路错了。value为ALL的option本身是一个独立的选项部门id是另一批optionselect的选中状态就看value数组里存了什么。把所有部门id都push进数组它们自然全部高亮。用户想要的语义是用一个特殊值表达全部而不是把全部子项逐个选中。el-select本身没有把父级和子级做联动一旦我手动展开成子idUI上就会同时高亮父级和所有子级这就是其他也选上的来源。3.3 修复用特殊值表达全部语义正确的做法是数据里保留ALL作为全部的语义值不要提前展开成子部门id。子部门是否勾选交给el-select自己的判断逻辑我们只做业务上的互斥handleDeptChange(val) { if (val.includes(ALL)) { this.form.deptIds [ALL] return } // 当用户选具体部门时确保ALL被移除 const index val.indexOf(ALL) if (index -1) val.splice(index, 1) }这样点击全部部门时数组里只留ALL其他子部门选项不会高亮。选具体部门时把ALL踢出去两者互斥。提交前根据需要决定传给后端什么。如果后端能识别ALL这个特殊值就直接传ALL如果后端要求子部门id提交前做一次映射function normalizeDeptIds(deptIds) { if (deptIds.length 1 deptIds[0] ALL) { return deptOptions.map(d d.id) } return deptIds }映射这层放在提交函数里展示层和传输层的格式各自独立维护比一开始就混在同一个数据源里干净得多。3.4 同类问题在el-tree与el-cascader中的蔓延这个坑在el-tree和el-cascader里也会出现。el-tree的check-strictly开启后父节点和子节点是独立的勾选关系如果业务上要勾选父节点就代表勾选所有子节点却直接把leafOnly设为false会出现父节点勾选后所有子节点也全部勾选和el-select的问题如出一辙。解决思路一样约定一个表达全部/父级的语义值前端展示和提交格式之间加一层转换不要图省事直接展开成所有子项。哪怕要展开也要在提交那一层做而不是在change事件里动v-model绑定值。4. 列表页缓存与keep-alive内存泄漏从卡顿到定位写员工管理没多久就收到反馈在员工列表里翻到第3页点进某个员工的详情再返回列表直接从头开始之前的页码和筛选条件全没了。这是因为默认情况下router-view切换组件会销毁重建。对数据密集型后台来说这个体验不能忍我就给列表页加了keep-alive缓存。4.1 用keep-alive解决列表状态丢失App.vue里改成router-view v-slot{ Component } keep-alive :includecachedViews component :isComponent / /keep-alive /router-viewcachedViews在store里维护路由meta里标记keepAlive: true的页面路径会被加进include列表。这种做法的好处是缓存范围可控登录页、404页不进缓存。第一版缓存很快生效了返回列表页时页码、筛选条件、表格数据都还在。但用了一两天运维反馈浏览器开久了内存占用越来越高最极端时一个标签页占1GB以上。我逐个页面排查最后定位到员工列表页里的一个轮询接口。4.2 缓存生效后内存却开始不正常增长当时列表页有一块待处理审批的数字进入页面后每30秒轮询一次最新数量。最初的实现created() { this.pollTimer setInterval(() { this.fetchPendingCount() }, 30000) }, destroyed() { clearInterval(this.pollTimer) }这个写法在没有keep-alive时是正常的离开列表页组件销毁定时器跟着清掉。加了keep-alive之后组件实例被缓存destroyed根本不会执行定时器就一直留在内存里。更麻烦的是我在多个页面用了类似逻辑每个被缓存的页面都有一个定时器在跑。页面切来切去内存里挂了一堆setInterval同时执行这种累积效应是keep-alive场景下内存泄漏的典型来源。4.3 用Performance面板实锤定时器泄漏排查过程不复杂。在fetchPendingCount里临时加了一行console.log然后离开员工列表页去操作其他模块控制台每隔30秒仍然打印一次。这说明组件虽然看不见了定时器还在跑。再用Chrome DevTools的Performance面板录制一段时间Memory图表显示JS heap呈阶梯式上涨每次进入离开列表页都涨一截而且不回落。两个证据合并基本可以确定是定时器没有被正确清理。4.4 修复把生命周期从destroyed换成deactivatedkeep-alive下的组件生命周期变成了activated/deactivated不再走mounted/destroyed。正确做法activated() { this.fetchList() this.startPolling() }, deactivated() { this.stopPolling() }, methods: { startPolling() { if (this.pollTimer) clearInterval(this.pollTimer) this.pollTimer setInterval(() { this.fetchPendingCount() }, 30000) }, stopPolling() { if (this.pollTimer) { clearInterval(this.pollTimer) this.pollTimer null } } }startPolling里先clear再setInterval防止activated被重复调用时定时器叠加。少了这一步页面来回多切几次定时器数量还是会累积。还有一类类似问题在mounted里给window.addEventListener(scroll, this.handleScroll)离开页面后没有清理。没有keep-alive时走destroyed清理没问题有keep-alive后必须在deactivated里removeEventListener移除时还要传入同一个函数引用匿名函数是移除不掉的。这些都是keep-alive引入后最容易忽视的生命周期变化。5. 动态菜单与按钮权限从后端菜单树到前端路由落地人力后台系统的权限需求很典型HR专员和HR主管登录后看到的菜单不一样同一个员工列表里主管能看到删除员工按钮专员看不到。菜单由后端权限中心下发前端不能把权限规则写死在代码里。5.1 与后端约定的权限数据结构后端返回的结构{ code: 200, data: { user: { id: 1, name: 张三 }, menus: [ { path: /employee, name: Employee, component: employee/index, meta: { title: 员工管理, icon: user }, children: [] } ], buttons: [employee:add, employee:delete] } }menus是当前用户可见的菜单树buttons是按钮权限标识数组。登录成功后存到Vuex里。这里要求后端把菜单和页面组件路径对应好前端只做映射不做逻辑判断权限规则天然收口在权限中心。5.2 从菜单树到动态路由的转换Vue Router 3.x里动态加路由的方法是addRoutes。关键是后端的component字段是字符串比如employee/index前端要映射成真正的组件。我用require.context统一注册views目录下的所有vue文件const viewModules require.context(/views, true, /\.vue$/) function resolveComponent(componentPath) { return viewModules(./${componentPath}.vue).default } function generateRoutes(menus) { const routes [] menus.forEach(menu { const route { path: menu.path, name: menu.name, component: resolveComponent(menu.component), meta: menu.meta } if (menu.children menu.children.length) { route.children generateRoutes(menu.children) } routes.push(route) }) return routes }在路由守卫beforeEach里判断Vuex里是否已有menus没有就先拉权限再addRoutesrouter.beforeEach(async (to, from, next) { const token getToken() if (!token) { if (to.path /login) return next() return next(/login?redirect${encodeURIComponent(to.fullPath)}) } if (!store.getters.menus.length) { try { const { menus, buttons } await getUserInfo() store.commit(SET_MENUS, menus) store.commit(SET_BUTTONS, buttons) const accessRoutes generateRoutes(menus) router.addRoutes(accessRoutes) next({ ...to, replace: true }) } catch (e) { clearToken() next(/login) } return } next() })刷新页面后Vuex里的menus清空路由守卫会重新拉权限再addRoutes用户侧不会白屏。这里有个关键点动态路由加完之后用next({ ...to, replace: true })重新触发导航确保到目标页面时动态路由已经注册完成否则可能出现匹配不到路由的闪屏。5.3 按钮权限一个v-permission自定义指令按钮权限用自定义指令判断逻辑简单直接按钮上的权限标识不在当前用户的buttons数组里就移除这个DOM节点。Vue.directive(permission, { inserted(el, binding) { const buttons store.getters.buttons if (buttons !buttons.includes(binding.value)) { el.parentNode el.parentNode.removeChild(el) } } })模板里使用el-button v-permissionemployee:delete typedanger删除/el-button也有团队封装成Permission组件配合v-else显示无权限占位但后端管理系统里没有就是没有不需要解释指令方案最省事。如果同一个页面上不同权限的按钮影响交互逻辑比如没有删除权限时不能选中行那除了指令隐藏DOM还要在业务代码里再多判断一次光靠指令管不了逻辑。5.4 动态权限的隐藏坑404注册顺序、刷新与重新登录404路由不能一开始就注册成path: 否则动态路由加进来之前所有不匹配的路由都命中404addRoutes之后动态页面照样打不开。正确做法是先不注册404等动态路由全部加完之后再addRoute({ path: , redirect: /404 })。另一个坑是重新登录。addRoutes多次调用会不断追加路由如果用户退出后换了个权限较小的账号登录旧账号的路由还挂在router里菜单和权限数据会残留。需要在退出登录时遍历一遍当前router手动移除旧路由。团队里通常写一个resetRouter函数在logout时调用。6. 构建产物体积可视化与一次基础优化项目越加越多build一次的时间从十几秒涨到一两分钟产物文件单个chunk超过1MB线上加载明显变慢。我想看清楚到底是哪些依赖占了体积于是引入webpack-bundle-analyzer。6.1 接入webpack-bundle-analyzer在vue.config.js里const { BundleAnalyzerPlugin } require(webpack-bundle-analyzer) module.exports { chainWebpack: config { if (process.env.npm_config_report) { config .plugin(webpack-bundle-analyzer) .use(BundleAnalyzerPlugin) } } }执行npm run build --report构建完会自动打开可视化页面每个打包后的模块大小一目了然。我第一次看到时vendor.js里主要是element-ui全量、moment.js的locale、还有echarts这三样占了将近一半体积。6.2 三个立竿见影的优化动作第一个路由懒加载。把静态import改成动态importcomponent: () import(/views/employee/index.vue)这一步最基础每个页面单独成chunk首屏只加载登录页和首页相关的js。第二个Element UI按需引入。用babel-plugin-component在babel.config.js里配置module.exports { presets: [vue/cli-plugin-babel/preset], plugins: [ [ component, { libraryName: element-ui, styleLibraryName: theme-chalk } ] ] }然后main.js里不要Vue.use(ElementUI)改成import { Button, Table, TableColumn, Dialog, Form, FormItem, Select, Option } from element-ui Vue.use(Button) Vue.use(Table) // ...这一步需要把用到的组件都列一遍工作量稍大但对体积的改善非常明显。老项目怕漏的话先跑一版看哪些组件报错再补进列表基本一两个来回就齐了。第三个moment.js瘦身。moment.js体积大是因为把所有locale都打包进去了我只用中文用moment-locales-webpack-plugin去掉多余localeconst MomentLocalesPlugin require(moment-locales-webpack-plugin) // chainWebpack里 config.plugin(moment-locales).use(MomentLocalesPlugin, [ { localesToKeep: [zh-cn] } ])如果项目里只有日期格式化这种轻量需求我更建议直接替换成dayjsAPI几乎兼容体积缩小90%以上。替换代价是grep一下所有moment用法date()、format()、add()这些方法在dayjs里都有对应半天能改完。6.3 优化效果对比与gzip预压缩优化项做法效果注意点路由懒加载动态importvendor拆分为多chunk首屏只加载必要jsElement UI按需babel-plugin-componentvendor里element-ui部分明显减小需要维护组件清单moment.js瘦身moment-locales-webpack-plugin减少约300KB保留统一语言格式gzip预压缩compression-webpack-plugin传输体积再降60%左右需要nginx开启gzip_staticgzip预压缩的配置const CompressionWebpackPlugin require(compression-webpack-plugin) config.plugin(gzip).use(CompressionWebpackPlugin, [ { filename: [path].gz[query], algorithm: gzip, test: /\.(js|css|html|svg)$/, threshold: 10240, minRatio: 0.8 } ])配合nginx的gzip_static on直接返回预压缩文件web服务器不用每次实时压缩。做完这些之后我这边的vendor入口从1.2MB降到600多KBgzip之后大概200KB左右线上加载速度提升很明显。优化完之后我每次build也会顺手看一眼分析报告。不是要把所有包都拆到极小而是心里要有数哪个依赖体积大、为什么大、值不值得换。很多依赖体积大是历史原因贸然换掉可能引入新问题可视化工具的意义是让决策有依据而不是逼着自己数字好看。写到这里这个系列第三篇的主要内容和坑基本都记录完了。回头看看Vue2 Element UI这套技术栈到今天已经算不上新但国内大量内部系统和中后台项目依然在用原因也很简单生态成熟、文档齐、坑都被踩平了。做项目过程中我越来越觉得Vue2里的一些写法在Vue3里已经变了比如动态路由的addRoutes在Vue Router 4改成了addRoute全局API从new Vue换成了createApp但业务设计本身——权限模型、页面缓存、请求层拆解——这些思路是通用的。如果还要在Vue2上维护类似项目建议有意识地把这些横切逻辑抽干净将来迁Vue3的时候业务代码几乎不用动要动的就是框架层那一小片。