Vite 设计与实现

第9章 JavaScript 与 TypeScript 转换

作者 杨艺韬 · 11,481 字

第9章 JavaScript 与 TypeScript 转换

开篇引言

当浏览器请求一个 .ts.tsx 文件时,开发服务器不能直接返回源文件——浏览器不理解 TypeScript 类型注解,也无法处理 JSX 语法。Vite 需要在毫秒级的时间内完成代码转换,同时还要处理 import.meta.globimport.meta.env 等 Vite 特有的语法扩展,以及重写所有导入路径为浏览器可理解的 URL。

这条从源码到浏览器可执行代码的路径,就是 Vite 的 JavaScript 转换管线。

与传统的 Webpack loader 链不同,Vite 的 JS 转换管线是由多个专职插件组成的流水线。每个插件只负责一种特定的转换,它们通过 Vite 的插件容器按顺序执行。这种设计让每个环节都可以独立优化和替换——最显著的例子就是 Vite 正在从 esbuild 迁移到基于 Oxc 的转换器。

本章将深入 plugins/ 目录中与 JS/TS 转换相关的六个核心插件,揭示代码从 TypeScript 源文件到浏览器可执行模块的完整转换过程。

本章要点

  • Vite 从 esbuild 到 Oxc 的转换器迁移路径
  • plugins/oxc.ts 中 Oxc 转换插件的完整实现
  • plugins/esbuild.ts 的历史角色和向后兼容
  • plugins/importAnalysis.ts 如何重写导入路径并注入 HMR 代码
  • plugins/define.ts 的编译时变量替换机制
  • plugins/importMetaGlob.ts 的 glob 导入展开策略
  • JSX 处理的跨框架支持设计

JS 转换管线全景

一个 .tsx 文件从磁盘到浏览器,需要经过以下转换阶段:

flowchart LR
    A["源文件<br/>.ts / .tsx / .jsx"] --> B["vite:oxc<br/>TS/JSX 转换"]
    B --> C["vite:define<br/>变量替换"]
    C --> D["vite:import-glob<br/>glob 展开"]
    D --> E["vite:import-analysis<br/>导入重写 + HMR"]
    E --> F["浏览器可执行<br/>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 的封装:

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
}

几个关键设计点:

  1. 语言自动检测:根据文件扩展名自动选择转换模式,支持 .ts.tsx.jsx.mts.cts
  2. TSConfig 缓存:通过 getTSConfigResolutionCache 在多次转换之间复用 tsconfig 的解析结果
  3. 同步执行:使用 transformSync 而非异步版本,因为 Rust 层的转换速度极快,同步调用避免了 Promise 调度开销

oxcPlugin 的两种模式

oxcPlugin 函数根据 config.isBundled 标志选择不同的实现策略:

flowchart TD
    Start[oxcPlugin] --> Check{config.isBundled?}
    Check -->|是| Native["使用 nativeTransformPlugin<br/>Rolldown 原生转换<br/>最高性能"]
    Check -->|否| JS["使用 JS 层插件<br/>手动调用 transformWithOxc<br/>更多控制能力"]

    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 边界的序列化开销:

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 钩子,提供更细粒度的控制:

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 函数提供了预设配置:

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 的相同逻辑:

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 函数提供了自动转换:

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
}

Oxc 转换的分类处理:ext → lang 的兜底映射

打开 oxc.ts:75-88(本章前面给了核心 transformWithOxc 的片段),一个常被忽略的细节是扩展名到 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'
}

三条工程细节:

1、validExtensionRE.test(filename) ? filename : cleanUrl(filename)——这个三元运算解决一个具体问题:文件名可能带 ? query 参数(如 foo.ts?worker&inline)。如果文件名本身的扩展名是合法(比如 foo.ts?xxx.ts?xxx 不是合法扩展名需要清洗),就走 cleanUrl 剥离 query;否则直接取完整名。这条 boolean 把两种情况统一到一个代码路径上——比分别写两条 if-else 少一半代码。

2、cjs/mjs → js / cts/mts → ts——这两组扩展名是 Node ESM 引入的模块模式标识(Node 根据扩展名决定 require vs import 语义),但对 Oxc 编译器而言,它们和普通 .js/.ts 的编译规则完全一致——模块模式不影响 type erase 或 JSX 转换。所以 Vite 把这四种映射到两种,让 Oxc 只需支持 4 种核心 lang(js/jsx/ts/tsx)。

3、直接 ext as 'js' | 'jsx' | 'ts' | 'tsx' 的类型断言——没有 runtime 验证。如果用户尝试转换 .php 或其他非 JS 文件,Oxc 会在内部抛一个不友好的错误。这是 Vite 对”非法输入的静默假设”——认为所有走到这里的文件都已经是 JS 家族,校验责任在前置的 resolve 阶段。

Oxc 的 TSConfig 解析缓存——getTSConfigResolutionCache

transformWithOxc 接收一个 config?: ResolvedConfig 参数,传入 getTSConfigResolutionCache(config)。这个缓存机制是 Oxc 转换能达到微秒级的重要原因——同一个 tsconfig 文件不重复 parse

tsconfig 的解析不是简单的 JSON.parse:

  • 要处理 extends 字段(继承父 tsconfig)
  • 要展开 paths 别名映射
  • 要合并 compilerOptions 的各个字段
  • 要追踪所有参与合并的文件路径(用于文件监听)

如果每次转换都做一次,10 个文件的编译就是 10 次 tsconfig 读-parse-合并——毫秒级开销累加起来很可观。getTSConfigResolutionCache 用一个 Map 存 “tsconfig 文件路径 → 解析结果”,后续请求命中缓存直接返回。

