多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Vue3后台管理项目实战:动态表单、权限路由与样式调优

Vue3后台管理项目实战:动态表单、权限路由与样式调优 从早上九点坐下来到现在我中途只起来接了三次水剩下的时间全耗在“大事件”这个Vue3后台管理项目的第三天开发上。前两天把项目骨架和登录页搭好了今天要处理的是这批页面里最磨人的动态表单然后是路由权限和一堆Element Plus组件的视觉调整。这一天下来我对Vue3的组合式API、ref和reactive的边界问题还有全局改样式那点破事理解比前两个月加起来都透。这篇笔记就是把今天实际踩过的坑、改过的代码、以及为什么这么写的思考完整记下来给同样在Vue3项目里挣扎的人一个参考。1. 第三天到底在做什么从零散页面到可用的后台雏形先说清楚“大事件”是啥。它就是一个内容管理后台用来维护一系列事件新闻的展示、排序和上下架。前两天已经完成了Vite工程搭建、路由的基础配置还有登录表单的静态页面。但静态登录页只是摆设第三天开始要让它真正工作起来表单要能动态增删、路由要有守卫、页面进入前要校验登录状态还得把后台最常见的Tabs标签页样式统一掉不然一眼看过去就知道是拿组件库默认样式糊上去的。很多新手学到第三天最容易犯的毛病是急着写业务结果一边写一边发现前两天的代码根本没法扩展。我今天一开始也差点走这条路先打开昨天的文件看到setup函数里堆了一百多行的变量和函数状态分散得到处都是连一个删除事件都要在methods里翻半天。这种代码在简单页面还行一旦今天要加“批量审核”功能改起来就是场灾难。所以我早上花了四十分钟做了一次重构把页面逻辑全部拆成组合式API的自定义hook。这里说的不是把methods挪到setup里就完了而是把“表单数据”“分页逻辑”“列表请求”这些独立的状态抽成一个个use开头的函数。比如我建了一个useEventList里面封装了列表数据、加载状态、页码变化和刷新函数又建了一个useEventForm专门管动态表单的初始数据、增删行、校验规则。页面调起来像拼积木一样const { list, loading, page, total, fetchList } useEventList(apiUrl); const { formModel, addRow, removeRow, validateForm } useEventForm();后来发现这个做法的收益远超预期不仅是代码短了更重要的是状态变化能被精确追踪。比如某一行输入框的报错信息通常组件库里是挂在每一项的rule上如果用options API去写this指向来回切换人很容易晕。用hook把formModel和rules放在同一个作用域里连响应式代理都不用反复确认眼睛就能看出来。这次重构也让我认真对比了Composition API和Options API的差异。如果项目只有十几个页面而且页面之间完全没有共享逻辑那options确实看着亲切method、computed、watch分类清楚。但后台管理系统最典型的情况是十几个列表页长得几乎一样每个列表页都需要分页、搜索、状态过滤、重置表格。把这些重复逻辑拖到mixins里又会产生数据来源不明、命名冲突的危险。组合式API的解法是让每个hook显式声明自己管理哪些状态显式返回给调用方数据从哪来、能干什么一目了然。今天这个决定的意义在于后面两天的功能开发不会再被“以前写的逻辑缠住”。还有一个实际好处是调试以前控制台里打印this.$data是一大坨信息现在打印一个自定义hook返回的对象里面就只有那个功能模块的东西配合Vue Devtools的setup状态查看定位响应式变量变化非常快。1.1 为什么说ref和reactive不是随便用用重构过程中最大的反应式问题出在ref和reactive的选择上。官方文档说ref用于基础类型和对象reactive只能用于对象。但工程实践里远没有这么简单。我第一天写的列表数据用的是reactive({ list: [], page: 1 })然后所有组件都是props传下去子组件里又reactive拷贝一份结果数据不同步。后来才明白reactive会把整个对象变成代理如果你把reactive对象的某个字段解构出来赋值给普通变量响应式连接就断了。我犯过的经典错误是const state reactive({ list: [], count: 0 }); // 错误count是普通值了 const { count } state; // 正确保持对state的引用或者用toRefs const { count } toRefs(state);而ref其实是在内部用一个RefImpl类来持有value所有对value的读写都走getter/setter所以解构ref不会丢失响应性。今天做动态表单时我大量用了ref来管理“当前正在编辑的行的唯一标识数组”因为每个行的状态相对独立用ref数组很容易配合v-for渲染也能在computed里通过editRowIds.value快速filter。一个很直白的建议在写业务代码时能统一用ref就去用ref因为大家已经习惯在模板里写editRowIds而不是editRowIds.valueVue的模板编译器会帮忙脱壳。但要在js逻辑里修改数组就必须想着.value否则拿到的是一个Ref对象做push会直接报错。今天重构成useApi相关函数时我也感受到了响应式底层的一个特点reactive对嵌套对象是懒代理的只有访问到那一层才会代理。这在性能上是好处但调试时想看完整快照必须用JSON.parse(JSON.stringify())或者Vue内置的toRaw。今天我为了排查一个“明明改了数据页面没更新”的问题就靠toRaw打印真实值才定位到是一个对象被复制后失去了代理关系。1.2 自定义hook的输入输出设计思路既然是项目笔记具体hook怎么设计值得记一笔。我写useEventForm时的约束很简单hook只负责表单状态和验证逻辑不关心组件长什么样。它返回一个formModel对象、一个addRow方法、一个removeRow方法、一个resetForm方法还有一个设置好rules的数组。组件里只需要这样接const { formModel, rules, addRow, removeRow, resetForm, validate } useEventForm(initialRow);其中initialRow是一个保证每一行都有相同结构的工厂函数返回一个全新的行数据。为什么要工厂函数因为我吃过“直接复用同一个对象导致多行数据互相覆盖”的亏。后台动态表单里最常见的场景是点“添加”要出现新的一行每一项都是一个包含若干输入框的对象。如果你用的是同一个对象赋值给新行那表格里所有行其实都指向同一个内存地址改一行等于改全部。用const createEmptyRow () ({ title: , url: , startTime: , endTime: })这样每次调用返回新对象避免引用共享。至于validate我是直接返回组件的formRef.value.validate()用的Element Plus表单验证。但这里有个坑hook自身不持有DOM实例让组件把表单的ref对象传进来我一开始觉得这违反了“组合式API框架无关性”但后来想了想hook本来就是给组件用的接收组件实例并不是坏味道。也可以更优雅地用defineExpose或者回调函数但说实话后端管理系统里简单直接反而好维护。2. 动态添加删除表单行不只是v-for循环那么简单今天的核心功能就是“动态添加删除form表单一行数据”。页面上有一个事件配置表格每条事件可以有三四个字段运营同学需要临时增加或者减少配置项。最开始我想得比较简单表单数据是一个数组用v-for渲染然后添加一行就push一个空对象删除就splice。但真正接入Element Plus的el-form之后发现问题一个接一个最典型的是校验规则失效新增的一行输入框没有触发校验老的一行删除之后后面的校验报错还挂在页面上。先说为什么不能简单的用数组的下标当作key。Vue的diff算法靠key来追踪节点的身份如果我用index当key那么删除中间一行时后面所有行的复用关系就乱了输入框里的值可能会没有规律地错位。我调试时遇到过点击删除第三行结果第五行的数据被清掉了就是因为key用了index。正确做法是为每一行生成一个唯一id可以用crypto.randomUUID()或者一个自增计数器然后把id作为row-key。实际业务里我建议两段式id用于循环key再保留一个数据库字段id用于提交给后端两者不要混用。然后重点说校验。Element Plus的el-form通过model和rules来做校验rules可以是一个数组其中每一项还可以是函数。对于动态行model必须是包含整个数组的对象比如const formModel reactive({ list: [] });如果你把list单独拿出来作为v-model数组那么校验器根本找不到路径。我一开始写成v-foritem in listrules里也写list结果表单组件内部会根据prop去找model.list[0]这样的值只有用formModel.list才行。设置prop的时候也必须带索引:proplist. index .eventName。这个点是早上的第一个坑卡了我大概四十分钟。删除行之后校验残留是另一个经典问题。当你删掉一行Element Plus的表单域还在内存里保留着校验状态尤其是行数变少之后被删除行的prop已经无意义但错误消息依然展示。解决方法是删除行后调用一次formRef.value.clearValidate()把整个表单的校验清除然后通过nextTick重新验证当前行。为什么要nextTick因为clearValidate在DOM更新之前执行此时新数组还没渲染clear的不是最新状态。可以这样async function removeRow(index) { list.splice(index, 1); await nextTick(); formRef.value.clearValidate(); }还有一个追加行的细节当你想在新增行上也立即应用校验得确保新行被渲染后再去校验或者只需让用户输入时触发即可。我更推荐后者因为强制校验会让用户还没输入就红一片体验很糟。只用设置validate-on-rule-change为false避免规则变化触发不必要的校验。2.1 动态表单项的自定义校验如何绑定有些行不是普通的输入框而是一个下拉选项并且下拉选项的选项列表根据另一行的值联动。比如事件类型选“定时推送”就会多出一个时间字段选“立即推送”时间字段就不需要。这种动态联动最容易导致校验规则失效因为被隐藏的字段可能还在DOM上校验rule仍然生效。我的做法是用计算属性动态生成rules数组。比如一个行数据的rules是一个函数函数内部判断当前行上某个字段的状态返回通过或不通过。Element Plus支持rule作为一个自定义validatorconst ruleList computed(() formModel.list.map((item, index) ({ type: [{ required: item.mode timer, message: 定时推送必须设置时间, trigger: change }], })));但prop是按index索引的你并不能方便地在模板里直接给:rulesrowRules传一个数组。我后来换了个思路不在rules里写这种联动而是在表单的validate方法里统一处理——写一个validateCustom函数先遍历所有行检查必要的关联字段如果不符合条件用formRef.value.validateField(prop)来单独标记错误。这个方法看着没rules那么Redux式但扩展性很好可以随意写if-else。对于后台项目复杂联动用这种手动编排逻辑最清晰至少自己三个月后回来看还能明白。另一个容易忽略的是“添加一行后自动滚动到该行”。需求方要求新增行必须在可视区域实现很简单给新行加一个ref标记比如用ref数组收集行DOM然后nextTick里调用rowRef.value[list.length-1].scrollIntoView()。别问我为什么专门记这个下午改了三遍才想起所有新增行的索引是动态的不能在模板里写死。2.2 提交数据前的清洗与格式化动态表单提交时最怕把空行、未填写的脏数据发给后端。我在submit方法里先做filter把列表中所有字段都为空的行过滤掉然后对某些时间字段做格式化。格式化要趁早因为Element Plus的日期选择器返回的是Date对象直接JSON.stringify会变成一串很长的UTC字符串后端根本没法入库。我建议所有这些清洗逻辑放在一个独立的prepareSubmitData函数里不跟UI耦合。function prepareSubmitData() { return formModel.list .filter(row row.eventName.trim() ! || row.startTime) .map(row ({ ...row, startTime: row.startTime ? dayjs(row.startTime).format(YYYY-MM-DD HH:mm:ss) : null, })); }这个函数我留了一个后悔的口子原始数据从表单到提交有过一次深拷贝避免对formModel的引用污染。用JSON.parse(JSON.stringify(model))记下脏数据再在脏数据上做格式化这样即使校验失败回滚后表单里还保留着用户的时间对象方便二次编辑。3. 路由守卫和权限控制让页面不再裸奔第三天下午的任务是把整个后台的路由保护起来不然任何没有登录的人都能直接访问管理页这在曾经上线过的项目里就是安全事故了。之前两天我配置的路由只是简单的静态路由没有任何守卫。这次要做的有三件事一是判断用户是否登录未登录跳转登录页二是根据用户角色生成可访问的动态路由三是按钮级别的权限控制给不同角色显示不同操作按钮。路由守卫主要用router.beforeEach在跳转前读取Pinia里的token和userInfo。这里有个细节如果token存在且要去登录页应该直接跳回首页如果token不存在但目标路由需要认证则重定向到登录页并带上redirect参数。代码不复杂router.beforeEach((to, from, next) { const store useUserStore(); if (!store.token) { if (to.meta.public) { next(); } else { next({ path: /login, query: { redirect: to.fullPath } }); } } else { if (to.path /login) { next(/); } else { next(); } } });但真正的权限框架应该在初始化时就把角色允许的路由动态添加进去。今天我用router.addRoute实现了这个逻辑把角色与路由映射表放在一个常量文件里。比如普通编辑能访问列表页和详情页管理员还能访问审核页。登录成功后根据角色遍历这张表将对应页面动态挂载到router上。有一个很重要但容易踩坑的点动态添加路由后如果用户刷新页面所有动态路由都会因为内存清空而丢失。所以必须在应用启动时、路由初始化前先读取本地持久化存储里的角色信息再动态addRoute。我采用的是router.isReady().then(() {})里做初始化保证首次导航前路由已经注册完毕。另外addRoute是异步的吗官方API声明是同步注册但如果你在判断to.matched时发现一个路由没有匹配到通常就是还没注册完。建议不要依赖addRoute之后的立即next()而用next({ ...to, replace: true })让它重新走一遍动态路由查找。3.1 按钮权限的两种实现思路按钮权限我采用了指令方式。因为后台管理里相同按钮在不同角色下可见性不一样用v-permissionauditBtn这种自定义指令包裹按钮最直接。指令内部读当前用户角色列表如果不包含权限点就直接el.parentNode?.removeChild(el)。这样做的好处是模板干净缺点是权限变化后不会动态更新必须重新渲染组件。另一种方式是封装一个AuthButton组件内部用v-if判断适合有tooltip和二次确认的需求。两种都可以看团队习惯。我这里的场景比较简单所以用了指令。但需要注意权限指令必须挂载在按钮的挂载阶段如果按钮是异步渲染出来的指令可能没执行到稳妥做法是在指令里同时检查角色并在updated钩子再移除一次。不过实际项目中更推荐直接v-if因为权限逻辑往往伴随着禁用、提示文案一起出现写死在模板里反而更容易trace。3.2 动态路由带来的菜单映射问题加上动态路由后菜单变得不老实了。侧边栏菜单是根据路由表配置的但动态路由是之后才注册的如果菜单组件已经初始化完成就不会包含动态部分。解决方法是把菜单数据做成一个computed它从路由表里过滤出带有meta.title的记录然后组合出一棵菜单树。因为computed会响应式更新一旦addRoute后路由表变化菜单也会自动重新渲染。我试过用watch去监控router.options.routes但路由表不是响应式对象所以watch根本不会触发。还是老老实实从router.getRoutes()去计算并且配合一个菜单刷新标志位addRoute完成后把标志位取反。今天在这里我犯了一个逻辑错误原本我在登录页直接把用户信息存到了localStorage但刷新后Pinia里的角色是空的动态路由重新注册时没有权限数据导致所有动态路由都加不进去。后来改成启动时先从localStorage读取用户角色再初始化Pinia再注册路由这个顺序彻底解决。4. Tabs标签页样式魔改全局覆写Element Plus的三板斧后台系统里标签页是高频组件但Element Plus自带的tabs样式太素标题栏背景、下划线颜色都跟项目主题不搭。第三天下午的最后两小时就是在干这件事。先说结论改组件库样式不要直接去node_modules里改源码也不要用!important满屏飞。正确路径是找到组件的样式变量用CSS变量覆盖如果变量没有暴露再写全局样式并用深度选择器。Element Plus的Tabs组件本身暴露了一些CSS变量比如--el-tabs-header-height、--el-color-primary。但我们要改的是导航栏的背景色、活动标签的字体颜色和边框这些常用变量还没覆盖到所有场景。所以我的方案是给tabs外层加一个自定义class然后用:deep()修改内部样式。比如.app-tabs { --el-tabs-header-height: 44px; } .app-tabs :deep(.el-tabs__header) { background: #f5f7fa; border-radius: 4px; padding: 0 12px; margin-bottom: 12px; } .app-tabs :deep(.el-tabs__item) { font-size: 13px; color: #606266; border-radius: 4px; transition: all 0.2s; } .app-tabs :deep(.el-tabs__item.is-active) { background: #ffffff; color: #1a73e8; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }这个方案的好处是作用域锁定在这个页面的tabs上不会污染其他页面。如果你想把所有Tabs都统一可以直接在全局样式中写不带class。但后台多页面风格一致时全局统一更方便。我自己的经验是像这种管理后台样式统一优先全局覆盖省心很多但一定要写在reset样式之后、业务样式之前。还有一个常见的需求是修改标签页的下划线颜色和位置。Element Plus的Tabs默认下划线是一段绝对定位的div而不是border。用CSS变量不好改要直接定位到.el-tabs__active-bar。记住下划线是绝对定位的会跟随标签切换动画。改颜色直接用background-color就行.app-tabs :deep(.el-tabs__active-bar) { background-color: #ff6a00; }但如果你想换一种交互比如不用下划线而是用整块背景色高亮当前标签那就需要关掉内置下划线然后给is-active加背景。这时下划线还是会占用定位空间最好设置display: none。我实际测试后发现直接隐藏下划线会留出几像素的偏移需要微调.el-tabs__item的高度和padding。建议用一个div包裹tabs后设置height: 44px; overflow: hidden来兜底。样式调试过程中最容易遇到的就是scoped样式失效。Vue的scoped会为模板元素加>router-view v-slot{ Component } keep-alive :includecachedViews component :isComponent / /keep-alive /router-view需要注意只有组件name和数组里匹配的组件才会被缓存。我之前用默认导出的setup语法组件name是文件名系统无法识别必须额外用defineOptions({ name: EventList })指定名字。今天我把所有主要页面都加上的名字然后维护一个useTagsView的hook去管理哪些页面需要缓存、哪些关闭标签后要清除缓存。实际上很多教程里会把这一块做成“tagsView”但我今天只做了最简单的一层缓存列表页状态是真的香。5. 从JSX看Vue3的取舍项目里该不该用因为热词里提到vue3使用jsx我也借机把jsx在项目内的可用场景测试了一遍。其实Vue3的jsx跟React很像但Vue的模板功能太强了装饰器和tsx在指令、样式作用域上有很多不顺手的地方。比如在tsx里不能直接用v-model得自己写value和onUpdate:modelValue用起来比模板啰嗦。但某些场景下比如写一个动态渲染的列配置用tsx比模板更灵活因为可以直接用JavaScript的map和条件判断。今天在动态表格里我尝试用tsx渲染操作列通过一个render函数返回按钮组代码确实比模板简洁但调试断点时不能再看到嵌套的元素标签让我不太习惯。后来还是回到模板。我的建议是如果不是组件库二次开发或者特别复杂的动态渲染不建议大范围引入tsx。Vue生态里模板仍然是主流热更新、开发体验都比tsx顺滑。如果你确实喜欢jsx的灵活性可以局部使用比如给el-table的column加自定义render但要注意在Vue3里render函数接收的是FunctionalComponent需要用h函数创建元素节点。render: ({ row }) el-button typedanger onClick{() removeRow(row)}删除/el-button这样写确实比模板少几行但响应式数据的读取方式变成row对象跟模板里的自动解包不一样容易踩坑。所以整体决策是第三天先不引入tsx保持项目模板统一降低维护成本。6. 今日Bug复盘与排查技巧实录每做一天项目最后都要把今天跳过的坑整理一遍不然过几天全忘了。以下是按实际出现顺序记录的Bug排名不分先后全是真实体验。第一个问题发生在早上重构后列表页突然不刷新了报错信息是“Invalid value for state variable list”。打印处理才发现我在useEventList里用的是ref([])但在setup里返回时写成了list: list.value这就直接把value传给响应式状态再赋值时已经不是ref的引用了。修复很简单返回时保证list还是一个ref不要在setup中间解包。记住模板里用list没错但逻辑层传值时要传整个Ref。第二个问题是动态表单删除行时偶尔出现“Cannot read properties of undefined (reading validate)”这是因为删除后我调用的formRef.value.clearValidate()但此时表单的model已经更新而DOM还在旧状态导致某些字段的prop对应不到。后来改成在删除后await nextTick()再clearValidate就稳定了。第三个问题是Tabs样式只在第一次打开页面时生效切换其他路由再回来样式丢失。排查了很久最后发现是KeepAlive缓存了组件而给tabs加的class是在组件的根元素上缓存组件不会重新创建根元素但样式是通过外部CSS加载的按理不会丢。最终的问题是路由切换时同一个外层容器类名和别的页面冲突了。将tabs包裹类名改为更独特的event-tabs-container后解决。我还踩了一个关于watch的坑。我想监听动态表单的长度变化来自动计算“已配置事件数量”用watch(() formModel.list.length, val { count.value val })结果每次删除一行都触发但新增一行时不触发。原因是在v-for渲染下新增行实际是push了一个对象数组length变了watch确实应该触发才对。打印发现formModel.list不是普通的数组而是一个响应式代理数组length变化在代理对象里也被代理了理应触发。后来问题定位到设置初始值时我在useEventForm里直接赋值了formModel.list [createEmptyRow()]这导致formModel的list属性从reactive的数组换成了一个普通数组响应式连接彻底断了。正确的做法是formModel.list.splice(0, 1, createEmptyRow())或者用formModel.list.push(...)。这个教训让我意识到reactive对象的属性不能随意替换因为对象的代理机制只对最初给定的值生效。今天最后一个技术挑战是“如何把组合式函数的返回值安全地暴露给模板”。我在useEventList里返回了一个refresh函数页面销毁前不会清理结果在路由离开后再次进入refresh还在调用旧的列表接口导致报错。正确的做法是在onUnmounted里清理定时器或取消请求。接口请求我用的是Axios可以用AbortController来取消。我把取消逻辑也塞进了hook里从外部传入一个signal这样页面销毁时自动abort。最后记录一个很重要的排查技巧在响应式数据大量嵌套时别指望打印整个对象能看到原始值。我推荐用watch配合{ deep: true }打印具体的变更字段或者用toRaw查看非代理对象。今天大部分时间都是靠这两招定位问题的。还有对于表单校验问题最有效的是在Element Plus的form组件上加上validate-on-rule-change属性为false减少不必要的校验触发再配合validateField指定字段进行单独调试。7. 收个尾第三天什么才是关键收获如果硬要提炼今天最值的一句话我会说是“状态管理的第一原则是保持引用稳定”。无论是ref还是reactive本质上都在教我们一件事不要让响应式对象被普通对象替换不要轻易解构响应式字段。后台系统的复杂度全在状态上动态表单的增删、路由的动态注册、组件的缓存都是围绕状态的生命周期展开的。今天所有踩过的坑到最后都能归纳到“数据是不是同一份引用”这个问题上。还有一个实际建议是给每天的项目笔记留一个“明天必做”清单。我今天在收工前写了一行“明天上午优先处理列表页批量操作和状态筛选联动别等需求的刀子落下再补。”这个习惯帮我把复杂功能拆成可以消化的小任务也防止第二天早晨不知道从哪动手。做项目就是这样第三天的感觉跟前两天完全不同两天前还在学API今天已经可以在真实业务里权衡方案了。如果你也在跟进一个Vue3后台项目不用怕遇到问题把每个问题的前因后果记下来那才是项目笔记最有价值的地方。
返回列表