把 Vue 项目从 Vue CLI 迁到 Vite,代价到底出在哪几处
Vite 今年二月才刚由尤雨溪发起,现在连 1.0 正式版都还没到,版本号一直卡在 beta/rc 阶段,明显还是"刚起步"的状态。它启动快、热更新快,这两点已经不需要再验证——真正要想清楚的是另一件事:一个已经在 Vue CLI 上跑了一两年、构建配置里塞满自定义规则的项目,值不值得现在动它。
我们组里没打算碰核心的中后台主应用,那个项目页面多、权限逻辑复杂、Webpack 配置也改了不知道多少轮,谁都不敢保证迁移后一切照旧。真正拿来试的是内部一个小工具项目,页面十几个,给运营同事用来核对库存和退货单,功能相对独立,就算迁移中间出问题,影响面也可控。这次迁移的价值,与其说是验证"Vite 好不好用",不如说是搞清楚迁移这件事本身要花多大力气。
迁移之前先把旧项目的假设摊开看
Vue CLI 背后是 Webpack 4,这套体系跑久了,项目里会攒下很多没人愿意再重新解释一遍的约定。迁之前,我把这个小工具项目的构建配置过了一遍,主要看四类东西。
第一类是自定义的 Webpack 配置。这个项目改的不算多,主要是给 vue.config.js 加了个 chainWebpack,往生产构建里塞了个 webpack-bundle-analyzer,还有一处针对 SVG 图标做了 svg-sprite-loader 的定制。这几处都得找 Vite 里的等价方案,不能指望原样保留。
第二类是路径别名和动态引入。项目里 @ 指向 src,这个在 Vite 里配置 resolve.alias 就能对应,没有难度。但另有一处用了 require.context 去扫描目录下所有的表单校验规则模块,这个在 Vite 里没有直接对应的 API,得换写法,后面会细讲。
别名这块看着简单,验证的时候还是发现了一个需要留意的地方:项目里除了 @ 指向 src,还有一个 @components 指向 src/components/common 的二级别名,用来给一批公共组件提供更短的引用路径。Vue CLI 下这类别名是靠 chainWebpack 里 config.resolve.alias.set(...) 一条条加的,迁移时照着 vite.config.js 的 resolve.alias 写成对象或者数组都行,但要注意别名的匹配顺序——如果 @ 和 @components 同时存在,配置项里数组形式的别名是按声明顺序做前缀匹配的,@ 如果声明在前面,可能会把 @components/xxx 也命中成 @ 的规则再拼接,导致路径解析错误。保险的写法是把更具体的别名放在数组前面:
1// vite.config.js 2const path = require('path'); 3 4module.exports = { 5 resolve: { 6 alias: [ 7 { find: '@components', replacement: path.resolve(__dirname, 'src/components/common') }, 8 { find: '@', replacement: path.resolve(__dirname, 'src') }, 9 ], 10 }, 11};
这个顺序问题在 Webpack 的 resolve.alias(对象形式)里不太会碰到,因为对象形式下大部分实现是按 key 长度或者精确匹配优先处理的;换成 Vite 底层依赖的解析逻辑之后,数组形式的声明顺序变成了一个需要显式关注的细节,不注意的话现象是"部分用 @components 引入的组件突然找不到模块",报错信息也不会直接指向别名配置,得靠经验才能想到这一层。
第三类是环境变量的用法。项目里到处写 process.env.VUE_APP_API_BASE,这是 Vue CLI 基于 dotenv 注入的方式。Vite 走的是完全不同的机制——它在构建时通过 import.meta.env 暴露变量,而且只暴露以 VITE_ 开头的变量,这跟 Vue CLI 要求 VUE_APP_ 前缀是同一个思路,但两边的实现路径不共享,.env 文件里的变量名得跟着改一遍前缀,代码里所有引用也得跟着换。
第四类是代理和 mock。开发环境代理到测试环境的接口,之前配在 vue.config.js 的 devServer.proxy 里,Vite 里对应 server.proxy,配置项基本能照抄,但要注意 Vite 的 dev server 默认端口和路径重写行为跟 webpack-dev-server 不完全一致,得逐条对一遍。
把 vue.config.js 完整对照成 vite.config.js
光在脑子里过一遍分类还不够踏实,我把这个项目原来的 vue.config.js 摊开,逐段对应着写了一份 vite.config.js,方便迁移时照着抄,也方便回头检查有没有漏掉的分节。原来的配置大概是这样:
1// vue.config.js(迁移前) 2const path = require('path'); 3const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 4 5module.exports = { 6 publicPath: process.env.NODE_ENV === 'production' ? '/stock-tool/' : '/', 7 chainWebpack: (config) => { 8 config.resolve.alias.set('@', path.resolve(__dirname, 'src')); 9 10 config.module 11 .rule('svg-sprite') 12 .test(/\.svg$/) 13 .include.add(path.resolve(__dirname, 'src/icons')) 14 .end() 15 .use('svg-sprite-loader') 16 .loader('svg-sprite-loader') 17 .options({ symbolId: 'icon-[name]' }); 18 19 if (process.env.ANALYZE) { 20 config.plugin('bundle-analyzer').use(BundleAnalyzerPlugin); 21 } 22 }, 23 devServer: { 24 port: 8088, 25 proxy: { 26 '/api': { 27 target: 'http://test-gateway.internal.com', 28 changeOrigin: true, 29 pathRewrite: { '^/api': '' }, 30 }, 31 }, 32 }, 33 css: { 34 loaderOptions: { 35 sass: { 36 prependData: `@import "@/styles/variables.scss";`, 37 }, 38 }, 39 }, 40};
对照下来的 vite.config.js 是这样:
1// vite.config.js(迁移后) 2const path = require('path'); 3const vue2 = require('vite-plugin-vue2'); 4const { visualizer } = require('rollup-plugin-visualizer'); 5 6const isProd = process.env.NODE_ENV === 'production'; 7 8module.exports = { 9 base: isProd ? '/stock-tool/' : '/', 10 plugins: [ 11 vue2(), 12 process.env.ANALYZE && visualizer({ open: true }), 13 ].filter(Boolean), 14 resolve: { 15 alias: { 16 '@': path.resolve(__dirname, 'src'), 17 }, 18 }, 19 server: { 20 port: 8088, 21 proxy: { 22 '/api': { 23 target: 'http://test-gateway.internal.com', 24 changeOrigin: true, 25 rewrite: (p) => p.replace(/^\/api/, ''), 26 }, 27 }, 28 }, 29 css: { 30 preprocessorOptions: { 31 scss: { 32 additionalData: `@import "@/styles/variables.scss";`, 33 }, 34 }, 35 }, 36 build: { 37 outDir: 'dist', 38 sourcemap: !isProd, 39 }, 40};
Vite 1.x 的配置文件走的是 CommonJS 形态,跟 Vue CLI 的 vue.config.js 是同一套写法习惯,require 进来直接 module.exports 一个对象,倒也不算陌生。真正陌生的是插件本身:Vue 2 项目要接进 Vite,靠的是社区维护的 vite-plugin-vue2,不是官方出品,装的时候我特意确认了一下它的更新频率,最近一次提交是两周前,活跃度还可以,但终究是个人维护的项目,遇到问题没有官方团队接手排查,这个心理准备得提前做好。
对照着写下来才发现,chainWebpack 里那种"链式改一条规则"的写法在 Vite 里基本没有等价物——Vite 的插件体系是扁平的 plugins 数组,不存在按规则名去改某一条 loader 的操作方式。像 svg-sprite-loader 这种定制,得找专门的 Vite 插件(比如 vite-plugin-svg-icons 这类,刚出来没多久,稳定性还在观察),而不是把原来那条 Webpack rule 翻译过来。webpack-bundle-analyzer 也一样,Vite 生态里对应的是基于 Rollup 的 rollup-plugin-visualizer,产出的报告形式和交互都不一样,团队看惯了原来那张图的人得重新适应一下。
pathRewrite 和 rewrite 这两处也不是无脑照抄——Vue CLI 那边接的是一个对象形式的正则映射表,Vite 这边接的是一个函数,直接对路径字符串做替换。如果原来的 pathRewrite 规则比较复杂(比如多条正则叠加),得手动把它们捏成一个函数,不能简单地把 key-value 对丢过去。
define 配置:全局常量替换也要重新配一遍
项目里有几处历史遗留的全局注入,比如埋点用的版本号、构建时间戳,之前是靠 Webpack 的 DefinePlugin 塞进去的:
1// vue.config.js 里额外配的 DefinePlugin 2const webpack = require('webpack'); 3 4module.exports = { 5 configureWebpack: { 6 plugins: [ 7 new webpack.DefinePlugin({ 8 __APP_VERSION__: JSON.stringify(require('./package.json').version), 9 __BUILD_TIME__: JSON.stringify(new Date().toISOString()), 10 }), 11 ], 12 }, 13};
代码里直接用 __APP_VERSION__ 这个全局变量上报埋点。Vite 这边有专门对应的 define 配置项,写法类似,但要注意值必须是字符串形式的 JS 表达式,不能直接传对象:
1// vite.config.js 2const pkg = require('./package.json'); 3 4module.exports = { 5 define: { 6 __APP_VERSION__: JSON.stringify(pkg.version), 7 __BUILD_TIME__: JSON.stringify(new Date().toISOString()), 8 }, 9};
这一步看着平移过去很顺利,但踩了一个小坑:Vite 的 define 替换只在生产构建和依赖预构建阶段生效,开发环境下直接用 <script type="module"> 加载源码文件时,某些场景下替换时机跟 Webpack 不完全一致,如果这个全局变量被用在了模块顶层、且被其他模块在初始化阶段就引用了,偶尔会因为替换顺序问题读到没被替换的原始占位符。这种情况目前没有一个特别优雅的解法,只能把这类全局注入的使用位置往后挪一挪,避免在模块加载的第一时间就依赖它。
静态资源引用路径的写法差异
这一块是这次迁移里最琐碎、但踩坑频率最高的地方。项目里有不少历史写法是 Webpack 特有的:
1<!-- webpack 时代常见写法 --> 2<img src="~@/assets/logo.png" />
这个 ~ 前缀是 url-loader/file-loader 认的一种约定,告诉 Webpack 这是一个模块路径而不是普通的相对路径字符串。Vite 里没有这个约定,因为 Vite 处理静态资源走的是原生 ESM 的 import 语义,~ 这个前缀直接会被当成字面量路径去解析,导致资源加载失败。改法是要么换成标准的 import 语句拿到最终 URL,要么直接用相对路径:
1// 组件里改成显式 import 2import logoUrl from '@/assets/logo.png';
1<template> 2 <img :src="logoUrl" /> 3</template>
或者干脆去掉 ~,用相对路径的方式让 Vite 按 ESM 规则解析:
1<img src="../assets/logo.png" />
第二处差异是 public 目录和 assets 目录的处理边界。Vue CLI 项目里习惯把不需要经过打包处理的静态文件放进 public,构建时原样拷贝到输出目录,这条约定 Vite 延续了下来,行为基本一致。但有一处这个项目里的历史写法是把一张体积较大的 Excel 导入模板图标直接放进了 src/assets,代码里用相对路径引用,指望 Webpack 的 url-loader 在超过阈值时自动转成文件拷贝而不是内联成 base64。Vite 这边处理静态资源也有类似的体积阈值机制(默认 4KB 以下会被内联成 base64 塞进代码里,超过阈值走文件拷贝并加哈希),但阈值配置项和默认值跟 Webpack 不是同一套参数,迁移时这张图判断口径变了,构建产物体积也跟着有了明显差异,最后是手动把这张图挪进了 public 目录,绕开这条判断口径不确定的路径。
第三处是 CSS 里用 url() 引用图片的写法。Vite 会对 .css/.scss 文件里的 url() 做类似的路径重写和资源处理,但如果 CSS 文件里用的是别名路径(比如 url("@/assets/bg.png")),需要确认 resolve.alias 对 CSS 文件里的 url() 解析同样生效——这个项目试下来是生效的,但这属于没有文档明确写清楚的行为,属于"跑一遍验证过了才敢信"的那类结论,不是看文档就能确定的。
CSS Modules 配置也要单独过一遍
项目里有两个历史组件用了 CSS Modules 做样式隔离,Vue CLI 下的写法是给 <style> 块加 module 属性,文件名不需要特殊后缀,Webpack 靠 vue-loader 内部约定识别。Vite 这边的默认约定不完全一样,单文件组件内联的 <style module> 能直接工作,但如果是拆出来的独立样式文件走 CSS Modules,Vite 要求文件名符合 *.module.scss 这种命名约定才会启用模块化处理:
1// 原来独立样式文件:table-cell.scss,Vue CLI 靠 loader 配置识别
1// 迁移后要改成 table-cell.module.scss,Vite 靠文件名约定识别
1<script> 2import styles from './table-cell.module.scss'; 3 4export default { 5 computed: { 6 cellClass() { 7 return styles.highlightCell; 8 }, 9 }, 10}; 11</script>
另外 Vite 处理 CSS Modules 时,类名的驼峰转换行为也需要配置,默认情况下 .highlight-cell 这种连字符类名在 JS 里只能通过 styles['highlight-cell'] 取到,如果习惯了驼峰访问,得显式配置 css.modules.localsConvention:
1// vite.config.js 2module.exports = { 3 css: { 4 modules: { 5 localsConvention: 'camelCaseOnly', 6 }, 7 }, 8};
这项配置项名字和 Webpack 下 css-loader 的 modules.localsConvention 是同一个思路,值也基本对应,算是这次迁移里少数几处"文档说的和跑出来的行为完全一致"的地方,不需要额外验证太久。
require.context 没有替代品,只能手动列清单
这是这次迁移里最让人意外的一处——不是"写法不一样",是 Vite 现在这个版本压根没有对应的能力。原来的写法是这样:
1// webpack 时代:批量引入 validators 目录下所有规则文件 2const validatorFiles = require.context('./validators', false, /\.js$/); 3const validators = {}; 4 5validatorFiles.keys().forEach((key) => { 6 const name = key.replace(/^\.\/(.*)\.js$/, '$1'); 7 validators[name] = validatorFiles(key).default; 8});
我一开始以为 Vite 会有个对应的批量导入语法,翻了一圈文档和 issue,发现现在这个版本根本没有——按目录批量导入这件事,Vite 目前完全不管,得自己老老实实一个个 import:
1// Vite 1.x:没有批量导入的语法糖,只能手动列出来 2import required from './validators/required.js'; 3import email from './validators/email.js'; 4import phone from './validators/phone.js'; 5import idcard from './validators/idcard.js'; 6 7const validators = { required, email, phone, idcard };
这条改动带来一个真实的维护成本:以后谁在 validators 目录下新加一个校验规则文件,光加文件不够,还得记得回到这里补一行 import,不像 require.context 那样目录一扫全自动纳入。团队里因为这个漏加过一次,新加的校验规则文件在页面上一直不生效,排查了好一会儿才想起来是漏了这行手动 import。
项目里还有一处更隐蔽的用法:某个表单页面里根据业务类型动态 require 对应的校验规则文件,写法类似 require(\./rules/${bizType}.js`),这种带变量插值的动态 require在 Webpack 里能工作,是因为 Webpack 会在打包时把./rules 目录下所有匹配的文件都打进一个模块映射表,运行时按需取用。这类写法 Vite 完全不支持——import()` 要求路径在语法层面能被静态分析出来,不能是运行时拼出来的完整变量,而且既然没有批量导入的能力,也没法像 webpack 那样"打个映射表兜底"。改法是把所有可能的业务类型都显式列出来,手动建一张映射表:
1// 改写后:手动枚举每个业务类型对应的规则模块,建一张映射表 2import serviceRule from './rules/service.js'; 3import productRule from './rules/product.js'; 4import orderRule from './rules/order.js'; 5 6const ruleModules = { 7 service: serviceRule, 8 product: productRule, 9 order: orderRule, 10}; 11 12function getRuleByBizType(bizType) { 13 return ruleModules[bizType] || null; 14}
这种写法本质上是把"运行时决定要哪个模块、按需去 require"改成了"编译时全部显式列出来,运行时只是从一个现成的表里查",如果业务类型将来还会增加,每加一种都要回来补一行映射,这也是迁移时需要跟业务方同步的一条真实代价,不是纯粹的语法替换那么简单。
process.env 和 import.meta.env 不是简单换个写法
这处坑比想象中要隐蔽一点。process.env 是 Node 里的全局对象,Webpack 通过 DefinePlugin 在编译期把 process.env.XXX 替换成字符串常量,本质上是文本替换。import.meta.env 则是 ES 模块规范里 import.meta 的扩展属性,Vite 在开发环境下是真的注入了一个对象、由浏览器原生的 ESM 环境读取,生产构建时才做静态替换。
实际影响是:在 Vite 项目里,任何用到环境变量的地方都不能写成 const env = process; console.log(env.env.VITE_API_BASE) 这种绕一层的写法,因为编译期替换依赖能静态识别到 import.meta.env.XXX 这个完整路径,绕开写法会导致替换失败,运行时拿到 undefined。我们项目里刚好有一处把 process.env 整体解构赋值给了一个变量,迁移时这处直接报错,反而是最快暴露出来的问题。
另外 Vite 默认只会把 import.meta.env.MODE、import.meta.env.BASE_URL、import.meta.env.PROD、import.meta.env.DEV 这几个内置变量和 VITE_ 前缀的自定义变量暴露给客户端代码,这是刻意的安全设计——避免不小心把服务端配置的敏感变量打进前端包里。Vue CLI 那边其实也有类似机制,只是前缀换成了 VUE_APP_,behavior 上是一致的,只是没人平时会去在意这条规则。
.env 文件本身也要跟着调整。原来项目里分了 .env.development、.env.test、.env.production 三份,Vite 延续了这套多环境文件的加载约定,文件名规则基本一致,真正要改的只是里面变量名的前缀,从 VUE_APP_ 换成 VITE_。看着是个简单的批量替换,但代码里引用这些变量的地方分布得比较散——路由守卫、请求拦截器、埋点初始化脚本、甚至个别 Vuex 模块里都有直接读取,全量替换完之后跑了一遍全局搜索确认没有漏网的 VUE_APP_ 前缀残留,这一步花的时间比想象中要多,也是这次迁移里工作量被低估的一项。
依赖预构建:optimizeDeps 踩的坑
Vite 开发环境下第一次冷启动会有一次专门的依赖预构建过程,这步是用 esbuild 把 node_modules 里那些用 CommonJS 或者 UMD 格式写的第三方依赖转换成浏览器能直接识别的 ESM 格式,同时也会把一些依赖数量很多的包合并打包,减少浏览器发起请求的数量。第一次跑起来的时候能明显感觉到命令敲下去之后有几秒钟的等待,控制台会打印类似 Optimizable dependencies detected 的提示,之后只要这些依赖没有变化,这个预构建结果会被缓存到 node_modules/.vite 目录下,后续冷启动就不需要再走一遍。
这个项目里遇到过一次预构建没识别到某个依赖导致报错的情况。用的是一个内部维护的表单组件库,包里模块导出方式比较特殊,package.json 的 main 字段指向的入口文件本身又转手 require 了另一个子路径文件,属于比较绕的 CommonJS 写法。Vite 的依赖扫描机制是基于静态分析项目源码里出现过的 import 语句去自动发现哪些依赖需要预构建,这种嵌套转发的写法让自动扫描没能正确识别到需要预构建的完整依赖树,结果是开发环境下直接抛出类似"不能在浏览器里直接使用 CommonJS 语义"的报错。
解法是在 vite.config.js 里手动把这个包加进 optimizeDeps.include,强制让它进入预构建流程:
1// vite.config.js 2module.exports = { 3 optimizeDeps: { 4 include: ['internal-form-widgets', 'internal-form-widgets/lib/utils'], 5 }, 6};
加完这条配置,重新删掉 node_modules/.vite 缓存目录再启动一次,问题就消失了。这件事带来的判断是:迁移一个存量项目到 Vite,光看现有依赖能不能装上是不够的,还得把每个第三方依赖实际的模块导出方式过一遍,尤其是团队内部自己维护、发布节奏不那么规范的包,最容易在预构建这一步栽跟头。为此专门整理了一份清单,把项目里所有非官方维护的内部包过了一遍,确认它们的导出方式,省得每次冷启动都提心吊胆。
生产构建这块,Vite 现在用的还是 Rollup
容易被"快"这个印象带偏的一点是:Vite 的开发服务器基于原生 ESM 和 esbuild 做依赖预构建,这部分确实是即时反馈的关键;但生产环境的打包,Vite 1.x 走的是 Rollup,不是 esbuild。也就是说,vite build 出来的产物在打包策略、代码分割、Tree Shaking 的行为上更接近 Rollup 生态而不是 Webpack,两者对 CommonJS 模块的兼容处理方式也不同。
这次迁移里踩到的那个第三方库,是团队自己封装的一个日期处理小工具,写法上是典型的 CommonJS 混合导出:
1// date-utils 包内部(简化版) 2function formatDate(date, pattern) { 3 // ... 4} 5 6module.exports = formatDate; 7module.exports.formatDate = formatDate; 8module.exports.parseDate = function parseDate(str) { 9 // ... 10};
在开发环境下,Vite 靠 esbuild 做预构建,esbuild 对这种 module.exports = fn 附带挂载额外属性的写法处理得比较宽松,import dateUtils from 'date-utils' 和 import { parseDate } from 'date-utils' 两种引用方式都能正常拿到值。但生产构建走到 Rollup 这边,Rollup 对 CommonJS 模块要经过 @rollup/plugin-commonjs 转换成 ESM,这层转换对"默认导出函数本身还挂了额外属性"这种写法的处理不如 esbuild 宽松,转换出来的具名导出对不上,项目里用了 import { parseDate } from 'date-utils' 具名引入方式的地方,构建产物里拿到的是 undefined,这个问题一直到构建产物上了测试环境跑真实调用才暴露出来,开发环境完全看不出异常,这也是这次迁移里最容易被漏掉的一类问题——凡是"开发环境正常、生产构建才出错"的情况,基本都出在 esbuild 和 Rollup 对同一段 CommonJS 代码处理方式不一致这条线上。
最后的处理办法有两种:一种是把这个内部工具包里的写法改成规规矩矩的 ESM 具名导出,从源头上让两边工具都不用做兼容猜测;另一种是暂时不动这个包,在业务代码里统一改成默认导入再取属性的写法,即 import dateUtils from 'date-utils'; dateUtils.parseDate(...),绕开具名导入这条不稳定的路径。这次选的是后者,因为改第三方包本身涉及别的团队在用,改动范围不可控,业务代码里手动改引用方式反而是眼下成本最低的一步。
这也是现在这个阶段最大的现实——Vite 生态才半年,插件不多,遇到 CommonJS 兼容问题经常要么自己在业务代码里绕一下,要么等上游更新,不能指望有一个通用工具一次性解决所有历史遗留的模块导出写法。
代理和接口链路要单独验证
页面能打开不代表接口是通的。迁移这个小工具项目时,开发环境的代理规则改完之后,我们没有直接开始测业务功能,而是先写了个统一的请求封装,让错误直接可见:
1export async function requestJson(url, options) { 2 try { 3 const response = await fetch(url, options); 4 5 if (!response.ok) { 6 return { ok: false, message: `请求失败:${response.status}` }; 7 } 8 9 return { ok: true, data: await response.json() }; 10 } catch (error) { 11 return { ok: false, message: '网络异常,请检查代理配置或接口服务状态' }; 12 } 13}
这一步帮我们提前发现了 base 路径配置的问题——Vite 的 base 选项和 Vue CLI 的 publicPath 对应,但默认值和生效范围不完全一致,如果不特意验证,很容易在部署到子路径时才发现资源和接口路径都不对。
具体表现是这样:这个小工具项目部署在公司内部网关的子路径 /stock-tool/ 下,base 配好之后,页面本身能正常加载,但打开浏览器控制台发现有几个图标请求返回了 404,路径拼出来是 /icons/xxx.svg 而不是预期的 /stock-tool/icons/xxx.svg。查下来是项目里有一处历史代码直接拼接了字符串路径去请求图标资源,而不是走 import 或者 <img> 标签这类会被构建工具处理路径的方式,这类硬编码的绝对路径不会被 base 配置自动修正,属于构建工具管不到的地方,只能手动改成用 import.meta.env.BASE_URL 拼出正确前缀:
1// 迁移前:硬编码路径,构建工具介入不到 2const iconUrl = `/icons/${iconName}.svg`;
1// 迁移后:显式用 BASE_URL 拼前缀 2const iconUrl = `${import.meta.env.BASE_URL}icons/${iconName}.svg`;
这处改动量不大,但排查过程提醒了一件事:base/publicPath 这类路径配置项,光改配置文件是不够的,项目里所有硬编码绝对路径的地方都得单独揪出来看一遍,这类代码平时不会出问题,是因为部署在根路径下巧合地"看起来对",一旦挪到子路径就会露馅,跟迁移工具本身关系不大,纯粹是历史代码里遗留的隐性假设。
回归测试范围怎么划
构建工具换了一整套,光靠"页面能打开"是不够的判断标准。一位资深前端提了个问题:这次迁移到底要不要走一遍完整回归,还是抽测就够了。跟 QA 一起把这个小工具项目的页面过了一遍,按风险等级分了三档。
第一档是必须全量测的:所有涉及动态资源路径、CSS Modules、以及原来靠 require.context 批量导入现在改成手动列清单的页面,因为这几处是这次迁移里语法和机制都变了的地方,出问题的概率最高,改完的表单校验规则模块、图标组件、几个用了独立样式文件做隔离的表格组件,都属于这一档,一个个手动点过一遍,同时对照迁移前的截图看有没有样式错位。
第二档是抽测:普通的展示型页面,逻辑简单,没有用到任何前面提到的这些新写法,只是走了一遍构建流程,这类页面抽了三分之一左右手动点一遍,确认基础渲染和路由跳转没问题。
第三档是走自动化脚本兜底:项目里原本就有几条端到端的关键路径脚本,覆盖登录、库存核对、退货单提交这几个主流程,这次直接原样跑了一遍,不用人工盯着,脚本本身没有变化,出问题的话大概率是接口代理或者构建产物路径出了岔子,跟前面第一档的排查思路能对上。
这套分级不是照搬什么标准流程,是照着这次迁移改动的位置反推出来的——改动越集中在语法机制层面的地方,回归优先级越高,纯业务逻辑没有因为迁移而改变的地方,抽测加自动化脚本兜底基本够用。这个思路后面如果要迁别的项目,大概率还会照这个划分方式来,不用每次都重新想一遍。
Vue 3 这条线,现在还只能观望
顺带说一句,这次迁移完全没有涉及 Vue 3。Vue 3 现在还是 beta/rc 阶段,正式版没出,我们项目本身也还是 Vue 2.6,Vite 对 Vue 2 的支持要靠 vite-plugin-vue2 这类社区插件,成熟度和官方对 Vue 3 那套支持没法比。Vite 最初的设计目标其实更偏向 Vue 3 和轻量级项目,用在 Vue 2 老项目上,本身就是在拿一个还在快速变化的工具去接一套已经稳定的历史代码,这条组合现阶段能跑通,但谈不上"官方推荐路径"。
这次迁移之后我们打算怎么走
这个内部工具项目迁完之后,构建速度确实好了不少,改一行样式基本是保存即所见,不用再等几秒钟的编译。但这不代表我们打算把核心中后台也搬过去。那个项目页面多、Webpack 配置深、部署链路跟公司内部的发布系统绑得比较紧,任何一处迁移代价没摸清楚就动手,风险都不是这一个小工具项目能类比的。团队里一位资深前端看完这次迁移记录之后的态度也很直接:这套经验能沉淀成一份检查清单,但不能当成"下次迁移会更快"的保证,因为核心应用里那些自定义 Webpack 规则,复杂度不是一个量级的。
比较现实的态度是:先在这类范围清楚、影响面小的项目里把迁移路径跑顺,把 require.context、环境变量前缀、样式预处理器配置、静态资源路径、CSS Modules 命名约定、依赖预构建这几处常见的坑记下来,等 Vite 的插件生态再往前走一段、Vue 3 正式版落地之后,再评估要不要往主应用推。工具链要不要换,最后还是取决于项目现在的节奏能不能承受这次替换的成本,而不是取决于新工具本身有多快。