这条缓存配合下面 §9.9.3 讲的 “tsconfig 变更触发 full-reload” 机制——缓存 + 失效组成一对完整的性能-正确性权衡:平时享受缓存收益、tsconfig 真变更时果断 invalidate 全部缓存 + 重建所有模块。缓存不是万能药,没有配套的失效机制就是 bug 的温床

开发模式的 moduleType 返回值语义

开发模式下 oxcPlugin 的 transform 钩子返回:

return {
  code: result.code,
  map: result.map,
  moduleType: 'js',
}

moduleType: 'js' 告诉 Vite 后续插件:“这个文件已经转换为 JS 代码了、当成 JS 处理”。这个字段是 Rolldown 插件 API 的扩展——它让一个文件能在链上改变自己的宣称类型,而不仅仅是内容。

为什么这很重要?考虑 .tsx 文件:

  • 源文件的 module type 是 tsx
  • oxcPlugin 转换后变成 plain JS,没有 JSX 语法
  • 下一个插件(importAnalysis)如果按 .tsx 处理,可能再次触发 JSX 相关逻辑——重复转换浪费 CPU

通过 moduleType: 'js' 显式降级,后续插件按 JS 处理——避免重复工作。这是 Rolldown 的一个比 Rollup 更精细的 API 点——Rollup 里没有类似字段,插件间要通过文件名后缀猜类型,容易错。

esbuild 插件的历史与现状(plugins/esbuild.ts)

vite:esbuild 曾经是 Vite 的默认 JS/TS 转换器。在当前版本中,它已被标记为 deprecated,但仍保留了完整的实现以支持向后兼容。

transformWithEsbuild

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 自己的):

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',
    ]
    // ...
  }
}

一个值得注意的默认值处理:当 useDefineForClassFieldstarget 都未指定时,Vite 将 useDefineForClassFields 设为 false,与 TypeScript 的默认行为保持一致,而非跟随 esbuild 的 true 默认值。

构建时的 esbuild 转译

buildEsbuildPlugin 仍然在生产构建中用于最终的代码转译和压缩:

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 语句的路径,使浏览器能够正确加载模块。

核心职责

flowchart TB
    subgraph "importAnalysis 的职责"
        A[解析 import 语句] --> B[路径解析与重写]
        B --> C["裸导入重写<br/>react -> /@fs/.../react"]
        B --> D["相对路径规范化<br/>./foo -> /src/foo.ts"]
        B --> E["添加查询参数<br/>?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 函数是路径重写的核心:

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' 的处理流程:

sequenceDiagram
    participant Browser as 浏览器
    participant IA as importAnalysis
    participant DOpt as DepsOptimizer

    Browser->>IA: 请求含 import React from "react"
    IA->>DOpt: optimizedDepInfoFromFile(resolvedId)
    DOpt-->>IA: depInfo { needsInterop: true }
    IA->>IA: 重写路径为 /@fs/.../react.js?v=hash
    IA->>IA: 注入 __vite__cjsImport 封装
    IA-->>Browser: 返回转换后的代码

跳过逻辑

并非所有文件都需要导入分析。canSkipImportAnalysis 函数提供了快速跳过:

const skipRE = /\.(?:map|json)(?:$|\?)/
export const canSkipImportAnalysis = (id: string): boolean =>
  skipRE.test(id) || isDirectCSSRequest(id)

.map 文件、.json 文件和直接 CSS 请求(?direct)不包含 import 语句,可以安全跳过。

源码核对:extractImportedBindings 的 import 使用追踪

§9.6 importAnalysis 讲了路径重写,但没有展开Vite 如何精确追踪”每个模块实际用到哪些导出”。真实的 extractImportedBindings(importAnalysis.ts:146-193)是这个追踪的核心:

function extractImportedBindings(id, source, importSpec, importedBindings) {
  let bindings = importedBindings.get(id)
  if (!bindings) {
    bindings = new Set<string>()
    importedBindings.set(id, bindings)
  }

  const isDynamic = importSpec.d > -1
  const isMeta = importSpec.d === -2
  if (isDynamic || isMeta) {
    // this basically means the module will be impacted by any change in its dep
    bindings.add('*')
    return
  }

  // 静态 import:解析 import { foo, bar } from 'xxx' 的精确绑定
  const staticImport: StaticImport = { ... }
  const parsed = parseStaticImport(staticImport)
  if (parsed.namespacedImport) {
    bindings.add('*')
  }
  if (parsed.defaultImport) {
    bindings.add('default')
  }
  if (parsed.namedImports) {
    for (const name of Object.keys(parsed.namedImports)) {
      bindings.add(name)
    }
  }
}

两条从代码能读出的机制:

1、动态导入一律记 '*'——全依赖importSpec.d > -1 是 es-module-lexer 对”动态 import()“的标记——你写 import('./x') 时 bindings 里记 '*',表示”这个文件对依赖的任何 export 变化都敏感”。因为动态 import 返回整个 namespace object、哪个属性会被访问编译时不知道。

2、静态导入被精确拆解import { foo, bar } from 'x' 只记录 {foo, bar} 两个名字——不关心 x 是否还导出 baz/qux。这个精度让 Vite 的 HMR 更新范围更小——如果 x 的源码只改了 baz、而当前文件只 import 了 foo,HMR 可以判断”baz 变了但我不用、不需要重新加载”。

这条追踪是 Vite HMR 远比 Webpack 精细的关键之一——Webpack 的 HMR 通常是”模块级失效”,Vite 可以做到”binding 级失效”。代价是 lexer 要扫每一个 import 语句——每次 transform 多花几十微秒——但 HMR 速度的改善肉眼可见。

源码核对:normalizeResolvedIdToUrl 的 React Refresh 特例

normalizeResolvedIdToUrl(importAnalysis.ts:107-144)里有一段以 “temporary hack” 为名、但实际跑了多年的代码

} else if (
  depsOptimizer?.isOptimizedDepFile(resolved.id) ||
  // vite-plugin-react isn't following the leading \0 virtual module convention.
  // This is a temporary hack to avoid expensive fs checks for React apps.
  // We'll remove this as soon we're able to fix the react plugins.
  (resolved.id !== '/@react-refresh' &&
    path.isAbsolute(resolved.id) &&
    fs.existsSync(cleanUrl(resolved.id)))
) {

注释里的道理:虚拟模块应该以 \0 开头(Rollup 约定)——Vite 识别 \0foo 是虚拟的、不走文件系统检查。但 @vitejs/plugin-react 的 React Refresh 实现用 /@react-refresh 作为虚拟模块名——没前缀 \0——导致这里的 path.isAbsolute + fs.existsSync/@react-refresh 做无谓的文件系统检查。

一次 fs.existsSync 在 Linux 下几十微秒、在 Windows 慢一个量级;一个 React 应用可能导入 React Refresh 几十次——累加起来 dev server 启动时的几秒额外 IO。这条 hack 专门把 /@react-refresh 排除——省掉这笔开销。

注释说 “We’ll remove this as soon we’re able to fix the react plugins.”——但实际这条 hack 已经活了 3 年多。框架作者的”临时”往往变成永久——因为要让 @vitejs/plugin-react 改成 \0 前缀需要打通 React 社区、改的是个字符串但要协调多方。这种”技术债务的外部依赖困境”是开源协作里最常见的坑——你不能改别人的 crate,只能在自己的代码里加补丁。

这条启示:看到”temporary hack”注释,先看它写了多久。超过 1 年的 temporary 几乎一定会变成永久。在自己的代码里写 hack 时,记得留一个”如果 X 被 fix 就删掉”的追踪 issue,否则永远不会被清理。

define 变量替换(plugins/define.ts)

vite:define 插件负责将编译时常量(如 process.env.NODE_ENVimport.meta.env)替换为实际值。

替换模式的双引擎设计

flowchart TD
    Start[vite:define] --> Mode{config.isBundled?}

    Mode -->|是| Bundled["options 钩子<br/>设置 Rolldown transform.define"]
    Mode -->|否| Dev["transform 钩子<br/>使用 Oxc replaceDefine"]

    Bundled --> BD1["Rolldown 原生处理 define<br/>零额外开销"]
    Dev --> DD1["先用正则快速检测"]
    DD1 --> DD2{匹配到关键字?}
    DD2 -->|否| DD3[跳过此文件]
    DD2 -->|是| DD4["调用 transformSync<br/>执行 AST 级别替换"]

    style Bundled fill:#f6ffed,stroke:#52c41a
    style Dev fill:#e8f4fd,stroke:#1890ff

在生产构建模式下,define 替换被委托给 Rolldown 的原生 transform 能力:

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.envprocess.envimportAnalysisclientInjection 插件在浏览器端处理:

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 级别的正确性——例如不会替换字符串内部或注释中的匹配:

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 值应该不同:

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 对象字面量,保持原始值的语义:

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 模式批量导入模块。

基本机制

// 源代码
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 导入也有两种实现路径:

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 的模块重新转换:

// 记录每个模块使用的 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)
      }
    }
  }
}

