移植完不等于还完债:一个存量 Vue 3 项目的瘦身与治理实录

移植完不等于还完债:一个存量 Vue 3 项目的瘦身与治理实录

某天然气储备库工程的完整性管理平台前端(Vue 3 + Vite + TypeScript),从母系统仓库的 feature 分支整体移植为独立部署应用,并在移植窗口内完成了模块瘦身、类型治理、lint 收敛与规范重建。本文记录这条治理路径、踩过的坑,以及沉淀下来可以直接复用的方法。

先看结果

指标 治理前 治理后
src 类型错误(vue-tsc) 500+ 0
ESLint 错误 真实基线近 20(此前被 3000+ 条噪声报错淹没) 0(CI 阻断项)
ESLint 警告 60 余 40 余(契约占位等有意保留)
业务模块 十余条业务线 设备完整性 + 系统权限
API 层零调用方法 上百 0
地图组件 4 套 1 套
踩坑记录 散落在提交信息和脑子里 近 40 条错误案例 + 提交前自查清单

数字本身不是重点,重点是中间的顺序:先让基线可信,再谈治理,最后把规范固化下来。顺序错了,治理就是自欺。

一、接手的是什么

平台前端要从公司主系统(管理端)的一个 feature 分支移植出来,变成独立部署的 SPA:自带登录页、鉴权守卫和账户权限体系,摘掉 wujie 微前端,不再依赖宿主注入 token 和菜单。

业务范围同时做减法:管道基础数据、检测与评价、工单派工、工作流表单、可视化大屏等十余条业务线——全部移除,只保留「设备完整性管理」和「系统权限管理」两条线。

但移植只是把「能跑」搬了过来。搬完之后的真实状态是:

  • npm run type-check 报 src 侧 500 多个类型错误;
  • 一跑全量 ESLint,3000+ 条报错里根本分不清哪些是自己的债;
  • 微前端时代的事件监听、token 透传、iframe 嵌入链路还在代码里"潜伏";
  • 三年积累的死代码、零调用的 API 方法、早已没人用的依赖,全须自己扛。

二、先降噪,再谈治理

治理的第一步不是修,而是把真实基线从噪声里挖出来。

ESLint 侧:public/ 目录下的压缩 UMD 库和 iconfont 压缩包贡献了 3000 多条报错——第三方产物不该进 lint 范围,加进 ignorePatterns 后,真实基线现形:近 20 个错误、60 余条警告。在此之前,任何"lint 进度"都无从谈起。

类型检查侧的坑更隐蔽。我们最初用这样的统计命令:

npx vue-tsc --noEmit 2>&1 | grep -oE 'src[^:(]*\.(vue|ts)'

结果把 node_modules/内部组件库/src/utils/XxxUtils.ts 截断成了 src/utils/XxxUtils.ts,凭空造出一份「XxxUtils 有几十个错」的假债务清单,还差点顺着清单去修 node_modules——改动不入库,npm ci 一下就归零。

根源在于:公司内部组件库(不开源)以 TS 源码直接发包(入口指向 .ts 而非 .d.ts),这些文件会被 import 拉进编译范围;而 skipLibCheck 只对 .d.ts 生效,管不到 .ts。这 70 多个第三方报错永远在 type-check 总数里,修不了(node_modules 不入库),只能记账。

所以基线数字必须带着口径才有意义。我们最终定下的口径:

  • type-check:先排除 ^node_modules 再计数,src 侧债务单独记账;
  • lint:0 错误是 CI 阻断项,警告单独记账、逐步收敛;
  • 每次汇报进度,先说口径,再说数字。

三、类型治理:src 侧 500+ → 0

类型错误看起来五花八门,但约六成同根:

const roleList = ref([])              // 推断 never[]:下游 v-for、属性访问全炸
defineProps({ leftFields: Array })    // 推断 unknown[]:连发 TS18046
const entity = ref({})                // 推断 {}:entity.value.xxx 全部 TS2339

