接手时后台的权限是「按角色写死在前端」——代码里到处是 if (role === 'admin')。问题很明显:
一是加一个角色要改一堆文件。运营说「要给客服主管开退款审核权限但不能改价格」,这在当时的实现里得翻遍所有页面找判断条件。二是前端判断压根不是安全边界,把 role 改成 admin 就能看到所有菜单,接口也真的能调通——因为后端没做对应校验。三是数据范围完全没有概念,东南亚区的运营能看到全球所有订单。
第三点是最严重的,它不是使用不便的问题而是数据越权。所以重做时把「权限」这个笼统的词拆开:菜单可见(左侧显示什么)、路由可达(直接输 URL 能不能进)、操作可用(页面里的按钮)、数据可见(同一个列表页不同人看到的行不同)。这四层的实现位置和责任方完全不同,混在一起谈必然出漏洞。
order:refund。不用位运算、不用角色名。字符串虽然占空间但可读、可搜索、可组合——排查「谁能退款」的时候直接全局搜这个码就行。角色只是权限码的集合,是配置层的概念,代码里不出现任何角色名。router.addRoute 注册。没有权限的路由压根不注册,用户输 URL 直接落到 404,而不是进去再弹「无权限」——后者会暴露系统里有哪些功能。next({ ...to, replace: true }) 重新进入一次当前路由。这个写法的细节要能讲——直接 next() 会因为路由表刚变还没生效而匹配失败。v-perm 在 mounted 时判断,无权限就把元素从 DOM 里删掉。用 display:none 隐藏的话,改一下样式就能点出来。为什么不做菜单和按钮的可视化配置到底。理论上可以做到「运营自己在后台拖拽配置每个按钮的权限」,但那需要前端把所有按钮都注册到权限中心,维护成本极高而且容易漏。折中方案是权限码在代码里声明、角色与权限码的绑定关系在后台配置。加新功能还是要发版(因为要写新的权限码),但调整谁能用什么不需要发版——后者的变更频率是前者的十倍以上,所以这个折中很划算。
权限数据不存 localStorage。存了刷新更快,但存在本地就意味着可以被改。虽然改了也过不了后端校验,但会造成「界面显示有按钮点了却报无权限」的困惑。所以每次刷新重新拉,多一次请求换取状态可信。
踩过的坑:权限变更不即时。管理员把某人的权限收回了,但那人的页面还开着,仍然能看到按钮并且点了会调接口。后端会拦住所以没有安全问题,但体验很差。做法是在接口返回里带一个权限版本号,前端发现版本变了就提示刷新。这比用长连接推送权限变更简单得多,代价是最长延迟一次请求。
「约 40 个越权入口」是怎么来的:把新的权限码体系建好之后,写脚本把旧代码里所有 if (role === ...) 的判断点列出来,逐个对照「这个功能应该谁能用」,找出判断缺失或者判断错误的地方。数量是数出来的,不是估的。被追问时要能举一两个具体例子——比如「订单导出按钮任何登录用户都能看到」、「跨区域的数据没有过滤」。举不出具体例子的数字就是编的。
这个模块本身不适合硬凑性能数字。权限体系的价值是安全性和可维护性,硬写「权限校验耗时降低 X ms」反而可疑(权限判断本来就是内存里的字符串比对,压根不是瓶颈)。该定性的地方就定性,用「变更从发版到配置即时生效」这种可验证的描述,比假的性能数字强。
router.beforeEach 里判断权限是否已加载。没加载就 await 拉权限、循环 addRoute 注册,然后 next({ ...to, replace: true })。这里有两个细节:一是必须重新进入,因为守卫执行时的路由匹配结果是旧路由表算出来的,直接 next() 会匹配不到;二是 replace: true 是为了不在历史记录里留下一条重复的。还有个容易漏的点——404 的兜底路由必须在动态路由之后注册,否则它会先匹配,所有动态路由都进不去。
后台有四十多个页面,结构高度雷同:上面一排筛选条件,中间一个表格,右上角新增按钮,点开是个表单弹窗。每次新业务要加一个管理页,就是把上一个页面复制过来改字段——复制出来的代码里连注释和变量名都是上一个业务的,改漏一处就是隐性 bug。
更麻烦的是变更。运营说「这个字段加个必填校验」、「这两个下拉要联动」,都得走前端排期、改代码、发版。一个五分钟的改动走完流程要一两天,运营和前端都在这上面消耗。
所以核心判断是:这些页面的差异其实只在「有哪些字段、每个字段什么类型、什么校验」,这部分完全可以数据化。把它抽成配置之后,改配置不需要发版,而且新页面不用写代码。
最后那个「逃生舱」是这类方案能不能落地的关键。纯配置化一定会遇到配不出来的需求,如果没有退回手写的口子,团队就会绕过整套机制自己复制页面,方案很快作废。
type -> 组件 的映射表,渲染时用 <component :is> 动态渲染。新增类型就是往表里加一项,不用改引擎。遇到未知 type 要降级成文本框并在控制台告警,不能直接白屏——配置是运营在改的,出错必须能撑住。required、min、max、pattern 这些直接对应表单库的校验配置。正则要谨慎——让运营在后台填正则风险很大,可能填出灾难性回溯的表达式把页面卡死。做法是提供预置的常用校验(手机号、邮箱、URL),复杂的走特殊字段。showWhen: { field: 'type', eq: 'paid' } 这种结构能被 JSON 表达,转成计算属性求值。如果允许配置里写函数字符串然后 eval,那就是把代码注入的口子开在了后台配置页上,绝对不能做。为什么不用现成的低代码平台。评估过,但两个问题:一是它们的产出物往往和自己的组件库、权限体系不通,接进来的成本不比自己做低;二是能力过剩——我们只需要「配置化的增删改查」,不需要页面拖拽、流程编排这些。引入一个大平台会带来学习成本和不可控的黑盒。所以选了「小范围自研 + 留手写逃生舱」。
配置化的边界必须画清楚。标准的增删改查配置化收益极高;但一旦某个页面有复杂交互(比如订单详情页里的多步骤操作、带实时计算的表单),配置化的成本会超过手写。我的判断标准是「配置能不能在半小时内配完」,超过就直接手写。硬把所有页面塞进配置化框架,最后会做出一个谁都看不懂的巨型配置。
代价:调试变难了。手写页面出问题直接看代码,配置驱动的页面出问题要先看配置对不对、再看引擎解析对不对,链路长了一层。所以配套做了配置校验和错误提示——配置保存时先校验结构,运行时遇到异常配置在页面上显示明确的错误而不是崩掉。这个代价要主动承认,说配置化只有好处没有坏处,一听就是没真做过。
「约半天变十几分钟」:拿改造后真实新增的几个页面计时。改造前的「半天」是从 git 记录估的——找几个历史上新增管理页的提交,看从第一次提交到合并的时间(去掉等待评审的部分)。要说清这半天包含什么:复制页面、改字段、对接接口、自测、发版。改造后的「十几分钟」是在配置页操作的实际耗时,不含需求沟通。
「减少约 3000 行」:用 git diff --stat 看改造涉及的提交,删除行数减去新增行数(引擎本身的代码要算进新增里,不能只报删除数)。这个数字是净减少,如果只报「删了 5000 行」而不提「引擎写了 2000 行」,是不诚实的。
财务和运营的报表页是投诉最多的地方。两类问题。
一是渲染卡死。报表默认查一个月的数据,一万多行,每行十几列,还有几列带格式化和条件样式。一次性渲染出来是十几万个 DOM 节点,浏览器要卡三四秒,滚动也不流畅。运营的反馈是「点了查询以为没反应,又点了一次」。
二是导出直接崩。原来的导出是前端把所有数据拉下来、在浏览器里拼 Excel。几千行还行,十万行时页面直接无响应——因为要把全量数据放进内存再构造出一个巨大的数组和字符串。
这两个问题的解法方向是相反的:渲染问题是「少渲染」(虚拟滚动),导出问题是「别在前端做」(挪到服务端异步)。共同点是都不再让前端持有全量数据。
select * 全查进内存再写文件,那只是把 OOM 从前端搬到了后端。用游标或者分页循环,查一批写一批,写完刷盘。生成的文件上传对象存储,通过时效链接下载,避免占用应用服务器带宽。为什么不直接分页,非要虚拟滚动。产品上确实先建议了分页,但财务的使用习惯是上下对比不同行的数据,翻页会打断。折中方案是「后端分页 + 前端虚拟滚动」的组合——一次拉一大页(比如两千行)在前端虚拟滚动,滚到底部自动拉下一批。既没有一次十万行的压力,也没有翻页的割裂感。纯技术最优不一定是产品最优,要能说出这个权衡过程。
异步导出的体验代价。原来点了立刻下载(虽然会卡),现在要等任务完成。对小数据量来说体验反而变差了。所以做了分流:行数小于某个阈值走同步导出立刻返回,超过才走异步任务。这个阈值是试出来的——同步导出的耗时控制在几秒内是可接受的。
踩过的坑:导出的数据和页面看到的不一致。因为导出是服务端重新查的,而用户看到的是几分钟前查询的结果,期间数据变了。解决办法是导出的文件里带上「数据截止时间」,并且导出请求带上查询时的时间戳作为数据边界。这类一致性细节容易被忽略,但财务对账时对不上会引发很严重的质疑。
渲染耗时:用 Performance 面板录制,量「点击查询后数据返回」到「表格渲染完成、页面可交互」这段。要固定数据量(一万行)和列数,并说明机器配置——同样的代码在开发机和运营的办公电脑上差别很大,性能数字必须带测试环境。改造前约 3.2s,改造后 350ms 上下。
为什么改造后不是更小的数字:350ms 里包含了数据处理和四十行的渲染,这已经接近这个量级的合理下限。报出 50ms 之类的数字反而不可信。
导出耗时:十万行约 40 秒是服务端任务的执行时间,从任务日志里取。改造前的「不可用」不是估的——实测在浏览器里跑十万行导出,页面无响应到需要强制关闭标签页,这个结论比编一个「原来要 5 分钟」更真实。做不到的事情就说做不到,不要为了对比强行造一个数字。
overflow: auto,里面放一个占位元素,高度设成「总行数 × 行高」。真实渲染的那几十行用 transform: translateY() 偏移到正确位置。用 transform 而不是改 top 或 margin,因为 transform 不触发重排,滚动时性能好很多。行高不定的情况就要维护一个高度累积表,滚动位置反查行索引,复杂度高一个档次。
没有匹配的内容,换个关键词试试。
项目拆解 · 运营管理后台(Vue)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据