转换管线的协调

插件执行顺序

flowchart TB
    subgraph "Vite 内部插件执行顺序(transform 阶段)"
        direction TB
        P1["vite:oxc / vite:esbuild<br/>TS/JSX -> JS"] --> P2["vite:define<br/>变量替换"]
        P2 --> P3["vite:import-glob<br/>glob 展开"]
        P3 --> P4["用户插件 (enforce: undefined)"]
        P4 --> P5["vite:import-analysis<br/>导入重写"]
    end

    subgraph "moduleType 标记"
        MT["每个插件返回 moduleType: 'js'<br/>确保后续插件知道文件类型"]
    end

    P1 -.-> MT

vite:oxc(或 vite:esbuild)必须第一个执行,因为后续插件期望处理的是标准 JavaScript,而非 TypeScript 或 JSX。vite:import-analysis 必须最后执行,因为它需要看到所有其他转换完成后的最终导入路径。

moduleType 的重要性

每个转换插件在返回结果时都会设置 moduleType: 'js'

return {
  code: result.code,
  map: result.map,
  moduleType: 'js',  // 告诉后续处理器这是 JS
}

这个标记在 Rolldown 中用于确定文件的解析方式。一个 .tsx 文件在经过 Oxc 转换后变成了纯 JS,moduleType: 'js' 确保后续阶段不会再尝试解析 TypeScript 语法。

性能优化策略

转换管线的性能至关重要——每一个模块请求都要经过完整的管线。Vite 采用了多层优化:

  1. 正则预检:在调用昂贵的 AST 转换前,先用正则表达式检查文件是否包含需要处理的模式。define 插件的 pattern.test(code)importMetaGlobfilter: { code: 'import.meta.glob' } 都是这种策略

  2. Filter 短路oxcPluginesbuildPlugin 使用 createFilter 创建高效的 include/exclude 过滤器,默认排除 .js 文件(它们不需要 TS/JSX 转换)

  3. 同步转换:Oxc 的 transformSync 避免了异步开销。在开发服务器的请求-响应模型中,同步转换实际上比异步更高效,因为请求处理本身就是串行的

  4. Rust 层过滤:Rolldown 支持在 Rust 层进行文件过滤(filter: { id: /regex/, code: 'string' }),避免了将不需要的文件传递到 JS 层