never[] / unknown[] / {} 是零信息类型,错误像瘟疫一样从声明处往下游扩散。修法不是逐个救火,而是立口径:后端行结构未建模的统一 ref<any[]>([])(不为过检查去建模整棵响应结构),结构稳定的才写 interface;props 用 PropType 补型,调用方必传的加 required: true。

其余高频项:

  • verbatimModuleSyntax 开启后,vue API 的类型一律 type-only import(import type { PropType } from 'vue');
  • 事件必须进 defineEmits——未声明的事件运行时经 vnode.props 仍能触发(老代码因此能跑,极具迷惑性),但监听器会漏进 $attrs 透传,埋下误接线隐患;
  • 环境模块声明(declare module 'xxx')必须放在无顶层 import/export 的脚本型 .d.ts 里。我们踩过的坑:根目录 env.d.ts 因为 axios 模块增强带顶层 export,是 module 文件,里面的 declare module 'splitpanes' 被 TS 当作对既有模块的增强——而 splitpanes 无类型可增强,声明整体失效,layout.vue 恒报 TS7016。拆到 src/shims-splitpanes.d.ts 才治好;
  • 请求层契约:拦截器实际返回后端 body 而非 AxiosResponse,调用处按真实契约标注并注释 Why,不为过检查去改响应层。

这一批下来,500 多个 src 侧错误清零。

四、「类型误报」背后是两个静默失效的真 Bug

类型收敛最大的意外收获,是抓出了两个潜伏已久的运行时缺陷。

第一个:一个被定性为「vue-tsc 模板误报」的 TS2345。 它在基线里挂了很久,大家都当编译器误报。直到收官复核时较真了一次:筛选面板给查询条件行子组件漏传了必传的 index prop——子组件内部 form-item 的校验路径靠 index 拼接([index, 'leftFieldId']、index + '.symbol'),漏传后运行时路径拼成 undefined.symbol,「字段必选」校验从未生效过。补上 :index 后,类型错误与运行时缺陷同时消失。

教训:别急着把编译器错误定性为误报,先当 bug 查。查不实,再定性。

第二个:孤儿 el-form-item。 筛选弹层里,包含 el-form-item 的行组件直接裸放在 popover 里——rules 配置得再全,没有 el-form 承载,validate() 无从触发,提交时静默放行不完整行。页面不报任何错,规则就是摆设。修复只要两步:弹层里补挂 el-form(:model 传行数组),提交前 await formRef.value?.validate(),校验不过不再丢行。

顺手还修了一个刷屏问题:内部组件库的 install 已经注册了 Element Plus,宿主再 app.use(ElementPlus) 一次,启动时对每个 El* 组件刷一条 "has already been registered"——百余条 dev 告警把真实告警全淹了,而生产构建里 warn 被树摇,什么输出都没有。「能跑」不等于「没病」。

五、瘦身:依赖、代码与微前端

依赖用引用计数说话,不凭感觉:echarts / echarts-gl / html2canvas / animate.css 零引用,移除;xgplayer / wavesurfer.js / jszip / docx-preview / pdfjs-dist 各有 1 处活引用,保留。

代码:API 层删除上百个零调用方法;四套地图组件收敛到一套(三个子系统专属地图随模块移除);旧版图层配置、流程记录等死文件、全项目 console 清零。

微前端:wujie 摘除后,跨应用 bus 事件、window.$wujie 兜底、URL 携 token 的 iframe 嵌入链路整体退役。App.vue 从"监听一堆宿主事件"回归纯净,会话 token 由登录页写入 sessionStorage,main.ts 同步到 IndexedDB 供 Service Worker 文件鉴权读取。

瘦身有一条纪律:每删一批,基线比对一次。删错引用的情况真会发生——我们靠 type-check 抓过一次「整理 import 时误删了仍在用的 reactive」:ESLint 对 TS 文件不查未定义标识,esbuild 构建也不做类型检查,于是 lint 全绿、build 通过,组件一挂载就崩。整理 import 之后,type-check 是唯一闭环验证。

