前言

最近半年,我在公司内部的两个中后台项目里先后把构建工具从 Webpack 迁到了 Vite,一个是从 Vue CLI 老项目迁移,一个是 React 新项目直接上手。迁移过程中冷启动从一分多钟降到两秒出头的体验,说实话有点”回不去”的感觉。这篇文章就把 Vite 为什么快这件事掰开揉碎讲一遍,再附上我自己的实战配置和踩坑记录,希望能帮到还在 Webpack 冷启动里干等着的同学。

1. Webpack 的痛点:一切皆模块的代价

1.1 冷启动为什么慢

Webpack 的核心哲学是”一切皆模块”,为了让你能随意使用各种模块语法和资源类型,它在启动时会做一件很重的事情:从入口文件出发,递归解析所有 import/require,把整个项目的模块依赖图(Module Graph)完整地构建出来,中间还要经过 loader 转译、plugin 处理,最后打包成一个或多个 bundle。

这个流程在开发环境下也一点不少。哪怕你只是要改一行首页文案,Webpack 也要先把几百上千个模块全部编译打包完,才能启动 dev server 响应你的第一次请求。项目越大,这个”开机时间”越长,我见过最夸张的老项目冷启动要一分半,摸鱼都摸完一轮了页面还没出来。

1.2 HMR 的瓶颈

热更新(HMR)慢的根源和冷启动一脉相承:某个模块改了,Webpack 需要重新做一次增量打包,以该模块为起点的受影响子图全部要重新编译、重新拼接进 bundle。如果这个模块被太多地方引用,或者触发了整个 chunk 的重打包,HMR 就会从”毫秒级”退化成”秒级”,甚至直接卡死等编译。

问题的本质在于:Webpack 必须先打包,才能服务。而浏览器明明早就支持了原生 ES Modules,为什么开发环境还要把几百个模块合并成几个大文件再喂给浏览器呢?Vite 就是冲着这个问题来的。

2. Vite 的核心思路:基于 ESM 的按需编译

2.1 dev 与 build 的差异

Vite 在开发环境和生产环境用的是两套完全不同的策略,理解了这一点就理解了 Vite 的一半:

  • 开发环境:Vite 根本不打包。它启动一个 dev server,利用浏览器原生的 ES Module 加载能力,浏览器请求哪个模块,Vite 才按需编译哪个模块,然后以单个模块的粒度返回给浏览器。真正的”按需加载、用到才编译”。
  • 生产环境:Vite 使用 Rollup 做打包,因为把几百个零散请求直接扔到线上会造成网络瀑布,打包后的代码分包、tree-shaking、压缩都更有利于线上加载。

用文字画个示意图,对比一下两种模式下浏览器拿到页面的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
【Webpack dev 模式】
启动 → 递归解析全部模块 → 打包成 bundle → dev server 就绪
浏览器 ← 返回打包后的大 bundle(启动前所有工作必须完成)

【Vite dev 模式】
启动 → 只做依赖预构建 → dev server 立即就绪(秒级)
浏览器请求 index.html
└─ <script type="module" src="/src/main.ts">
└─ 浏览器解析到 import,逐个向 dev server 发起请求
├─ GET /src/App.vue → Vite 现场编译 SFC,返回 JS
├─ GET /src/components/Header.vue → 按需编译
└─ GET /node_modules/.vite/vue.js → 命中预构建缓存
浏览器边加载边编译,未访问到的页面永远不会被编译

也就是说,Webpack 的启动时间和项目体积成正比,而 Vite dev 模式的启动时间基本和项目体积无关——因为启动时它只处理依赖,不处理你的业务源码。

2.2 请求拦截与模块改写

Vite dev server 本质上是一个带”模块改写中间件”的静态服务器。浏览器发出的每一个模块请求,都会被 Vite 拦截并实时转译成浏览器能直接执行的 JavaScript:

  • .vue 单文件组件请求会被拆解,模板部分当场编译成 render 函数;
  • .ts 文件被 esbuild 当场转译成 JS(只做转译,不做类型检查,类型检查交给编辑器和 tsc);
  • import 语句里的裸模块名(bare import,比如 import { createApp } from 'vue')会被改写成预构建产物的路径,比如 /node_modules/.vite/vue.js?v=xxx;
  • LESS/SCSS 请求当场编译成 CSS 并以 JS 模块形式注入。

