之前写过浏览器渲染原理与前端性能优化,回答的是"资源到了浏览器之后,怎么跑得快";这篇补上另一半——资源是怎么被高效造出来的。项目从几十个模块膨胀到几千个之后,Webpack 时代的冷启动动辄一两分钟、改一行代码等半天才热更新,开发体验肉眼可见地垮掉。Vite 用"启动时不打包、按需编译、预构建依赖"三板斧把冷启动压到秒级。本文把它的核心机制一次讲透:依赖预构建、HMR 原理与失效排查、生产期为什么换 Rollup、以及一份可以直接抄的工程化优化清单。
一、为什么是 Vite:先看清 Webpack 慢在哪
Webpack dev 模式的工作方式是"先打包,再启动":必须从入口出发,把整个模块图解析、转换、拼接成一个或多个 bundle,服务器才能响应第一个请求。项目越大,这个"打包"阶段越长——而你在浏览器里第一眼只看到了登录页。
Vite 的思路完全相反:"先启动,再按需编译"。dev 阶段它根本不打包业务代码,而是启动一个开发服务器,浏览器请求哪个模块,就实时编译哪个模块返回原生 ESM。页面只加载用到的模块,项目再有 1 万个模块,冷启动时间几乎不涨。
| 维度 | Webpack | Vite |
|---|---|---|
| dev 冷启动 | 全量打包后再启动,随项目变大线性变慢 | 启动服务器即可,与项目规模基本无关 |
| 模块交付 | 拼成 bundle 返回 | 原生 ESM 按需编译单个模块 |
| 依赖处理 | 递归打包进 bundle(或 externals) | esbuild 预构建成单文件 ESM |
| 更新速度 | HMR 需重新执行打包部分链条 | 精确到单模块失效,毫秒级 |
| 生产构建 | 自己打包 | 默认 Rollup(esbuild 做压缩与转译) |
| 配置成本 | loader + plugin 组合拳 | 约定大于配置,开箱即用 |
一句话概括:Webpack 把"打包"做在启动前,Vite 把"编译"推迟到请求时。这就是为什么项目越大,Vite 的体感优势越明显。
二、依赖预构建:快的第一根支柱
2.1 为什么裸 ESM 跑不起来
"直接让浏览器加载 node_modules 里的依赖"有两个致命问题:
- 格式不兼容:很多包仍然只发布 CommonJS(如
react、lodash),浏览器不认识require; - 请求瀑布:像
lodash-es有 600+ 个小模块文件,裸 ESM 会让浏览器发出几百个串行请求,瀑布图直接爆掉。
2.2 esbuild 预构建做了什么
Vite 在首次启动时,用 esbuild(Go 编写,比 JS 打包器快 10~100 倍)把第三方依赖做三件事:
- 格式转换:CJS / UMD → ESM;
- 打包合并:把一个包内部的上百个文件合成单个 ESM 文件,消灭请求瀑布;
- 缓存落盘:产物写入
node_modules/.vite/deps/,并生成_metadata.json记录版本与哈希。
// vite.config.js
export default {
optimizeDeps: {
// 强制预构建:动态 import、CJS-only 依赖建议显式声明,减少二次预构建
include: ['lodash-es', 'axios', 'dayjs/locale/zh-cn'],
// 排除 CJS 互操作有问题的包,改用浏览器原生加载
exclude: ['@lit'],
},
}
预构建缓存什么时候会失效?只有三种情况: 里的依赖版本变化、 变更、配置里相关字段改动。业务代码怎么改都不会触发重新预构建——这是它和"打包"的本质区别。
2.3 一个经典坑:新增依赖导致页面莫名 reload
开发中突然 npm i 了一个新依赖,保存代码后页面整页刷新、控制台打印 new dependencies optimized... reloading——因为浏览器发现了未预构建的依赖,Vite 中断当前会话重新预构建后强制 reload。
两个规避手段:
- 把常用依赖写进
optimizeDeps.include,提前预构建; - 深层依赖(如
@mui/icons-material这类有几百个入口的包)务必显式include,否则每次访问到都会触发一次重新预构建。
三、按需编译与 HMR:快的第二根支柱
3.1 源码按需编译
dev 服务器对业务源码的处理极轻:
*.vue/*.tsx/*.css只有被 import 时才经 esbuild / 编译器转换;- 转换结果以
Cache-Control: no-cache(带 ETag)返回,改代码后浏览器会重新验证; - 依赖预构建产物则走强缓存(
max-age=31536000, immutable),除非重新预构建。
3.2 HMR 的完整链路
- 文件变化:chokidar 监听文件系统,模块图里对应节点失效;
- 确定边界:沿 import 链向上找,直到找到声明了
import.meta.hot.accept的 HMR 边界; - 推送更新:通过 WebSocket 把更新模块列表发给客户端;
- 执行更新:客户端动态 import 新模块,调用旧模块的
dispose清理副作用,再执行边界回调重新渲染。
// 纯 JS 模块手动声明 HMR 边界(框架组件由插件自动生成)
import { createChart } from './chart'
export let chart = createChart(document.getElementById('app'))
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// 用新模块重建,避免整页刷新
chart.dispose()
chart = newModule.createChart(document.getElementById('app'))
})
import.meta.hot.dispose(() => chart.dispose())
}
3.3 HMR 失效的三个高频原因
| 现象 | 根因 | 解决 |
|---|---|---|
| 改一个文件整页刷新 | 该模块到入口的链路上没有任何 HMR 边界 | 组件文件确保被框架插件处理;纯 TS 工具模块补 hot.accept |
| 改了但不生效 | 模块被 import 时立即执行副作用(如初始化单例、绑定全局事件) |
把副作用移进函数或 dispose 中清理 |
| 改一个底层工具全项目刷新 | 底层模块被无边界模块广泛引用 | 给上层组件建立边界,或拆分"纯数据"与"带副作用"模块 |
排查口诀:从被改的文件沿 import 链向上找,第一个没有 的分叉点就是刷新的源头。Vue/React 项目里,/ 组件由插件自动注入 accept,所以"改 utils 全刷新"几乎都是 utils 被非组件文件引用导致的。
四、生产构建:为什么打包换成了 Rollup
esbuild 那么快,为什么 vite build 默认用 Rollup?三个硬性差距:
- Code Splitting 策略:esbuild 的分块算法较粗,容易产生细碎 chunk 与重复加载;Rollup 基于模块级的共享分析,能精确把公共依赖提成异步 chunk;
- Tree Shaking 精度:Rollup 基于 ESM 静态结构做作用域分析,"未使用的导出"剔除得更彻底;
- 插件生态:Rollup 插件协议成熟,大量生产必需能力(遗留兼容、CDN 外链、analyze)开箱可用。
// vite.config.js —— 生产构建核心配置
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
build: {
target: 'es2020', // 现代语法目标,少转译、产物更小
cssCodeSplit: true, // CSS 按入口拆分
assetsInlineLimit: 4096, // 4KB 以下资源内联为 data URL
sourcemap: false, // 生产按需开启
rollupOptions: {
output: {
// 手动分块:把大而稳定的依赖单独拆出,业务迭代不影响其缓存
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-echarts': ['echarts'],
},
// 入口文件名带哈希,利于长缓存
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
plugins: [
// 构建产物分析:生成 stats.html,找出体积大头
visualizer({ filename: 'dist/stats.html', gzipSize: true }),
],
})
是双刃剑:把不相关的包硬塞进同一个 vendor chunk,会让"只改了一个页面"也导致整个 vendor 哈希变化、缓存全失效。原则是按"更新频率 + 使用范围"分组,而不是无脑塞一个 。
五、插件机制:一套配置通吃 dev 与 build
Vite 插件是 Rollup 插件的超集:兼容 transform、load、resolveId 等 Rollup 钩子,另加 configureServer、transformIndexHtml、handleHotUpdate 等 Vite 专属钩子。同一个插件,dev 走开发服务器管线,build 走 Rollup 管线。
| 钩子 | 执行时机 | 典型用途 |
|---|---|---|
config / configResolved |
读到用户配置后 / 定稿后 | 修改或读取最终配置 |
configureServer |
dev 服务器创建时 | 注册自定义中间件、mock 接口 |
resolveId / load |
模块解析阶段 | 虚拟模块(注入运行时常量、env) |
transform |
模块内容转换 | 自定义 DSL、代码注入 |
handleHotUpdate |
dev 文件变化时 | 自定义 HMR 过滤与推送 |
renderChunk / generateBundle |
产物生成阶段 | 移除 console、注入版本号、产物清单 |
手写一个"虚拟模块"插件,把构建信息注入代码:
// plugins/virtual-build-info.js
export default function buildInfo() {
const virtualModuleId = 'virtual:build-info'
const resolvedId = '\0' + virtualModuleId
return {
name: 'virtual-build-info',
resolveId(id) {
if (id === virtualModuleId) return resolvedId
},
load(id) {
if (id === resolvedId) {
// 以 \0 开头的模块不会被其他插件误处理
return `export const buildTime = ${JSON.stringify(new Date().toISOString())}`
}
},
}
}
// 业务代码中直接使用
import { buildTime } from 'virtual:build-info'
console.log('构建于', buildTime)
六、环境变量与多模式构建
.env # 所有模式加载
.env.development # dev 模式
.env.production # build 模式
.env.local # 本地覆盖,默认被 git 忽略
// 只有 VITE_ 前缀的变量会暴露给客户端代码
console.log(import.meta.env.VITE_API_BASE)
console.log(import.meta.env.MODE, import.meta.env.PROD)
安全红线:所有 开头的变量会被静态替换、明文打进产物,任何人打开 F12 都能看到。密钥、数据库连接、私有 API token 一律不要加 前缀;需要私密配置时走后端接口下发,或在 里用 读取后仅用于服务端逻辑。
七、性能优化清单:从启动到产物
| 阶段 | 手段 | 做法 |
|---|---|---|
| 冷启动 | 预热高频依赖 | server.warmup: { clientFiles: ['./src/main/**/*.vue'] } |
| 冷启动 | 稳定预构建 | 高频依赖显式写入 optimizeDeps.include |
| HMR | 缩小监听范围 | server.watch.ignored: ['**/dist/**', '**/node_modules/**'] |
| HMR | 组件化副作用 | 初始化逻辑收敛到组件边界内 |
| 构建 | 现代语法目标 | build.target: 'es2020',减少 polyfill 与转译 |
| 产物 | 路由级懒加载 | () => import('./views/Dashboard.vue') |
| 产物 | 大依赖单独分块 | manualChunks 按更新频率分组 |
| 产物 | 压缩与瘦身 | esbuild minify;rollup-plugin-visualizer 定位大模块 |
| 产物 | 长效缓存 | 文件名带 hash + 静态资源 Cache-Control: max-age=31536000, immutable |
// 路由级代码分割:首屏只加载登录/首页,其余按需
const routes = [
{ path: '/login', component: () => import('./views/Login.vue') },
{ path: '/dashboard', component: () => import('./views/Dashboard.vue') },
{ path: '/report', component: () => import('./views/Report.vue') },
]
八、生产避坑清单
- 新增依赖触发整页 reload:把高频深层依赖提前写进
optimizeDeps.include。 - 改 utils 全项目刷新:沿 import 链排查 HMR 边界,把副作用收敛进组件。
- vendor 缓存频繁失效:
manualChunks按"更新频率 + 使用范围"分组,别把所有依赖塞一个块。 - 环境变量泄露密钥:
VITE_前缀即公开,私密配置绝不放前端。 - 产物体积失控:接入
rollup-plugin-visualizer,先分析再动手;优先处理 echarts、moment、lodash 全量引入。 - 双包问题:peer 依赖版本不一致时,预构建可能把同一个库打两份,用
npm ls <pkg>排查重复版本。 - Windows 路径大小写:import 路径大小写与文件名不一致,macOS 能跑、CI/Windows 直接报错,保持大小写严格一致。
- Node API 版本:Vite 5/6 要求 Node 18+/20+,团队统一
.nvmrc避免"我本地能跑"。
一句话总结:Vite 的快来自三层分工——esbuild 预构建解决"依赖又杂又碎",按需编译 + HMR 解决"改代码反馈慢",Rollup 解决"产物要小要缓存友好"。理解了"dev 不打包、build 换引擎"这条主线,绝大多数启动慢、刷新整页、体积失控的问题都能定位到对应层。