从 Vue CLI 迁到 Vite:真正麻烦的不是改命令

把一个跑了两年多的 Vue CLI 后台迁到 Vite,npm run serve 改成 npm run dev 只要五分钟。真正要担的成本不在这五分钟,而在项目历史里悄悄攒下的那些构建假设——环境变量怎么读、别名怎么解析、图片路径怎么拼、老依赖是不是 CommonJS、mock 数据怎么接。这些假设平时被 Vue CLI 和 Webpack 悄悄兜住了,换一套工具链就会全部暴露出来。

这一次是我自己主动提的迁移。团队这个内部后台用 Vue CLI 3 起手,业务代码倒是干净,但每次改一个 Webpack 配置项都要等重启、等半天热更新,效率上明显吃亏。这个老后台暂时还是 Vue 2.6,没打算跟着框架版本一起动——迁移工具链和迁移框架版本是两件独立的事,先把构建链路换掉,框架的事以后再说。

先想清楚这次迁移可能在哪些地方翻车

在动手之前,我先把可能翻车的地方过一遍,而不是直接开干。大致分三类:

第一类是"看起来能跑,其实行为变了"——比如环境变量注入规则不同,代码不报错,但线上读到的值是 undefined,页面某个分支悄悄降级成了默认值,不会有报错日志。这类问题最难查,因为它不崩溃。

第二类是"本地能跑,CI 或生产跑不了"——dev 服务器用的是 esbuild 做依赖预构建,生产构建走的是 Rollup,两条链路对同一段代码的处理方式不完全一致,一些动态导入、CommonJS 兼容的问题只会在 vite build 阶段暴露。

第三类是"生态没跟上"——Vite 现在(2.8 左右)比一年前的 1.x 成熟了不少,插件规范也改成兼容 Rollup 插件了,但一些细分场景的插件生态还是单薄,尤其是 mock 数据这块,能直接拿来用的插件选择远不如 Webpack 时代丰富,很多时候得自己拼一个开发中间件。

把这三类风险想清楚之后,迁移清单实际上就是照着这三类问题去逐项核对,而不是漫无目的地"试试看"。

先整理入口和脚本

Vue CLI 常见命令:

1{
2  "serve": "vue-cli-service serve",
3  "build": "vue-cli-service build"
4}

Vite 里通常变成:

1{
2  "dev": "vite",
3  "build": "vite build",
4  "preview": "vite preview"
5}

不要只改这里就算完事。CI 脚本、部署流水线、Dockerfile、甚至团队文档里,都可能写死了旧命令。我们这次真正卡壳的不是本地起不来,是 CI 里一个老掉的部署脚本还在跑 vue-cli-service build,构建目录结构对不上,部署完页面直接 404,排查了小半天才发现是脚本没同步。

环境变量规则不同

Vite 只把 VITE_ 前缀的环境变量暴露给客户端代码。

1VITE_API_BASE_URL=https://api.example.com

代码里通过 import.meta.env 读取:

1const baseUrl = import.meta.env.VITE_API_BASE_URL

老项目里到处是 Vue CLI 那一套写法:

1process.env.VUE_APP_API_BASE_URL

这两套写法看着像是简单的前缀替换,实际差别更大。Vue CLI 是在 Webpack 编译阶段把 process.env.VUE_APP_* 静态替换成字符串常量,process.env 这个对象本身在浏览器端其实是不存在的,全靠 DefinePlugin 做文本替换;Vite 则是真的往 import.meta.env 这个对象上挂了一份 .env 文件解析出来的值,运行时可以直接当对象访问、甚至打印出来看,两者的心智模型不一样。有些老代码里图省事写了 process.env.NODE_ENV === 'development' 这种判断,迁过来第一次跑是好的(因为 Vite 也兼容性地定义了 process.env.NODE_ENV),但如果哪天顺手把这行改成 import.meta.env.MODE,返回的字符串是 'development' 还是 'production',跟原来的判断逻辑要对一遍,别想当然。

替换的时候我没有直接全局搜索替换完就收工,而是把项目里所有 VUE_APP_ 前缀的变量过了一遍,先分了个类:哪些是纯客户端配置(接口地址、埋点开关),哪些其实是构建期才需要、根本不该出现在浏览器端的(内部服务地址、某些鉴权相关的默认值)。不是所有变量换个前缀就该原样搬过去,VITE_ 前缀只是"决定要不要注入客户端"的开关,不是环境变量迁移的免检章。

