Vite 原理与优势
问题
Vite 是什么?它为什么比 Webpack 快?原理是什么?
答案
Vite 是由尤雨溪创建的下一代前端构建工具,名字来源于法语"快"(vite)。它的核心设计理念是 利用浏览器原生 ES Module(ESM)能力,在开发阶段跳过打包步骤,实现极速的冷启动和热更新。
Vite = 开发时 No-Bundle(原生 ESM + esbuild 预构建) + 生产时 Rollup 打包
开发模式原理:No-Bundle
传统打包器的问题
Webpack 等传统打包器在开发时需要先将所有模块 打包成 bundle 才能启动开发服务器。随着项目规模增长,冷启动时间从几秒增长到几十秒甚至数分钟。
Vite 的解决思路
Vite 将模块分为两类:
- 依赖(Dependencies):使用 esbuild 预构建,速度极快(Go 编写,比 JavaScript 打包器快 10-100 倍)
- 源码(Source Code):利用浏览器原生 ESM,按需编译,无需打包
浏览器发起 import 请求时,Vite 的开发服务器拦截请求,按需编译对应模块并返回。这意味着:
- 冷启动极快:不需要分析整个模块图、打包所有文件
- 按需加载:只编译当前页面用到的模块
- 编译速度快:单文件编译,利用 esbuild 转译 TypeScript/JSX
// 浏览器发出的请求(已被 Vite 处理)
import { createApp } from '/node_modules/.vite/deps/vue.js?v=abc123'
import App from '/src/App.vue?t=1234567890'
import { setupRouter } from '/src/router/index.ts'
// Vite 开发服务器会:
// 1. 拦截每个 import 请求
// 2. 按需编译对应文件(.vue → JS、.ts → JS)
// 3. 返回浏览器可执行的 ESM 模块
依赖预构建
为什么需要预构建?
虽然 Vite 基于原生 ESM,但直接使用 node_modules 中的依赖会有两个问题:
- 格式不兼容:很多第三方包只提供 CommonJS 格式(如
lodash),浏览器无法直接import - 请求数量爆炸:有些 ESM 包会产生大量细粒度导入。例如
lodash-es有 600+ 个子模块,浏览器需要发 600+ 个 HTTP 请求,严重影响性能
esbuild 预构建流程
Vite 在首次启动时,使用 esbuild 对 node_modules 中的依赖进行预构建:
// 预构建前:lodash-es 有 600+ 个文件
import { debounce } from 'lodash-es'
// 浏览器需要请求:lodash-es/debounce.js → lodash-es/_debounce.js → ...(瀑布请求)
// 预构建后:esbuild 将 lodash-es 合并为单个文件
import { debounce } from '/node_modules/.vite/deps/lodash-es.js?v=abc123'
// 只需要一个请求!
预构建结果缓存在 node_modules/.vite 目录。只有当以下内容变化时才会重新预构建:
package.json中的dependencies列表- 包管理器的 lock 文件(
package-lock.json、pnpm-lock.yaml等) vite.config.ts中的optimizeDeps相关配置
HMR 原理
基于 ESM 的精确 HMR
Vite 的 HMR 是在 原生 ESM 之上实现的。当一个模块发生变化时,Vite 只需精确地使已修改的模块与其最近的 HMR 边界之间的链路失效,而不需要重新构建整个 bundle。
// Vite 提供的 HMR API
if (import.meta.hot) {
// 接受自身更新
import.meta.hot.accept((newModule) => {
// 用新模块替换旧模块
console.log('模块已更新', newModule)
})
// 接受依赖更新
import.meta.hot.accept('./module.ts', (newModule) => {
// 依赖模块更新后的回调
})
// 清理副作用
import.meta.hot.dispose(() => {
// 模块被替换前执行清理
clearInterval(timer)
})
}
Vite HMR vs Webpack HMR
| 维度 | Vite HMR | Webpack HMR |
|---|---|---|
| 更新粒度 | 精确到单个模块 | 需要重新编译受影响的 chunk |
| 更新速度 | 与项目大小无关,始终保持快速 | 项目越大越慢 |
| 实现方式 | 基于原生 ESM import | 基于自实现的模块系统 |
| 框架集成 | 框架插件自动处理(如 @vitejs/plugin-vue) | 需要 react-hot-loader 等额外配置 |
Vite HMR 的核心优势:更新速度与项目规模无关。无论项目有多大,HMR 都能保持毫秒级响应,因为它只需处理变更的模块本身,而不是整个模块图。
生产构建
为什么不用 esbuild 打包?
虽然 esbuild 编译速度极快,但 Vite 生产环境选择 Rollup 打包,原因如下:
- 代码分割(Code Splitting):esbuild 的代码分割能力不够成熟,无法生成最优的 chunk 分割策略
- CSS 处理:esbuild 对 CSS 代码分割的支持有限
- 插件生态:Rollup 拥有成熟的插件生态和灵活的插件 API
- 产物优化:Rollup 对 Tree Shaking 的支持更加彻底,能生成更小的 bundle
- 稳定性:Rollup 经过多年验证,产物稳定可靠
esbuild 仍然在生产构建中发挥作用——它负责 代码压缩(minify) 步骤,替代 Terser,速度快很多。Vite 4+ 默认使用 esbuild 进行 JS 和 CSS 压缩。
Rollup 打包策略
import { defineConfig } from 'vite'
export default defineConfig({
build: {
// 使用 Rollup 打包
rollupOptions: {
output: {
// 手动分割 chunk
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-utils': ['lodash-es', 'dayjs'],
},
// 自定义文件名
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
// 使用 esbuild 压缩(默认),比 terser 快 20-40 倍
minify: 'esbuild',
// chunk 大小警告阈值(KB)
chunkSizeWarningLimit: 500,
// 启用 CSS 代码分割
cssCodeSplit: true,
},
})
Vite vs Webpack 详细对比
| 维度 | Vite | Webpack |
|---|---|---|
| 开发冷启动 | 毫秒级(No-Bundle) | 秒级到分钟级(需全量打包) |
| HMR 速度 | 毫秒级,与项目大小无关 | 随项目增大而变慢 |
| 开发模式原理 | 原生 ESM + 按需编译 | Bundle + 自实现模块系统 |
| 生产构建 | Rollup | 自身打包 |
| 构建速度 | 快(esbuild 预构建 + 压缩) | 较慢(JS 编写的 loader/plugin) |
| 配置复杂度 | 开箱即用,约定大于配置 | 配置灵活但复杂 |
| 插件生态 | 兼容 Rollup 插件 + Vite 专属 | 丰富的 loader 和 plugin 生态 |
| Tree Shaking | Rollup 原生支持,效果好 | 支持,但不如 Rollup 彻底 |
| 代码分割 | Rollup 自动分割 | SplitChunksPlugin |
| CSS 处理 | 原生支持 PostCSS、CSS Modules | 需配置 css-loader 等 |
| TypeScript | esbuild 直接编译(仅转译,不类型检查) | 需 ts-loader 或 babel-loader |
| 静态资源 | 原生 import 支持 | 需 file-loader / asset modules |
| 适用场景 | 新项目首选、中小型到大型项目 | 大型老项目、需要高度定制的场景 |
| 浏览器兼容 | 默认支持现代浏览器,可配 @vitejs/plugin-legacy | 原生支持,通过 Babel 转译 |
| SSR 支持 | 内置实验性 SSR | 需额外配置 |
Vite 核心配置示例
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://cn.vite.dev/config/
export default defineConfig({
// 插件配置
plugins: [
vue(),
],
// 路径解析
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
},
// 导入时可省略的扩展名
extensions: ['.ts', '.tsx', '.js', '.jsx', '.vue', '.json'],
},
// 开发服务器配置
server: {
port: 3000,
open: true,
// 代理配置(解决开发环境跨域)
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
},
},
},
// 构建配置
build: {
target: 'es2015',
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: (id: string) => {
// 将 node_modules 中的依赖分割为独立 chunk
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
},
// CSS 配置
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`,
},
},
modules: {
localsConvention: 'camelCase',
},
},
// 环境变量前缀
envPrefix: 'VITE_',
})
安装 Vite
- npm
- Yarn
- pnpm
- Bun
npm install vite -D
yarn add vite --dev
pnpm add vite -D
bun add vite --dev
Vite 插件机制
Vite 的插件机制基于 Rollup 的插件接口进行了扩展,分为 通用钩子(兼容 Rollup)和 Vite 专属钩子 两类。
插件执行时机
- 绿色节点为 Vite 专属钩子
- 蓝色节点为 兼容 Rollup 的通用钩子
自定义插件示例
import type { Plugin } from 'vite'
export function customPlugin(): Plugin {
return {
// 插件名称
name: 'vite-plugin-custom',
// Vite 专属钩子:修改配置
config(config, { mode }) {
if (mode === 'production') {
return {
build: { sourcemap: true },
}
}
},
// Vite 专属钩子:配置开发服务器
configureServer(server) {
server.middlewares.use((req, res, next) => {
// 自定义中间件逻辑
next()
})
},
// 通用钩子(兼容 Rollup):解析模块 ID
resolveId(source: string) {
if (source === 'virtual:my-module') {
return '\0virtual:my-module'
}
},
// 通用钩子:加载模块内容
load(id: string) {
if (id === '\0virtual:my-module') {
return `export const msg = "Hello from virtual module!"`
}
},
// 通用钩子:转换模块代码
transform(code: string, id: string) {
if (id.endsWith('.ts')) {
// 对 TypeScript 文件进行自定义转换
return {
code: code.replace(/__TIMESTAMP__/g, Date.now().toString()),
map: null,
}
}
},
// Vite 专属钩子:自定义 HMR 处理
handleHotUpdate({ file, server }) {
if (file.endsWith('.custom')) {
server.ws.send({
type: 'custom',
event: 'custom-update',
data: { file },
})
return [] // 阻止默认 HMR
}
},
}
}
| 钩子 | 说明 |
|---|---|
config | 修改 Vite 配置(合并前) |
configResolved | 读取最终配置(合并后) |
configureServer | 配置开发服务器(添加中间件) |
configurePreviewServer | 配置预览服务器 |
transformIndexHtml | 转换 index.html |
handleHotUpdate | 自定义 HMR 更新处理 |
常见面试问题
Q1: Vite 为什么比 Webpack 快?
答案:
需要从 开发模式 和 生产构建 两个维度分析:
开发模式(差异最大):
- No-Bundle 策略:Webpack 需要先打包所有模块才能启动开发服务器;Vite 直接启动服务器,利用浏览器原生 ESM 按需加载和编译模块,所以冷启动极快
- esbuild 预构建:第三方依赖通过 esbuild(Go 编写)预构建,比 JavaScript 编写的打包器快 10-100 倍
- 按需编译:只有浏览器实际请求的模块才会被编译,未访问的页面代码不会被处理
- HMR 速度:基于 ESM 的 HMR 只需替换变更模块本身,更新速度与项目规模无关
生产构建:
- esbuild 压缩:使用 esbuild 进行代码压缩,比 Terser 快 20-40 倍
- esbuild 转译:TypeScript/JSX 由 esbuild 转译,跳过类型检查,速度极快
// Webpack(需要全量打包)
// 1. 解析所有模块依赖关系(慢)
// 2. 编译所有模块(慢)
// 3. 生成 Bundle(慢)
// 4. 启动 Dev Server
// 总耗时:30s ~ 几分钟
// Vite(No-Bundle + 预构建)
// 1. esbuild 预构建依赖(快,有缓存更快)
// 2. 直接启动 Dev Server
// 3. 浏览器按需请求模块,即时编译
// 总耗时:几百毫秒 ~ 几秒
Q2: Vite 的依赖预构建是什么?为什么需要?
答案:
依赖预构建是 Vite 在开发服务器首次启动时,使用 esbuild 对 node_modules 中的第三方依赖进行预处理的过程。
为什么需要预构建? 主要解决两个问题:
1. 格式兼容性——CJS 转 ESM:
// React 只提供 CommonJS 格式
// node_modules/react/index.js
module.exports = { createElement, useState, ... }
// 浏览器无法直接 import CommonJS 模块
// esbuild 预构建后转为 ESM:
// node_modules/.vite/deps/react.js
export { createElement, useState, ... }
2. 请求合并——减少 HTTP 请求数量:
// lodash-es 有 600+ 个独立的 ESM 文件
import { debounce } from 'lodash-es'
// 未经预构建:浏览器需要依次请求 debounce.js → _debounce.js → _toNumber.js → ...
// 可能产生 600+ 个 HTTP 请求!
// esbuild 预构建后:将整个 lodash-es 合并为一个文件
// node_modules/.vite/deps/lodash-es.js
// 只需 1 个请求
预构建的缓存策略:
- 结果缓存在
node_modules/.vite目录 - 依赖列表或 lock 文件变化时自动重新构建
- 可通过
vite --force强制重新预构建
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
// 强制预构建某些依赖
include: ['lodash-es', 'axios'],
// 排除某些依赖(已经是合格的 ESM)
exclude: ['my-esm-package'],
},
})
Q3: Vite 生产环境为什么不用 esbuild 打包而用 Rollup?
答案:
虽然 esbuild 构建速度极快,但 Vite 在生产构建中选择 Rollup 而非 esbuild,主要有以下原因:
| 维度 | esbuild | Rollup |
|---|---|---|
| 代码分割 | 支持有限,策略不够灵活 | 成熟的自动和手动代码分割 |
| CSS 代码分割 | 支持不完善 | 完善支持,CSS 与 JS chunk 对应 |
| Tree Shaking | 基础支持 | 深度 Tree Shaking,效果更彻底 |
| 插件生态 | 生态较新 | 成熟丰富的插件生态 |
| 产物格式 | 支持有限 | 支持 ESM、CJS、UMD、IIFE 等 |
| 产物稳定性 | 仍在快速迭代 | 经过多年生产验证 |
| 构建速度 | 极快 | 较慢(但够用) |
核心原因总结:
- 代码分割不成熟:esbuild 的代码分割无法生成最优的 chunk,可能导致浏览器加载多余代码
- CSS 处理能力不足:生产环境需要精确的 CSS 代码分割,确保每个 JS chunk 关联对应的 CSS
- 产物优化不够:Rollup 的 Tree Shaking 更加彻底,能生成更小的最终产物
- 生态和稳定性:生产环境对稳定性要求高,Rollup 经过更充分的验证
esbuild 并非完全不参与生产构建。Vite 在生产构建中仍然使用 esbuild 来进行 代码压缩(minify),这一步比传统的 Terser 快 20-40 倍。因此 Vite 的生产构建是 Rollup 打包 + esbuild 压缩 的组合策略,兼顾了产物质量和构建速度。
此外,Vite 团队正在开发 Rolldown——一个用 Rust 编写的 Rollup 兼容打包器。未来 Vite 计划用 Rolldown 统一开发和生产的构建工具,同时获得极致性能和完善的打包能力。
Q4: Vite 的预构建(Pre-bundling)是什么?为什么需要?
答案:
预构建(也叫依赖预构建、Pre-bundling)是 Vite 在开发服务器首次启动时,使用 esbuild 对 node_modules 中的第三方依赖进行预处理的过程。它主要解决两个核心问题:
问题一:CJS 转 ESM——格式兼容性
浏览器只能通过原生 import 加载 ESM 格式的模块,但大量 npm 包仍然只提供 CommonJS 格式(如 react、moment)。预构建会将这些 CJS 模块转换为 ESM:
// ❌ react 只提供 CJS 格式
// node_modules/react/index.js
module.exports = { createElement, useState, useEffect, ... };
// 浏览器无法直接 import CJS 模块!
// ✅ esbuild 预构建后,转为 ESM
// node_modules/.vite/deps/react.js
export { createElement, useState, useEffect, ... };
// 浏览器可以通过原生 import 加载
问题二:依赖合并——减少 HTTP 请求瀑布
有些 ESM 包内部由大量小文件组成,如果浏览器逐个请求这些文件,会造成严重的网络瀑布问题:
// lodash-es 包含 600+ 个独立的 ESM 文件
import { debounce } from 'lodash-es';
// ❌ 未预构建:浏览器需要串行请求
// debounce.js → _debounce.js → _toNumber.js → _isObject.js → ...
// 可能触发 600+ 个 HTTP 请求(每个文件都需要一次网络往返)
// ✅ 预构建后:esbuild 将整个包合并为单个文件
// node_modules/.vite/deps/lodash-es.js
// 只需要 1 个 HTTP 请求!
预构建的流程:
缓存策略——不会每次都重新构建:
预构建结果会缓存到 node_modules/.vite 目录,只有以下条件变化时才会重新触发:
package.json中的dependencies列表变化- 包管理器的 lock 文件(
pnpm-lock.yaml、package-lock.json)变化 vite.config.ts中optimizeDeps相关配置变化
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
// 强制预构建某些依赖(即使 Vite 没有自动检测到)
include: ['lodash-es', 'axios', 'vue'],
// 排除不需要预构建的依赖(已经是标准 ESM)
exclude: ['my-esm-only-package'],
},
});
可以强制重新预构建:运行 vite --force 或删除 node_modules/.vite 目录。当你更新了某个依赖但预构建缓存没有自动失效时,这个操作很有用。
Q5: Vite 的 HMR 为什么比 Webpack 快?
答案:
Vite 的 HMR 之所以比 Webpack 快,根本原因在于架构设计的差异。可以从以下几个维度对比:
1. 更新粒度不同
- Webpack:文件变更后需要重新构建包含该模块的整个 Chunk,Chunk 越大(包含的模块越多),重建越慢
- Vite:基于原生 ESM,只需重新编译变更的那一个模块,然后浏览器通过原生
import重新请求这个模块即可
2. 更新速度与项目规模的关系
// Webpack HMR:项目越大越慢
// 100 个模块 → HMR 约 200ms
// 1000 个模块 → HMR 约 800ms
// 5000 个模块 → HMR 约 3000ms
// Vite HMR:始终保持毫秒级
// 100 个模块 → HMR 约 50ms
// 1000 个模块 → HMR 约 50ms
// 5000 个模块 → HMR 约 50ms
// Vite 的 HMR 速度与项目总模块数无关!
3. 编译工具不同
| 维度 | Webpack HMR | Vite HMR |
|---|---|---|
| 编译工具 | Babel/ts-loader(JS 编写) | esbuild(Go 编写) |
| 单文件编译速度 | 数十毫秒 | 数毫秒 |
| TypeScript 处理 | 需要完整的类型检查 | 仅做转译(transpile only) |
4. 模块传输方式不同
- Webpack:需要生成
[hash].hot-update.js文件,浏览器通过 JSONP/fetch 加载 - Vite:浏览器直接通过原生 ESM
import()请求变更模块的新 URL(带时间戳参数避免缓存),利用浏览器原生的模块加载机制
// Vite HMR Runtime 收到更新通知后
// 直接用动态 import 加载变更模块(带时间戳避免缓存)
const newModule = await import(`/src/components/Header.vue?t=${Date.now()}`);
// 浏览器原生 ESM 加载 → 无额外序列化/反序列化开销
5. HTTP 协商缓存的利用
Vite 对源码模块的请求使用 304 Not Modified 协商缓存,对预构建的依赖使用 Cache-Control: max-age=31536000,immutable 强缓存。这意味着未变更的模块不需要重新从服务端获取,进一步提升了 HMR 后页面的响应速度。
Vite HMR 快的核心原因:原生 ESM 实现模块级精确替换 + esbuild 毫秒级编译 + 更新量与项目规模解耦。无论项目有多大,HMR 只需要处理变更的那一个模块。
Q6: Vite 在生产环境为什么用 Rollup 而不是 esbuild?
答案:
虽然 esbuild 的编译和压缩速度极快(Go 编写,比 JS 工具快 10-100 倍),但 Vite 在生产构建时选择 Rollup 作为打包器,核心原因是 esbuild 在产物优化方面的能力尚不成熟。
具体原因对比:
| 维度 | esbuild | Rollup |
|---|---|---|
| 代码分割 | 基础支持,策略不灵活 | 成熟的自动/手动分割,支持 manualChunks |
| CSS 代码分割 | 支持不完善 | 完善支持,CSS 与 JS chunk 精确对应 |
| Tree Shaking | 基础支持 | 深度 Tree Shaking,Scope Hoisting |
| 插件生态 | 生态较新,插件有限 | 庞大成熟的插件生态 |
| 产物格式 | ESM / CJS 为主 | 支持 ESM、CJS、UMD、IIFE、SystemJS |
| 产物体积 | 较大 | 更小(更彻底的死代码消除) |
| 稳定性 | 快速迭代中,API 可能变化 | 经多年生产验证,高度稳定 |
代码分割能力对比:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
// Rollup 支持精细化的手动分割策略
manualChunks(id: string): string | undefined {
// 框架核心
if (id.includes('vue') || id.includes('react')) {
return 'framework';
}
// UI 组件库
if (id.includes('ant-design') || id.includes('element-plus')) {
return 'ui-lib';
}
// 工具库
if (id.includes('lodash') || id.includes('dayjs')) {
return 'vendor-utils';
}
// 其他 node_modules
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
});
esbuild 目前无法实现如此灵活的分割策略,这对生产环境的加载性能至关重要。
CSS 代码分割的重要性:
生产环境需要将 CSS 按异步 chunk 拆分,确保每个路由只加载所需的 CSS:
// 路由 /home → 加载 home.[hash].js + home.[hash].css
// 路由 /about → 加载 about.[hash].js + about.[hash].css
// Rollup 能精确实现 CSS 与 JS chunk 的一一对应
// esbuild 在这方面支持不完善
esbuild 在生产构建中的角色:
esbuild 在 Vite 生产构建中承担 代码压缩(minify) 的工作,替代传统的 Terser:
export default defineConfig({
build: {
minify: 'esbuild', // 默认值,比 terser 快 20-40 倍
// minify: 'terser', // 也可以切换为 terser(需安装)
},
});
所以 Vite 的生产构建实际是 Rollup 打包 + esbuild 压缩 的组合策略,兼顾产物质量和构建速度。
未来展望——Rolldown:
Vite 团队正在开发 Rolldown——一个用 Rust 编写的、与 Rollup API 兼容的打包器。它的目标是:
- 兼容 Rollup 的插件生态和配置
- 拥有接近 esbuild 的构建速度
- 统一 Vite 的开发和生产构建工具
这意味着未来 Vite 有望用 Rolldown 同时解决开发速度和生产产物质量的问题。