前言
Vite的快,我们并不陌生,主要体现在开发环境时的体验。
而相较于其他构建工具,Vite核心是依靠了现代浏览器对于原生esm模块的支持+按需实时编译将性能达到了极致。
我们基于源码来看看esbuild编译的完整过程。
核心流程图
bash
Browser Request
↓
Vite DevServer (Connect 中间件)
↓
请求路由判断
├─ /.vite/client → 注入客户端代码
├─ /@modules/* → node_modules 导入
├─ /src/* → 源代码文件
└─ *.json, *.css 等 → 特殊处理
↓
ModuleGraph 缓存检查
├─ 命中缓存 → 返回
└─ 未命中 → esbuild 编译
↓
TransformPlugin 流程
├─ pre plugins
├─ esbuild transform
└─ post plugins
↓
发送给浏览器 (ES Modules)
DevServer入口代码
这里初始化了开发服务器、模块图(缓存系统)、很多中间件(用于拦截实时编译)。
ts
// packages/vite/src/node/server/index.ts
import connect from 'connect'
import { createPluginContainer } from './pluginContainer'
export async function createServer(inlineConfig: InlineConfig = {}) {
const config = await resolveConfig(inlineConfig, 'serve')
// 创建 Express-like 应用
const middlewares = connect()
const httpServer = createHttpServer(middlewares)
// 创建模块图(缓存系统)
const moduleGraph = new ModuleGraph((url) =>
pluginContainer.resolveId(url)
)
// 创建插件容器(执行插件)
const pluginContainer = await createPluginContainer(config)
// 核心中间件们
middlewares.use(timeMiddleware)
middlewares.use(cors)
middlewares.use(transformMiddleware(server)) // ⭐ 重点
middlewares.use(servePublicDir)
middlewares.use(serveRawFs)
const server = {
middlewares,
httpServer,
moduleGraph,
pluginContainer,
ws: createWebSocketServer(httpServer),
// ... 其他属性
}
return server
}
Transform中间件(请求拦截)
这里是一个很经典的例子,从浏览器发起第一次main.ts请求开始,Vite做了ts文件的转换。
而后续的请求会从main.ts中发起。
ts
// packages/vite/src/node/server/middlewares/transform.ts
export function transformMiddleware(server: ViteDevServer) {
return async (req: IncomingMessage, res: ServerResponse, next: NextFunction) => {
if (req.method !== 'GET' || isSkipped(req.url)) {
return next()
}
let url = req.url
const { pathname, search, hash } = new URL(url, `http://${req.headers.host}`)
// 示例:/src/main.ts?t=123 → /src/main.ts
url = pathname + search + hash
try {
// ⭐ 核心:调用加载和转换
const result = await transformRequest(url, server, {
raw: req.headers['accept']?.includes('application/octet-stream'),
})
if (result) {
const type = isDirectCSSRequest(url) ? 'text/css' : 'application/javascript'
res.setHeader('Content-Type', type)
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('ETag', getEtag(result.code))
return res.end(result.code)
}
} catch (e) {
// 错误处理
if (e.code === 'ENOENT') {
return next()
}
// HMR 错误通知浏览器
server.ws.send({
type: 'error',
event: 'vite:error',
err: e,
})
}
next()
}
}
请求转换核心逻辑
这里是核心的源码转换逻辑,基于源码优先从模块缓存表中取,如果没有才走该模块的首次转换,最后会落到缓存中。
ts
// packages/vite/src/node/server/transformRequest.ts
export async function transformRequest(
url: string,
server: ViteDevServer,
options?: TransformOptions,
) {
// 1️⃣ 获取文件内容 + 元数据
const { code: raw, map } = await loadRawRequest(url, server)
let code = raw
const inMap = map
// 2️⃣ 检查缓存
const cached = server.moduleGraph.getModuleByUrl(url)
if (!server.config.command === 'serve' && cached?.transformedCode) {
return {
code: cached.transformedCode,
map: cached.map,
}
}
// 3️⃣ 执行插件转换
const result = await pluginContainer.transform(code, url)
if (result) {
code = result.code
}
// 4️⃣ 特殊处理:自动导入注入
if (!options?.raw) {
code = injectHelper(code, url)
}
// 5️⃣ 缓存结果
server.moduleGraph.updateModuleInfo(url, {
transformedCode: code,
map: result?.map,
})
return { code, map: result?.map }
}
加载原始请求(磁盘读写)
而加载和编译源码则是直接通过esbuild能力来实现。
ts
// packages/vite/src/node/server/transformRequest.ts
async function loadRawRequest(url: string, server: ViteDevServer) {
let id = decodeURIComponent(parseUrl(url).pathname)
// ⭐ 调用插件的 resolveId hook
const resolveResult = await server.pluginContainer.resolveId(id)
if (resolveResult?.id) {
id = resolveResult.id
}
// 从文件系统读取
let code = await fs.promises.readFile(id, 'utf-8')
let map: SourceMap | null = null
// 如果是 TypeScript,用 esbuild 转译
if (id.endsWith('.ts') || id.endsWith('.tsx')) {
const result = await esbuildService.transform(code, {
loader: 'ts',
target: 'esnext',
sourcemap: true,
})
code = result.code
map = result.map
}
return { code, map }
}
因此一次完整的编译流程如下:
ruby
// 实际请求处理过程
// 浏览器请求:GET /src/main.ts
// ↓
// transformMiddleware 拦截
// ↓
// transformRequest('/src/main.ts', server)
// ↓
// loadRawRequest: 从磁盘读取 main.ts
// ├─ 如果是 .ts,用 esbuild 转译为 .js
// └─ 返回 { code, map }
// ↓
// pluginContainer.transform(code, '/src/main.ts')
// ├─ vue plugin: .vue 转换为 { script, template, style }
// ├─ css-in-js plugin: 处理 styled-components 等
// ├─ import-analysis plugin: 分析依赖,重写为 /@modules/xxx
// └─ ...其他插件
// ↓
// 返回转换后的代码给浏览器
// ↓
// 浏览器 import './main.ts'
// → 收到 ESM 代码,正常执行
依赖解析重写
而Vite如果这样设计,会面临一个问题:请求的数量特别大,导致浏览器首屏时间反而更久。
Vite做了一层设计,将多个模块合并到一个模块,即依赖解析重写,如vue -> @modules/vue?v=xxx
ts
// packages/vite/src/node/plugins/importAnalysis.ts
export function importAnalysisPlugin(): Plugin {
return {
name: 'vite:import-analysis',
async transform(code: string, id: string) {
// 匹配 import/export 语句
const imports = parse(code) // 用 es-module-lexer 解析
let s = new MagicString(code)
for (const imp of imports) {
// 例如:import { ref } from 'vue'
const source = imp.source
if (isRelative(source)) {
// 相对路径,保持不变
// import Foo from './foo.ts'
} else if (isBuiltin(source)) {
// Node 内置模块,忽略
} else {
// ⭐ NPM 包,重写为 /@modules/xxx
// import { ref } from 'vue'
// ↓
// import { ref } from '/@modules/vue?v=xxx'
const resolved = await resolveImport(source)
const rewritten = `/@modules/${resolved.id}`
s.overwrite(imp.startPos, imp.endPos,
`import {...} from '${rewritten}'`
)
}
}
return {
code: s.toString(),
map: s.generateMap(),
}
}
}
}
处理node_modules三方库请求
既然将三方库依赖路径重写,那处理对应的请求也需要进行一次路径转换。
ts
// 当浏览器请求 /@modules/vue?v=xxx 时
middlewares.use('/@modules/', async (req, res, next) => {
const moduleName = req.url.split('/')[2]?.split('?')[0]
// /@modules/vue → node_modules/vue/dist/vue.esm.js
const modulePath = require.resolve(moduleName, {
paths: [config.root],
})
const code = await fs.promises.readFile(modulePath, 'utf-8')
// 继续执行 transform 中间件处理
// 确保 node_modules 中的代码也被正确处理
res.end(code)
})
HMR热更新
那按照这样的设计,所有模块只要经过一次编译,就会保存在模块缓存表中,热更新如何处理呢?
Vite做的也比较通俗易懂,当文件系统监听到文件变化,则清除该模块相关缓存信息,然后websocket通知浏览器,Vite client runtime会重新发起相关改动模块的请求。
ts
// packages/vite/src/node/server/hmr.ts
// 当文件变更时
watcher.on('change', async (file) => {
const url = urlFromFile(file, config.root)
// 1️⃣ 清除模块缓存
server.moduleGraph.invalidateModule(url)
// 2️⃣ 收集受影响的模块
const affectedModules = server.moduleGraph.getImporters(url)
// 3️⃣ 通过 WebSocket 通知浏览器
server.ws.send({
type: 'update',
event: 'vite:beforeUpdate',
updates: affectedModules.map(m => ({
type: m.isSelfAccepting ? 'js-update' : 'full-reload',
event: 'vite:beforeUpdate',
path: m.url,
acceptedPath: url,
timestamp: Date.now(),
}))
})
})
HMR客户端脚本注入
这就是客户端热更新的核心代码。
ts
// packages/vite/src/client/client.ts
// 注入到每个 HTML 的脚本
const hotModule = import.meta.hot
if (hotModule) {
hotModule.accept(({ default: newModule }) => {
// 接收模块更新
// 执行自定义 HMR 逻辑或完整重载
})
// 监听服务器推送
hotModule.on('vite:beforeUpdate', async (event) => {
if (event.type === 'js-update') {
// 动态 import 新版本模块
await import(event.path + `?t=${event.timestamp}`)
} else {
// 完整页面刷新
window.location.reload()
}
})
}
因此热更新的流程总结如下:
scss
用户编辑文件保存
↓
文件系统监听器检测变化
↓
清除 ModuleGraph 缓存
↓
WebSocket 通知浏览器
↓
浏览器发起新请求(带时间戳)
↓
transformMiddleware 拦截
↓
loadRawRequest (esbuild 编译 TS/JSX)
↓
pluginContainer.transform (执行插件 Vue/CSS 等)
↓
返回最新的 ESM 代码
↓
浏览器执行 HMR 回调更新页面
结尾
这就是Vite开发环境的核心机制!按需编译+缓存+HMR推送,相比于Webpack,少了最早的整个bundle的构建,自然而然会快非常多,因为Vite在初始化根本就没有build的过程,甚至连main.ts入口文件都是实时编译的。