我把 Vue 2 项目的迁移成本算成了一个数字

0 阅读7分钟

Vue 2 迁移要多久?我把答案算成了一个数字

一个答不上来的问题

"迁到 Vue 3 要多久?"

这个问题,我被问过,也问过自己很多次。一次都没答上来。

不是我不知道技术方案。方案很清楚:生命周期改名、.syncv-model:xxxslot-scopev-slot、Vuex 换 Pinia、element-uielement-plus、构建从 webpack 换掉。

问题是工作量

要方案的人要的不是技术方案,是一个数。而当时我手里能拿出来的,只有一句"感觉挺大的"。

"感觉挺大的"这句话在排期会上没有任何作用。它既不能要人要时间,也不能说服任何人。


一、为什么"盘点"比"改造"更难

迁移这件事,改造的难度是确定的 —— 每一条规则怎么改,官方迁移指南写得明明白白。

难的是盘点:你手上这个项目里,到底有多少地方要改。

我试过三种办法,都不行:

① 人肉翻代码。 几百个 .vue 文件,翻到第 30 个就开始失忆。而且翻完你记不住哪一行有问题 —— 这对后面开工毫无帮助。

② grep。 grep beforeDestroy 确实能搜到。但接着你要搜 .syncslot-scopefiltersVue.prototype$onrequire.context……搜完十几次,还得把结果手动汇总成一张表。更麻烦的是,slot-scope="scope" 这种写法和变量名重名的情况全靠自己判断。

③ 让 AI 通读整个项目。 我先试的是这条。结果是:装不下。几百个 .vue 文件、几兆源码,模型读不完;就算读完了,问它"第 800 行那个 .sync 还在不在",它也答不上来。

所以卡点其实很具体:我需要一个能在 30 秒内,把"要改什么、各多少、在哪一行、改起来多贵"一次性列清楚的工具。

没有,就自己写一个。


二、10 条规则,只收"能稳定判定"的

vue2-migration-scanner 的核心只有 10 条规则,全部基于正则可以稳定判定的模式:

