《Vite 设计与实现》完整目录
第9章 JavaScript 与 TypeScript 转换
开篇引言
当浏览器请求一个 .ts 或 .tsx 文件时,开发服务器不能直接返回源文件------浏览器不理解 TypeScript 类型注解,也无法处理 JSX 语法。Vite 需要在毫秒级的时间内完成代码转换,同时还要处理 import.meta.glob、import.meta.env 等 Vite 特有的语法扩展,以及重写所有导入路径为浏览器可理解的 URL。
这条从源码到浏览器可执行代码的路径,就是 Vite 的 JavaScript 转换管线。
与传统的 Webpack loader 链不同,Vite 的 JS 转换管线是由多个专职插件组成的流水线。每个插件只负责一种特定的转换,它们通过 Vite 的插件容器按顺序执行。这种设计让每个环节都可以独立优化和替换------最显著的例子就是 Vite 正在从 esbuild 迁移到基于 Oxc 的转换器。
本章将深入 plugins/ 目录中与 JS/TS 转换相关的六个核心插件,揭示代码从 TypeScript 源文件到浏览器可执行模块的完整转换过程。
:::tip 本章要点
- Vite 从 esbuild 到 Oxc 的转换器迁移路径
plugins/oxc.ts中 Oxc 转换插件的完整实现plugins/esbuild.ts的历史角色和向后兼容plugins/importAnalysis.ts如何重写导入路径并注入 HMR 代码plugins/define.ts的编译时变量替换机制plugins/importMetaGlob.ts的 glob 导入展开策略- JSX 处理的跨框架支持设计 :::
JS 转换管线全景
一个 .tsx 文件从磁盘到浏览器,需要经过以下转换阶段:
.ts / .tsx / .jsx"] --> B["vite:oxc
TS/JSX 转换"] B --> C["vite:define
变量替换"] C --> D["vite:import-glob
glob 展开"] D --> E["vite:import-analysis
导入重写 + HMR"] E --> F["浏览器可执行
ES Module"] style A fill:#f0f0f0,stroke:#666 style B fill:#e8f4fd,stroke:#1890ff style C fill:#fff7e6,stroke:#fa8c16 style D fill:#f6ffed,stroke:#52c41a style E fill:#fff1f0,stroke:#f5222d style F fill:#f0f0f0,stroke:#666
这些插件按照 Vite 内部插件排序规则依次执行。每个 transform 钩子接收前一个插件的输出作为输入,形成一条处理链。下面我们逐一深入每个环节。
Oxc 转换插件(plugins/oxc.ts)
Oxc(Oxidation Compiler)是一套用 Rust 编写的高性能 JavaScript 工具链。在 Vite 最新版本中,vite:oxc 已取代 vite:esbuild 成为默认的 TypeScript/JSX 转换器。
transformWithOxc 核心函数
所有 Oxc 转换都通过 transformWithOxc 函数执行,它是对 Rolldown 内置的 transformSync 的封装:
typescript
import { transformSync } from 'rolldown/utils'
export async function transformWithOxc(
code: string,
filename: string,
options?: OxcTransformOptions,
inMap?: object,
config?: ResolvedConfig,
watcher?: FSWatcher,
): Promise<Omit<OxcTransformResult, 'errors'>> {
let lang = options?.lang
if (!lang) {
const ext = path.extname(
validExtensionRE.test(filename) ? filename : cleanUrl(filename)
).slice(1)
if (ext === 'cjs' || ext === 'mjs') {
lang = 'js'
} else if (ext === 'cts' || ext === 'mts') {
lang = 'ts'
} else {
lang = ext as 'js' | 'jsx' | 'ts' | 'tsx'
}
}
const result = transformSync(
filename,
code,
{ sourcemap: true, ...options, lang },
getTSConfigResolutionCache(config),
)
if (result.errors.length > 0) {
// 构建友好的错误信息
throw new Error(summary)
}
return result
}
几个关键设计点:
- 语言自动检测 :根据文件扩展名自动选择转换模式,支持
.ts、.tsx、.jsx、.mts、.cts等 - TSConfig 缓存 :通过
getTSConfigResolutionCache在多次转换之间复用 tsconfig 的解析结果 - 同步执行 :使用
transformSync而非异步版本,因为 Rust 层的转换速度极快,同步调用避免了 Promise 调度开销
oxcPlugin 的两种模式
oxcPlugin 函数根据 config.isBundled 标志选择不同的实现策略:
Rolldown 原生转换
最高性能"] Check -->|否| JS["使用 JS 层插件
手动调用 transformWithOxc
更多控制能力"] Native --> N1[include/exclude 过滤] Native --> N2[JSX Refresh 处理] Native --> N3[sourcemap 配置] JS --> J1[createFilter 过滤] JS --> J2[JSX Refresh 条件判断] JS --> J3[jsxInject 注入] JS --> J4[warning 过滤] style Native fill:#f6ffed,stroke:#52c41a style JS fill:#e8f4fd,stroke:#1890ff
Bundled 模式 (生产构建):直接使用 Rolldown 的原生转换插件 nativeTransformPlugin,它在 Rust 层完成所有转换,避免了 JS/Rust 边界的序列化开销:
typescript
if (config.isBundled) {
return perEnvironmentPlugin('native:transform', (environment) => {
return nativeTransformPlugin({
root: environment.config.root,
include,
exclude,
jsxRefreshInclude,
jsxRefreshExclude,
isServerConsumer: environment.config.consumer === 'server',
jsxInject,
transformOptions,
})
})
}
非 Bundled 模式 (开发服务器):使用 JS 层的 transform 钩子,提供更细粒度的控制:
typescript
return {
name: 'vite:oxc',
async transform(code, id) {
if (filter(id) || filter(cleanUrl(id)) || jsxRefreshFilter?.(id)) {
const modifiedOxcTransformOptions = getModifiedOxcTransformOptions(
oxcTransformOptions, id, code, this.environment,
)
const result = await transformWithOxc(
code, id, modifiedOxcTransformOptions,
undefined, config, server?.watcher,
)
if (jsxInject && jsxExtensionsRE.test(id)) {
result.code = jsxInject + ';' + result.code
}
return {
code: result.code,
map: result.map,
moduleType: 'js',
}
}
},
}
JSX 处理
Oxc 插件的 JSX 处理具有丰富的灵活性。getRollupJsxPresets 函数提供了预设配置:
typescript
export function getRollupJsxPresets(
preset: 'react' | 'react-jsx',
): OxcJsxOptions {
switch (preset) {
case 'react':
return {
runtime: 'classic', // React.createElement
pragma: 'React.createElement',
pragmaFrag: 'React.Fragment',
importSource: 'react',
}
case 'react-jsx':
return {
runtime: 'automatic', // jsx-runtime(React 17+)
pragma: 'React.createElement',
importSource: 'react',
}
}
}
JSX Refresh(React Fast Refresh)的启用条件经过精心设计,遵循 @vitejs/plugin-react 的相同逻辑:
typescript
const getModifiedOxcTransformOptions = (
oxcTransformOptions, id, code, environment
) => {
const [filepath] = id.split('?')
const isJSX = filepath.endsWith('x')
// 在以下情况禁用 JSX Refresh:
// 1. 服务端环境
// 2. 被 jsxRefreshFilter 排除
// 3. 非 JSX 文件且不包含 jsx-runtime 导入
if (
jsxOptions.refresh &&
(environment.config.consumer === 'server' ||
(jsxRefreshFilter && !jsxRefreshFilter(id)) ||
!(isJSX || code.includes(jsxImportRuntime) || ...))
) {
result.jsx = { ...jsxOptions, refresh: false }
}
return result
}
从 esbuild 迁移的兼容层
对于仍然使用 config.esbuild 配置的项目,convertEsbuildConfigToOxcConfig 函数提供了自动转换:
typescript
export function convertEsbuildConfigToOxcConfig(
esbuildConfig: ESBuildOptions,
logger: Logger,
): OxcOptions {
const oxcOptions: OxcOptions = {
jsxInject: esbuildConfig.jsxInject,
include: esbuildConfig.include,
exclude: esbuildConfig.exclude,
}
// 将 esbuild 的 jsx 选项映射到 Oxc 格式
switch (esbuildTransformOptions.jsx) {
case 'automatic':
jsxOptions.runtime = 'automatic'
break
case 'transform':
jsxOptions.runtime = 'classic'
break
}
// 映射 define、jsxDev 等其他选项
// ...
return oxcOptions
}
esbuild 插件的历史与现状(plugins/esbuild.ts)
vite:esbuild 曾经是 Vite 的默认 JS/TS 转换器。在当前版本中,它已被标记为 deprecated,但仍保留了完整的实现以支持向后兼容。
transformWithEsbuild
typescript
export async function transformWithEsbuild(
code: string,
filename: string,
options?: EsbuildTransformOptions,
inMap?: object,
config?: ResolvedConfig,
watcher?: FSWatcher,
): Promise<ESBuildTransformResult> {
// 现在需要单独安装 esbuild
let transform: typeof import('esbuild').transform
try {
transform = (await importEsbuild()).transform
} catch (e) {
throw new Error(
'Failed to load `transformWithEsbuild`. ' +
'It is deprecated and requires esbuild to be installed separately.',
)
}
// ...
}
注意 esbuild 现在是通过动态 import() 延迟加载的,不再是 Vite 的内置依赖。这使得不使用 esbuild 的项目可以完全避免其安装开销。
TSConfig 处理
esbuild 的 TSConfig 处理使用了 Rolldown 提供的 resolveTsconfig 函数(而非 esbuild 自己的):
typescript
if (loader === 'ts' || loader === 'tsx') {
const result = resolveTsconfig(
filename,
getTSConfigResolutionCache(config),
)
if (result) {
const { tsconfig, tsconfigFilePaths } = result
// 监视 tsconfig 文件变化
if (watcher && config) {
for (const tsconfigFile of tsconfigFilePaths) {
ensureWatchedFile(watcher, tsconfigFile, config.root)
}
}
// 提取影响编译的字段
const meaningfulFields = [
'alwaysStrict', 'experimentalDecorators',
'jsx', 'jsxFactory', 'jsxFragmentFactory', 'jsxImportSource',
'target', 'useDefineForClassFields', 'verbatimModuleSyntax',
]
// ...
}
}
一个值得注意的默认值处理:当 useDefineForClassFields 和 target 都未指定时,Vite 将 useDefineForClassFields 设为 false,与 TypeScript 的默认行为保持一致,而非跟随 esbuild 的 true 默认值。
构建时的 esbuild 转译
buildEsbuildPlugin 仍然在生产构建中用于最终的代码转译和压缩:
typescript
export const buildEsbuildPlugin = (): Plugin => {
return {
name: 'vite:esbuild-transpile',
async renderChunk(code, chunk, opts) {
const options = resolveEsbuildTranspileOptions(config, opts.format)
if (!options) return null
const res = await transformWithEsbuild(code, chunk.fileName, options)
// 对于库构建,需要将 esbuild 的 helper 代码注入到正确位置
if (config.build.lib) {
res.code = injectEsbuildHelpers(res.code, opts.format)
}
return res
},
}
}
injectEsbuildHelpers 函数解决了一个特定的问题:esbuild 会将 helper 函数放在文件顶部,但对于 IIFE 和 UMD 格式,这些 helper 需要在包装函数内部才能正确工作。
导入分析(plugins/importAnalysis.ts)
vite:import-analysis 是开发模式下最重要的转换插件之一。它负责重写所有 import 语句的路径,使浏览器能够正确加载模块。
核心职责
react -> /@fs/.../react"] B --> D["相对路径规范化
./foo -> /src/foo.ts"] B --> E["添加查询参数
?v=hash / ?import"] A --> F[HMR 处理] F --> G["解析 import.meta.hot.accept"] F --> H["注入 HMR boundary"] F --> I["记录 accepted 模块"] A --> J[其他转换] J --> K["import.meta.env 注入"] J --> L["import.meta.url 处理"] J --> M["预构建依赖 interop"] end
导入路径重写
normalizeResolvedIdToUrl 函数是路径重写的核心:
typescript
function normalizeResolvedIdToUrl(
environment: DevEnvironment,
url: string,
resolved: PartialResolvedId,
): string {
const root = environment.config.root
const depsOptimizer = environment.depsOptimizer
if (resolved.id.startsWith(withTrailingSlash(root))) {
// 在项目根目录内:使用根目录相对路径
// /Users/xxx/project/src/App.tsx -> /src/App.tsx
url = resolved.id.slice(root.length)
} else if (
depsOptimizer?.isOptimizedDepFile(resolved.id) ||
path.isAbsolute(resolved.id) && fs.existsSync(cleanUrl(resolved.id))
) {
// 预构建依赖或根目录外的文件:使用 /@fs/ 前缀
// /Users/xxx/project/node_modules/.vite/deps/react.js
// -> /@fs/Users/xxx/project/node_modules/.vite/deps/react.js
url = path.posix.join(FS_PREFIX, resolved.id)
} else {
url = resolved.id
}
// 确保 URL 是合法的浏览器导入路径
if (url[0] !== '.' && url[0] !== '/') {
url = wrapId(resolved.id)
}
return url
}
预构建依赖的 interop 处理
当导入一个需要 interop 的 CommonJS 依赖时,importAnalysis 会注入额外的封装代码。例如 import React from 'react' 的处理流程:
跳过逻辑
并非所有文件都需要导入分析。canSkipImportAnalysis 函数提供了快速跳过:
typescript
const skipRE = /\.(?:map|json)(?:$|\?)/
export const canSkipImportAnalysis = (id: string): boolean =>
skipRE.test(id) || isDirectCSSRequest(id)
.map 文件、.json 文件和直接 CSS 请求(?direct)不包含 import 语句,可以安全跳过。
define 变量替换(plugins/define.ts)
vite:define 插件负责将编译时常量(如 process.env.NODE_ENV、import.meta.env)替换为实际值。
替换模式的双引擎设计
设置 Rolldown transform.define"] Mode -->|否| Dev["transform 钩子
使用 Oxc replaceDefine"] Bundled --> BD1["Rolldown 原生处理 define
零额外开销"] Dev --> DD1["先用正则快速检测"] DD1 --> DD2{匹配到关键字?} DD2 -->|否| DD3[跳过此文件] DD2 -->|是| DD4["调用 transformSync
执行 AST 级别替换"] style Bundled fill:#f6ffed,stroke:#52c41a style Dev fill:#e8f4fd,stroke:#1890ff
在生产构建模式下,define 替换被委托给 Rolldown 的原生 transform 能力:
typescript
if (isBundled) {
return {
name: 'vite:define',
options(option) {
const [define] = getPattern(this.environment)
define['import.meta.env'] = importMetaEnvVal
define['import.meta.env.*'] = 'undefined'
option.transform ??= {}
option.transform.define = { ...option.transform.define, ...define }
},
}
}
在开发模式下,只有 SSR 环境会执行 define 替换------客户端的 import.meta.env 和 process.env 由 importAnalysis 和 clientInjection 插件在浏览器端处理:
typescript
transform: {
async handler(code, id) {
if (this.environment.config.consumer === 'client') {
// 客户端在 importAnalysis 中处理,此处跳过
return
}
// SSR 环境执行实际替换
const [define, pattern] = getPattern(this.environment)
if (!pattern) return
pattern.lastIndex = 0
if (!pattern.test(code)) return // 正则快速跳过
return await replaceDefine(this.environment, code, id, define)
},
},
replaceDefine 的实现
实际的替换使用 Oxc 的 transformSync,而非简单的字符串替换。这确保了 AST 级别的正确性------例如不会替换字符串内部或注释中的匹配:
typescript
export async function replaceDefine(
environment: Environment,
code: string,
id: string,
define: Record<string, string>,
) {
const result = transformSync(id, code, {
lang: 'js',
sourceType: 'module',
define,
sourcemap: environment.config.command === 'build'
? !!environment.config.build.sourcemap
: true,
tsconfig: false, // 不需要 tsconfig,纯粹做 define 替换
})
return { code: result.code, map: result.map || null }
}
环境差异化
define 插件的一个精妙之处在于 import.meta.env.SSR 的处理。同一个项目可能同时运行客户端和服务端环境,它们的 SSR 值应该不同:
typescript
function generatePattern(environment: Environment) {
const ssr = environment.config.consumer === 'server'
if ('import.meta.env.SSR' in define) {
define['import.meta.env.SSR'] = ssr + ''
}
const importMetaEnvVal = serializeDefine({
...importMetaEnvKeys,
SSR: ssr + '',
...userDefineEnv,
})
// ...
}
serializeDefine 函数将 define 对象序列化为 JavaScript 对象字面量,保持原始值的语义:
typescript
export function serializeDefine(define: Record<string, any>): string {
let res = `{`
const keys = Object.keys(define).sort()
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const val = define[key]
res += `${JSON.stringify(key)}: ${handleDefineValue(val)}`
if (i !== keys.length - 1) res += `, `
}
return res + `}`
}
import.meta.glob 展开(plugins/importMetaGlob.ts)
import.meta.glob 是 Vite 的标志性特性之一,它允许在编译时通过 glob 模式批量导入模块。
基本机制
typescript
// 源代码
const modules = import.meta.glob('./pages/*.vue')
// 转换后
const modules = {
'./pages/Home.vue': () => import('./pages/Home.vue'),
'./pages/About.vue': () => import('./pages/About.vue'),
'./pages/Contact.vue': () => import('./pages/Contact.vue'),
}
双引擎实现
与 define 插件类似,glob 导入也有两种实现路径:
typescript
export function importGlobPlugin(config: ResolvedConfig): Plugin {
if (config.isBundled) {
// 使用 Rolldown 原生插件
return nativeImportGlobPlugin({
root: config.root,
sourcemap: !!config.build.sourcemap,
restoreQueryExtension:
config.experimental.importGlobRestoreExtension,
})
}
// 开发模式:使用 JS 层的 transformGlobImport
return {
name: 'vite:import-glob',
transform: {
filter: { code: 'import.meta.glob' },
async handler(code, id) {
const result = await transformGlobImport(
code, id, config.root,
(im, _, options) =>
this.resolve(im, id, options).then((i) => i?.id || im),
)
if (result) {
// 记录 glob 模式用于 HMR 文件监听
// ...
return transformStableResult(result.s, id, config)
}
},
},
}
}
注意 filter: { code: 'import.meta.glob' } 这个优化------只有包含 import.meta.glob 字面量的文件才会进入 transform 逻辑。Rolldown 在 Rust 层执行这个字符串匹配,避免了不必要的 JS 函数调用。
HMR 集成
glob 导入需要与 HMR 系统集成。当匹配 glob 模式的新文件被创建或删除时,需要触发使用该 glob 的模块重新转换:
typescript
// 记录每个模块使用的 glob 匹配器
const importGlobMaps = new Map<
Environment,
Map<string, Array<(file: string) => boolean>>
>()
// handleHotUpdate 中检查文件变更是否匹配某个 glob 模式
handleHotUpdate({ file, modules, server }) {
const importGlobMap = importGlobMaps.get(this.environment)
if (importGlobMap) {
for (const [id, globMatchers] of importGlobMap) {
if (globMatchers.some((matcher) => matcher(file))) {
// 触发使用此 glob 的模块重新加载
const mod = server.moduleGraph.getModuleById(id)
if (mod) modules.push(mod)
}
}
}
}
转换管线的协调
插件执行顺序
TS/JSX -> JS"] --> P2["vite:define
变量替换"] P2 --> P3["vite:import-glob
glob 展开"] P3 --> P4["用户插件 (enforce: undefined)"] P4 --> P5["vite:import-analysis
导入重写"] end subgraph "moduleType 标记" MT["每个插件返回 moduleType: 'js'
确保后续插件知道文件类型"] end P1 -.-> MT
vite:oxc(或 vite:esbuild)必须第一个执行,因为后续插件期望处理的是标准 JavaScript,而非 TypeScript 或 JSX。vite:import-analysis 必须最后执行,因为它需要看到所有其他转换完成后的最终导入路径。
moduleType 的重要性
每个转换插件在返回结果时都会设置 moduleType: 'js':
typescript
return {
code: result.code,
map: result.map,
moduleType: 'js', // 告诉后续处理器这是 JS
}
这个标记在 Rolldown 中用于确定文件的解析方式。一个 .tsx 文件在经过 Oxc 转换后变成了纯 JS,moduleType: 'js' 确保后续阶段不会再尝试解析 TypeScript 语法。
性能优化策略
转换管线的性能至关重要------每一个模块请求都要经过完整的管线。Vite 采用了多层优化:
-
正则预检 :在调用昂贵的 AST 转换前,先用正则表达式检查文件是否包含需要处理的模式。
define插件的pattern.test(code)和importMetaGlob的filter: { code: 'import.meta.glob' }都是这种策略 -
Filter 短路 :
oxcPlugin和esbuildPlugin使用createFilter创建高效的 include/exclude 过滤器,默认排除.js文件(它们不需要 TS/JSX 转换) -
同步转换 :Oxc 的
transformSync避免了异步开销。在开发服务器的请求-响应模型中,同步转换实际上比异步更高效,因为请求处理本身就是串行的 -
Rust 层过滤 :Rolldown 支持在 Rust 层进行文件过滤(
filter: { id: /regex/, code: 'string' }),避免了将不需要的文件传递到 JS 层
TSConfig 变更检测
reloadOnTsconfigChange 函数监听 tsconfig.json 的变更,触发完整的模块图失效和页面重载:
typescript
export async function reloadOnTsconfigChange(
server: ViteDevServer,
changedFile: string,
): Promise<void> {
if (changedFile.endsWith('/tsconfig.json')) {
server.config.logger.info(
`changed tsconfig file detected: ${changedFile}`,
)
// 清除 tsconfig 解析缓存
const cache = getTSConfigResolutionCache(server.config)
cache.clear()
// 失效所有模块(tsconfig 可能影响任何 TS 文件的编译)
for (const environment of Object.values(server.environments)) {
environment.moduleGraph.invalidateAll()
}
// 触发所有环境的全页重载
for (const environment of Object.values(server.environments)) {
environment.hot.send({ type: 'full-reload', path: '*' })
}
}
}
这是一个"核弹级"的处理------tsconfig 变更会导致所有模块重新编译和全页重载。这看起来很重,但在实践中 tsconfig 极少变更,而且要精确追踪哪些模块受影响几乎不可能(因为 tsconfig 的 paths、target 等选项可以改变任何文件的编译结果)。
设计决策
为什么从 esbuild 迁移到 Oxc
esbuild 作为 Vite 的转换引擎服务了多年,但存在几个问题:
- Go 语言生态隔离:esbuild 用 Go 编写,与 Vite 其他 Rust 组件(Rolldown、SWC)无法共享内存和数据结构
- 维护独立性:esbuild 有自己的发展路线图,不一定与 Vite 的需求完全一致
- Rolldown 集成 :Oxc 内置在 Rolldown 中,使用 Rolldown 的
transformSync意味着零额外依赖
迁移过程通过 convertEsbuildConfigToOxcConfig 实现了平滑过渡,用户无需立即修改配置。
为什么 importAnalysis 只在开发模式运行
生产构建时,Rolldown 自身会处理模块解析和导入重写,不需要 importAnalysis 插件。但开发模式下浏览器直接请求单个模块,需要 Vite 在响应中将相对路径和裸导入转换为浏览器可理解的 URL。这是开发服务器"按需服务"模型的核心环节。
为什么 define 客户端不做替换
客户端的 import.meta.env 不在 define 插件中替换,而是在 importAnalysis 中注入到 @vite/client 模块。这样所有客户端模块共享同一个 import.meta.env 对象实例,而非每个文件都内联一份。这减少了代码体积并确保了一致性。
小结
Vite 的 JavaScript 转换管线是一个精心编排的插件协奏曲。从 Oxc 的类型擦除和 JSX 转换,到 define 的编译时常量替换,到 glob 导入的展开,最后到 importAnalysis 的路径重写和 HMR 注入------每个插件各司其职,通过清晰的接口传递数据。
从 esbuild 到 Oxc 的迁移展示了 Vite 架构的灵活性:核心的转换逻辑被封装在独立的函数(transformWithOxc、transformWithEsbuild)中,插件层只是调度器。当底层引擎更换时,上层的插件接口保持不变。
isBundled 标志贯穿多个插件,实现了开发和构建模式的差异化处理。开发模式追求最快的单文件转换速度,构建模式则将更多工作委托给 Rolldown 的原生能力以获得最优的整体性能。
下一章我们将进入 Vite 代码库中最庞大的文件------CSS 处理引擎。