TSConfig 变更检测

reloadOnTsconfigChange 函数监听 tsconfig.json 的变更,触发完整的模块图失效和页面重载:

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 的 pathstarget 等选项可以改变任何文件的编译结果)。

源码核对:define 的 pattern 正则是性能关键

§9.7 讲了 define 的双引擎设计。真实的 generatePattern(define.ts:49-100)里有一段把快速筛选做到极致的逻辑:

// Create regex pattern as a fast check before running esbuild
const patternKeys = Object.keys(userDefine)
if (!keepProcessEnv && Object.keys(processEnv).length) {
  patternKeys.push('process.env')
}
if (Object.keys(importMetaKeys).length) {
  patternKeys.push('import.meta.env', 'import.meta.hot')
}
const pattern = patternKeys.length
  ? new RegExp(
      patternKeys
        // replace `\.` (ignore `\\.`) with `\??\.` to match with `?.` as well
        .map((key) => escapeRegex(key).replaceAll(escapedDotRE, '\\??\\.'))
        .join('|'),
    )
  : null

两条关键设计:

1、先正则筛、再 AST 转换。Vite 不是每个文件都走 Oxc 的 transformSync 做 define 替换——那样开销太大(每个 JS 文件都 parse 一次 AST)。而是先用一个包含所有 define key 的 OR 正则快速测试 “这个文件里有没有可能命中 define”——如果连 process.env 这样的字符串都没出现,就直接跳过不走 transform。正则扫一遍字符串比 AST parse 快 50 倍。

2、\??\. 匹配可选链操作符。正则里把 \.(字面点号)替换成 \??\.——这让 pattern 既匹配 process.env.NODE_ENV 又匹配 process?.env?.NODE_ENV(optional chaining)。不做这个替换的话,用户写了 process?.env?.NODE_ENV 会被跳过不替换、导致生产 bundle 里还带 process?.env 引用、可能在某些运行时报错。

这条小小的字符串操作背后是对”用户可能写的所有 ways of accessing define”的穷尽考虑。现代 JavaScript 里访问属性有 ./?./[] 三种——[...] 用 string key 的场景 define 管不了(语义不匹配),但 ?. 必须支持——Vite 用 6 个字符的正则改写把它内置了。

源码核对:serializeDefine 的 sort + 直接字符串拼接

serializeDefine(define.ts:202-214)是一个极小的函数但值得看:

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 + `}`
}

三条细节:

1、Object.keys(define).sort() 保证顺序稳定。不同机器、不同 Node 版本、不同插件注册顺序——用户的 define 对象 key 顺序可能不同。sort 让生成的 import.meta.env 字符串在任何环境下都一致——build output 确定性、cache key 稳定、diff 友好。

2、手写字符串拼接而不是 JSON.stringify(define)。因为 define 的 value 可能是字面 JS 表达式(比如 "window.location.href" 这种字符串本身就是要插进代码的 JS 表达式)——JSON.stringify 会把它 escape 成 "\"window.location.href\""——就不是 JS 表达式了、变成字符串字面量。handleDefineValue 对字符串类型原样返回,让它作为 JS 代码片段嵌入。

3、key 用 JSON.stringify,val 用 handleDefineValue——key 和 value 用不同的序列化策略。这是 define 替换最关键的一条约定:用户传字符串 value 时不 quote、这个字符串会作为 JS 代码;传其他类型时走 JSON.stringify。用户写 define: { DEBUG: "true" },生成的 import.meta.env.DEBUG 值是 JS boolean 字面量 true,不是字符串 "true"

这条规则让 define 成为 Vite 最容易误用的 API 之一——很多新手写 define: { MY_VAR: "hello" } 以为传了一个字符串、实际被当成变量名插进代码、运行时因 hello is not defined 崩溃。正确写法是 define: { MY_VAR: '"hello"' }(双引号包裹,或用 JSON.stringify("hello"))。这条”陷阱”源自 Rollup 时代的 rollup-plugin-replace、延续到今天的 Vite——API 稳定性压过了易用性

源码核对:import.meta.hot.accept 的四种方言——importAnalysis 如何区分

importAnalysis.ts:475-509 有一段特别精巧的字符串扫描逻辑,专门处理 import.meta.hot 的四种 accept 调用方式:

if (rawUrl === 'import.meta') {
  const prop = source.slice(end, end + 4)
  if (prop === '.hot') {
    hasHMR = true
    const endHot = end + 4 + (source[end + 4] === '?' ? 1 : 0)
    if (source.slice(endHot, endHot + 7) === '.accept') {
      // further analyze accepted modules
      if (source.slice(endHot, endHot + 14) === '.acceptExports') {
        const importAcceptedExports = (orderedAcceptedExports[index] =
          new Set<string>())
        lexAcceptedHmrExports(
          source, source.indexOf('(', endHot + 14) + 1,
          importAcceptedExports,
        )
        isPartiallySelfAccepting = true
      } else {
        const importAcceptedUrls = (orderedAcceptedUrls[index] =
          new Set<UrlPosition>())
        if (
          lexAcceptedHmrDeps(
            source, source.indexOf('(', endHot + 7) + 1,
            importAcceptedUrls,
          )
        ) {
          isSelfAccepting = true
        }
      }
    }
  } else if (prop === '.env') {
    hasEnv = true
  }
}

五条值得拆解的细节:

1、用字符串 substring 对比而不是 ASTsource.slice(end, end + 4) === '.hot' 是 O(1) 字符串比对;如果走 AST parse,是 O(n) 的代价。Vite 在热路径上选字符串 slice——几十万字符的文件里找”import.meta.hot.accept”只花几微秒