六、lint:让例外显式化

近 20 个历史错误清零,其中绝大多数是 vue/no-mutating-props。要害在于定性:它其实是我们标准弹窗的契约——父组件持有响应式对象、子组件直改属性,几十个页面消费这个模式,重构代价远大于收益。

评审决议不是全局关规则,而是:文件级 eslint-disable + 契约注释说明为什么豁免,规则对新代码保持生效。全局关规则是最省事的,但那会把未来新代码里的同类问题也一起豁免掉。

警告从 60 余条收敛到 40 余条:删未使用导入、死函数,保留契约占位参数等有意义的部分。

七、规范也会腐烂:一次全量巡检

治理的最后一环,是把经验固化成规范。我们的载体:

  • AGENTS.md 作为唯一事实源(CLAUDE.md 只是薄壳引用)——Codex / Cursor 等 AI 工具都识别这个标准,规范是写给人和 AI 一起看的;
  • spec/ 低频参考:架构细节、模式模板(标准列表页 / 标准弹窗 / 附件上传)、近 40 条错误案例库(每条自带 ❌/✅ 代码对照、Why、真实文件参考)、提交前自查清单。

但文档不跟代码一起动就会烂。移植 + 瘦身 + 重构三连之后,我们做了一次规范全量巡检,发现四类漂移:

  1. 事实性错误:文档写着「ECharts 依赖仍保留在 package.json」——实际上早删了;
  2. 死引用:标准列表页文档的正面示例指向 key-area、检测评价、管道变更——模块已删除,照着示例找文件是 404;
  3. 行号失准:核心列表组件重构(文件从近 1500 行瘦到 1200 余行)后,文档里所有 index.vue:848-891 式引用全部失效;
  4. 数字失实:「公共工具对象有 30+ 方法、文件 1400+ 行」——实际只有 10 来个方法、文件不到 600 行。

巡检方法简单粗暴但有效:把 spec 里所有代码路径引用 grep 出来逐个验存在性,行号引用逐个打开核对。处理原则:正面示例必须指向现存文件(我们替换成压水管道、设备台账、文档库等存活页面并逐一核对行号);历史案例不删除、统一加背景注记——教训仍有价值,但读者得知道文件没了。

制度化两条:规范同步与代码改动同一批提交;错误案例库持续增长,从「忘记手动 import Vue API」到「孤儿 form-item 校验静默失效」,每条都是真踩过的坑。

八、沉淀下来可复用的六条

  1. 先降噪,再治理。基线不可信时,一切"进度"都是自欺。
  2. 数字必须带口径。排除 ^node_modules、错误与警告分开记、第三方债务单独记账。
  3. 错误当场修。批量攒到最后是连锁返工,定位成本远高于顺手修。
  4. lint 全绿 ≠ 安全。ESLint 不查 TS 未定义标识,esbuild 不做类型检查——整理 import 后,type-check 是唯一闭环。
  5. 例外要显式化。文件级 disable + 契约注释,不全局豁免;豁免也要留档(为什么、影响面、何时收回)。
  6. 文档是代码的影子。改代码时同步改文档,定期对规范做引用存活巡检——包括校验文档里的事实性陈述,而不只是链接。

九、还剩什么

诚实记账:内部组件库包里还有 70 多个第三方报错(升级组件库才能根治),type-check 因此在 CI 里暂时 allow_failure,待第三方债务消解后升级为阻断项;lint 还有 40 余条警告待继续收敛。

移植是借来的窗口。业务瘦身的同时把债还了、把规范立起来,这个项目才算真正"独立"了——而不是把母系统的债,原样搬进了新仓库。

posted on 2026-10-28 17:25  中文还在写码  阅读(9)  评论(0)    收藏  举报