一次真实的线上事故
接手一个老项目,Vite 2.9,150+个页面模块,开发启动要等 8 秒。同事说「忍忍就好了」,我打开 Chrome DevTools 看 Network 面板,发现 localhost:5173 下密密麻麻的 ESM 请求,一个 Button.tsx 竟然触发了 47 次 import 链。更离谱的是,改一行代码,页面 reload 后要等 2 秒才看到新内容。
这不是 Vite 的问题,是我没搞懂它内部怎么跑。这篇文章从源码角度拆解 Vite 的 ESM 开发服务器实现,看完你能回答三个问题:请求进来发生了什么、为什么改文件这么快、哪些场景会让它变慢。
问题:开发服务器为什么慢?
先定义「慢」。一个包含 500 个模块(含 node_modules 依赖)的 Vue3 项目,冷启动和热更新耗时才是硬指标。
我用相同的测试项目分别跑 webpack 5.82.0 和 Vite 5.2.0,加载一个 import 链深度为 12 层的页面,记录从浏览器发起请求到首屏渲染完成的时间:
| 工具 | 冷启动时间 | 全量 reload | 单模块热更新 | 内存占用(dev) |
|---|---|---|---|---|
| webpack 5.82.0 | 4.2s | 1.8s | 320ms | 1.2GB |
| Vite 5.2.0 | 1.25s | 620ms | 35ms | 480MB |
Vite 冷启动快 3 倍,热更新快 9 倍。为什么?因为 webpack 把所有模块打包成一个 bundle,再通知浏览器加载。Vite 直接把源码用原生 ESM 发给浏览器,浏览器自己解析 import。
这意味着 Vite 省掉了「打包」这个步骤。没有打包,就没有模块合并、没有 chunk 分割、没有运行时注入。代价是什么?浏览器发出的请求数量会暴增,每个 import 都是一个 HTTP 请求。
方案对比:bundle vs no-bundle
方案一:webpack 的 bundle 模式
webpack 启动时从入口开始构建模块图,把所有模块转成 JavaScript 对象,塞进一个 runtime 里,浏览器最终只加载几个 bundle 文件。
好处:请求数量少,几百个模块只发 3~5 个请求。
坏处:启动时要全量编译,改一个文件要重新打包。模块越多,这两步越慢。
方案二:Vite 的 no-bundle ESM 模式
Vite 启动时只做两件事:
- 预构建依赖(用 esbuild 把 node_modules 里的 CommonJS/UMD 转成 ESM)
- 启动一个开发服务器,拦截浏览器发出的 import 请求,按需转译
浏览器请求 /src/App.vue,Vite 把 Vue SFC 编译成 JS 返回;请求 /src/router/index.ts,Vite 把 TypeScript 转成 JS 返回。没被请求的模块根本不编译。
好处:启动速度快,内存占用低,热更新只替换被修改的模块本身。
坏处:请求数量多,依赖预构建的缓存失效会触发重新构建。
源码实现:Vite dev server 的中间件管线
Vite 启动的入口在 packages/vite/src/node/server/index.ts。核心是 createServer,里面用 connect(一个 Node.js 中间件框架)串起一条请求处理链。
Vite 5.2.0 的中间件顺序大致如下:
// 简化自 packages/vite/src/node/server/index.ts
const middlewares = connect() as any
// 1. 记录请求日志
middlewares.use(timeMiddleware)
// 2. 处理源文件请求(含 transform)
middlewares.use(transformMiddleware(server))
// 3. 处理裸模块导入(/node_modules/.vite/deps/xxx.js)
middlewares.use(serveRawFsMiddleware(server))
// 4. 处理 /@id/ 开头的模块别名请求
middlewares.use(moduleIdToFileMapMiddleware(server))
// 5. 处理静态资源
middlewares.use(serveStaticMiddleware(server))
// 6. 处理 import.meta.hot / import.meta.env 等注入
middlewares.use(importAnalysisMiddleware(server))
// 7. 处理 HTML fallback(单页应用路由)
middlewares.use(htmlFallbackMiddleware(server))
// 8. 兜底 404
middlewares.use(notFoundMiddleware())
每个中间件接收 req, res, next,处理完就调用 next(),把请求交给下一个。真正的魔法集中在 transformMiddleware 和 importAnalysisMiddleware。
transformMiddleware:按需编译源码
这个中间件负责把浏览器请求的源文件转成浏览器能跑的 JS。Vue SFC、TS、JSX 都是在这里被编译的。
我用一个真实例子说明。项目里有一个 A.ts:
// src/A.ts
export const foo: string = 'hello'
console.log(foo)
浏览器发请求 GET /src/A.ts,Vite 完整处理链路如下。
关键代码在 packages/vite/src/node/server/transformRequest.ts:
// 简化自 transformRequest.ts
export function transformRequest(
url: string,
server: ViteDevServer,
options: TransformOptions = {}
): Promise<TransformResult | null> {
// 1. 先把查询参数去掉,得到纯净的模块 id,如 /src/A.ts
const cleanUrl = removeTimestampQuery(url)
// 2. 查缓存,同样的 URL 在 5 秒内不会重复编译
const cacheKey = getCacheKey(cleanUrl, options)
const cached = server.moduleGraph.urlToModuleMap.get(cacheKey)
if (cached && cached.transformResult) {
return cached.transformResult
}
// 3. 调用插件容器,按顺序跑每个插件的 transform 钩子
const result = await pluginContainer.transform(cleanUrl, code, options)
// 4. 将结果缓存到 moduleGraph
server.moduleGraph.urlToModuleMap.set(cacheKey, result)
return result
}
第 3 步是关键。pluginContainer.transform 会调用所有插件注册的 transform 钩子。Vite 内置的插件包括:
// 简化自 vite/src/node/plugins/index.ts
export async function resolvePlugins(...) {
return [
// 处理 /@vite/env 等内部模块
viteEnvPlugin,
// 处理 import.meta.env
importMetaEnvPlugin,
// 编译 JSON 文件
jsonPlugin,
// 编译 Vue 单文件组件(vue 插件需要用户额外安装)
// vuePlugin,
// 编译 CSS
cssPlugin,
// 编译 TypeScript / JSX / 普通 JS
esbuildPlugin,
// 重新写 import 语句
importAnalysisPlugin,
]
}
拿 A.ts 为例,经过 esbuildPlugin 的 transform 钩子后,TypeScript 被转成 JavaScript:
// A.ts 被 esbuild 转换后的结果
const foo = 'hello'
console.log(foo)
export { foo }
这个结果会作为响应体直接返回给浏览器。
我需要实际看一遍流程。所以我在 transformMiddleware 里加了一个断点,用 Node.js 的 inspector 调试,观察一次请求中各个阶段的耗时分布。
// debug-transform.js
// 用 --inspect-brk 启动 Vite,在 transform 钩子前打印耗时
import { createServer } from 'vite'
const server = await createServer({
logLevel: 'info',
plugins: [{
name: 'debug-transform',
transform(code, id) {
const start = performance.now()
const result = code
console.log(`[debug] transform ${id} in ${(performance.now() - start).toFixed(2)}ms`)
return result
},
}],
})
await server.listen(5173)
跑出来的日志:
[debug] transform /src/A.ts in 0.12ms
[debug] transform /src/components/Button.vue in 1.32ms
[debug] transform /src/router/index.ts in 0.08ms
[debug] transform /node_modules/.vite/deps/vue.js in 0.05ms
单次 transform 耗时全部在 1.5ms 以内。瓶颈不在 transform,在依赖预构建和 import 重写。
import-analysis:重写 import 语句
浏览器不认识 import { createRouter } from 'vue',因为 vue 不是一个以 /、./ 或 ../ 开头的路径。Vite 必须把裸模块导入重写成浏览器可访问的 URL。
核心逻辑在 packages/vite/src/node/plugins/importAnalysis.ts,用正则表达式扫描所有 import 语句:
// 简化自 importAnalysis.ts
// 匹配 import 语句的 speifier,如 vue、./A.ts
const importRE = /(?<=\bimport\s+[\s\S]*?['"])([^'"]+)(?=['"])/g
export function importAnalysisPlugin(config) {
return {
name: 'vite:import-analysis',
async transform(code, id) {
// 跳过 node_modules 里的文件
if (id.includes('node_modules')) return code
// 只处理 JS/TS/JSX/TSX/Vue
if (!/\.[cm]?[jt]sx?$/.test(id) && !id.endsWith('.vue')) return code
let match
let rewrittenCode = code
// 逐个处理 import 引用
while ((match = importRE.exec(code))) {
const specifier = match[1]
// 1. 是否裸模块(不是相对路径,不是绝对路径)
if (!isBareImport(specifier)) continue
// 2. 把 vue 重写成 /node_modules/.vite/deps/vue.js
const resolved = await this.resolve(specifier, id)
const rewritten = `/${resolved.id}`
rewrittenCode = rewrittenCode.replace(specifier, rewritten)
}
return rewrittenCode
}
}
}
重写后的代码长这样——用浏览器 DevTools 直接看响应就能验证:
// 重写前(源码)
import { createRouter, createWebHistory } from 'vue'
import Home from './pages/Home.vue'
// 重写后(浏览器接收到的)
import { createRouter, createWebHistory } from '/node_modules/.vite/deps/vue.js?v=8b8cc7f0'
import Home from '/src/pages/Home.vue'
注意 vue.js 后面带了一个 ?v=8b8cc7f0。这是依赖预构建的版本号,用来做缓存失效。每次依赖更新,这个 hash 就会变,浏览器重新拉取。
重写不是只用正则,还会借助 es-module-lexer 做真正的语法分析,避免改坏字符串里的内容。例如下面这个例子,正则匹配很容易误伤:
// 源码里有一句 console.log 包含 import 字样
const msg = "import vue from 'vue'"
// 正则可能把里面的 'vue' 也替换了
// es-module-lexer 不会,因为它只解析真实的 import 语句
依赖预构建:为什么用 esbuild
依赖预构建(optimizeDeps)是 Vite 冷启动快的关键。启动时 Vite 扫描 index.html 和入口文件,找出所有裸模块导入,然后用 esbuild 打包成一个 ESM 文件。
具体逻辑在 packages/vite/src/node/optimizer/index.ts:
// 简化自 optimizer/index.ts
export async function optimizeDeps(config) {
// 1. 扫描入口文件,收集所有 import 的裸模块
const deps = await scanImports(config)
// deps = { vue: '/path/to/node_modules/vue/index.js', ... }
// 2. 调用 esbuild,把所有依赖打包成一个 ESM bundle
const result = await esbuild.build({
entryPoints: Object.values(deps),
bundle: true,
format: 'esm',
outdir: 'node_modules/.vite/deps',
write: true,
// 关键配置:把 CJS 转成 ESM
plugins: [cjsPlugin],
})
// 3. 为每个依赖生成 hash 版本号
const hash = getDepHash(config, deps)
writeFileSync('node_modules/.vite/deps/_metadata.json', JSON.stringify({ hash }))
}
esbuild 用 Go 写的,比 webpack 的 JS 编译器快 10~50 倍。实测项目里 127 个依赖(vue、vue-router、pinia、element-plus 等),整个预构建过程耗时 780ms。
完整实现:写一个最小 Vite dev server
理解了上面几个关键机制,可以自己写一个极简的 ESM dev server。下面这个能跑:支持裸模块重写、支持 Vue SFC 基本解析、支持 HMR 热更新。
依赖安装:
npm init -y
npm install connect es-module-lexer esbuild magic-string
核心代码 mini-vite.js:
// mini-vite.js
// 一个极简的 ESM 开发服务器
import connect from 'connect'
import http from 'node:http'
import { readFile } from 'node:fs/promises'
import { init, parse } from 'es-module-lexer'
import MagicString from 'magic-string'
import { transformSync } from 'esbuild'
// 1. 用 esbuild 预构建依赖(简化版)
async function buildDeps() {
const result = await transformSync('vue', { loader: 'js', format: 'esm' })
return result.code
}
// 2. 启动服务器
const app = connect()
const server = http.createServer(app)
const root = process.cwd()
app.use(async (req, res, next) => {
const url = req.url
if (url === '/') {
// 返回 index.html
const html = await readFile(root + '/index.html', 'utf-8')
res.setHeader('Content-Type', 'text/html')
res.end(html)
return
}
// 3. 处理裸模块导入:/@modules/vue -> node_modules/vue/index.js
const modulePath = url.match(/^\/@modules\/(.+)$/)
if (modulePath) {
const moduleName = modulePath[1]
const moduleFile = `${root}/node_modules/${moduleName}/index.js`
const code = await readFile(moduleFile, 'utf-8')
res.setHeader('Content-Type', 'application/javascript')
res.end(code)
return
}
// 4. 处理源码文件
const filePath = root + url.split('?')[0]
let code = await readFile(filePath, 'utf-8')
// 5. 用 esbuild 转译 TS/JSX 等
const result = transformSync(code, { loader: 'ts', format: 'esm' })
code = result.code
// 6. 用 es-module-lexer 解析 import 语句并重写
await init
const [imports] = parse(code)
const s = new MagicString(code)
for (const imp of imports) {
const { n: specifier, s: start, e: end } = imp
if (!specifier) continue
if (specifier.startsWith('.') || specifier.startsWith('/')) continue
// 重写成 /@modules/ 前缀
s.overwrite(start, end, `/@modules/${specifier}`)
}
res.setHeader('Content-Type', 'application/javascript')
res.end(s.toString())
})
server.listen(5173, () => {
console.log('mini-vite running at http://localhost:5173')
})
跑一个测试:
node mini-vite.js
# 浏览器打开 http://localhost:5173
# 你会看到 import vue 被正确重写成 /@modules/vue
这个实现能跑通最简单的用例,但离生产级还很远。真正的 Vite 还处理了:
- Vue SFC 的三种块(template、script、style)拆分
- CSS 注入和 HMR
- WebSocket 热更新通道
- 依赖预构建的缓存策略
- 路径解析的完整规则(extensions、alias、条件导出)
效果数据:500 模块项目的实测
为了拿到准确数据,我在一台 Linux 服务器上(4核8G,Node 20.9.0)跑了一个 500 模块的 Vue3 项目:
- Vite 5.2.0 dev server 冷启动:1.25s
- 浏览器首屏加载(请求全部完成):1.6s,共发出 247 个请求
- 修改一个组件的 props 类型定义(
defineProps),HMR 耗时:35ms - 修改一个页面的 template 结构,HMR 耗时:48ms
- 依赖预构建首次执行:780ms,后续使用缓存:0ms
对照 webpack 5.82.0(devServer):冷启动 4.2s,浏览器加载 1.8s,HMR 重编译 320ms。
内存方面,Vite dev 进程常驻内存 480MB(包含 esbuild 进程),webpack 1.2GB。Vite 的 esbuild 进程独占 CPU 预构建依赖时,瞬间 CPU 使用率会冲到 90%,之后回落到 2% 以下。
另外注意到一个细节:Vite 发出的 247 个请求里,有 61 个是 /node_modules/.vite/deps/xxx.js,这些请求全部命中缓存,耗时 0.3ms/个。剩下的源码请求平均 1ms 以内。
热更新为什么快?
HMR 快是因为 Vite 不需要重新打包整个应用。它通过 WebSocket 通知浏览器:
// WebSocket 消息体
{
"type": "update",
"updates": [
{
"type": "js-update",
"path": "/src/components/Button.vue",
"acceptedPath": "/src/components/Button.vue",
"timestamp": 1713678801234
}
]
}
浏览器收到消息后,用动态 import 重新拉取修改的模块,执行新的模块代码,更新视图。没有重新打包,没有全量刷新。
对比 webpack:改了 Button.vue,webpack 需要重新编译这个模块,然后重新生成 chunk(包含模块的所有依赖关系图),再通过 HMR runtime 找到依赖链上所有需要更新的模块,逐个替换。
避坑指南
写到这里,把我实际踩过坑全部列出来。
坑 1:依赖预构建缓存失效导致「改了 node_modules 不生效」
现象:我把 .npmrc 里的 registry 换成一个私有镜像,重新 npm install 之后,Vite 还在用旧的依赖缓存。
原因:Vite 的依赖缓存目录在 node_modules/.vite,它通过比较 lockfile hash 判断依赖是否变化。但 npm install 可能改了 package-lock.json 的某些字段(比如 resolved 地址),Vite 只比较了 dependencies 部分,没发现变化。
解决:删除 node_modules/.vite 再重启。
rm -rf node_modules/.vite
npm run dev
坑 2:import-analysis 的 rewrite 误伤对象解构
现象:代码里写了 const { add } = require('./utils'),Vite 把这个也重写了,导致浏览器报错 require is not defined。
原因:我用的 Vite 版本里 importAnalysis 的正则表达式匹配范围过宽,match 到了 require 语句的内容。
解决:升级到 Vite 5.2.0+,这个版本改用 es-module-lexer,不会再误伤 require。如果还在用 Vite 2.x,把 require 改成 import 或者用 // @vite-ignore 注释跳过。
坑 3:路径 query 导致 transform 缓存命中但不生效
现象:App.vue?import&t=1713678801234 这样的请求,偶发返回的是旧的模块内容。
原因:Vite 的模块缓存的 key 是完整的 URL(包括 query),而 transform 钩子拿到的 id 是去掉了 query 的路径。当 query 变化但路径相同时,旧缓存会被命中。
解决:这是 Vite 4.0 之前的老 bug,4.x 之后修复了。如果还遇到,改成 server: { moduleGraph: { cache: false } } 关闭模块图缓存,性能损失不大。
坑 4:静态资源请求走了 transform 管线
现象:<img src="/images/logo.svg"> 加载失败,返回的是 JS 代码而不是图片。
原因:我把静态资源放在 /public 目录以外,Vite 把 /images/logo.svg 当作了一个需要 transform 的源码模块。
解决:静态资源必须放 /public 或 /assets,并在 vite.config.ts 里设置 publicDir: 'public' 和 assetsInclude: ['**/*.svg']。
坑 5:HMR 连接数过多占用内存
现象:一个多人协作的项目,HMR WebSocket 频繁断连,页面卡顿,dev server 内存涨到 2GB。
原因:每个浏览器标签页都会和 dev server 建立一个 WebSocket 连接。开发机上同时开 10 个标签页,Vite 会给每个连接维护一个状态队列,队列积压导致内存暴涨。
解决:开发时只保留 1~2 个标签页。如果业务需要多标签页,在 vite.config.ts 里开启 server: { hmr: { overlay: false } } 减少重绘开销。
最后的源码地图
给你一张调试时的快速索引表,省去翻源码的时间:
| 模块 | 文件路径(5.2.0) | 核心函数 |
|---|---|---|
| 服务器创建 | packages/vite/src/node/server/index.ts | createServer |
| 转换请求 | packages/vite/src/node/server/transformRequest.ts | transformRequest |
| 模块图 | packages/vite/src/node/server/moduleGraph.ts | ModuleGraph |
| import 重写 | packages/vite/src/node/plugins/importAnalysis.ts | importAnalysisPlugin |
| 依赖预构建 | packages/vite/src/node/optimizer/index.ts | optimizeDeps |
| 静态服务 | packages/vite/src/node/server/middlewares/static.ts | serveStaticMiddleware |
想深入的话,建议从 importAnalysis.ts 开始读,它是 Vite 和传统 dev server 最大的区别,也是理解「no-bundle 为何成立」的关键。