2、source[end + 4] === '?' ? 1 : 0 处理 optional chaining。用户写 import.meta.hot?.accept(...) 时,? 出现在 .hot 之后、.accept 之前——代码的 endHot 要向前跳一个字符避开 ?,否则下一步的 .accept 比对就失败。

3、区分 .accept.acceptExports。两者功能不同:.accept 接受整个模块更新;.acceptExports(['foo']) 只接受特定导出的变化。代码用 14 字符 substring 精确匹配 .acceptExports——如果不做区分,.acceptExports 会错误匹配 .accept、丢掉 Exports 的第二部分。

4、source.indexOf('(', endHot + 7) 定位左括号位置,然后 +1 跳过它——这就是 lexAcceptedHmrDeps 从圆括号内部开始扫描的入口点。两个扫描函数 lexAcceptedHmrDepslexAcceptedHmrExports 专门处理 accept 参数的字符串字面量或数组——提取出被 accept 的 URL 或 export names。

5、isSelfAccepting vs isPartiallySelfAccepting 的语义差。前者表示”整个模块 self-accepting”——HMR 直接在这个模块处停止冒泡;后者表示”仅对特定 export 的 self-accepting”——其他 export 变化还是会往上冒。这两个状态会被写进 module graph,HMR 算法根据它们决定传播范围。

这一小段 35 行代码覆盖了 .hot.accept() / .hot?.accept() / .hot.acceptExports() / .hot?.acceptExports() 四种写法——完整的边界情况处理。不做这些细分 HMR 就会在某些用户写法下失效——但用户只会感觉”HMR 不灵”、不会知道是这几个字符的问题

源码核对:importAnalysis 的 no-imports fast path + updateModuleInfo 的依赖清理

importAnalysisPlugin 的 transform(importAnalysis.ts:298-319)有个 “零 import”快速路径

if (
  !imports.length &&
  !(this as unknown as TransformPluginContext)._addedImports
) {
  const prunedImports = await moduleGraph.updateModuleInfo(
    importerModule,
    new Set(),     // ← 传空 Set 清空依赖
    null,
    new Set(),
    null,
    false,
  )
  if (prunedImports) {
    handlePrunedModules(prunedImports, environment)
  }
  debug?.(`${timeFrom(msAtStart)} [no imports] ${prettifyUrl(importer, root)}`)
  return source
}

这条代码触发于”这个文件连一个 import 都没有”的场景——比如一个纯粹的 constants 文件、utility helpers、或者刚删光 import 的文件。此时 Vite 做两件事:

1、清空模块图里这个文件的依赖信息updateModuleInfo(importerModule, new Set(), ...) 把该模块的依赖集置空——之前它可能依赖了 lodashreact,现在全无依赖。

2、调 handlePrunedModules 触发 HMR prune 逻辑。之前依赖的模块可能已经被 accepted 了(挂在这个文件的 HMR 监听上),现在依赖关系断了——需要通知那些 accepted 的监听者:你不再是它的依赖、可以释放相关的 accepting 状态

这条处理看起来边缘,实际是HMR 正确性的关键一环。想象你有个文件 utils.tsapp.ts import,你保存 app.ts 时删光了 import utils——如果不做这个 prune,utils.ts 的 HMR 更新还会尝试通知已经不存在的 accept 关系、造成诡异的热更新失败。

Vite HMR 能在大型项目里稳定工作的一个重要原因就是这类依赖图的实时清理——不是”只记新增、从不清除”,而是每次 transform 都重建完整依赖关系。代价是每个 transform 都要 prune 一次;收益是任意复杂的依赖重构都能被正确处理。

源码核对:importAnalysis 的 error.pos 增强

normalizeUrl(importAnalysis.ts:357-362)捕获 this.resolve 错误时有一段”顺手加 pos”的代码:

const resolved = await this.resolve(url, importerFile).catch((e) => {
  if (e instanceof Error) {
    ;(e as RollupError).pos ??= pos
  }
  throw e
})

pos ??= pos 的语义:如果错误没有 pos 字段,追加当前解析位置;如果已经有了,尊重原值。这是一个典型的”错误对象增强”模式——错误在哪一行哪一列,用户看到的 stack trace 会多一条”具体 import 位置”的信息。

为什么要 ??= 而不是直接赋值?因为下游插件可能已经设置了更准确的 pos(比如它知道精确到列号),这里只在完全缺失时补齐。保留下游的精度。

这是 Rollup/Vite 错误处理里的一条黄金实践——捕获错误时富化上下文、但不覆盖已有信息。用户最终看到的错误可能经过了 5-10 层 catch-enrich,每层加一点定位信息,最终在终端展示时是”完整的、从外到内的”错误上下文。对比那种每层都用 new Error(...) 重新包裹的写法,Vite 的方式保留了原始 stack trace——调试体验好太多。

设计决策

为什么从 esbuild 迁移到 Oxc

esbuild 作为 Vite 的转换引擎服务了多年,但存在几个问题:

  1. Go 语言生态隔离:esbuild 用 Go 编写,与 Vite 其他 Rust 组件(Rolldown、SWC)无法共享内存和数据结构
  2. 维护独立性:esbuild 有自己的发展路线图,不一定与 Vite 的需求完全一致
  3. Rolldown 集成:Oxc 内置在 Rolldown 中,使用 Rolldown 的 transformSync 意味着零额外依赖

迁移过程通过 convertEsbuildConfigToOxcConfig 实现了平滑过渡,用户无需立即修改配置。

为什么 importAnalysis 只在开发模式运行

生产构建时,Rolldown 自身会处理模块解析和导入重写,不需要 importAnalysis 插件。但开发模式下浏览器直接请求单个模块,需要 Vite 在响应中将相对路径和裸导入转换为浏览器可理解的 URL。这是开发服务器”按需服务”模型的核心环节。

