从原理到源码,从踩坑到落地,一个 Webpack 老用户的 Vite 深度学习记录。
我写 Webpack 配置写了三年。三年里,每次 npm run dev 后盯着终端转圈等 30 秒,我都告诉自己"习惯就好"。直到有天我接手一个用 Vite 搭的项目——冷启动不到 1 秒,改一行代码页面瞬间更新——那种丝滑感让我有点恍惚,好像之前三年一直在骑自行车上高速。
我决定花两周系统学一遍 Vite。不是看两篇文章就上手那种,而是从"它为什么快"一路啃到"源码里到底干了啥"。这篇文章是我整个学习过程的浓缩,不讲怎么安装怎么起项目(官方文档写得够好了),只聊那些让我真正建立起认知的东西。
一、Vite 到底为什么快
这是我学 Vite 的第一个问题,也是最重要的一个。不搞明白这个,后面所有的配置和优化都是在背参数。
Webpack 的问题出在哪
Webpack 的开发模式有一个根深蒂固的设计:先把所有模块打成一个 Bundle,再启动 Dev Server。项目小的时候无所谓,项目大了之后,光启动就要把整个依赖图走一遍,该编译的编译,该打包的打包。几千个模块的项目,这个过程可能要一两分钟。
更痛苦的是 HMR。你改了一个组件,Webpack 需要重新走一遍受影响的模块链,重新生成 chunk。项目一大,改一行代码等两三秒是常态。
问题的根源很简单:Webpack 在浏览器请求之前做了太多事情。
Vite 换了一个思路
Vite 的核心思路可以用一句话概括:不打包,直接发 ESM 给浏览器。
现代浏览器原生支持 <script type="module">,能自己解析 import 语句。既然浏览器自己会处理模块依赖,那为什么还要在服务端帮它打包?
Vite 的开发模式是这样工作的:
浏览器:我要 /src/main.ts
↓
Vite Dev Server:收到,让我编译一下这个文件
↓(esbuild 编译 TS → JS,耗时几毫秒)
Vite:给你,这是一个标准的 ES Module
↓
浏览器:好的,我解析到里面有 import App from './App.vue'
浏览器:我要 /src/App.vue
↓
Vite:收到,编译中...
↓(vue 插件把 .vue 转成 JS)
...以此类推
关键区别在于:Vite 只编译浏览器实际请求的模块。首屏只用到 10 个文件?那就只编译 10 个。另外 200 个组件?没人请求就不编译。这就是所谓的"懒编译"。
我打开 DevTools 的 Network 面板验证了这一点。Vite 的开发模式下,每个 .vue 文件、每个 .ts 文件都是一个独立的网络请求,Response 是标准的 ES Module 代码。这不是打包产物——这就是你写的源码,只做了最小限度的转换。
Bare Import 重写
但有一个问题。我在代码里写 import { ref } from 'vue',浏览器是看不懂这个 'vue' 的——它不是一个合法的 URL。浏览器只认识 ./xxx 或者 /xxx 这种路径。
Vite 在这里做了一件聪明的事。它有一个内部插件叫 importAnalysis,会扫描每个模块的 import 语句,把 bare import 重写成实际路径:
// 你写的代码
import { ref } from 'vue'
// Vite 重写后浏览器收到的代码
import { ref } from '/node_modules/.vite/deps/vue.js?v=a1b2c3d4'
后面那个 ?v=a1b2c3d4 是版本 hash,用来做浏览器强缓存的。依赖没变,hash 不变,浏览器直接用缓存,连请求都省了。
esbuild:快到离谱的编译器
Vite 在开发时用 esbuild 编译 TypeScript 和 JSX。esbuild 用 Go 语言写的,编译速度是 Babel 的 10 到 100 倍。
这个速度差不是优化级别的差距,是架构级别的。Babel 是用 JavaScript 写的,单线程,解释执行。esbuild 是编译型语言实现的,原生多线程并行处理。就像一个是自行车,另一个是摩托车,优化自行车齿轮比没有意义。
不过 esbuild 也有代价:它不做类型检查。所以 Vite 的 dev 命令能在有类型错误的情况下正常启动——它压根不检查类型。类型检查需要单独跑 vue-tsc 或 tsc,一般放在 CI 流水线里。这一点刚开始会觉得"不安全",用久了反而觉得合理:开发时要的是速度,类型安全交给 CI 守门。
二、依赖预构建:Vite 启动时唯一在做的事
如果 Vite 真的什么都不打包就启动,那第一次 npm run dev 的时候是不是应该瞬间完成?
并不是。第一次启动会比后续慢一点,因为 Vite 要做依赖预构建。
为什么需要预构建
原因有三个,每一个都很实际。
第一,格式不兼容。 npm 上大量的包还是 CommonJS 格式(用 module.exports 和 require)。浏览器的 ES Module 不认识 require,必须转成 import/export。比如 axios 就是 CommonJS 的,Vite 用 esbuild 把它转成 ESM。
第二,请求数爆炸。 lodash-es 这个包是 ESM 格式的,理论上浏览器能直接用。但它有 600 多个独立的小文件。如果不做处理,浏览器要发 600 多个网络请求才能加载完 lodash。预构建会把这 600 个文件合并成 1 个,一个请求搞定。
第三,缓存一致性。 预构建后的文件统一放在 node_modules/.vite/deps/ 目录下,每个文件的 URL 带版本 hash。只要 package-lock.json 没变,hash 就不变,浏览器可以永久缓存。
预构建的流程
Vite 启动
↓ 扫描 index.html 和入口文件中的所有 import 语句
↓ 收集所有 bare import(vue、axios、lodash-es...)
↓ 对每个依赖用 esbuild 做 bundle
↓ 输出到 node_modules/.vite/deps/
↓ 计算 lockfile 的 hash,写入元数据
↓ 下次启动时对比 hash,没变就跳过
你可以在 npm run dev 后检查 node_modules/.vite/deps/ 目录,里面就是预构建的产物。打开 vue.js 看看,你会发现 Vue 的所有模块都被合并到了一个文件里。
什么时候需要手动干预
大多数时候预构建是自动的。但有一个场景经常遇到:动态 import 的依赖。
Vite 在启动时通过静态分析收集依赖。如果一个依赖是在运行时才 import() 的,Vite 扫描不到它,就不会预构建。这时候页面加载到那里会突然卡一下——Vite 在补做预构建。
解决办法很简单,在配置里显式告诉 Vite:
// vite.config.ts
optimizeDeps: {
include: ['some-dynamic-dep']
}
还有一个坑:改了 optimizeDeps 配置后,必须删掉缓存或者用 --force 参数重启,否则旧的缓存不会更新:
npx vite --force # 强制重新预构建
# 或者手动删
rm -rf node_modules/.vite
三、HMR 比你想的复杂
Hot Module Replacement 是开发体验的灵魂。改了一行代码,不刷新页面就能看到效果,组件状态还保留着——这才是现代开发该有的样子。
但我在学 Vite 的过程中发现,大多数人(包括之前的我)对 HMR 的理解停留在"它能热更新"。真正深入之后,才知道里面有多少讲究。
Vue 组件的 HMR:你不需要操心
如果你只写 Vue 组件,HMR 基本是开箱即用的。@vitejs/plugin-vue 会自动注入 HMR 处理代码。你改模板,组件原地更新;你改样式,CSS 即时替换;你改 <script setup>,组件重新执行但 ref 的值尽量保留。
这些都不需要你写一行 HMR 相关的代码。
普通 .ts 文件的 HMR:必须手动处理
问题出在非组件文件上。假设你有一个 timer.ts,里面启动了一个 setInterval:
let count = 0
const timer = setInterval(() => {
count++
console.log(count)
}, 1000)
export function getCount() {
return count
}
你修改了这个文件,Vite 的 HMR 会用新模块替换旧模块。但旧模块的 setInterval 还在跑!没人停掉它。于是你会看到控制台同时有两个定时器在打日志——每改一次文件多一个,这就是经典的HMR 内存泄漏。
正确的做法是用 import.meta.hot API 手动处理:
let count = 0
let timer: ReturnType<typeof setInterval> | null = null
export function startTimer(callback: (n: number) => void) {
stopTimer()
timer = setInterval(() => { count++; callback(count) }, 1000)
}
export function stopTimer() {
if (timer) { clearInterval(timer); timer = null }
}
// HMR 处理
if (import.meta.hot) {
// 接受自身更新,不刷新整页
import.meta.hot.accept()
// 模块被替换前,清理副作用
import.meta.hot.dispose((data) => {
stopTimer() // 停掉旧的定时器
data.count = count // 把当前计数传给新模块
})
// 从旧模块接收传递的数据
if (import.meta.hot.data.count) {
count = import.meta.hot.data.count
}
}
三个关键 API:
accept():告诉 Vite "这个模块能自己处理更新,不要刷新整页"dispose(cb):模块被替换之前调用,用来清理定时器、移除事件监听、关闭 WebSocket 之类的副作用data:一个在新旧模块之间传递数据的对象。在dispose里写入,在新模块里读取
说实话,日常开发中需要手动处理 HMR 的场景不多。绝大多数代码都是 Vue/React 组件,框架插件已经处理好了。但理解这个机制很重要——下次 HMR 表现异常的时候,你至少知道该去哪里排查。
HMR 的完整链路
贴一张我自己整理的流程图:
文件保存
→ chokidar 监听到文件变化
→ Vite 查 moduleGraph 找到该模块的依赖链
→ 向上冒泡寻找 HMR 边界(有 accept() 的模块)
→ 找到边界 → 通过 WebSocket 发送更新通知
→ 浏览器收到通知,动态 import 新模块(URL 带 ?t=时间戳 绕过缓存)
→ 执行模块注册的 accept 回调
→ 框架层(plugin-vue)做组件替换
找不到边界 → 整页刷新(fallback)
四、手写三个 Vite 插件
Vite 插件的 API 继承自 Rollup,但额外增加了一些开发模式独有的钩子。我在学习过程中手写了几个插件,这是真正帮我理解 Vite 工作机制的方式——光看文档记不住,自己写一遍就全通了。
插件的骨架
每个 Vite 插件就是一个返回对象的函数:
import type { Plugin } from 'vite'
function myPlugin(): Plugin {
return {
name: 'vite-plugin-xxx', // 必填,报错时显示
enforce: 'pre', // 可选,在核心插件之前执行
apply: 'serve', // 可选,只在 dev 时生效
// 下面是各种钩子...
}
}
enforce 和 apply 这两个选项很有用。enforce: 'pre' 让你的插件在 Vite 内置插件之前执行,apply: 'serve' 让它只在开发时生效。Mock 插件就应该加 apply: 'serve'——你肯定不希望生产包里还带着 Mock 数据。
实战一:虚拟模块——自动路由生成
虚拟模块是 Vite 插件里最"魔法"的能力。它让你可以 import 一个不存在的模块,插件在运行时动态生成代码。
我写了一个自动路由插件:扫描 src/pages/ 目录下的 .vue 文件,自动生成路由配置。
import type { Plugin } from 'vite'
import { readdirSync } from 'fs'
import { resolve, basename } from 'path'
const VIRTUAL_ID = 'virtual:auto-routes'
const RESOLVED_ID = '\0' + VIRTUAL_ID // \0 前缀是 Rollup 约定
export function autoRoutesPlugin(): Plugin {
return {
name: 'vite-plugin-auto-routes',
// resolveId:拦截模块请求
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_ID
},
// load:返回模块代码
load(id) {
if (id !== RESOLVED_ID) return
const dir = resolve(process.cwd(), 'src/pages')
const files = readdirSync(dir).filter(f => f.endsWith('.vue'))
const routes = files.map(file => {
const name = basename(file, '.vue')
const path = name === 'Home' ? '/' : `/${name.toLowerCase()}`
return ` { path: '${path}', name: '${name}', component: () => import('/src/pages/${file}') }`
})
return `export default [\n${routes.join(',\n')}\n]`
},
}
}
使用的时候直接 import:
import routes from 'virtual:auto-routes'
// routes 是动态生成的路由数组,不需要手动维护
这里有个细节值得聊聊:\0 前缀。这是 Rollup 生态的约定——加了 \0 前缀的模块 ID 表示"这是虚拟的,其他插件别来处理它"。如果不加,Vite 的其他插件可能会尝试从文件系统加载这个模块,然后报"文件不存在"的错。
实战二:Mock 服务——configureServer 的妙用
Vite 有一个 Rollup 没有的独有钩子:configureServer。它让你直接操作开发服务器的中间件栈,相当于往 Express/Koa 里加中间件。
我用它做了一个 Mock 服务插件:
import type { Plugin } from 'vite'
interface MockRoute {
method?: string
url: string
delay?: number
response: ((params: { query: any; body: any }) => any) | any
}
export function mockPlugin(routes: MockRoute[]): Plugin {
return {
name: 'vite-plugin-mock',
apply: 'serve', // 关键:只在开发时生效
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = req.url?.split('?')[0] || ''
const method = req.method?.toUpperCase() || 'GET'
const mock = routes.find(r => r.url === url && (r.method || 'GET') === method)
if (!mock) return next() // 没匹配到,交给 Vite 处理
if (mock.delay) await new Promise(r => setTimeout(r, mock.delay))
const data = typeof mock.response === 'function'
? mock.response({ query: {}, body: {} })
: mock.response
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ code: 200, data }))
})
},
}
}
使用:
// vite.config.ts
plugins: [
mockPlugin([
{ url: '/api/users', response: [{ id: 1, name: '张三' }] },
{ url: '/api/login', method: 'POST', delay: 500, response: { token: 'xxx' } },
])
]
apply: 'serve' 确保这段代码在生产构建时完全不存在。不是"不执行",是根本不会被打包进去。
实战三:自定义文件格式——transform 钩子
transform 是用得最多的钩子。每个模块被加载后都会经过 transform,你可以在这里做任何代码转换。
我写了一个让 .md 文件可以直接 import 为 HTML 字符串的插件:
export function markdownPlugin(): Plugin {
return {
name: 'vite-plugin-markdown',
transform(code, id) {
if (!id.endsWith('.md')) return null // 不是 .md 就跳过
// 简易 Markdown → HTML 转换
const html = code
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code>$1</code>')
// 返回一个 JS 模块,默认导出 HTML 字符串
return {
code: `export default ${JSON.stringify(html)}`,
map: null,
}
},
}
}
然后在组件里:
<script setup>
import content from './readme.md'
</script>
<template>
<div v-html="content" />
</template>
transform 的精髓在于:它的输入是源代码字符串,输出也是代码字符串,但必须是合法的 JavaScript/ESM 模块。你可以把任何文件格式"翻译"成 JS 模块。
钩子速查
写了三个插件之后,我对钩子的选择形成了直觉:
| 我想做什么 | 用哪个钩子 |
|---|---|
| 凭空生成一个模块 | resolveId + load |
| 转换已有文件的内容 | transform |
| 修改 HTML | transformIndexHtml |
| 添加开发服务器中间件 | configureServer |
| 操作构建产物 | generateBundle |
| 修改 Vite 配置 | config |
五、工程化:从能用到好用
原理搞懂了,插件会写了,接下来是另一个层面的问题:怎么在团队项目里把 Vite 用好。
目录结构不是个人偏好,是团队契约
我在企业级模板里用的目录结构:
src/
├── api/ # 接口层,按业务模块拆分
│ └── request.ts # axios 封装(拦截器集中在这里)
├── composables/ # Vue 组合式函数(跨组件复用逻辑)
├── components/ # 通用组件
├── layouts/ # 布局组件
├── router/ # 路由
├── stores/ # Pinia 状态管理
├── styles/ # 全局样式和变量
├── types/ # TypeScript 类型定义
├── utils/ # 工具函数
└── views/ # 页面组件
看起来很"标准",但每一层的职责边界要想清楚。比如 api/request.ts 只做三件事:创建 axios 实例、请求拦截、响应拦截。业务接口定义放在 api/modules/ 下按模块拆分。不要把业务逻辑塞进拦截器里。
axios 拦截器的分层错误处理
这是我在实际项目中打磨出来的模式。错误分三层,每层处理不同的事:
const service = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 15000,
})
// 请求拦截器:只做 token 注入
service.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:分层处理错误
service.interceptors.response.use(
(response) => {
const { code, message, data } = response.data
// 第一层:业务错误(后端返回 HTTP 200,但 code 不是 0)
if (code !== 0) {
// 特殊处理:token 过期
if (code === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
return Promise.reject(new Error('登录已过期'))
}
// 其他业务错误:弹提示
console.error(`[API] ${message}`)
return Promise.reject(new Error(message))
}
return data // 只返回 data 层,调用方不用写 res.data.data
},
(error) => {
// 第二层:HTTP 错误(404、500、网络断开等)
if (error.response) {
console.error(`[HTTP] ${error.response.status}`)
} else if (error.code === 'ECONNABORTED') {
console.error('[HTTP] 请求超时')
} else {
console.error('[HTTP] 网络异常')
}
return Promise.reject(error)
}
)
这样设计的好处:调用方的代码非常干净。
// 调用方只关心业务数据,错误已经在拦截器里统一处理了
const users = await request.get<User[]>('/users')
环境变量的安全机制
Vite 有一个常被忽略的安全设计:只有 VITE_ 前缀的环境变量才会暴露给前端代码。
# .env
SECRET_KEY=abc123 # 前端代码读不到!
VITE_API_URL=http://xxx # 前端可以读到
这是故意的。前端代码是公开的,任何人都能在浏览器里看到你的 JS。如果不做前缀过滤,SECRET_KEY 这种东西直接就暴露了。
但 vite.config.ts 里可以通过 loadEnv() 读取所有变量(包括没有 VITE_ 前缀的),因为配置文件在 Node.js 端运行,不会被打包到前端。
// vite.config.ts
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '') // 第三个参数为空 = 读取所有变量
console.log(env.SECRET_KEY) // 可以读到,但不会暴露给前端
})
CSS 方案我最终选了什么
试了一圈之后,我的选择是 Scss + <style scoped> + PostCSS autoprefixer。理由:
- Scss 的变量和 mixin 在大项目里确实好用,团队接受度也高
- Vue 的
scoped对于组件隔离已经够用了,不需要 CSS Modules 那么重 autoprefixer是必备品,不管用什么 CSS 方案- UnoCSS/Tailwind 适合快速原型和个人项目,但大团队的接受度参差不齐
如果是 React 项目,没有 scoped 可用,那 CSS Modules 是更好的选择。
六、踩坑备忘录
这些坑每一个我都真实遇到过(或者在 code review 里看别人踩过)。
打包后白屏
现象:npm run dev 一切正常,npm run build && npm run preview 也正常,但部署到服务器上打开就是白屏。
原因:用了 createWebHistory() 的 history 路由模式。用户直接访问 /about 时,Nginx 试图找 /about/index.html,找不到就返回 404。
解决:Nginx 配置加一行:
location / {
try_files $uri $uri/ /index.html;
}
这行的意思是:先找精确文件,找不到就返回 index.html,让前端路由接管。
server.proxy 只在开发有效
现象:开发时 /api/users 代理到后端没问题,上线后接口全 404。
原因:server.proxy 是 Vite Dev Server 的能力,构建产物是纯静态文件,没有代理能力。
解决:生产环境必须在 Nginx(或其他网关)配代理:
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
}
这个坑很常见,因为开发时一切正常,你以为 proxy 配置会"跟着走"到生产——并不会。
环境变量的类型陷阱
现象:if (import.meta.env.VITE_DEBUG) 永远为 true,即使 .env 里写的是 VITE_DEBUG=false。
原因:.env 文件里的值全部是字符串。'false' 是一个非空字符串,在 JS 里是 truthy。
解决:用字符串比较:
const isDebug = import.meta.env.VITE_DEBUG === 'true'
或者封装一个工具函数:
function getEnvBoolean(key: string): boolean {
return import.meta.env[key] === 'true'
}
alias 和 tsconfig paths 双配置
现象:vite.config.ts 里配了 alias: { '@': '/src' },代码能跑,但 VS Code 满屏红线报 Cannot find module '@/xxx'。
原因:Vite 的 resolve.alias 和 TypeScript 的 compilerOptions.paths 是两套独立的系统。Vite 负责运行时路径解析,TypeScript 负责编辑器类型检查。两边都要配,缺一不可。
// vite.config.ts
resolve: {
alias: { '@': resolve(__dirname, 'src') }
}
// tsconfig.json
{
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}
漏配哪个都有问题:只配 Vite → IDE 报错;只配 TS → 运行报错。
legacy 插件的隐性依赖
现象:装了 @vitejs/plugin-legacy,构建时报 terser not found。
原因:legacy 插件需要 terser 来压缩 legacy 产物,但它没有把 terser 列为依赖。你必须手动安装。
npm install -D @vitejs/plugin-legacy terser
另外,开启 legacy 模式会让构建时间翻倍(要生成现代版 + legacy 版两套代码),产物体积也明显增大。如果你的用户全是现代浏览器,不要开这个。
写在最后
学完这一整圈下来,我对 Vite 的态度从"快就完了"变成了"确实设计得好"。
它快,不是因为做了多少缓存优化,而是从架构层面改变了开发模式的工作方式——把打包这件事从开发阶段移到了构建阶段,开发时直接用浏览器原生的 ESM 能力。这不是优化,是范式转换。
如果你还在犹豫要不要迁移,我的建议是:
- 新项目:无脑 Vite,没有理由再用 Webpack 起新项目了
- 中小型存量项目:建议迁移,迁移成本不高,收益明显
- 大型存量项目(复杂 Webpack 配置):评估成本。如果有深度定制的 Webpack loader、Module Federation 之类的需求,Vite 的生态可能还没完全覆盖
最后说一个体感:当你习惯了 Vite 的速度之后,再回去用 Webpack,会生理性不适。这大概就是科技进步的代价——回不去了。
本文基于我两周系统学习 Vite 的过程整理而成。如果对你有帮助,欢迎点赞收藏。有问题可以在评论区交流,我都会看。