路径别名要同步两处

老项目常用 @ 指向 src

Vite 配置:

1import { defineConfig } from 'vite'
2import vue from '@vitejs/plugin-vue'
3import path from 'path'
4
5export default defineConfig({
6  plugins: [vue()],
7  resolve: {
8    alias: {
9      '@': path.resolve(__dirname, 'src'),
10    },
11  },
12})

如果项目里用了 TypeScript,还要同步 tsconfig.json

1{
2  "compilerOptions": {
3    "paths": {
4      "@/*": ["src/*"]
5    }
6  }
7}

这一步很容易漏掉,因为漏了之后的表现是分裂的:只改 Vite 配置不改 tsconfig.json,编辑器里跳转、类型检查会报"找不到模块",但 vite build 照样能过,问题只在编辑体验里;反过来只改 tsconfig.json,类型检查是干净的,但运行时直接报模块找不到。团队里有同事一开始没意识到这是两套独立的解析系统,看编辑器不报红就以为配置好了,结果部署上去直接白屏。

静态资源路径要重新过一遍

Webpack 时代常见的资源引用写法,搬到 Vite 里有一部分行为会变。

推荐的几种写法:

1import logoUrl from '@/assets/logo.png'

或者用原生的 URL 构造:

1const url = new URL('./logo.png', import.meta.url).href

放进 public 目录的资源从根路径直接访问:

1<img src="/logo.png" />

最麻烦的是老代码里那种字符串拼接的动态路径:

1const icon = require(`@/assets/icons/${name}.png`)

Vite 的开发环境基于原生 ESM,不走 Webpack 那套 require 语义,这行代码直接会报错。得改成 import.meta.glob

1const icons = import.meta.glob('@/assets/icons/*.png', {
2  eager: true,
3  import: 'default',
4})
5
6const icon = icons[`/src/assets/icons/${name}.png`]

这里有个容易踩的细节:import.meta.glob 匹配出来的 key 是相对项目根目录的路径,不是你随手拼的相对路径,调试的时候我先把 icons 打印出来看了一眼实际的 key 长什么样,再去对着改业务代码里的取值逻辑,省得瞎猜。动态资源这类写法最好统一收敛到一两个工具函数里,别散落在各个业务组件里,不然以后想换资源加载方式又要满仓库搜。

CommonJS 依赖是这次最费时间的部分

Vite 的 dev 环境基于 ESM,一部分年头比较老、只发布 CommonJS 格式的包,会在预构建阶段出各种问题。

常见的表现:

  • 默认导出取不到值,导入进来是 undefined 或者整个模块对象
  • 包内部依赖了 Node 的内置模块(比如某些日期库、加密库历史遗留代码里判断了 typeof window),在浏览器端跑不通
  • 开发环境好好的,vite build 报错,或者反过来
  • 依赖版本升级之后,预构建缓存没清,行为对不上新版本

我们项目里一个老的图表库和一个内部维护的表单校验包都撞上了类似的问题。图表库那个是升级到官方发的 ESM 版本解决的;内部表单校验包一时半会没法升级,只能先在 vite.config.ts 里手动把它塞进 optimizeDeps.include,让 Vite 强制预构建它,暂时能跑,但心里清楚这只是权宜之计:

1export default defineConfig({
2  optimizeDeps: {
3    include: ['legacy-form-validator'],
4  },
5})

这类问题排查起来最烦的地方是报错信息经常文不对题,看起来像是业务代码写错了,其实根源在依赖包本身的模块格式。遇到诡异的导入报错,先怀疑是不是 CommonJS/ESM 混用的问题,比直接去改业务代码效率高。顺带也借这次机会清理掉了两个早就没人维护、其实可以用更轻量方案替代的老依赖——迁移工具链的时候顺手做依赖体检,比事后单独排期做要划算。

mock 数据这块生态还没跟上

Vue CLI 时代团队一直用一个基于 Webpack DevServer 中间件写的本地 mock 方案,随便加个中间件函数就能拦截接口。Vite 这边类似的插件生态目前还很单薄,能查到的几个 mock 插件不是配置繁琐就是维护不活跃,试了两个都不太顺手。

最后没有硬找现成插件,而是用 Vite 配置里的 configureServer 钩子自己写了个最小可用的拦截层:

