先说我踩的坑
2024年我在维护一个内部中后台项目,技术栈是 Vue3 + Webpack5,200+个路由页面。dev server冷启动要 12.8秒,保存一次代码到浏览器刷新平均 3.2秒。一个下午改bug,光等编译就浪费了40分钟。
后来我花了两周时间把 Vite 4.4 的源码读了一遍,看懂之后就两个字:服气。本文就讲 Vite 的 ESM dev server 到底是怎么实现的,我会带着你从零实现一个简化版。不是为了造轮子,是为了让你在遇到问题的时候,知道去哪查。
问题:浏览器为什么不直接用 ESM?
ESM 标准 2015 年就定了,浏览器 2018 年开始支持。但你在工程里写的代码,没法直接扔给浏览器跑。拿一个最简单的 Vue3 项目举例:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
这段代码浏览器有3个问题处理不了:
vue是一个bare import(裸模块名),浏览器不知道去哪找这个模块。你需要告诉它一个完整URL。App.vue是单文件组件,浏览器只认JS,不认识 .vue 文件。import要经过深度遍历,可能涉及几百个循环依赖的ESM模块,浏览器逐个请求会产生大量HTTP往返。
Vite 的处理方式:开发环境下,把每个源码文件当成独立的 ESM 模块,浏览器请求哪个文件就实时转换哪个。node_modules 里的依赖,在启动时一次性预构建成 ESM。
方案对比:Bundle vs Native ESM
主流的开发服务器有两类:打包型和原生ESM型。
| 维度 | Webpack 5 dev server(打包型) | Vite dev server(原生ESM型) |
|---|---|---|
| 冷启动耗时(200页项目) | 12.8s | 0.9s |
| 启动时全量编译 | 是,所有模块构建模块图 | 否,只预构建node_modules依赖 |
| 变更时更新范围 | 重新构建依赖链上的模块 | 只替换被修改的模块(import.meta.hot) |
| HMR更新延迟(保活10s) | 800ms~3.2s | 60~120ms |
| 模块间通信 | webpack runtime在浏览器执行模块解析 | 浏览器原生支持ESM,服务器推送修改的模块 |
| 调试体验 | 需要SourceMap还原 | 浏览器直接看到源文件代码 |
还有一个方案:Snowpack。它也是原生ESM的思路,但把所有依赖(包括未变动的)都每次请求时重新转换。Vite加了一层预构建缓存,命中缓存时性能碾压。
Vite的模型是:用 esbuild 在启动时预构建依赖(node_modules里的包),把CommonJS转成ESM并合并成少量文件。源码文件则保持原样,按浏览器请求逐个用插件转换。
核心架构:中间件洋葱模型
Vite dev server本质上就是一个Node.js HTTP服务器,挂了一堆中间件。HTTP请求进来,按顺序通过每个中间件,直到某个中间件返回了响应。
开发服务器启动流程(Vite 5.0代码):
// vite/packages/vite/src/node/server/index.ts
export async function createServer(inlineConfig = {}) {
const config = await resolveConfig(inlineConfig, 'serve')
const root = config.root
const server = {
_config: config,
moduleGraph: new ModuleGraph((url) =>
resolveId(url, config)
),
// 中间件栈
middlewares: connect(),
httpServer: null,
}
// ...
// 1. 预构建依赖
await initDepsOptimizer(config, server)
// 2. 创建WebSocket服务,用于HMR
const ws = createWebSocketServer(server)
// 3. 注册中间件
const middlewares = server.middlewares
// 请求注入中间件
middlewares.use(transformMiddleware(server))
// 静态文件中间件
middlewares.use(serveStaticMiddleware(root, config))
// 4. 启动HTTP服务
return new Promise((resolve, reject) => {
server.httpServer = http.createServer(server.middlewares)
server.httpServer.listen(config.server.port, config.server.host)
})
}
中间件是按注册顺序匹配的。Vite内部运行时实际注册了20+个中间件。核心的就这几个:
timeMiddleware:给请求计时,推送到HMR客户端viteHMRMiddleware:处理HMR websocket连接transformMiddleware:核心转换逻辑,处理所有源码转换请求serveRawFsMiddleware:处理/?raw请求,返回文件原始内容serveStaticMiddleware:静态资源服务spaFallbackMiddleware:单页应用路由fallback,访问任意路径返回index.html
核心链路:请求是怎么被处理的
浏览器请求 http://localhost:5173/src/main.ts,服务器端处理流程:
// 简化版的transformMiddleware
import { transformRequest } from './transformRequest'
export function transformMiddleware(server) {
return async function viteTransformMiddleware(req, res, next) {
// 只处理GET请求,且有对应文件
if (req.method !== 'GET') {
return next()
}
const url = req.url
// 1. 处理带查询参数的请求(?import, ?raw, ?url等)
const withoutQuery = cleanUrl(url)
const file = await server.moduleGraph.getModuleByUrl(url)
// 2. 检查是否有缓存
if (file && file.transformResult) {
const cached = file.transformResult
// 如果ETag匹配,返回304
if (req.headers['if-none-match'] === cached.etag) {
res.statusCode = 304
return res.end()
}
res.setHeader('ETag', cached.etag)
res.setHeader('Content-Type', 'text/javascript')
return res.end(cached.code)
}
// 3. 没有缓存,执行转换
try {
const result = await transformRequest(url, server, { html: false })
if (result) {
res.setHeader('Content-Type', 'text/javascript')
res.setHeader('ETag', result.etag)
res.end(result.code)
} else {
next()
}
} catch (e) {
next(e)
}
}
}
transformRequest 是核心函数,做了这些事:
- 用
resolveId把/src/main.ts解析成绝对路径 - 检查moduleGraph缓存,命中直接返回
- 读取文件内容
- 调用插件容器的
transform钩子(这就是Vite插件的核心) - 把生成的代码写入moduleGraph缓存
- 返回 { code, etag, map }
完整代码实现:从零写个ESM dev server
我按Vite的设计思路,写了一个简化版可运行的实现。够跑通一个Vue单文件组件的转换,400行左右。关键部分如下。
3.1 依赖预构建(esbuild)
启动时扫描所有源码文件,找到裸导入的模块,然后用esbuild打包成ESM,放到 node_modules/.vite/deps 目录。
// prebundle.js
import { build } from 'esbuild'
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
import { dirname, resolve } from 'path'
const CACHE_DIR = 'node_modules/.vite/deps'
export async function optimizeDeps(root) {
const deps = new Set()
// 递归扫描源码里的 import 语句
scanImports(root, deps)
// 用esbuild把每个依赖打包成ESM
const result = await build({
entryPoints: [...deps],
bundle: true,
format: 'esm',
outdir: CACHE_DIR,
splitting: true,
sourcemap: true,
treeShaking: true,
// 关键:外部化源码文件的导入
external: [/^\/.*/, /^\.\//, /^\.\.\//],
logLevel: 'error',
})
// 生成 deps 映射表(模块名 -> URL路径)
const metadata = {}
for (const dep of deps) {
const depPath = require.resolve(dep)
const hash = hashId(depPath)
metadata[dep] = `/node_modules/.vite/deps/${hash}.js`
}
writeFileSync(resolve(CACHE_DIR, '_metadata.json'), JSON.stringify(metadata))
}
function scanImports(root, deps) {
const files = walk(root).filter(f => f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.vue'))
for (const file of files) {
const code = readFileSync(file, 'utf-8')
const imports = parseESMImports(code)
for (const spec of imports) {
// 判断是裸模块名(不是相对路径也不是绝对路径)
if (!spec.startsWith('.') && !spec.startsWith('/') && !spec.startsWith('~')) {
deps.add(spec.split('/')[0])
}
}
}
}
这里用到的 parseESMImports 是个简化版本。Vite实际用的是 es-module-lexer,一个用C++写的极速解析器,能在微秒级别解析ESM的导入导出。
// 用es-module-lexer解析import语句
import { init, parse } from 'es-module-lexer'
export function parseESMImports(code) {
await init
const [imports] = parse(code)
return imports
.filter(i => i.d > -1) // 忽略动态import
.map(i => code.slice(i.s, i.e)) // 获取导入说明符
}
3.2 模块转换(transform)
每个源码文件被请求时,走转换管道。Vue SFC被拆成三个部分:script、template、style。这里给出script和template的转换逻辑:
// transformVueSFC.js
import { parse, compileTemplate, compileScript } from '@vue/compiler-sfc'
import { hash } from './utils'
const cache = new Map()
export async function transformVueSFC(code, id, query) {
// 解析SFC,获取AST
const { descriptor } = parse(code, { filename: id })
// 1. 处理 style 块:每个style独立请求
if (query.type === 'style') {
const styleBlock = descriptor.styles[Number(query.index)]
const styleCode = styleBlock.content
// 返回JavaScript代码,负责把style注入页面
return {
code: `
const css = ${JSON.stringify(styleCode)}
const style = document.createElement('style')
style.innerHTML = css
document.head.appendChild(style)
export default css
`,
}
}
// 2. 处理 script 块
if (!query.type) {
let scriptCode = ''
let scriptMap = null
if (descriptor.script || descriptor.scriptSetup) {
const scriptResult = compileScript(descriptor, { id })
scriptCode = scriptResult.content
} else {
scriptCode = 'export default {}'
}
// 3. 处理 template:编译成render函数
let templateCode = ''
if (descriptor.template) {
const templateResult = compileTemplate({
source: descriptor.template.content,
filename: id,
id: hash(id),
})
templateCode = templateResult.code
}
// 4. 组装成最终的模块代码
const finalCode = `
${scriptCode}
import { render as __vue_render__ } from ${JSON.stringify(id + '?type=template&t=' + Date.now())}
export default { ..._sfc_main, render: __vue_render__ }
`
return {
code: finalCode,
map: null,
}
}
// template块单独请求时
if (query.type === 'template') {
const templateResult = compileTemplate({
source: descriptor.template.content,
filename: id,
id: hash(id),
})
return {
code: templateResult.code,
map: templateResult.map,
}
}
}
3.3 import重写:把裸模块变成URL
转换阶段最关键的一步:把代码里的 import vue from 'vue' 重写成 import vue from '/node_modules/.vite/deps/vue.js'。这是让浏览器能跑起来的关键。
// rewriteImports.js
import { init, parse } from 'es-module-lexer'
export async function rewriteImports(code, importer, depsMetadata) {
await init
const [imports] = parse(code)
let result = code
// 从后往前替换,避免位置偏移
for (let i = imports.length - 1; i >= 0; i--) {
const imp = imports[i]
const specifier = code.slice(imp.s, imp.e)
if (imp.d > -1) {
// 动态import,处理为 /src/xxx.js 形式
const rewritten = `import(${JSON.stringify(resolveDynamicImport(specifier, importer, depsMetadata))})`
result = result.slice(0, imp.s) + rewritten + result.slice(imp.e)
continue
}
if (imp.n) {
// 静态import
if (imp.n.startsWith('.')) {
// 相对路径:转换为绝对URL
const url = resolve(importer, imp.n)
result = rewriteAt(result, imp.s, imp.e, JSON.stringify(url))
} else if (!imp.n.startsWith('/')) {
// 裸模块:查预构建映射表
const depsUrl = depsMetadata[imp.n] || depsMetadata[imp.n.split('/')[0]]
if (depsUrl) {
result = rewriteAt(result, imp.s, imp.e, JSON.stringify(depsUrl))
}
}
}
}
return result
}
拿一个真实例子展示重写前后的对比:
// 重写前
import { createApp } from 'vue'
import App from './App.vue'
import { ref } from 'vue'
const Home = () => import('./views/Home.vue')
// 重写后
import { createApp } from '/node_modules/.vite/deps/vue.js'
import App from '/src/App.vue'
import { ref } from '/node_modules/.vite/deps/vue.js'
const Home = () => import('/src/views/Home.vue')
3.4 缓存机制:moduleGraph
Vite的模块图(ModuleGraph)是该方案能快的原因。每个模块在首次请求后,转换结果被缓存。以下是我的简化实现:
// moduleGraph.js
export class ModuleGraph {
constructor() {
// url(不带查询参数) -> ModuleNode
this.urlToModuleMap = new Map()
// 文件路径 -> ModuleNode
this.idToModuleMap = new Map()
}
async ensureModuleFromUrl(rawUrl) {
const [url, query] = rawUrl.split('?')
let mod = this.urlToModuleMap.get(url)
if (!mod) {
mod = {
url,
id: null, // 绝对路径
file: null, // 磁盘路径
transformResult: null,
importers: new Set(),
importedModules: new Set(),
lastHMRTimestamp: 0,
}
this.urlToModuleMap.set(url, mod)
}
return mod
}
invalidateModule(mod) {
// 清除该模块和所有importers的缓存
mod.transformResult = null
mod.lastHMRTimestamp = Date.now()
for (const importer of mod.importers) {
this.invalidateModule(importer)
}
}
}
当文件被修改时(文件监听器捕获到 fs.watch 事件),Vite调用 invalidateModule 递归清除该模块所有依赖方的缓存,再通过WebSocket通知浏览器拉取新模块。
3.5 HMR:WebSocket推送更新
HMR的核心逻辑在 client.ts。服务器端推送更新消息,客户端收到后执行热更新逻辑。服务器端代码:
// hmr.js
import { WebSocketServer } from 'ws'
export function createHMRServer(server) {
const wss = new WebSocketServer({ server: server.httpServer, path: '/__vite_hmr' })
const clients = new Set()
wss.on('connection', (socket) => {
clients.add(socket)
socket.send(JSON.stringify({
type: 'connected',
timestamp: Date.now(),
}))
socket.on('close', () => clients.delete(socket))
})
return {
async sendUpdatedModule(mod) {
// 1. 找出这个模块在浏览器端可以接受热更新的自模块
// 2. 找出它的父模块链上有哪些`import.meta.hot.accept`处理
// 3. 推送更新消息
const updates = []
const boundaries = await getHMRBoundaries(mod)
for (const boundary of boundaries) {
updates.push({
type: 'js-update',
path: boundary.url,
acceptedPath: mod.url,
timestamp: mod.lastHMRTimestamp,
})
}
const msg = JSON.stringify({ type: 'update', updates })
for (const client of clients) {
client.send(msg)
}
},
}
}
效果数据:对比Webpack5
我用相同的测试项目做了对比。测试项目是一个200个页面的Vue3管理后台,包含路由懒加载、ECharts图表、Element Plus组件库。
| 指标 | Webpack 5.89.0 | Vite 5.0.0 | 优化幅度 |
|---|---|---|---|
| 冷启动时间 | 12.8s | 0.9s | 93% |
| 首次页面加载(开发模式) | 4.7s | 243ms | 94.8% |
| 保存代码到%HMR更新完成(修改组件props) | 1.2s~3.2s | 85~120ms | 96% |
| 保存代码到%HMR更新完成(修改CSS) | 800ms~2.1s | 35~60ms | 97% |
| 内存占用(空闲时) | 1.2GB | 420MB | 65% |
| 模块数(启动后,不含node_modules) | 2,847 | 486 | — |
注意:Vite的首次页面加载是 按需编译,只编译当前路由用到的模块。如果一个模块没被任何页面引用,它永远不会被编译。这就是「按需加载」带来的差异。
压测环境:MacBook Pro M1 Pro 16GB,Node.js 18.18.0,Chrome 120。Vite和Webpack的dev server都开了gzip压缩和缓存。
为什么Vite会快:三个核心原理
原理一:预构建是性能分水岭
如果没有预构建,浏览器要发几百个请求去加载node_modules里的每个文件。每个文件一次HTTP往返 + 一次文件读取 + 一次编译。Vite用esbuild把数百个依赖文件打包成少数几个ESM文件,浏览器只需要发几个请求。
比如 element-plus 有 800+ 个文件。打包前,浏览器要请求800+次。打包后,只有 3-5 个文件。
esbuild为什么快?因为它用Go写的,直接编译成机器码。对应的JS打包器如Rollup,需要先启动JIT。实测esbuild打包1000个ESM模块耗时218ms,Rollup需要2.1s(同一台机器)。
原理二:源码文件不合并,采用浏览器友好的URL
Vite把每个源码文件映射成一个可访问的URL。浏览器请求 /src/App.vue 时,服务器读取磁盘文件,转换成JS后返回。这样做的优势:
- 转换结果可以按URL缓存(ETag)
- 文件之间天然隔离,不用处理作用域冲突
- 浏览器可以直接显示源码,不依赖SourceMap
原理三:更细粒度的缓存失效
Webpack的增量编译是整个module graph重新构建。Vite是只重建修改文件及其importers链上的缓存(通常只有几个模块)。
// 文件变更时,Vite的处理逻辑(简化版)
watcher.on('change', (file) => {
// 1. 在moduleGraph中找到这个文件对应的模块
const mod = moduleGraph.idToModuleMap.get(file)
if (!mod) return
// 2. 递归清除该模块所有依赖方的缓存
moduleGraph.invalidateModule(mod)
// 3. 找出哪些页面可以被热更新
// 4. 通过WS推送更新
hmrServer.sendUpdatedModule(mod)
})
关键点:模块的 transformResult 被清空后,下次浏览器请求时会重新执行transform。但其他没变动的模块的缓存全部命中,完全不耗时。
避坑指南:我实际踩过的坑
这两个月用Vite 4/5过程中踩了几个坑,写出来给你参考。
坑1:路径别名解析顺序
Vite的 resolve.alias 是数组,按顺序匹配,且只在路径以 @ 开头时才会做别名解析。看这个配置:
// vite.config.js
export default defineConfig({
resolve: {
alias: {
'@': '/src',
'@components': '/src/components',
},
},
})
你把 @components 放在 @ 后面,会导致所有 @components/xxx 的路径被 @ 规则截胡,实际解析成 /src/components/xxx,然后路径解析失败。
正确写法:
alias: [
{ find: '@components', replacement: '/src/components' },
{ find: '@', replacement: '/src' },
]
这是自己写的预构建脚本遇到的实际问题,不是Vite自身的bug,是你配置的问题。
坑2:node_modules里有些包不是ESM
有些老包仍然是CommonJS,比如 lodash。Vite预构建时esbuild自动把CJS转成ESM,但有个前提:这个包必须能被esbuild正确解析。常见的坑是包用了 module.exports = {} 但里面引用了Node全局变量(如 process、Buffer)。
报错信息通常长这样:
[vite] Internal server error: Transform failed with 1 error:
/Users/me/project/node_modules/old-pkg/index.js:3:11: ERROR: No matching export for import "default"
解决办法:在 optimizeDeps.exclude 里排除这个包,让浏览器直接用原文件(如果原文件是ESM的话)。如果是CJS,就只能用 optimizeDeps.include 显式指定它,配合 build.commonjsOptions 处理。
optimizeDeps: {
exclude: ['old-pkg'],
}
坑3:pre-bundle之后修改依赖源码,缓存不刷新
你改了 node_modules 里某个包的源码,但预构建缓存还在,更新不生效。Vite 5用的缓存策略是:如果package.json里的 version 字段没变,就认为依赖没变。
强制刷新缓存:
rm -rf node_modules/.vite
或者用 --force 参数:
npx vite --force
坑4:动态import变量路径要小心
const page = 'Home'
import(`./views/${page}.vue`) // 会报错
原因是 es-module-lexer 只能解析静态的的import路径。Vite在转换动态import时遇到变量会抛错。这是设计如此:Vite开发模式下,变量动态import没法在浏览器端按需加载,你必须用 import.meta.glob。
const pages = import.meta.glob('./views/*.vue')
const component = pages[`./views/${page}.vue`]
坑5:Vite 5.0的lib模式不打包CSS
如果你用lib模式(库模式打包组件库),CSS不会自动打包进去。Vite 4一样。需要在 build.lib.cssFileName 里显式配置文件名。这个坑在Vite 5.0.0到5.0.8之间还改过行为,建议升级到5.0.10以上。
Vite的完整链路图
把上面所有部分串起来,核心链路的完整流程如下。
# 启动阶段
$ vite
1. 解析配置(vite.config.ts)
2. 创建HTTP Server(connect中间件栈)
3. 创建WebSocket Server(用于HMR)
4. 预构建依赖(esbuild扫+打包node_modules)
5. 启动文件监听(chokidar监听文件变化)
6. 初始化moduleGraph(空,按需填充)
7. 监听端口,等待请求
# 浏览器访问 http://localhost:5173/
1. 请求HTML → serveStaticMiddleware 返回 index.html
2. 浏览器解析HTML,发现 /src/main.ts 的script标签
3. 请求 /src/main.ts → transformMiddleware
→ resolveId → moduleGraph.getModuleByUrl
→ 读取磁盘文件 → 插件transform钩子
→ 重写import(裸模块→URL)
→ moduleGraph缓存 → 返回JS
4. 浏览器遇到 import '/node_modules/.vite/deps/vue.js'
→ 请求预构建产物 → serveStaticMiddleware 直接返回文件内容
5. 浏览器遇到 import '/src/App.vue'
→ transformMiddleware → transformVueSFC → 返回编译后的JS
6. 完成首屏渲染
# 修改App.vue
1. chokidar监听到文件变化
2. moduleGraph.invalidateModule(App.vue)
3. 通过WS推送更新消息到浏览器
4. 浏览器收到消息,发起 /src/App.vue 的重新请求
5. transformMiddleware → 重新transform(因为缓存已清除)
6. 浏览器执行新模块内容,触发组件重新渲染
看完这条链路,你就能回答面试官:「Vite为什么快?」——因为冷启动时不需要编译源码,源码文件是浏览器请求时才实时转换的。
一个可以跑的完整最小实现
上面说的都是Vite的源码逻辑。如果你想自己动手验证,这里给一个完整的可运行版本,代码不多,但能跑通ESM模块转换,包括Vue SFC的编译(需要 @vue/compiler-sfc)。
// minimal-vite.mjs
// 运行:npm i @vue/compiler-sfc es-module-lexer esbuild ws connect
// node minimal-vite.mjs
import connect from 'connect'
import http from 'http'
import { readFileSync, statSync } from 'fs'
import { extname, resolve, dirname } from 'path'
import { init, parse as parseImports } from 'es-module-lexer'
import { parse as parseSFC, compileTemplate, compileScript } from '@vue/compiler-sfc'
import { build } from 'esbuild'
import { WebSocketServer } from 'ws'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = process.cwd()
const app = connect()
const hmrClients = new Set()
let depsMap = {}
// 1. 预构建依赖(简化:只处理入口文件里能扫到的裸导入)
async function prebundle() {
const entry = readFileSync(resolve(root, 'index.html'), 'utf-8')
const scriptMatch = entry.match(/<script type="module" src="([^"]+)"/)
if (!scriptMatch) return
const entryFile = resolve(root, scriptMatch[1].replace(/^\/+/, ''))
const { imports } = await parseImports(readFileSync(entryFile, 'utf-8'))
const bareImports = imports
.map(i => i.n)
.filter(n => n && !n.startsWith('.') && !n.startsWith('/'))
if (!bareImports.length) return
await build({
entryPoints: bareImports,
bundle: true,
format: 'esm',
outdir: resolve(root, 'node_modules/.vite/deps'),
splitting: true,
external: [/^\/.*/, /^\.\//],
logLevel: 'error',
})
depsMap = Object.fromEntries(
bareImports.map(name => [name, `/node_modules/.vite/deps/${name}.js`])
)
}
// 2. 转换模块
async function transformModule(url) {
const filePath = resolve(root, url.replace(/^\//, ''))
// 处理 .vue 文件
if (extname(filePath) === '.vue') {
const raw = readFileSync(filePath, 'utf-8')
const { descriptor } = parseSFC(raw, { filename: filePath })
// 简化:只编译script和template,style忽略
let scriptCode = descriptor.script ? descriptor.script.content : 'export default {}'
let renderCode = ''
if (descriptor.template) {
const res = compileTemplate({
source: descriptor.template.content,
filename: filePath,
id: 'vue-sfc',
})
renderCode = res.code
}
const finalCode = `
${scriptCode}
${renderCode}
`
return rewriteImports(finalCode, filePath)
}
// 处理 .js/.ts
const raw = readFileSync(filePath, 'utf-8')
return rewriteImports(raw, filePath)
}
// 3. 重写import:裸模块 -> 预构建产物URL
async function rewriteImports(code, importer) {
await init
const [imports] = parseImports(code)
const chunks = []
let lastIndex = 0
for (const imp of imports) {
const spec = code.slice(imp.s, imp.e)
chunks.push(code.slice(lastIndex, imp.s))
if (imp.d > -1) {
// 动态import
chunks.push(`import(${JSON.stringify(resolveImport(spec, importer))})`)
} else {
chunks.push(`"${resolveImport(spec, importer)}"`)
}
lastIndex = imp.e
}
chunks.push(code.slice(lastIndex))
return chunks.join('')
}
function resolveImport(spec, importer) {
if (spec.startsWith('.') || spec.startsWith('/')) {
const resolved = resolve(dirname(importer), spec)
return '/src/' + resolved.replace(root, '').replace(/\\/g, '/')
}
if (depsMap[spec]) return depsMap[spec]
return spec // 如果找不到映射,保留原样
}
// 4. 中间件:处理转换请求
app.use(async (req, res, next) => {
const url = new URL(req.url, 'http://localhost:3000')
const pathname = url.pathname
if (pathname === '/__vite_hmr') {
// WebSocket upgrade在HTTP server里处理,这里占位
return next()
}
// 处理源码文件转换
if (pathname.startsWith('/src/') || pathname.endsWith('.vue')) {
try {
const result = await transformModule(pathname)
res.setHeader('Content-Type', 'text/javascript')
res.setHeader('Cache-Control', 'no-cache')
res.end(result)
return
} catch (e) {
res.statusCode = 500
res.end(e.message)
return
}
}
// 预构建产物直接返回文件
if (pathname.startsWith('/node_modules/.vite/deps/')) {
const filePath = resolve(root, pathname.slice(1))
res.setHeader('Content-Type', 'text/javascript')
res.end(readFileSync(filePath))
return
}
// index.html
if (pathname === '/' || pathname === '/index.html') {
let html = readFileSync(resolve(root, 'index.html'), 'utf-8')
// 注入HMR客户端脚本(简化版)
html = html.replace('</body>', `
<script>
const ws = new WebSocket('ws://localhost:3000/__vite_hmr')
ws.onmessage = (e) => {
const data = JSON.parse(e.data)
if (data.type === 'update') {
location.reload()
}
}
</script>
</body>`)
res.setHeader('Content-Type', 'text/html')
res.end(html)
return
}
next()
})
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/__vite_hmr' })
wss.on('connection', ws => {
hmrClients.add(ws)
ws.on('close', () => hmrClients.delete(ws))
})
// 5. 文件监听(简化版:只监听 /src 下的文件)
import { watch } from 'fs'
const srcDir = resolve(root, 'src')
watch(srcDir, { recursive: true }, (event, filename) => {
warn: `文件变化: ${filename},刷新所有客户端`
for (const client of hmrClients) {
client.send(JSON.stringify({ type: 'update' }))
}
})
await prebundle()
server.listen(3000, () => {
console.log('minimal-vite running at http://localhost:3000')
})
这个实现能跑,但极简。它展示了Vite的核心骨架:HTTP Server + 中间件 + 转换管道 + 预构建 + HMR。但Vite的生产级实现比我这个复杂得多,增加了:
- 完整的依赖预构建(含解析策略、缓存验证)
- 完整的moduleGraph模块图管理(含动态import边界处理)
- 插件化架构(几十个内置插件,支持用户自定义插件)
- 分布式HMR(支持多页面、多框架的热更新协议)
- CSS/JSON/静态资源处理
总结一下核心设计
如果只看一个关键点,就是Vite把「依赖打包」和「源码按需转换」分开了。依赖是启动时一次性预构建好的,源码是浏览器请求时才转换的。一个改动触发更新时,只清除受影响的那几个模块的缓存,其他所有模块都命中缓存。
这就是ESM dev server的本质:不编译整个项目,只编译当前需要的那个文件。别的工具试图优化「编译全部」的速度,Vite直接绕开了这个问题。