编译发生在”请求时”而不是”启动时”,所以改一个文件后 HMR 只需要让浏览器重新请求这一个模块,替换掉旧模块的执行结果即可,耗时和项目多大几乎没有关系,稳定在毫秒级。

3. esbuild 预构建:Go 与 JS 的速度差

esbuild 预构建:Go 与 JS 的速度差

3.1 为什么需要预构建

既然是按需编译,为什么启动时还要做”依赖预构建”(Dep Pre-Bundling)?主要是两个原因:

  1. CommonJS 兼容。很多 npm 包(比如老的 lodash、react 相关包)发布的是 CJS 格式,浏览器原生 ESM 不认识 module.exports。预构建会把它们统一转成 ESM 格式。
  2. 减少请求瀑布。像 lodash-es 这种包,内部有几百个小模块文件,如果浏览器按原生 ESM 去加载,会发起成百上千个请求形成请求瀑布。预构建会把一个包的所有模块合并成单个文件,浏览器一次请求搞定。

3.2 Go 与 JS 的速度差

预构建的执行者是 esbuild——一个用 Go 写的打包器。同样是”解析 JS → 转译 → 打包”这件事,Go 和 JavaScript 的差距是数量级的:

  • Go 是编译型语言,编译产物是本地机器码,多核并行是天然能力,goroutine 一开就是满核运转;
  • Node.js 下的 Webpack 这类工具,解析、AST 构建、闭包处理全在单线程 JS 里跑,还要受 GC 抖动影响;
  • esbuild 从算法层面也做了极致优化:一切皆字节数组、AST 只分配一次内存、并行调度做得非常激进。

我的直观感受是:同一个项目的依赖预构建,Rollup 方案要十几秒,esbuild 一般一两秒就完成了,第一次跑 vite 命令时的等待感几乎可以忽略。而且预构建结果会缓存在 node_modules/.vite 里,之后只要锁文件不变就不再重新预构建,冷启动更快。

4. vite.config.ts 实战配置

下面是我们项目里真实在用的 vite.config.ts,覆盖了最常用的三块:路径别名、开发代理和 CSS 预处理器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';

export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
// 让 @ 指向 src 目录,和老项目 webpack alias 保持一致
'@': path.resolve(__dirname, 'src'),
// 兼容老代码里对公共库的引用写法
components: path.resolve(__dirname, 'src/components'),
},
},
server: {
port: 8080,
proxy: {
// 与老项目 webpack devServer.proxy 迁移对齐
'/api': {
target: 'http://backend.test.example.com',
changeOrigin: true,
// 后端接口没有 /api 前缀时,用 rewrite 去掉
rewrite: (path) => path.replace(/^\/api/, ''),
// websocket 接口别忘了这个
ws: true,
},
},
},
css: {
preprocessorOptions: {
scss: {
// 全局注入变量和 mixin,业务代码里不用每个文件手动 @import
additionalData: `@import "@/styles/variables.scss"; @import "@/styles/mixins.scss";`,
},
less: {
// antd / element 主题变量覆盖走这里
modifyVars: { '@primary-color': '#1DA57A' },
javascriptEnabled: true,
},
},
},
build: {
target: 'es2015',
sourcemap: false,
// 分包策略:把体积大的第三方库单独拆出来,利用浏览器长缓存
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
echarts: ['echarts'],
},
},
},
},
});

配置里有三处容易被问到的点:

  • **alias 的 __dirname**:如果配置文件被当作 ESM 处理(package.json 里有 "type": "module"),__dirname 不存在,改用 fileURLToPath(new URL('./src', import.meta.url))。别直接取 .pathname,Windows 下会得到 /E:/code/... 这种路径,alias 直接失效。
  • proxy 的 rewrite:从 webpack 的 pathRewrite 迁移过来时注意写法变化,webpack 里是 pathRewrite: { '^/api': '' },Vite 里是个函数。
  • scss additionalData:老的 webpack 项目如果是在每个文件里手动 @import 变量文件的,迁移过来直接用 additionalData 注入,代码还能顺便瘦一圈。

5. 老项目迁移踩坑

从 Vue CLI(Webpack)迁移到 Vite,我总结下来大部分时间都花在两类问题上:CommonJS 依赖和环境变量。

5.1 CommonJS 依赖