1export default defineConfig({
2  plugins: [
3    {
4      name: 'simple-mock',
5      configureServer(server) {
6        server.middlewares.use('/api/mock-list', (req, res) => {
7          res.setHeader('Content-Type', 'application/json')
8          res.end(JSON.stringify({ code: 0, data: [] }))
9        })
10      },
11    },
12  ],
13})

这个方案谈不上优雅,接口一多就得手写一堆判断,但至少不用等一个还不成熟的第三方插件更新。记下来是想提醒自己:评估要不要迁移工具链的时候,配套生态是不是跟得上主流程同样重要,不能只看核心功能好不好用。

dev 和 build 不是同一条链路,必须都跑一遍

Vite 的 dev 用 esbuild 做依赖预构建,追求的是启动速度;build 用 Rollup 打包,追求的是产物体积和兼容性。这两条链路对同一段代码的处理方式并不完全一致,所以只跑通 npm run dev 远远不够,还要跑一遍生产构建和本地预览:

1npm run build
2npm run preview

迁移这种改动构建链路的场景,我会把这两条命令都跑一遍再收工,因为不少问题只在生产构建里暴露——动态导入的分包结果、静态资源的哈希路径、CSS 的加载顺序,这些在 dev 模式下要么被跳过要么表现不同。这次构建完之后我们还真的发现了一个 CSS 优先级问题:某个全局样式在 dev 模式下加载顺序靠后所以没生效,构建之后顺序变了,把业务组件的样式覆盖掉了,页面某个按钮的颜色跟设计稿对不上。这类问题不会报错,只能靠肉眼跑一遍页面才能发现。

请求封装顺手统一一下错误处理

迁移过程里经常要碰请求封装这部分代码。不只是改 baseURL,也顺手把错误处理理顺:

1async function request(url, options) {
2  const response = await fetch(`${baseUrl}${url}`, options)
3
4  if (!response.ok) {
5    throw new Error(`请求失败:${response.status}`)
6  }
7
8  return response.json()
9}

至少要保证 API 出错有统一的出口。迁移过程里如果接口地址配错了、代理配置漏改了,页面应该给出明确的错误提示,而不是安安静静地留一片空白——空白页面在迁移阶段是最容易被忽略的信号,看起来像是"什么都没发生",实际上往往是某个环境变量没读到。

CSS 相关的几个细节也要对一遍

Vue CLI 项目里如果用了 CSS 预处理器,迁到 Vite 之后大多数情况配置量会明显变少——Vite 内置了对 Sass、Less、Stylus 的支持,不用像以前那样自己配一堆 loader,装好对应的预处理器依赖就能直接用。但有两个地方容易被忽略。

一是全局注入的变量文件。Vue CLI 项目里常见的做法是在 vue.config.js 里配置 additionalData,把一个变量文件自动注入到每个 .scss 文件开头,迁过来的等价写法在 css.preprocessorOptions 里:

1export default defineConfig({
2  css: {
3    preprocessorOptions: {
4      scss: {
5        additionalData: `@import "@/styles/variables.scss";`,
6      },
7    },
8  },
9})

这段配置很容易漏迁,漏了之后的表现是一部分用了全局变量的样式文件突然报"变量未定义",只有牵扯到这个变量文件的组件会出问题,容易让人怀疑是别的地方改坏了。

二是 CSS Modules 的类名生成规则。Vue CLI 底下默认的哈希算法和 Vite 默认的不完全一致,如果项目里有代码依赖了具体的类名字符串(比如某些老旧的 E2E 测试脚本、或者手写的样式覆盖选择器),迁移之后这些字符串会全部失效。这类问题编译期不会报错,只能靠跑一遍页面、对一遍测试用例才能发现。

命令改完之后,还没算完

这次迁移做完盘一遍,npm run serve 换成 npm run dev 那五分钟根本算不上什么,占掉时间的是环境变量、路径别名、CommonJS 依赖这几处——每一处的坑都不在 Vite 的文档里,而是埋在这个项目两年多的历史提交里,得自己一条条刨出来。CI 里那个忘了同步的部署脚本,还有构建后才暴露的 CSS 优先级问题,都是这类"文档不会写、只有跑一遍才知道"的问题。

迁移做得仔细,后面开发体验会明显轻松;跳过 buildpreview 图快收工,只是把旧问题原样留在了新工具链里,回头还得再花一次时间处理,不如这次就跑完。