为什么 define 客户端不做替换

客户端的 import.meta.env 不在 define 插件中替换,而是在 importAnalysis 中注入到 @vite/client 模块。这样所有客户端模块共享同一个 import.meta.env 对象实例,而非每个文件都内联一份。这减少了代码体积并确保了一致性。

源码核对:buildEsbuildPlugin 的 renderChunk 注入 helpers 为什么必须

§9.4 给了 buildEsbuildPlugin 的代码:

renderChunk(code, chunk, opts) {
  ...
  const res = await transformWithEsbuild(code, chunk.fileName, options)
  if (config.build.lib) {
    res.code = injectEsbuildHelpers(res.code, opts.format)
  }
  return res
}

为什么 lib 构建需要 injectEsbuildHelpers?esbuild 在转换语法下放目标(比如把 async/await 降级到 ES5)时会插入 helper 函数(__awaiter__generator 等)。esbuild 默认把这些 helpers 放在文件顶部——对 ESM/CJS 格式没问题;但对 IIFE/UMD 格式就出错——因为 IIFE 是 (function(){ ...code... })() 的形式、helpers 必须在 ...code... 内部才能被 code 引用到。

injectEsbuildHelpers 做的事:

  1. 识别 esbuild 产出的代码里哪些是 helper 函数(它们有固定命名约定)
  2. 把 helpers 从顶部挪到 IIFE wrapper 内部
  3. 保留正确的执行顺序

这条处理看似细枝末节,实际是库构建能正确产出 UMD bundle 的必要条件。没有它——用户发布 UMD 库给老浏览器——浏览器里 __awaiter is not defined 直接报错。

这也反映了 Vite 的一条架构原则:底层工具(esbuild)的缺陷在 Vite 层被兜住,不向用户暴露。用户只需要选 formats: ['umd']、不需要知道 esbuild helper 位置的坑——Vite 把这些补丁默默打上。对比 Webpack 的”一切都得用户自己配”哲学,Vite 更接近 Rails 的 “convention over configuration”——默认就是正确的

源码核对:importAnalysis 对 @react-refresh 和其他”无斜线前缀”的 wrapId 处理

normalizeResolvedIdToUrl 的最后一段(importAnalysis.ts:137-141):

// if the resolved id is not a valid browser import specifier,
// prefix it to make it valid. We will strip this before feeding it
// back into the transform pipeline
if (url[0] !== '.' && url[0] !== '/') {
  url = wrapId(resolved.id)
}

浏览器 ESM 规范要求 import URL 必须以 .// 或完整 http(s):// 开头——不能是裸字符串。某些插件返回的虚拟模块 id 可能是 virtual:app-config 这样的纯字符串——浏览器拿到这个 id 无法解析。

wrapId 把它变成 /@id/virtual:app-config——符合浏览器要求、且 Vite 的 dev server 会识别 /@id/ 前缀、剥离后重新走 resolve/load 流程。这是一条 “用前缀做标记、让 server 识别并解包” 的经典技巧——URL 首字母被固定住的场景下,只能通过约定的前缀表达”我是特殊标记”。

这条 wrapId 在多个插件里被复用——vue/react 的 SFC 虚拟文件、vitest 的测试文件、storybook 的 story 文件——都走这条通道。理解 wrapId 的存在能让你在写自己的 Vite 插件时不踩”虚拟模块 id 含特殊字符导致浏览器报错”的坑。

源码核对:templateLiteralRE 的”可恢复的 import”——模板字符串里没插值时的还原

importAnalysis.ts:510-518 有一段小心翼翼的字符串识别逻辑:

} else if (templateLiteralRE.test(rawUrl)) {
  // If the import has backticks but isn't transformed as a glob import
  // (as there's nothing to glob), check if it's simply a plain string.
  // If so, we can replace the specifier as a plain string to prevent
  // an incorrect "cannot be analyzed" warning.
  if (!(rawUrl.includes('${') && rawUrl.includes('}'))) {
    specifier = rawUrl.replace(templateLiteralRE, '$1')
  }
}