浏览器 ESM 无法直接执行 CJS,而 Vite 的预构建只处理 node_modules 里的依赖。所以第一类坑是”依赖没问题,但写法有问题”:

1
2
3
4
5
// webpack 里可以,Vite dev 模式下不行(运行时报错)
const moment = require('moment');

// Vite 里必须用 ESM import
import moment from 'moment';

业务源码里的 require 要全部改成 import。如果数量太多,可以写个 codemod 批量替换。真正麻烦的是少数只发布了 CJS 版本的包,典型症状是浏览器控制台报 Uncaught SyntaxError: Unexpected token 'export' 或者 require is not defined。解决办法:

1
2
3
4
5
6
7
8
9
10
11
// vite.config.ts
export default defineConfig({
optimizeDeps: {
// 强制把 CJS 依赖纳入预构建,include 里的包会被 esbuild 转 ESM
include: ['lodash', 'moment', 'some-cjs-only-lib'],
},
// 极少数既不能转 ESM、又依赖 Node 全局变量的包,只能降级处理
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
});

还有一个隐蔽的坑:optimizeDeps.include 要把动态 import 的包也写进去,否则第一次动态加载到它时会触发”新依赖发现”,页面会整页刷新一次,体验很突兀。

5.2 环境变量 import.meta.env

这是迁移时必改的一批代码。Webpack 体系下我们习惯了 process.env.XXX,Vite 走的是浏览器标准的 import.meta.env:

1
2
3
4
5
// webpack 写法(Vite 下 process 未定义,直接白屏)
const baseURL = process.env.VUE_APP_API_BASE;

// Vite 写法
const baseURL = import.meta.env.VITE_API_BASE;

配套的几个差异点要一起改:

  1. 前缀约定变了:webpack 的 VUE_APP_ 前缀换成 Vite 的 VITE_,所有 .env 文件里的变量名都要改,否则读不到(Vite 出于安全考虑只暴露带前缀的变量给客户端)。
  2. 内置变量改名:process.env.NODE_ENV 换成 import.meta.env.MODE 或 import.meta.env.PROD / DEV。
  3. 类型提示:在 src 下建一个 env.d.ts,给 import.meta.env 补上类型声明,团队里才不会写出拼错名字的环境变量。
1
2
3
4
5
6
7
8
9
10
11
// src/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_BASE: string;
readonly VITE_UPLOAD_URL: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

5.3 其他小坑

  • 装饰器/旧语法:Vue CLI 里靠 babel 转译的老语法,Vite 默认的 esbuild target 可能不支持,需要显式配置 build.target 和 esbuild 插件;
  • 全局注入:webpack 的 ProvidePlugin(比如自动注入 $)在 Vite 里要用 esbuild 的注入或直接改显式 import;
  • 多页应用:pages 配置换成 build.rollupOptions.input 的多入口写法。

6. 性能数据对比

贴一下我们那个 Vue CLI 老项目迁移前后在同一台机器上的实测数据(业务代码约 500+ 模块):

指标 Webpack (Vue CLI 4) Vite 2
dev 冷启动(无缓存) 约 78 秒 约 2.3 秒
dev 冷启动(有缓存) 约 60 秒 约 1.1 秒
首次页面可交互 约 8 秒 约 2 秒
HMR 平均耗时 600ms~3s 80~150ms
生产构建 约 4 分 20 秒 约 1 分 40 秒

生产构建的提升主要来自两点:esbuild 做转译和压缩(替代 terser),以及 Rollup 本身打包效率高于 Webpack 4。当然要说明的是,生产构建的提升幅度因项目而异,dev 体验才是 Vite 的降维打击。

写在最后

Vite 快的秘诀说穿了并不神秘:把”先打包再服务”换成”服务时按需编译”,把”JS 写的构建工具”换成”Go 写的构建工具”,再把该合并的依赖提前合并好缓存起来。三件事叠加,就有了上面表格里那 30 倍的冷启动差距。

2022 年这个时间点,Vite 2 的生态已经相当成熟,Vue 3 项目可以放心全量上 Vite,React 项目也有完整的官方插件支持。如果你的项目还在被 Webpack 的冷启动折磨,建议先在新项目里尝鲜,再找时间把老项目迁过去——大概率花一两天,换回来团队今后几年的开发效率。下一篇我打算聊聊 Vite 插件机制和如何自己写一个插件,有兴趣的同学可以关注下。