级别检测项权重Vue 3 里的处置
highbeforeDestroy / destroyed×2改名 beforeUnmount / unmounted,但清理逻辑要逐一确认
high.sync 修饰符×1v-model:propName
highslot-scope / slot="name"×3只认 v-slot<template slot-scope="x"><template #default="x">
highfilters×2选项被移除,改 computed 或纯函数 {{ fmt(x) }}
highVue.prototype / Vue.use / Vue.set×3app.use / app.config.globalPropertiesVue.set 直接删
high$on / $off / $once / $listeners / $children×3实例事件 API 被移除,EventBus 换 mitt
mediumfunctional: true×2改成普通函数组件
mediumrequire.context / VUE_APP_ / chainWebpack×1构建配置要重写
mediummixins 引用×1.5仍可用,但数据来源不透明,建议转 composable
mediumEventBus(new Vue() + $emit×2mitt 或 Pinia

这里有个刻意的取舍:不做 AST 语义推断。

比如 this.$refs.foo.$emit('x') —— 严格说不是 EventBus,但正则分不出来,就只能按模式算。我宁可在这类边缘情况下少报,也不愿意多报。

理由很简单:误报会让团队不再信任工具,这比漏报更糟。 一个扫描器只要有 20% 的误报,第三次用它的人就会开始逐条怀疑,第五次就没人用了。


三、MDI:把"改一处的成本"变成权重

只给一个"共 185 处"是没用的 —— 185 处 slot-scope 和 185 处 .sync,工作量差着好几倍。

所以我给每类规则配了权重,代表**「改一处的成本」**,而不是出现频率:

MDI = Σ (该类命中数 × 该类权重)

权重是怎么定的?

  • slot-scope×3 —— 因为改插槽语法要动模板结构,一个组件里可能牵出好几层 <template>,还要处理作用域变量改名。
  • .sync×1 —— 基本是字符串替换,foo.sync="bar"v-model:foo="bar",改错了也很容易发现。
  • filters×2 —— 中间档,要决定这个 filter 变成 computed 还是纯函数,涉及一次判断。
  • mixins×1.5 —— 改起来不难,但改动会波及所有使用方,属于"要小心"而不是"要费力"。

最后按总分分档:

MDI量级含义
< 50写法基本干净,主要是依赖升级
50 – 200需要逐文件过一遍,可以流水线推进
200 – 600建议先立规范再动,避免边改边新增
≥ 600极高按模块分批,先冻结新增遗留写法

这个公式是公开的,也是可质疑的。 权重全部写在 src/rules.mjs 里,觉得哪一项不合理,改数字就行。

我唯一坚持的是:它是一个相对指标,用来横向对比、追踪趋势、设 CI 阈值。它的用途是粗粒度排期,不是精确工期。

任何声称能算出"还需要 37.5 人日"的工具,都该被怀疑。


四、实测:vue-element-admin 的账

为了验证,我扫了一个公开的、写得相当好的 Vue 2 项目 —— PanJiaChen/vue-element-admin(MIT 协议)。

一条命令,85 毫秒


  Vue 2 迁移债务扫描 · vue-element-admin (github.com/PanJiaChen/vue-element-admin)
  扫描 218 个文件(跳过 0),耗时 85ms
  已跳过目录:.github、build、public、src/assets、src/vendor(产物/依赖,扫它们会虚高)

  检测项                                         命中  处置建议
  ──────────────────────────────────────────────────────────────────────────
  beforeDestroy / destroyed 生命周期            20  Vue 3 重命名为 beforeUnmount / unm…
  .sync 修饰符                                 14  Vue 3 移除 .sync,改为 v-model:prop…
  slot-scope / slot="name" 旧插槽语法            98  Vue 3 只认 v-slot;<template slot…
  filters 过滤器                               14  Vue 3 移除 filters 选项,改成 compute…
  Vue 全局 API(prototype / use / set ...)     19  Vue 3 改为 app.use / app.config.…
  $on / $off / $once / $listeners / $children     4  Vue 3 移除实例事件 API 与 $listeners/…
  functional: true 函数式组件                     1  改成普通函数组件(props/emits 显式声明),Vue…
  Webpack / vue-cli 强耦合写法                    6  Rspack 兼容大部分 loader 链,但 requir…
  mixins 引用                                  9  mixins 在 Vue 3 仍可用但同样导致数据来源不透明…
  ──────────────────────────────────────────────────────────────────────────
  合计 185 处 · 72 个文件                             MDI 466.5  量级:高
  建议先立规范再动,避免边改边新增

  最重的文件
     17  ████████████████████ src\views\table\complex-table.vue
     10  ████████████ src\views\example\list.vue
      9  ███████████ src\views\table\drag-table.vue
      8  █████████ src\views\tab\components\TabPane.vue
      8  █████████ src\views\table\inline-edit-table.vue

  依赖风险
   · echarts@4.2.1 —— echarts 4 及以下 —— 5.x API 有破坏性变更
   · element-ui@2.13.2 —— Vue 2 生态 UI 库 —— 需换 element-plus / vant 4
   · vue@2.6.10 —— Vue 2 —— 迁移主体,升到 3.x
   · vue-router@3.0.2 —— Vue Router 2/3 —— 升到 4/5,API 从 new Router() 改为 createRouter()
   · vuex@3.1.0 —— Vuex 3 —— 官方推荐迁移到 Pinia
   · @vue/cli-service@4.4.4 —— vue-cli —— 换 Rspack/Vite 构建

  兼容性提醒(能装,但升级后要确认行为)
   · element-ui@2.13.2 —— Vue 2 生态 UI 库 —— 需换 element-plus / vant 4 / view-ui-plus
   · vuedraggable@2.20.0 —— vuedraggable 2 —— Vue 3 需换 vuedraggable@next 或 vue-draggable-plus
   · vuex@3.1.0 —— Vuex 3 —— Vue 3 里能跑但官方推荐 Pinia;this.$store 在 setup 中不可用

汇总一下:

指标
扫描文件218
遗留写法命中185 处,涉及 72 个文件
MDI466.5(高)
最多的一类slot-scope 98 处
依赖风险6 个(vue 2.6.10 / vue-router 3 / vuex 3 / element-ui 2.13 / echarts 4.2 / vue-cli 4.4)

同一个结果也可以输出成自包含的 HTML 报告(无外链、无脚本,能直接发给同事或存档):

Vue 2 迁移债务报告 · vue-element-admin 真实扫描样例转存失败,建议直接上传图片文件

几个值得说的细节:

98 处 slot-scope 一个人就占了全部命中的一半以上。 这不是这个项目写得不好 —— 而是 Vue 2 时代的写法就这样,slot-scope 在当时是最普通的写法。它之所以占大头,恰恰说明了为什么"拍脑袋估算"不靠谱:你脑子里想到"迁 Vue 3",第一个念头是"改生命周期钩子",而实际上这只占 20 处,是 slot-scope 的五分之一。

最重的单个文件是 src/views/table/complex-table.vue,17 处。 这类"表格页"是 Vue 2 中后台项目的典型重灾区:插槽多、.sync 多、filter 多,三样全占。

依赖里 6 个要动。 element-ui 要换 element-plusvue-cli 要换构建工具,echarts@4 升 5 有破坏性变更 —— 这些工作量和改代码是两笔账,必须分开算。

顺便说一句:这不是在评价这个项目。它写得相当好,只是恰好在 Vue 2 时代。同样的数字放在任何一个 2018–2020 年的中后台项目上,量级都不会差太多。


五、另一个案例:我真正要迁的那个项目

公开项目毕竟隔着一层。我真正要迁的,是手上那个真实的生产项目 —— 一个多人协作了很多年、现在还在持续迭代的 Vue 2 + webpack 中后台。

扫完的结果,比 vea 更值得说:

  • beforeDestroy / destroyed73 处
  • .sync 修饰符:66 处
  • slot-scope / 旧插槽语法:18 处
  • mixins 引用:71 处,其中一个 globalMixin 被引用了 14 次

(完整报告里还有 filters、全局 API、$on 那几类,细节就不公开了 —— 毕竟是公司项目。这里列的是最值得说的四项。)

注意最后一行。 它和其他三个不是一回事。

前三个是"必须改"的 —— 不改就报错,编译器会告诉你。

而 mixins 是不改也能跑的 —— Vue 3 支持 mixins,所以它不会被任何报错提醒你。但它才是真正难处理的部分:

globalMixin 被 14 个组件引用,这 14 个组件里都能访问到 mixin 里的 datacomputedmethods。你打开其中任何一个组件,看到的 this.xxx不知道是从哪来的 —— 是组件自己的?是 mixin 的?还是 mixin 里又引了别的 mixin?

这就是 mixins 的典型问题:数据来源不透明。它不制造 bug,它制造的是"没人敢改"。

所以我在权重里给了 mixins ×1.5,量级不高,但在报告里它是"建议转 composable"的第一优先级 —— 因为迁移期是重构这类问题成本最低的窗口:你反正要动这个文件了。

顺便,依赖层面也扫出几个典型问题:

  • echarts@4.9.0 全量引入,但项目里只有 2 处用到 → 白白背了几百 KB
  • moment 70 处引用 → 迁移期可以直接换成 dayjs
  • lodash 73 处引用,多为全量引入 → 换 lodash-es 按需
  • swiper@5.3.8 → 版本过旧,v6+ 有破坏性变更

这些不是迁移的阻塞项,但它们和迁移共享同一个动作:打开这个文件、改一次 import。既然要打开,就一次做完。


六、它真正有用的三个场景

① 立项排期:把"感觉很大"变成"MDI 466.5,高"

这是我最想要的。有了数字,讨论才能从"到底大不大"变成"按什么顺序做、分几批"。

② 迁移期防失控:基线对比

迁移是个持续几周的过程,最常见的失控方式不是改不完,而是边改边新增 —— 你在这边迁移老文件,那边业务需求又提交了一堆 .sync

vue2-scan . --json baseline.json     # 迁移前存一份基线
# …改了一阵子…
vue2-scan . --baseline baseline.json # 看进度:消除了多少、新增了多少

输出会告诉你每类规则的变化。数字往下走,说明真的在推进;数字没动甚至涨了,说明有人在往回填。

③ CI 门禁:冻结新增

- name: Vue 2 迁移债务检查
  run: npx vue2-migration-scanner . --ci --max-mdi 200

超阈值退出码为 1,挂在 PR 流水线上 —— 先冻结新增,再逐月递减阈值。


七、30 秒自己跑一遍

零依赖,只用 Node 内置模块,Node ≥ 18,不联网:

npx vue2-migration-scanner               # 扫描当前目录
npx vue2-migration-scanner ./src         # 扫描指定目录
npx vue2-migration-scanner . --html report.html   # 输出可存档的 HTML 报告

默认跳过 node_modulesdistlibesbuild 等目录 —— 这条很重要:如果把打包产物也扫了,你会把"压缩后的框架内部实现"算成自己的债务,数字直接虚高。

而且它不会静默忽略:跳过哪些目录、跳过了几个文件,都会明确打印出来。工具可以少报,但不能不告诉你它少报了。

扫完你会拿到一个数字。MDI 低于 50 说明你的项目比 vue-element-admin 干净;超过 600 的话,欢迎在评论区说说最重的那一类是什么 —— 我挺好奇大家的重灾区都长什么样。


八、局限(请先读这段)

  • 基于正则可稳定判定的模式,不做 AST 语义推断。 会漏掉一些需要语义分析才能确认的情况。这是刻意取舍,理由前面说了。
  • MDI 是相对指标,不是工期。 不要拿它当排期承诺。
  • filters / slot 之类规则在极少数写法下可能多报。 看报告时以「文件:行号」为准,逐条核对。
  • 它不自动改代码。 只报告。改代码请用 codemod 或人工 —— 迁移期最怕的就是批量改错。
  • 它和 gogocode / vue-codemod 不冲突。 那些是改代码的,这个是量工作量的。正确顺序是先扫一遍定范围,再上 codemod 批量改。

写在最后

迁移这件事最贵的成本,其实不是改代码。

说服别人给你时间。而说服的前提,是你能说出一个数字。

说不出数字的时候,讨论永远停在"我觉得挺大的"和"看着没那么大吧"之间来回。而这个讨论每多进行一轮,你的迁移就晚开始一个月 —— 直到某天某个依赖彻底不再维护,你被迫在没有预算的情况下动手。

先有一把尺子,再动手。

如果这把尺子对你有用,给个 star 是最大的鼓励。


相关链接

工具 MIT 协议 · 零依赖 · 只用 Node 内置模块