这段代码在处理一个边角场景:用户写了 import(\./foo.js`)(用反引号但没插值)。按常规,这不是"动态拼接路径"——它的值在编译时就能确定、应该被当成静态 import 处理。但 Vite 的识别链之前已经把它当成"可能动态"分派给了 dynamicImportVars 插件——dynamicImportVars 发现里面没 ${…}` 插值、没办法 glob、会给用户一条 “cannot be analyzed” 警告

这段 else if 的预警:识别出是 “反引号但无插值的纯字符串”——specifier = rawUrl.replace(templateLiteralRE, '$1')——把 `./foo.js` 还原成 ./foo.js,后续按静态 import 处理。避免让用户看到误导性警告

这是一个用户体验优先于抽象纯度的实现选择。严格来说,import(\./foo.js`)import(’./foo.js’)` 语义 100% 等价——用户应该自己写对。但实际上很多 codebase 用反引号是因为代码格式化工具或者习惯——Vite 宁愿多一段代码把它识别出来、也不给用户报一条无意义的警告。

这种”预警友好度”的投入累加起来让 Vite 的开发体验比一般构建工具细腻一档——用户遇到的 warning 更少、遇到的 warning 也都是真的 warning。和第 5 章的 Reconciliation 里 React 对 fast refresh 的 120 行字符串 ref 兼容是一类哲学——向后兼容、向用户友好

本章与全书体系的呼应

JS 转换管线不是孤立模块——它和 Vite 整个架构的其他部分深度咬合,梳理一下能把本章放进大图:

与第 4 章(插件系统)的依赖:本章讲的 5 个插件全部是 Vite/Rollup 标准插件 API 的实例。resolveId + load + transform + renderChunk 四个核心钩子贯穿其中——第 4 章铺垫好的”插件是什么”到这一章变成”插件的组合如何完成复杂转换”。没学过 §4 的读者看本章容易把每个插件当成一个黑盒;看过 §4 的读者能一眼看出哪些是插件之间的标准接口、哪些是 Vite 自己的特殊扩展。

与第 6 章(HMR 机制)的双向接口:本章 importAnalysis 的 hasHMR/isSelfAccepting/importedBindings 三个布尔+Map 都是为 HMR 准备的。它们被写到 module graph 上、§6 讲的 HMR 传播算法读取这些字段决定更新范围。本章相当于 HMR 的”前置情报收集”、§6 是”根据情报做决策”——分工清晰。

与第 7 章(依赖预构建)的联动normalizeResolvedIdToUrl 里对 depsOptimizer.isOptimizedDepFile(...) 的判断——预构建的依赖被识别后用 /@fs/ 前缀。第 7 章讲过预构建产出 ESM wrapped 版本的 CJS 包;本章这里消费预构建的结果——把它暴露给浏览器。这条接口让 CJS 包能在 ESM 世界无缝使用,用户完全无感。

与第 8 章(Server 主循环)的触发关系:本章所有的 transform 钩子被调用的时机是 §8 讲过的 HTTP 请求处理——浏览器请求 /src/App.tsx、Server 走 transform pipeline、每个本章讲的插件依次 invoke。本章是”每个文件的微观处理”、§8 是”整个服务的宏观调度”——两章对 Vite dev server 的完整理解缺一不可。

与第 10 章(CSS 处理)的对比:下一章 CSS 的转换管线走另一条路——不经过 Oxc(它不懂 CSS)、有自己的 postcss/lightningcss 引擎。但很多 pattern 是一致的——isBundled 二分、正则快速筛选、AST 转换、HMR 注入。对比阅读能让你看到 Vite 对不同文件类型应用同一套架构套路的美学。

源码核对:getModifiedOxcTransformOptions 的 JSX Refresh 跳过条件

§9.3 的 JSX 处理提到 React Fast Refresh 被精心控制。真实的判断逻辑(oxc.ts 附近)拆成四个条件的 AND

if (
  jsxOptions.refresh &&
  (environment.config.consumer === 'server' ||
   (jsxRefreshFilter && !jsxRefreshFilter(id)) ||
   !(isJSX || code.includes(jsxImportRuntime) || ...))
) {
  result.jsx = { ...jsxOptions, refresh: false }
}

四个”应该禁用 refresh”的条件:

1、consumer === 'server' —— SSR 环境不需要 Fast Refresh。服务端代码每次改都会重启或 HMR、Fast Refresh 的”保持组件 state”语义对服务端无意义。服务端没有浏览器里的 React state 要保留。

2、jsxRefreshFilter && !jsxRefreshFilter(id) —— 用户通过 jsxRefreshFilter 显式排除了某些文件。比如 node_modules 里的第三方组件——你不想给它加 Fast Refresh 挂钩(会改变第三方的函数引用、破坏它的 memo)。

3、!isJSX(文件名不以 x 结尾) 代码里不含 jsx-runtime 导入——这个文件根本不写 JSX、没必要加 Refresh 开销。一个只导出普通函数的 .ts 文件加了 Refresh helper 就是白白膨胀代码。

4、jsxOptions.refresh 本身是 false——用户在配置里禁用了 Refresh(某些场景下不需要,比如做 Storybook 或特殊工作流)。

这四个条件的 OR 组合让 Fast Refresh 只在真正需要的文件里启用——不是”所有 JSX 文件都加”,而是”JSX 文件 + 非 SSR + 未被 filter 排除 + 代码用了 JSX”才加。精确的启用条件让 dev server 不做无用功,dev 启动速度和转换速度都更快。

从这条判断链能读出 Vite 对 React 生态的深度考虑——不是简单集成 Fast Refresh,而是想清楚每一种场景下用不用。这种细致是 Vite 能在 React 社区获得比 Webpack+CRA 更好体验的关键原因之一。

源码核对:stripBomTag + es-module-lexer 的 try/catch

importAnalysisPlugin.transform(importAnalysis.ts:270-283)在 parse imports 之前做了两件事:

await init
let imports!: readonly ImportSpecifier[]
let exports!: readonly ExportSpecifier[]
source = stripBomTag(source)
try {
  ;[imports, exports] = parseImports(source)
} catch (_e: unknown) {
  const e = _e as EsModuleLexerParseError
  const { message, showCodeFrame } = createParseErrorInfo(
    importer,
    source,
  )
  this.error(message, showCodeFrame ? e.idx : undefined)
}

两条细节:

1、stripBomTag 剥离 UTF-8 BOM。Windows 下某些文本编辑器(旧 Notepad、VSCode 默认偶尔)保存的 JS 文件会在开头加 BOM(0xEFBBBF 三字节)——parseImports 拿到带 BOM 的字符串会在第一个字符报 “unexpected token”。Vite 剥掉 BOM 让 lexer 看到干净的 UTF-8 文本。

这是一个Windows 生态的历史包袱——BOM 对 ASCII 工具兼容性不友好、但 Windows 编辑器默认加。Unix/Mac 编辑器通常不加。Vite 无条件剥离让跨平台开发不出现”同样代码在 Windows 机器跑不过”的诡异故障。

2、await init 等 es-module-lexer 的 WASM 初始化。es-module-lexer 用 WASM 写的(Rust 编译)——初始化要一点时间(加载 WASM、实例化)。init 是个 Promise,第一次调用时等它 resolve、后续调用立刻返回(Promise 自动 dedupe)。把初始化成本摊到第一次——之后每次 parse 都是纯 WASM 速度(比 pure JS parser 快 10-20 倍)。

3、parseImports 错误时的精确定位createParseErrorInfo 生成一个带代码片段的错误消息、this.error 把它报给 Rollup 的错误处理系统——错误最终在浏览器控制台显示成 “这个文件第 X 行有语法错误、旁边标出出错位置”的 frame。比”Unexpected token”好上百倍的调试体验。

stripBomTag 的存在是一条”跨平台开发不能假设 UTF-8 永远纯净”的提醒——类似的细节在 Vite/Rollup 的 parser 路径里还有好几个(\r\n 换行、BOM、NUL 字符)——都是为用户的环境多样性兜底的工程投入

读完本章能回答的具体问题清单

本章内容的自测——以下 12 个问题在本章源码锚点里都有答案,能不回源码答出来的话,你对 Vite JS 转换管线的理解已经是”能独立定位问题”级:

  1. 为什么 Vite 从 esbuild 迁到 Oxc?(§9.3 末尾——Rust 生态整合 + 零额外依赖)
  2. .cts.ts 在 Oxc 眼里有区别吗?(§9.3-bis——没区别、都归一到 lang=ts)
  3. oxcPlugin 为什么要根据 isBundled 走两条路径?(§9.3 二模式——bundled 走 Rust 原生 transform 零开销;dev 走 JS transform 精细控制)
  4. define 为什么先用正则筛选再走 AST?(§9.7-bis——避免每个文件都 parse AST、50 倍性能差)
  5. define: { MY_VAR: "hello" } 为什么会抛 “hello is not defined”?(§9.7-ter——字符串 value 被当 JS 表达式、不是字符串字面量)
  6. import.meta.hot?.accept(...)import.meta.hot.accept(...) 都能识别吗?(§9.6-new——通过 optional chaining 的 ? 检测跳字符)
  7. .acceptExports(['foo']).accept(['foo']) 行为有什么差?(§9.6-new——前者只接受特定 export 变化、后者接受整个模块)
  8. 文件 import 被全删光时 Vite 做什么?(§9.6-prune——触发 moduleGraph.updateModuleInfo 清空依赖 + 通知被 prune 的监听者)
  9. tsconfig 变更触发什么?(§9.9——全项目 invalidate + full-reload)
  10. 为什么 /@react-refresh 会有 “temporary hack”?(§9.6-hack——React 插件没走 \0 虚拟模块约定、Vite 为它特殊绕过 fs check)
  11. buildEsbuildPlugin 为什么要 injectEsbuildHelpers?(§9.4-bis——IIFE/UMD 格式下 esbuild helpers 必须在 wrapper 内部)
  12. 虚拟模块 id 含特殊字符为什么要 /@id/ 前缀?(§9.6-wrapId——浏览器 ESM 规范要求 URL 以 .////http 开头)

能答 10 条以上——你对 Vite 的 JS 转换管线的理解已经超越 90% 的 Vite 使用者,可以独立定位大多数 transform/HMR/build 问题的根因。

本章源码定位索引

为便于读者按图索骥:

主题源文件关键行号
Oxc transformWithOxcplugins/oxc.ts67-103
Oxc plugin 两模式同上oxcPlugin 137-178
esbuild 兼容层plugins/esbuild.tstransformWithEsbuild 起点
importAnalysis 主逻辑plugins/importAnalysis.ts224+
canSkipImportAnalysis同上85-87
normalizeResolvedIdToUrl同上107-144
extractImportedBindings同上146-193
import.meta.hot 识别同上475-509
no-imports fast path同上298-319
normalizeUrl 错误增强同上357-362
definePlugin 主逻辑plugins/define.ts12+
generatePattern 正则构造同上49-100
replaceDefine同上167-195
serializeDefine同上202-214
tsconfig 变更处理本章 9.9transformRequest 旁路

源码版本:vite-latest 本地仓库。读者在自己的 vite 版本上 grep 函数名即可定位到最新行号。

小结

Vite 的 JavaScript 转换管线是一个精心编排的插件协奏曲。从 Oxc 的类型擦除和 JSX 转换,到 define 的编译时常量替换,到 glob 导入的展开,最后到 importAnalysis 的路径重写和 HMR 注入——每个插件各司其职,通过清晰的接口传递数据。

从 esbuild 到 Oxc 的迁移展示了 Vite 架构的灵活性:核心的转换逻辑被封装在独立的函数(transformWithOxctransformWithEsbuild)中,插件层只是调度器。当底层引擎更换时,上层的插件接口保持不变。

isBundled 标志贯穿多个插件,实现了开发和构建模式的差异化处理。开发模式追求最快的单文件转换速度,构建模式则将更多工作委托给 Rolldown 的原生能力以获得最优的整体性能。

实践建议:

  1. 在项目里打开 Vite 的 debug 日志(DEBUG=vite:* pnpm dev),观察本章各个插件的执行时间
  2. console.log 临时改 vite 源码的 normalizeUrl 或 extractImportedBindings,看项目的 import 是怎么被解析的
  3. 对比 importAnalysis 输出的转换前后代码——用浏览器 DevTools 的 Sources 面板看 /src/App.tsx 的 served 内容和磁盘原文的 diff

Vite 源码在每个小版本都在变化——本章引用的行号可能已偏移,但函数名、数据结构、核心正则、WASM-based lexer 这些骨架是稳定的。用 rg "function extractImportedBindings" 之类的全文搜索命令找到最新行号,比死记行号有效。