Vite 设计与实现

第17章 Web Worker 与特殊资源

作者 杨艺韬 · 12,963 字

第17章 Web Worker 与特殊资源

开篇引言

现代 Web 应用不仅包含 JavaScript 和 CSS,还需要处理各种特殊类型的资源:Web Worker 提供了多线程计算能力,WebAssembly 带来了接近原生的执行性能,JSON 导入需要与 Tree Shaking 协作,动态导入变量需要在构建时被静态化,import.meta.glob 则提供了文件系统级的批量导入能力。

这些特殊资源的处理是 Vite 插件系统的高级应用。每一种资源类型都需要在开发和构建两种模式下提供一致的行为,同时还要与 HMR、Source Map、代码分割等核心机制协作。

本章将深入分析 Vite 对这些特殊资源的处理实现,重点关注 Worker 插件(plugins/worker.ts)、WASM 支持(plugins/wasm.ts)、动态导入变量(plugins/dynamicImportVars.ts)和 import.meta.globplugins/importMetaGlob.ts)。

本章要点

  • 理解 Worker 插件的独立构建管线与产物缓存机制
  • 掌握 Worker 的内联(inline)模式与 Blob URL 的设计
  • 分析 WASM 的两种加载策略(fetch vs 文件系统)
  • 理解动态导入变量到 import.meta.glob 的转换
  • 掌握 import.meta.glob 的模式解析、代码生成与 HMR 联动

17.1 Web Worker 插件

17.1.1 Worker 的挑战

Web Worker 运行在独立的线程中,拥有独立的全局作用域。这给构建工具带来了独特的挑战:

  1. Worker 脚本需要被打包为独立的入口文件
  2. Worker 脚本可能依赖其他模块,需要递归处理
  3. 开发模式和构建模式下 Worker 的加载方式不同
  4. 内联 Worker 需要将代码转换为 Blob URL
  5. SharedWorker 不能使用 Blob URL(会导致多实例)
  6. IIFE 格式的 Worker 不支持 import.meta

17.1.2 Worker 插件架构

Worker 插件由两个主要部分组成:webWorkerPlugin(主插件)和 webWorkerPostPlugin(后处理插件)。

graph TB
    A["import MyWorker from './worker?worker'"] --> B{"开发模式?"}
    B -->|"是"| C["fileToUrl: 生成开发服务器 URL"]
    B -->|"否"| D{"内联模式?"}
    D -->|"是 (?inline)"| E["bundleWorkerEntry: 打包并内联"]
    D -->|"否"| F["workerFileToUrl: 打包为独立文件"]

    C --> G["返回 Worker 构造函数代码"]
    E --> H["返回 Blob URL Worker 代码"]
    F --> I["返回带占位符的 Worker 代码"]

    subgraph "webWorkerPostPlugin"
        J["renderChunk: 替换占位符为实际路径"]
        K["IIFE Worker: 替换 import.meta"]
    end

    I --> J
    G --> K

    style E fill:#fff3e0
    style F fill:#e3f2fd

17.1.3 WorkerOutputCache

Worker 插件使用 WorkerOutputCache 管理构建产物的缓存和去重:

class WorkerOutputCache {
  // Worker 打包信息:输入文件 -> 打包结果
  private bundles = new Map<string, WorkerBundle>()
  // 资源文件缓存
  private assets = new Map<string, WorkerBundleAsset>()
  // 文件名 hash -> 入口文件名的映射
  private fileNameHash = new Map<string, string>()
  // 因文件变更需要重新打包的 Worker
  private invalidatedBundles = new Set<string>()
}

缓存的设计确保了同一个 Worker 文件只被打包一次,即使它在多个地方被引用:

async function bundleWorkerEntry(config, id): Promise<WorkerBundle> {
  const input = cleanUrl(id)
  const workerOutput = workerOutputCaches.get(config.mainConfig || config)!

  // 检查缓存(含失效检查)
  workerOutput.removeBundleIfInvalidated(input)
  const bundleInfo = workerOutput.getWorkerBundle(input)
  if (bundleInfo) return bundleInfo  // 命中缓存,直接返回

  // 循环引用检测
  const newBundleChain = [...config.bundleChain, input]
  if (config.bundleChain.includes(input)) {
    throw new Error(
      'Circular worker imports detected. Vite does not support it. ' +
        `Import chain: ${newBundleChain.map((id) =>
          prettifyUrl(id, config.root)).join(' -> ')}`,
    )
  }

  // 启动独立的 Rolldown 构建
  const { rolldown } = await import('rolldown')
  // ...
}

17.1.4 独立构建管线

每个 Worker 文件都通过独立的 Rolldown 构建处理:

const workerEnvironment = new BuildEnvironment('client', workerConfig)
await workerEnvironment.init()

const bundle = await rolldown({
  ...rollupOptions,
  input,
  plugins: workerEnvironment.plugins.map((p) =>
    injectEnvironmentToHooks(workerEnvironment, chunkMetadataMap, p),
  ),
  preserveEntrySignatures: false,
  experimental: { viteMode: true },
})

const result = await bundle.generate({
  entryFileNames: path.posix.join(config.build.assetsDir, '[name]-[hash].js'),
  chunkFileNames: path.posix.join(config.build.assetsDir, '[name]-[hash].js'),
  format,
  sourcemap: workerEnvironment.config.build.sourcemap,
  minify: workerEnvironment.config.build.minify === 'oxc' ? true
    : workerEnvironment.config.build.minify === false ? 'dce-only'
    : undefined,
})
sequenceDiagram
    participant Main as 主构建
    participant Cache as WorkerOutputCache
    participant Worker as Worker 构建

    Main->>Cache: getWorkerBundle(input)
    alt 缓存命中
        Cache-->>Main: 返回缓存的 WorkerBundle
    else 缓存未命中
        Main->>Worker: 创建独立 Rolldown 构建
        Worker->>Worker: BuildEnvironment('client', workerConfig)
        Worker->>Worker: rolldown({ input, plugins })
        Worker->>Worker: bundle.generate({ format, sourcemap })
        Worker-->>Cache: saveWorkerBundle(file, ...)
        Cache-->>Main: 返回新的 WorkerBundle
    end
    Main->>Main: 生成 Worker 加载代码

17.1.5 内联 Worker 与 Blob URL

当 Worker 使用 ?inline 查询参数或通过 ?worker&inline 方式导入时,Worker 代码会被内联到主 bundle 中:

if (inlineRE.test(id)) {
  const result = await bundleWorkerEntry(config, id)

  const jsContent = `const jsContent = ${JSON.stringify(result.entryCode)};`

  // Worker 使用 Blob URL
  if (workerConstructor === 'Worker') {
    const code = `${jsContent}
      const blob = typeof self !== "undefined" && self.Blob &&
        new Blob([${
          workerType === 'classic'
            ? `'(self.URL || self.webkitURL).revokeObjectURL(self.location.href);',`
            : `'URL.revokeObjectURL(import.meta.url);',`
        }jsContent], { type: "text/javascript;charset=utf-8" });
      export default function WorkerWrapper(options) {
        let objURL;
        try {
          objURL = blob && (self.URL || self.webkitURL).createObjectURL(blob);
          if (!objURL) throw ''
          const worker = new Worker(objURL, ${workerTypeOption});
          worker.addEventListener("error", () => {
            (self.URL || self.webkitURL).revokeObjectURL(objURL);
          });
          return worker;
        } catch(e) {
          return new Worker(
            'data:text/javascript;charset=utf-8,' + encodeURIComponent(jsContent),
            ${workerTypeOption}
          );
        }
      }`
  }
  // SharedWorker 使用 data URL(避免多实例)
  else {
    const code = `${jsContent}
      export default function WorkerWrapper(options) {
        return new SharedWorker(
          'data:text/javascript;charset=utf-8,' + encodeURIComponent(jsContent),
          ${workerTypeOption}
        );
      }`
  }
}

这段代码展示了三个关键设计:

  1. Blob URL 优先,data URL 回退:Blob URL 性能更好,但创建失败时回退到 data URL
  2. 自动 revoke:Worker 启动后通过注入的代码自动调用 revokeObjectURL,避免内存泄漏。对于 classic 类型使用 self.location.href,对于 module 类型使用 import.meta.url
  3. SharedWorker 使用 data URL:Blob URL 每次创建都是新的 URL,SharedWorker 需要相同的 URL 才能共享实例

17.1.6 URL 占位符与 renderChunk 替换

非内联 Worker 在构建时使用占位符标记 URL:

private generateEntryUrlPlaceholder(entryFilename: string): string {
  const hash = getHash(entryFilename)
  if (!this.fileNameHash.has(hash)) {
    this.fileNameHash.set(hash, entryFilename)
  }
  return `_​_VITE_WORKER_ASSET_​_${hash}__`
}

renderChunk 阶段,这些占位符被替换为实际的相对路径:

renderChunk(code, chunk, outputOptions) {
  workerAssetUrlRE.lastIndex = 0
  if (workerAssetUrlRE.test(code)) {
    const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime(
      outputOptions.format, this.environment.config.isWorker,
    )
    let match
    s = new MagicString(code)
    while ((match = workerAssetUrlRE.exec(code))) {
      const [full, hash] = match
      const filename = workerOutputCache.getEntryFilenameFromHash(hash)
      const replacement = toOutputFilePathInJS(
        this.environment, filename, 'asset', chunk.fileName, 'js',
        toRelativeRuntime,
      )
      s.update(match.index, match.index + full.length,
        typeof replacement === 'string'
          ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1)
          : `"+${replacement.runtime}+"`,
      )
    }
  }
}

17.1.7 IIFE Worker 的 import.meta 处理

IIFE 格式的 Worker 不支持 import.metawebWorkerPostPlugin 负责在后处理阶段进行替换:

// webWorkerPostPlugin
if (this.environment.config.worker.format === 'iife') {
  await init
  let imports = parse(code)[0]

  for (const { s: start, e: end, d: dynamicIndex } of imports) {
    if (dynamicIndex === -2) {  // import.meta
      const prop = code.slice(end, end + 4)
      if (prop === '.url') {
        s.overwrite(start, end + 4, 'self.location.href')
      } else {
        if (!injectedImportMeta) {
          s.prepend('const _vite_importMeta = { url: self.location.href };\n')
          injectedImportMeta = true
        }
        s.overwrite(start, end, '_vite_importMeta')
      }
    }
  }
}

import.meta.url 被替换为 self.location.href(Worker 的全局 self 引用),其他 import.meta 属性访问则使用一个注入的 polyfill 对象。

17.1.8 文件变更与缓存失效

watchChange(file) {
  if (isWorker) return
  workerOutputCaches
    .get(config)!
    .invalidateAffectedBundles(normalizePath(file))
}

当文件发生变更时,Worker 插件通过 watchChange Hook 检查该文件是否被某个 Worker bundle 引用。如果是,则将对应的 bundle 标记为失效,下次构建时重新打包。

flowchart TB
    A["文件变更: utils.ts"] --> B["watchChange Hook"]
    B --> C["遍历所有 Worker bundles"]
    C --> D{"utils.ts 在此 bundle 的<br/>watchedFiles 中?"}
    D -->|"是"| E["标记 bundle 为失效"]
    D -->|"否"| F["跳过"]
    E --> G["下次 load Hook 调用时<br/>removeBundleIfInvalidated"]
    G --> H["重新执行 bundleWorkerEntry"]

17.1.9 源码核对:workerOutputCaches 用 WeakMap 而不是 Map

§17.1.3 给出了 WorkerOutputCache 类定义,但所有 cache 实例怎么查找这个关键问题没展开。打开 vite-latest/packages/vite/src/node/plugins/worker.ts:160

const workerOutputCaches = new WeakMap<ResolvedConfig, WorkerOutputCache>()

为什么是 WeakMap?Vite 的 dev 服务器可能开 N 个子构建(测试时创建多 config、或 Monorepo 多包并行构建)——每个 ResolvedConfig 是独立对象。用普通 Map 的话,这些 config 对象会永远被强引用,进程常驻多开多轮后内存只增不减。用 WeakMap 后,config 被 GC 的瞬间,对应的 WorkerOutputCache 也被自动清理——不需要任何显式的 cleanup 逻辑。

这条实践值得沉淀成一条规则:“用对象做 Map key 且不希望阻止对象被 GC” 的场景,首选 WeakMap。具体到 Vite 的场景,config 的生命周期是”build 结束就应该回收”,WeakMap 刚好吻合;如果用 Map,就得写额外的”构建结束后 delete config from cache”代码——不写就有泄漏,写了还要考虑异常路径。WeakMap 把”正确”的成本变成零

17.1.10 源码核对:saveAsset 的重名警告——一个很少见但存在的边界

WorkerOutputCache.saveAsset(worker.ts:83-96)里藏了一段容易忽略的 warning 逻辑

saveAsset(asset: WorkerBundleAsset, logger: Logger) {
  const duplicateAsset = this.assets.get(asset.fileName)
  if (duplicateAsset) {
    if (!isSameContent(duplicateAsset.source, asset.source)) {
      logger.warn(
        `\n` +
          colors.yellow(
            `The emitted file ${JSON.stringify(asset.fileName)} overwrites a previously emitted file of the same name.`,
          ),
      )
    }
  }
  this.assets.set(asset.fileName, asset)
}

两条关键:

1、只有内容不同才警告isSameContent 先比较——同名同内容静默覆盖,同名不同内容才警告。为什么这么设计?因为 Vite 的 asset 文件名里有 hash([name]-[hash].js),同名同内容本来就是同一个 asset 被多个 Worker 引用——这是正确的去重复用,不需要警告。只有哈希冲突(极小概率)或者 build 顺序影响 hash(更小概率)才会出现同名不同内容,这时才是真 bug

2、警告而不 throw。哈希冲突极不可能,但真发生时用户的构建不会被打断——只是显式告知。这是 Vite 在”严格性 vs 可用性”之间的典型选择:先告警让用户注意,不硬阻拦让用户继续工作

17.1.11 源码核对:IIFE 模式下 Worker 里的 new Worker(new URL(..., import.meta.url))

§17.1.7 只讲了 iife Worker 里 import.meta.url 怎么被替换。但有个更复杂的场景 workerImportMetaUrl.ts(297 行的独立插件)处理——用户用标准 Web API 写 new Worker(new URL('./nested.js', import.meta.url))

打开正则表达式 workerImportMetaUrl.ts:184

export const workerImportMetaUrlRE: RegExp =
  /\bnew\s+(?:Worker|SharedWorker)\s*\(\s*(new\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\))/dg

这条正则干了一件表面不起眼但实际复杂的事——静态识别 new Worker(new URL('./x.js', import.meta.url)) 这个 4 层嵌套语法。关键点:

  • \b word boundary 避免匹配到 XXXnew Worker
  • ('[^']+'|"[^"]+"|\[^`]+`)` 同时支持三种字符串字面量
  • 模板字符串必须是单段,不能有 ${} 插值——下面的代码会检测并 this.error() 抛错:
// potential dynamic template string
if (rawUrl[0] === '`' && rawUrl.includes('${')) {
  this.error(
    `\`new URL(url, import.meta.url)\` is not supported in dynamic template string.`,
    expStart,
  )
}

为什么不支持动态模板?因为 Vite 需要在构建时就能确定 worker 入口文件——有 ${name} 插值就只能等运行时、Vite 无法预先打包这个 worker。用户如果要动态 worker,应该用 ?worker&inline 查询参数 + import 方式(Vite 能处理)。

这个正则和它后面的静态识别路径,是 Vite 为了让使用者不需要学习 Vite 特定语法、直接用标准 new Worker(new URL(...)) 而做的工程——Web 标准语法、Vite 自动识别并打包。这是 Vite 作为构建工具”默认支持标准”哲学的体现:你不需要写 import W from './w?worker',写原生的 new Worker(new URL('./w', import.meta.url)) Vite 也能认。

17.1.12 源码核对:内联 Worker 的 try/catch 回退链——为什么三种 URL 都准备

§17.1.5 给出了内联 Worker 的 Blob URL 生成逻辑。真实生成的运行时代码(worker.ts:440-457)值得逐行读——它暗含一条三级容错策略:

// Vite 生成的 Worker 代理代码
const jsContent = "/* worker code */";
const blob = typeof self !== "undefined" && self.Blob && new Blob(
  ['URL.revokeObjectURL(import.meta.url);', jsContent],
  { type: "text/javascript;charset=utf-8" }
);

export default function WorkerWrapper(options) {
  let objURL;
  try {
    objURL = blob && (self.URL || self.webkitURL).createObjectURL(blob);
    if (!objURL) throw ''
    const worker = new Worker(objURL, { type: "module", name: options?.name });
    worker.addEventListener("error", () => {
      (self.URL || self.webkitURL).revokeObjectURL(objURL);
    });
    return worker;
  } catch(e) {
    return new Worker(
      'data:text/javascript;charset=utf-8,' + encodeURIComponent(jsContent),
      { type: "module", name: options?.name }
    );
  }
}

三层 fallback:

层 1:Blob 全局存在? 某些极端运行时(老 Node、Deno Compat、自定义 JS 引擎)可能没有 Blob 构造函数——typeof self !== "undefined" && self.Blob 做兜底检查。

层 2:(self.URL || self.webkitURL).createObjectURL(blob) 能创建 URL? 某些严苛的 CSP(Content Security Policy)禁止 Blob URL——createObjectURL 会 throw 或返回 null。此时 if (!objURL) throw '' 主动抛错进入 catch。

层 3:data URL 是永远能用的底'data:text/javascript;charset=utf-8,' + encodeURIComponent(jsContent) 直接把代码编码在 URL 里——所有浏览器都支持、不受 CSP Blob 限制。代价是 URL 会大很多(data URL 包含整段代码)、且有些浏览器对长 URL 有限制。

worker.addEventListener("error", () => revokeObjectURL(objURL)) 这行容易被漏看——它是 Worker 运行期出错时自动清理 Blob URL 的保险。Worker 正常启动后,Worker 自己的代码里第一行 URL.revokeObjectURL(import.meta.url); 会 revoke 自己的 URL(防内存泄漏);但如果 Worker 代码解析就失败(语法错误),runner 内部那行 revoke 永远跑不到——外层的 error listener 接手做这件事

三层容错 + 两套 revoke 机制一起保证:无论哪个环节挂了、无论什么样的 runtime、浏览器、CSP 配置,Vite 生成的 Worker 都能启动或者优雅降级。这种”极端边界都想到”的细节是 Vite 在生产可靠性上的投入。

17.1.13 源码核对:Worker 打包 format 的三种分支

worker.ts:408-412 定义了 Worker 的 format 选择:

const workerType = config.isBundled
  ? format === 'es'
    ? 'module'
    : 'classic'
  : 'module'

这段代码把三种状态压缩成了一组条件:

  • devconfig.isBundled === false):永远用 type: 'module' Worker——现代浏览器标配,直接 import ES 模块
  • build + format=estype: 'module' Worker——和 dev 一致
  • build + format=iifetype: 'classic' Worker——IIFE 不能用 ES module 语法

为什么不统一用 module?因为 format=iife 是给不支持 module worker 的旧浏览器准备的——比如需要兼容到 Safari < 15、Chrome < 80。用户如果配置了 build.target 到这些老版本,format 会被自动降级到 iife。

这条分支逻辑导出了一条实用选择题:你的 worker 要兼容到哪些浏览器?

  • 现代浏览器 only(Chrome 80+、Safari 15+、Firefox 114+):默认配置、format: 'es'type: 'module'
  • 需要兼容老版本:worker: { format: 'iife' } + 接受所有 IIFE Worker 的 import.meta 不可用限制(通过 §17.1.7 的 polyfill 绕过)

这条分支在未来 5 年大概率会被 deprecated——iife worker 场景会随浏览器升级消失,module worker 变成唯一选择。Vite 的设计允许你今天做保守选择、明天无痛升级到纯 module。

17.2 WASM 支持

17.2.1 两种加载策略

plugins/wasm.ts.wasm?init 导入提供支持。WASM 模块在客户端和服务端使用不同的加载策略:

graph TB
    A["import init from './module.wasm?init'"] --> B{"consumer 类型?"}
    B -->|"client"| C["fetch + WebAssembly.instantiateStreaming"]
    B -->|"server"| D["fs.readFile + WebAssembly.instantiate"]

    C --> E["const instance = await init(imports)"]
    D --> E

    style C fill:#e3f2fd
    style D fill:#e8f5e9
// 客户端:通过 fetch 获取
const instantiateFromUrl = async (url, opts) => {
  const response = await fetch(url)
  const contentType = response.headers.get('Content-Type') || ''
  if ('instantiateStreaming' in WebAssembly &&
      contentType.startsWith('application/wasm')) {
    return WebAssembly.instantiateStreaming(response, opts)
  } else {
    // 回退:先获取 ArrayBuffer 再实例化
    const buffer = await response.arrayBuffer()
    return WebAssembly.instantiate(buffer, opts)
  }
}

// 服务端:通过文件系统读取
const instantiateFromFile = async (fileUrlString, opts) => {
  const { readFile } = await import('node:fs/promises')
  const fileUrl = new URL(fileUrlString, import.meta.url)
  const buffer = await readFile(fileUrl)
  return WebAssembly.instantiate(buffer, opts)
}

17.2.2 WASM Helper 注入

wasmHelperPlugin 通过虚拟模块 \0vite/wasm-helper.js 提供辅助函数:

load: {
  filter: { id: [exactRegex(wasmHelperId), wasmInitRE] },
  async handler(id) {
    const ssr = this.environment.config.consumer === 'server'

    if (id === wasmHelperId) {
      return `
const instantiateFromUrl = ${ssr ? instantiateFromFileCode : instantiateFromUrlCode}
export default ${wasmHelperCode}
`
    }

    // 对 .wasm?init 文件,生成包装模块
    id = id.split('?')[0]
    let url = await fileToUrl(this, id, ssr)
    return `
import initWasm from "${wasmHelperId}"
export default opts => initWasm(opts, ${JSON.stringify(url)})
`
  },
},

辅助函数还处理了 data URL 格式的 WASM(Base64 编码),这在某些打包场景中会出现:

const wasmHelper = async (opts, url) => {
  let result
  if (url.startsWith('data:')) {
    const urlContent = url.replace(/^data:.*?base64,/, '')
    let bytes
    if (typeof Buffer === 'function') {
      bytes = Buffer.from(urlContent, 'base64')
    } else if (typeof atob === 'function') {
      const binaryString = atob(urlContent)
      bytes = new Uint8Array(binaryString.length)
      for (let i = 0; i < binaryString.length; i++) {
        bytes[i] = binaryString.charCodeAt(i)
      }
    }
    result = await WebAssembly.instantiate(bytes, opts)
  } else {
    result = await instantiateFromUrl(url, opts)
  }
  return result.instance
}

17.2.3 SSR 路径替换

在 SSR 构建的 renderChunk 阶段,WASM 资源的 URL 需要从服务器路径替换为基于 import.meta.url 的相对路径:

renderChunk: env.config.consumer === 'server'
  ? {
      filter: { code: wasmInitUrlRE },
      async handler(code, chunk, opts, meta) {
        const toRelativeRuntime =
          createToImportMetaURLBasedRelativeRuntime(opts.format, /*...*/)
        while ((match = wasmInitUrlRE.exec(code))) {
          const [full, referenceId] = match
          const file = this.getFileName(referenceId)
          chunk.viteMetadata!.importedAssets.add(cleanUrl(file))
          const { runtime } = toRelativeRuntime(file, chunk.fileName)
          s.update(match.index, match.index + full.length,
            `"+${runtime}+"`)
        }
      },
    }
  : undefined,

17.2.4 源码核对:wasmHelper 里的 Buffer vs atob 分支——Node 和浏览器的双路径

§17.2.2 展示了 wasmHelper 对 data URL 的处理,但没强调它同时支持 Node 和浏览器的细节。打开 wasm.ts:15-38 的真实函数:

const wasmHelper = async (opts = {}, url: string) => {
  let result
  if (url.startsWith('data:')) {
    const urlContent = url.replace(/^data:.*?base64,/, '')
    let bytes
    if (typeof Buffer === 'function' && typeof Buffer.from === 'function') {
      bytes = Buffer.from(urlContent, 'base64')
    } else if (typeof atob === 'function') {
      const binaryString = atob(urlContent)
      bytes = new Uint8Array(binaryString.length)
      for (let i = 0; i < binaryString.length; i++) {
        bytes[i] = binaryString.charCodeAt(i)
      }
    } else {
      throw new Error(
        'Failed to decode base64-encoded data URL, Buffer and atob are not supported',
      )
    }
    result = await WebAssembly.instantiate(bytes, opts)
  } else {
    result = await instantiateFromUrl(url, opts)
  }
  return result.instance
}

两条同义路径同时存在是因为Node 有 Buffer 但没 atob,老浏览器有 atob 但没 Buffer(新浏览器两个都有):

  • Node 环境(SSR、worker_threads)Buffer.from(x, 'base64') 是最快的路径——C++ 实现、零中间 string 分配。
  • 浏览器环境atob 先把 base64 decode 成一个二进制字符串(每个 char 的 charCode < 256),再用 for 循环构造 Uint8Array。charCodeAt 循环比 Buffer.from 慢一个数量级,但这是浏览器老 API 的唯一路径。
  • 都没有:比如某些极端的 IoT 嵌入式 JS 引擎——直接抛错,让用户知道环境不支持。

这条”双路径 + 抛错兜底”的写法对一个做 WASM 的第三方 helper 是必做的。Vite 把它写进框架,用户完全不用感知——这是”平台差异应该被基础库吃下去、不向上暴露”的典型工程哲学。

17.2.5 源码核对:为什么 SSR 走 instantiateFromFile 不走 fetch?

wasmHelperPlugin.load 里有一行关键判断(wasm.ts:90-94):

const ssr = this.environment.config.consumer === 'server'
if (id === wasmHelperId) {
  return `
const instantiateFromUrl = ${ssr ? instantiateFromFileCode : instantiateFromUrlCode}
export default ${wasmHelperCode}
`
}

根据 consumer 类型注入不同的 instantiateFromUrl 函数源码。SSR 侧的 instantiateFromFile(wasm.ts:63-71)是这样的:

const instantiateFromFile = async (fileUrlString: string, opts?) => {
  const { readFile } = await import('node:fs/promises')
  const fileUrl = new URL(fileUrlString, /** #__KEEP__ */ import.meta.url)
  const buffer = await readFile(fileUrl)
  return WebAssembly.instantiate(buffer, opts)
}

fs.readFile 直接读、不走 HTTP fetch。为什么?SSR 场景下 fetch 和 HTTP 服务器可能根本不存在——Node SSR 本身就是服务器、向谁发请求?读本地文件才是正确的语义。

这也解释了为什么 fileUrl 构造用 new URL(fileUrlString, import.meta.url)——把相对路径解析成 absolute file:// URL,不管 Vite 把 WASM 放在哪个目录,import.meta.url 能定位到当前模块,基于这个去找 WASM 文件就永远正确。/** #__KEEP__ */ 注释是告诉 Rolldown 这个 import.meta.url 不要被 inline——保留它的运行时求值语义。

17.2.6 源码核对:instantiateStreaming vs instantiate 的 MIME type 条件分支

客户端版的 instantiateFromUrl(wasm.ts:42-59)代码看似简单,但藏着一条关于 Web 服务器 MIME 配置的世界级头疼

const instantiateFromUrl = async (url: string, opts?) => {
  // https://github.com/mdn/webassembly-examples/issues/5
  // WebAssembly.instantiateStreaming requires the server to provide the
  // correct MIME type for .wasm files, which unfortunately doesn't work for
  // a lot of static file servers, so we just work around it by getting the
  // raw buffer.
  const response = await fetch(url)
  const contentType = response.headers.get('Content-Type') || ''
  if (
    'instantiateStreaming' in WebAssembly &&
    contentType.startsWith('application/wasm')
  ) {
    return WebAssembly.instantiateStreaming(response, opts)
  } else {
    const buffer = await response.arrayBuffer()
    return WebAssembly.instantiate(buffer, opts)
  }
}

关键:WebAssembly.instantiateStreaming 要求服务器响应头 Content-Typeapplication/wasm——不是 octet-stream、不是空——严格 application/wasm很多默认的静态文件服务器不发这个 MIME(Node 的 http.createServer 默认没配、某些 CDN 没配)——用户部署后 instantiateStreaming 就悄悄失败。

Vite 的解决方案不是”教用户配 MIME”(那会有大量 issue),而是自动检测 Content-Type,如果不对就降级到 arrayBuffer + instantiate 的慢路径。慢路径的代价是 WASM 文件要完整下载再 parse(不能流式),但功能永远不坏

注释里贴了 MDN 的 issue 链接——这是 Vite 开发者实际踩过坑、查过社区讨论、最后在代码里记录原因的证据。这种”源码注释带社区链接”的风格是判断一份代码”经过实战验证”的标志——没人愿意在没踩过坑的地方写这么具体的注释

17.3 动态导入变量

17.3.1 问题描述

动态导入的参数如果包含变量,构建工具在编译时无法确定实际的模块路径:

const module = await import(`./pages/${name}.vue`)

Vite 的 dynamicImportVars.ts 插件将这类模式转换为 import.meta.glob,利用文件系统匹配来穷举所有可能的模块。

17.3.2 转换流程

flowchart TB
    A["import(`./pages/${name}.vue`)"] --> B["es-module-lexer 解析"]
    B --> C["提取动态导入的模板字符串"]
    C --> D["dynamicImportToGlob 转换为 glob 模式"]
    D --> E["'./pages/${name}.vue' -> './pages/*.vue'"]
    E --> F["生成 import.meta.glob 表达式"]
    F --> G["注入 __variableDynamicImportRuntimeHelper"]
    G --> H["运行时根据路径查找匹配的模块"]

核心转换逻辑:

export async function transformDynamicImport(importSource, importer, resolve, root) {
  // 非相对路径先尝试解析
  if (importSource[1] !== '.' && importSource[1] !== '/') {
    const resolvedFileName = await resolve(importSource.slice(1, -1), importer)
    if (!resolvedFileName) return null
    const relativeFileName = normalizePath(
      posix.relative(posix.dirname(normalizePath(importer)), resolvedFileName),
    )
    importSource = '`' + (relativeFileName[0] === '.' ? '' : './') + relativeFileName + '`'
  }

  const dynamicImportPattern = parseDynamicImportPattern(importSource)
  if (!dynamicImportPattern) return null

  const { globParams, rawPattern, userPattern } = dynamicImportPattern
  const params = globParams ? `, ${JSON.stringify(globParams)}` : ''
  const exp = `(import.meta.glob(${JSON.stringify(userPattern)}${params}))`

  return { rawPattern: newRawPattern, pattern: userPattern, glob: exp }
}

17.3.3 运行时辅助函数

转换后的代码使用 __variableDynamicImportRuntimeHelper 在运行时查找匹配的模块:

const dynamicImportHelper = (glob, path, segs) => {
  const v = glob[path]
  if (v) {
    return typeof v === 'function' ? v() : Promise.resolve(v)
  }
  return new Promise((_, reject) => {
    ;(typeof queueMicrotask === 'function' ? queueMicrotask : setTimeout)(
      reject.bind(null, new Error(
        'Unknown variable dynamic import: ' + path +
          (path.split('/').length !== segs
            ? '. Note that variables only represent file names one level deep.'
            : ''),
      )),
    )
  })
}

当路径匹配到 glob 对象中的键时,调用对应的 loader 函数(懒加载)或直接返回值(eager 加载)。匹配失败时抛出带有诊断信息的错误。

17.3.4 native 模式

在构建模式下,动态导入变量使用 Rolldown 的内置实现:

if (config.isBundled) {
  return perEnvironmentPlugin('native:dynamic-import-vars', (environment) => {
    const { include, exclude } =
      environment.config.build.dynamicImportVarsOptions
    return nativeDynamicImportVarsPlugin({
      include, exclude,
      resolver(id, importer) {
        return resolve(environment, id, importer)
      },
      sourcemap: !!environment.config.build.sourcemap,
    })
  })
}

17.3.5 源码核对:dynamicImportHelper 的 segs 参数和”一级变量”约束

§17.3.3 给了 dynamic import helper 的代码但一个关键信号没讲——错误消息里的 segs 检查。打开 dynamicImportVars.ts:46-69

const dynamicImportHelper = (
  glob: Record<string, any>,
  path: string,
  segs: number,
) => {
  const v = glob[path]
  if (v) {
    return typeof v === 'function' ? v() : Promise.resolve(v)
  }
  return new Promise((_, reject) => {
    ;(typeof queueMicrotask === 'function' ? queueMicrotask : setTimeout)(
      reject.bind(
        null,
        new Error(
          'Unknown variable dynamic import: ' +
            path +
            (path.split('/').length !== segs
              ? '. Note that variables only represent file names one level deep.'
              : ''),
        ),
      ),
    )
  })
}

segs编译时算出的路径段数——比如模板串 ./pages/${name}.vue 去掉插值后是 ./pages/.vue,段数是 3(点、pages、点 vue)。运行时如果用户传的 path 对应的段数不一样——比如 ./pages/subdir/index.vue(4 段),helper 会在错误消息里加一句 “Note that variables only represent file names one level deep.”

为什么 Vite 要做这个限制?因为 dynamicImportToGlob 转换 ${name} 时,默认的 glob 模式是星号(单一路径段),不包括子目录。如果用户真的想跨子目录匹配,应该用多层变量或显式的双星号——Vite 不做”自动跨目录”的猜测。

这条约束避免了一个常见陷阱:用户期望动态 import 模板能匹配深层嵌套路径——但 glob 只是单层星号。没命中时 Vite 的 helper 专门检测”段数对不上 segs”场景并给出指向根因的错误消息——远比”模块未找到”的泛泛报错有帮助。

这种”特定错误场景给特定提示”的手法是 Vite 错误处理的一致风格——和后面 §17.5.3 将讲的 parseGlobOptions 的分类错误消息、§17.1 讲过的 Worker 循环引用检测错误一脉相承——错误消息的目的是指导修复,不是证明错误存在

17.3.6 源码核对:queueMicrotask 回退 setTimeout 的兼容性

dynamicImportHelper 代码里的 reject 触发方式看起来奇怪:

;(typeof queueMicrotask === 'function' ? queueMicrotask : setTimeout)(
  reject.bind(null, new Error(...)),
)

为什么不直接 return Promise.reject?答案藏在 Promise 的时序语义里——构造一个立刻 reject 的 Promise,如果没在同 tick 内挂 catch,会被浏览器报成 unhandledRejection 警告。但如果用户的 dynamic import 正常流程是 await 之后的 try/catch 接住——理论上不该有 unhandledRejection。但实践中某些浏览器或运行时的微任务调度次序会让”立刻 reject”的 Promise 比 await 机制先到达检测器——导致误报。

用 queueMicrotask 把 reject 推迟到下一个微任务,保证 await 的 catch 机制先安装好再触发拒绝——完全消除误报。setTimeout 是对没有 queueMicrotask 的老环境(旧 Safari、某些 node polyfill 场景)的兜底。

这种”一行代码解决一个异步时序的微妙问题”是 Vite 在 runtime helper 层积累的实战经验。用户写业务代码时几乎永远不会想到这个问题,但框架要为每一个”我的动态 import 失败时为什么控制台有 unhandledRejection”的 issue 负责——Vite 在 helper 里一次性修好,用户写代码时完全不需要考虑。

17.4 import.meta.glob

17.4.1 功能概览

import.meta.glob 是 Vite 的标志性特性之一,它允许使用 glob 模式批量导入文件:

// 懒加载所有 .vue 文件
const modules = import.meta.glob('./components/*.vue')
// { './components/Foo.vue': () => import('./components/Foo.vue'), ... }

// eager 加载
const modules = import.meta.glob('./components/*.vue', { eager: true })
// { './components/Foo.vue': Module, ... }

// 指定导入
const modules = import.meta.glob('./components/*.vue', { import: 'default' })

// 自定义查询
const modules = import.meta.glob('./data/*.json', { query: '?raw' })

17.4.2 解析流程

importMetaGlob.tstransform Hook 中处理 import.meta.glob 调用:

flowchart TB
    A["检测代码中的 import.meta.glob"] --> B["stripLiteral 处理字符串"]
    B --> C["正则匹配 glob 调用位置"]
    C --> D["rolldown parseAstAsync 解析参数"]
    D --> E["提取 glob 模式和选项"]
    E --> F["tinyglobby 匹配文件系统"]
    F --> G["生成代码替换"]

    subgraph "选项处理"
        H["eager: boolean"]
        I["import: string"]
        J["query: string | object"]
        K["as: string"]
        L["exhaustive: boolean"]
    end

    E --> H
    E --> I
    E --> J
    E --> K
    E --> L

17.4.3 代码生成

对于非 eager 模式,import.meta.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'),
}

对于 eager 模式,生成静态导入:

// 输入
const modules = import.meta.glob('./pages/*.vue', { eager: true })

// 输出
import * as __glob_0 from './pages/Home.vue'
import * as __glob_1 from './pages/About.vue'
import * as __glob_2 from './pages/Contact.vue'
const modules = {
  './pages/Home.vue': __glob_0,
  './pages/About.vue': __glob_1,
  './pages/Contact.vue': __glob_2,
}

17.4.4 选项处理

parseGlobOptions 函数解析和验证 glob 选项:

const knownOptions = {
  as: ['string'],
  eager: ['boolean'],
  import: ['string'],
  exhaustive: ['boolean'],
  query: ['object', 'string'],
  base: ['string'],
}

function parseGlobOptions(rawOpts, optsStartIndex, logger) {
  let opts = evalValue(rawOpts)  // 静态求值
  if (opts == null) return {}

  // 类型验证
  for (const key in opts) {
    if (!(key in knownOptions)) {
      throw err(`Unknown glob option "${key}"`, optsStartIndex)
    }
    const allowedTypes = knownOptions[key]
    const valueType = typeof opts[key]
    if (!allowedTypes.includes(valueType)) {
      throw err(
        `Expected glob option "${key}" to be of type ${allowedTypes.join(' or ')}, but got ${valueType}`,
        optsStartIndex,
      )
    }
  }

  // base 路径验证
  if (opts.base) {
    if (opts.base[0] === '!') {
      throw err('Option "base" cannot start with "!"', optsStartIndex)
    }
    if (!opts.base.startsWith('/') &&
        !opts.base.startsWith('./') &&
        !opts.base.startsWith('../')) {
      throw err(`Option "base" must start with '/', './' or '../'`, optsStartIndex)
    }
  }
}

选项通过 evalValue 进行静态求值。这意味着选项必须是编译时可确定的字面量,不能使用变量。这个限制是必要的,因为 glob 匹配在编译时执行。

17.4.5 HMR 联动

import.meta.glob 与 HMR 系统深度集成。当新文件被添加或已有文件被删除时,使用了该 glob 模式的模块需要被重新转换:

hotUpdate({ type, file, modules: oldModules }) {
  if (type === 'update') return  // 内容变更不影响 glob 结果

  const importGlobMap = importGlobMaps.get(this.environment)
  if (!importGlobMap) return

  const modules: EnvironmentModuleNode[] = []
  for (const [id, globMatchers] of importGlobMap) {
    // 检查变更的文件是否匹配某个 glob 模式
    if (globMatchers.some((matcher) => matcher(file))) {
      const mod = this.environment.moduleGraph.getModuleById(id)
      if (mod) modules.push(mod)
    }
  }
  return modules.length > 0 ? [...oldModules, ...modules] : undefined
}
sequenceDiagram
    participant FS as 文件系统
    participant Watcher as 文件监听器
    participant Plugin as importGlobPlugin
    participant HMR as HMR 系统

    FS->>Watcher: 新建文件 pages/New.vue
    Watcher->>Plugin: hotUpdate({ type: 'create', file: 'pages/New.vue' })
    Plugin->>Plugin: 检查 importGlobMaps
    Plugin->>Plugin: 'pages/New.vue' 匹配 './pages/*.vue'
    Plugin-->>HMR: 返回使用该 glob 的模块列表
    HMR->>HMR: 触发模块重新转换
    HMR->>HMR: 发送 HMR update

Glob matchers 使用 picomatch 库生成高效的匹配函数,支持否定模式(!pattern):

const globMatchers = allGlobs.map((globs) => {
  const affirmed: string[] = []
  const negated: string[] = []
  for (const glob of globs) {
    if (glob[0] === '!') {
      negated.push(glob.slice(1))
    } else {
      affirmed.push(glob)
    }
  }
  const affirmedMatcher = picomatch(affirmed)
  const negatedMatcher = picomatch(negated)

  return (file: string) => {
    return (
      (affirmed.length === 0 || affirmedMatcher(file)) &&
      !(negated.length > 0 && negatedMatcher(file))
    )
  }
})

17.4.6 与 Object.keys/Object.values 的优化

插件检测 Object.keys(import.meta.glob(...))Object.values(import.meta.glob(...)) 模式,分别标记为 onlyKeysonlyValues,以便在代码生成阶段进行优化 — 例如 onlyKeys 时不需要生成导入语句,只需要键列表。

const importGlobRE = /\bimport\.meta\.glob(?:<\w+>)?\s*\(/g
const objectKeysRE = /\bObject\.keys\(\s*$/
const objectValuesRE = /\bObject\.values\(\s*$/

17.5 Worker 环境注入

17.5.1 Worker 类型与环境变量

Worker 脚本需要访问 Vite 的环境变量(import.meta.env),但注入方式取决于 Worker 类型:

transform: {
  filter: { id: workerFileRE },
  async handler(raw, id) {
    const workerType = workerFileMatch[1] as WorkerType  // 'classic' | 'module' | 'ignore'

    if (workerType === 'classic') {
      // classic Worker: 通过 importScripts 加载环境变量
      const scriptPath = JSON.stringify(
        path.posix.join(config.base, ENV_PUBLIC_PATH),
      )
      injectEnv = `importScripts(${scriptPath})\n`
    } else if (workerType === 'module') {
      // module Worker: 通过 import 加载
      const scriptPath = JSON.stringify(ENV_PUBLIC_PATH)
      injectEnv = `import ${scriptPath}\n`
    } else if (workerType === 'ignore') {
      // 动态类型: 在开发模式下内联环境变量代码
      if (!config.isBundled) {
        const module = moduleGraph?.getModuleById(ENV_ENTRY)
        injectEnv = module?.transformResult?.code || ''
      }
    }
  },
}
graph TB
    A["Worker 环境注入"] --> B{"Worker 类型?"}
    B -->|"classic"| C["importScripts(/@vite/env)"]
    B -->|"module"| D["import '/@vite/env'"]
    B -->|"ignore (动态)"| E["内联 env 代码到文件头"]

    style C fill:#e3f2fd
    style D fill:#e8f5e9
    style E fill:#fff3e0

17.5.2 源码核对:importGlobPlugin 的 Map-of-Map 结构与 HMR 联动

§17.4 讲了 import.meta.glob 的转换,但一个关键问题没展开——一个文件变更时,Vite 怎么知道哪些模块的 glob 需要重新扫描? 答案在 importMetaGlob.ts:46-115

打开 importGlobPlugin,它维护一个双层 Map

const importGlobMaps = new Map<
  Environment,                                    // 外层 key
  Map<string, Array<(file: string) => boolean>>   // 外层 value
>()
  • 外层 Map key:Environment——对应不同的构建环境(client/ssr/worker)
  • 内层 Map key:id——使用了 import.meta.glob 的源文件 id
  • 内层 Map value:Array<(file: string) => boolean>——每个 glob 调用编译后得到的匹配函数

这层抽象让 HMR 变得极简——hotUpdate 钩子拿到变更文件、遍历所有 glob 匹配函数、找出哪些源文件的 glob 匹配到变更路径、把它们加入待重建模块列表:

hotUpdate({ type, file, modules: oldModules }) {
  if (type === 'update') return
  const importGlobMap = importGlobMaps.get(this.environment)
  if (!importGlobMap) return

  const modules: EnvironmentModuleNode[] = []
  for (const [id, globMatchers] of importGlobMap) {
    if (globMatchers.some((matcher) => matcher(file))) {
      const mod = this.environment.moduleGraph.getModuleById(id)
      if (mod) modules.push(mod)
    }
  }
  return modules.length > 0 ? [...oldModules, ...modules] : undefined
}

三条细节:

1、type === 'update' 的短路。文件更新(不是创建/删除)触发 glob 重扫——因为 glob 是按文件名匹配的,内容改变不影响匹配结果。这个短路避免了”每次你 save 代码都重新扫一遍全项目”的 N 倍开销——对大型 Monorepo 尤其重要。

2、匹配函数的构造逻辑体现了 glob 的联合语义。看 importMetaGlob.ts:74-94

const globMatchers = allGlobs.map((globs) => {
  const affirmed: string[] = []
  const negated: string[] = []
  for (const glob of globs) {
    if (glob[0] === '!') {
      negated.push(glob.slice(1))
    } else {
      affirmed.push(glob)
    }
  }
  const affirmedMatcher = picomatch(affirmed)
  const negatedMatcher = picomatch(negated)

  return (file: string) => {
    // (glob1 || glob2) && !(glob3 || glob4)...
    return (
      (affirmed.length === 0 || affirmedMatcher(file)) &&
      !(negated.length > 0 && negatedMatcher(file))
    )
  }
})

用户写 import.meta.glob(['./src/**/*.ts', '!./src/**/*.test.ts']) 时,Vite 把正向和反向模式分开 compile 成两个 matcher——最终匹配函数是 正向.any || 无正向 && !反向.any。picomatch 是 glob 匹配的高性能库,每个 matcher 预编译成一个 function,每次只花 O(glob 段数) 的时间。大型项目有几十个 import.meta.glob 调用、每次文件变更要遍历所有 glob——这份预编译 matcher 的价值会被放大。

3、Environment 级别的 scope 清空buildStart()importGlobMaps.clear()——每次 build 重新开始时清空所有 glob 状态。因为 build 过程可能改变 glob 参数(比如你改了 glob 调用的字符串),缓存下来的 matcher 可能陈旧。清空 cache 保证下一次 transform 重新注册 matcher

这条”预编译 matcher + HMR 精准定位 + buildStart 清空”的三段设计让 import.meta.glob 既支持热更新、又性能高、又不泄漏状态——是 Vite 在”方便性 + 性能 + 正确性”三维上的一次合格设计。

17.5.2-bis 源码核对:ignore 默认排除 **/node_modules/**

transformGlobImport(importMetaGlob.ts:444-453)里调 glob 时有一个容易被忽略的默认值:

const files = (
  await glob(globsResolved, {
    absolute: true,
    cwd,
    dot: !!options.exhaustive,
    expandDirectories: false,
    ignore: options.exhaustive ? [] : ['**/node_modules/**'],
    extglob: false,
  })
)
  .filter((file) => file !== id)
  .sort()

默认忽略 node_modules——除非用户显式传 { exhaustive: true }。这条默认值背后的考虑:

  • 用户写 import.meta.glob('/**/*.js') 几乎不会期望匹配到 node_modules 里的上万个文件——那会让 Vite 做爆炸性的工作
  • node_modules 的文件是三方代码,由外部包管理,不是项目内部需要被批量处理的内容
  • 如果某个用户真的需要扫 node_modules(罕见场景,比如自动化工具),显式传 exhaustive: true 表明意图

同一行还有 dot: !!options.exhaustive——默认不匹配点开头的文件(.env.git/**)。exhaustive 模式一切照收。这套 “默认做合理的事、exhaustive 一键切换严格模式”的设计让 import.meta.glob 在 99% 场景下”开箱即用”,1% 的高级用户有显式 opt-in 选项。

还有一条更隐蔽的处理:.filter((file) => file !== id)——排除源文件自己。防止用户在 src/foo.ts 里写 import.meta.glob('./*.ts') 时把自己也匹配进去——那会导致循环 import。这条过滤看似简单,但避免了一类极难调试的”为什么我的 glob 里有自己”bug

.sort() 让结果稳定有序——相同源码不同操作系统(不同文件系统遍历顺序)下生成的 bundle 应该一致。这是 reproducible build 的基础要求,sort() 这 6 个字符是它的兑现。

17.5.3 源码核对:parseGlobOptions 的静态求值限制

import.meta.glob(pattern, options) 的 options 参数必须是字面量对象——不能是变量、不能是引用。这条规则背后是 parseGlobOptions(importMetaGlob.ts:140-173)里的 evalValue

function parseGlobOptions(rawOpts, optsStartIndex, logger) {
  let opts: GeneralImportGlobOptions = {}
  try {
    opts = evalValue(rawOpts)
  } catch {
    throw err(
      'Vite is unable to parse the glob options as the value is not static',
      optsStartIndex,
    )
  }
  // ... 校验 opts 的各字段类型
}

evalValue 做的是受限 eval——只能处理字面量和常量表达式,不能执行函数调用或变量引用。这个限制源于 import.meta.glob 的根本性质——它必须在编译时处理,因为:

  1. Glob 匹配需要访问文件系统,运行时(浏览器)不具备这个能力
  2. 匹配结果决定了需要打包哪些模块,影响构建图
  3. Eager 模式需要展开为多个 import 语句、lazy 模式生成 dynamic import——编译时就要决定生成哪种

用户如果写 const opts = { eager: true }; import.meta.glob('./**', opts),Vite 会抛 “not static” 错误。这条规则是 Vite 为了让编译时静态分析能成立付的代价——牺牲了一点表达力,换来构建期能完全展开。

这和第 3 章讲过的”Vite 的 ESM 静态分析”路线一脉相承:任何需要编译时展开的东西,都必须编译时静态可确定。动态和静态的分界在 Vite 里是明确的——凡是 import.meta.* 这类前缀的 API,都走静态分析路径,都有”参数必须字面量”的约束。

17.5.4 源码核对:knownOptions 的类型校验——为什么要列 allowedTypes?

parseGlobOptions 除了静态性检查,还对 options 的每个 key 做类型校验(importMetaGlob.ts:159-172):

const knownOptions = {
  as: ['string'],
  eager: ['boolean'],
  import: ['string'],
  exhaustive: ['boolean'],
  query: ['object', 'string'],
  base: ['string'],
}

for (const key in opts) {
  if (!(key in knownOptions)) {
    throw err(`Unknown glob option "${key}"`, optsStartIndex)
  }
  const allowedTypes = knownOptions[key as keyof typeof knownOptions]
  const valueType = typeof opts[key as keyof GeneralImportGlobOptions]
  if (!allowedTypes.includes(valueType)) {
    throw err(
      `Expected glob option "${key}" to be of type ${allowedTypes.join(' or ')}, but got ${valueType}`,
      optsStartIndex,
    )
  }
}

knownOptions 列出的 6 个合法 options 映射到它们允许的 JS typeof 返回值。注意 query: ['object', 'string'] 有两个——因为 query 既可以是对象 {foo: 'bar'} 也可以是字符串 '?foo=bar'。其他 5 个 options 都只接受单一类型。

这套白名单 + 类型检查的校验设计把错误在构建时拦下——用户写 { eager: 'yes' }(字符串而非 boolean)会立刻得到清晰的错误:“Expected glob option ‘eager’ to be of type boolean, but got string”。没有这份校验,用户会发现 glob 根本没做 eager 打包但也不知道为什么——这种静默不正常比显式报错差一百倍。

另一个细节是 base 的格式进一步约束——必须以 /./../ 之一开头:

if (opts.base) {
  if (opts.base[0] === '!') {
    throw err('Option "base" cannot start with "!"', optsStartIndex)
  } else if (
    opts.base[0] !== '/' &&
    !opts.base.startsWith('./') &&
    !opts.base.startsWith('../')
  ) {
    throw err(
      `Option "base" must start with '/', './' or '../', but got "${opts.base}"`,
      optsStartIndex,
    )
  }
}

三选一。不以这些前缀开头意味着”可能是一个 npm 包名”——glob 对 npm 包没有良好语义(包内部路径和文件系统路径混合),Vite 拒绝处理。用户如果确实想匹配某个 npm 包下的文件,需要用 ../../node_modules/... 这种相对路径显式写出来。

这条约束看似繁琐,但它让 glob 模式的语义始终指向本项目的文件系统——不会因为 npm 包被 deduped 到不同位置而有歧义。这是把”可能的混乱”提前在 API 层消除的典型做法。

17.5.5 源码核对:getWorkerType 的两段解析——字符串 substring + AST parse

workerImportMetaUrl.ts:135-182getWorkerType 是一个展示 Vite 解析策略分层逻辑的极佳样本。它的任务:给定一段 new Worker(new URL(...), { type: "module" }) 代码和第一个括号位置,返回这个 Worker 的 type(module / classic / ignore)。

不直接调 AST parser——因为”跑一次 parse 只为了拿一个 type 字段”太贵。而是走三级降级:

第一级:字符串级别找逗号(wbkerImportMetaUrl.ts:140-149):

const commaIndex = clean.indexOf(',', i)
if (commaIndex === -1) return 'classic'     // 没逗号 → 没传 options → classic
const endIndex = findClosingParen(clean, i)
if (commaIndex > endIndex) return 'classic' // 逗号在闭合括号外 → 还是没 options

只花 O(n) 字符串扫描就淘汰了 80% 的 case——用户没传 worker options 时直接返回 classic。

第二级:检查 /* @vite-ignore */ 注释(第 152-156 行):

let workerOptString = raw.substring(commaIndex + 1, endIndex)
const hasViteIgnore = hasViteIgnoreRE.test(workerOptString)
if (hasViteIgnore) return 'ignore'

用户写 new Worker(url, /* @vite-ignore */ opts) 时 Vite 放弃静态分析,返回 ignore——让运行时自己决定 type。这条用注释做 opt-out 开关的设计和 webpack 的 /* webpackIgnore: true */ 是同源思路,给用户一个”告诉 bundler 别碰”的明确信号。

第三级:尝试 evalValue 静态求值,失败则 AST parse 找 type(parseWorkerOptions, wbkerImportMetaUrl.ts:96-133):

try {
  opts = evalValue<WorkerOptions>(rawOpts)
} catch {
  // evalValue 失败——可能 opts 里有变量、函数调用等非静态内容
  const optsNode = (
    (await parseAstAsync(`(${rawOpts})`))
      .body[0] as ESTree.ExpressionStatement
  ).expression
  const type = extractWorkerTypeFromAst(optsNode, optsStartIndex)
  if (type) return { type }
  throw err(
    'Vite is unable to parse the worker options as the value is not static. ' +
      'To ignore this error, please use /* @vite-ignore */ in the worker options.',
    optsStartIndex,
  )
}

evalValue 能处理 { type: "module" } 这种字面量;遇到 { type: getType() } 这种动态就失败。失败后 Vite 不放弃——上一条 AST 解析把整段 options 表达式 parse 成 AST,然后走 extractWorkerTypeFromAst 在 AST 上找一个 type: "module" / type: "classic" 字符串字面量 property——即使 options 其他字段是动态的,只要 type 是字面量,Vite 就能提取出来

这三级降级体现了一条 Vite 的一致工程原则:昂贵的操作(AST parse)只在更便宜的方法(字符串扫 + evalValue)都失败时才触发。大部分用户写的 Worker 调用都命中前两级——他们只付出微秒级代价;少数复杂场景才付 parse 代价;极少数表达力超出的场景才抛错要求用户 opt-out。这是一个编译器性能工程的教科书范例。

17.6 设计决策分析

17.6.1 Worker 独立构建的必要性

Worker 不能与主应用共享 bundle,因为它们运行在独立的线程中。每个 Worker 都需要一个完整的、自包含的入口文件。这就是为什么 Worker 插件为每个 Worker 创建独立的 Rolldown 构建 — 即使这增加了构建时间和复杂度。

WorkerOutputCache 通过缓存和去重来缓解这个问题。同一个 Worker 文件被多次引用时只打包一次,输出的资源文件也会去重。

17.6.2 import.meta.glob 的编译时特性

import.meta.glob 必须在编译时处理,因为:

  1. Glob 匹配需要访问文件系统,运行时(浏览器)不具备这个能力
  2. 匹配结果决定了需要打包哪些模块,影响构建图
  3. Eager 模式下生成的静态导入必须在编译时确定

这意味着 glob 模式和选项必须是静态的字面量。任何尝试使用变量的做法都会导致编译错误。

17.6.3 SharedWorker 与 Blob URL 的不兼容

graph LR
    A["Worker + Blob URL"] --> B["每次 createObjectURL 生成唯一 URL"]
    B --> C["每次 new Worker(url) 创建新实例"]
    C --> D["正确: Worker 本来就是每次新建"]

    E["SharedWorker + Blob URL"] --> F["每次 createObjectURL 生成唯一 URL"]
    F --> G["每次 new SharedWorker(url) 创建新实例"]
    G --> H["错误: SharedWorker 应该共享实例"]

    style D fill:#e8f5e9
    style H fill:#ffcdd2

SharedWorker 的共享机制基于 URL 匹配。Blob URL 每次调用 createObjectURL 都会生成不同的 URL,导致无法复用同一个 SharedWorker 实例。因此 Vite 对 SharedWorker 使用 data URL,确保内容相同的 Worker 共享同一个 URL。

17.6.3 源码核对:webWorkerPostPlugin 的 IIFE import.meta 改写——_vite_importMeta polyfill

§17.1.7 提了一句 IIFE Worker 不支持 import.meta,要替换。真实的 webWorkerPostPlugin 改写代码在 worker.ts 第 620 行附近,值得拆开来看完整机制。

当用户用 worker.format: 'iife' 打包 Worker,生成的代码不是 ES module——没有 import.meta 语法可用。但用户的 Worker 源码里可能用了 import.meta.url(Web 标准),直接输出会导致运行时 ReferenceError。

webWorkerPostPluginrenderChunk 阶段扫描 import.meta 引用,根据后跟的属性决定替换策略:

  • import.meta.url → 改成 self.location.href(Worker 的 self 是 globalThis,location.href 指向 Worker 脚本 URL)
  • 其他 import.meta.foo → 改成对一个注入的 polyfill 对象的引用:_vite_importMeta.foo

polyfill 对象只注入一次(通过 injectedImportMeta 标志位):

if (!injectedImportMeta) {
  s.prepend('const _vite_importMeta = { url: self.location.href };\n')
  injectedImportMeta = true
}
s.overwrite(start, end, '_vite_importMeta')

这里用 es-module-lexer 的 parse 结果来定位每个 import.meta 出现位置(dynamicIndex === -2 是它的专用信号)——比手写正则更精确、能正确处理字符串字面量内的 import.meta 不被误改写。

两条教学点:

1、为什么 url 特殊对待? 因为 import.meta.url 是最常用的 import.meta 属性(定位当前模块、做相对 fetch、配合 new URL() 等),把它直接展开成 self.location.href 可以省掉一次 property lookup——虽然开销不大,但 Worker 启动路径上的每一条开销都值得优化。

2、为什么不把 polyfill 写成 { url: self.location.href, get xxx() {...} } 完整对象? 因为 Vite 不知道用户会访问哪些 import.meta 属性——未来标准可能会加 import.meta.resolve 等。与其 enumerate,不如只保证最常用的 url 能正常工作,其他访问失败也能给用户清晰的 undefined。这是一条”最小可用 polyfill”原则。

17.6.4 源码核对:bundleChain 的循环引用检测——Worker 里 import 另一个 Worker 的边界

§17.1.3 给了 bundleWorkerEntry 的代码骨架,但循环引用的检测逻辑值得单独拆解。真实代码在 worker.ts:176-182:

const newBundleChain = [...config.bundleChain, input]
if (config.bundleChain.includes(input)) {
  throw new Error(
    'Circular worker imports detected. Vite does not support it. ' +
      `Import chain: ${newBundleChain.map((id) => prettifyUrl(id, config.root)).join(' -> ')}`,
  )
}

bundleChain 是一个记录 Worker 嵌套导入链的数组。Worker A 内部 import Worker B(用 new Worker(new URL(...)) 语法),B 内部又 import A——会形成循环。这种循环在运行时会变成无限递归、耗光内存;在构建时也无法正确产出 bundle(A 等 B、B 等 A,谁都不能先完成)。

Vite 的处理是在构建期就检测并抛错——把整条 import chain 用 prettifyUrl 转成相对路径字符串(可读性),然后 .join(' -> ') 拼成方向性的链。错误消息形如:

Circular worker imports detected. Vite does not support it.
Import chain: src/worker-a.ts -> src/worker-b.ts -> src/worker-a.ts

用户看这条消息立刻就能识别是哪个循环——不需要去翻整个项目。这又是一个”错误消息的目的是指导修复”的实例。

一个更深的工程问题:为什么不支持循环 Worker? 理论上可以通过延迟实例化解决(类似 ESM 的 circular import)——但 Worker 的语义里每个 Worker 是独立线程,循环创建会让线程数爆炸。Vite 选择”明确不支持”而不是”部分支持”,是因为部分支持的边界比完全禁止更容易出 bug。

这条决策和 §5.7 章 Reconciliation 的”不做最优 LIS 移动”、§9 章 serde_derive “不识别 Option” 是同一个哲学分支——当一个特性能支持但会带来极大心智成本时,果断不支持比勉强支持更好

17.6.5 本章与其他 Vite 章节的系统性关联

Reconciliation、特殊资源这一章看似和 Vite 的主流程(依赖预构建、模块解析、HMR)隔得较远,实际上它和前几章的机制紧密咬合——读完本章能把几个看似独立的概念重新串成一条线:

与第 4 章(插件系统)的接合点:本章讲的 5 个插件(worker、wasm、dynamicImportVars、importMetaGlob、workerImportMetaUrl)都是第 4 章介绍的标准插件 API 的实例化。resolveId + load + transform + renderChunk + hotUpdate + watchChange 六个 hook 在本章每个都出现了至少一次——Vite 的特殊资源支持不是”框架特性”,是”一组插件的组合”。这条认识让你以后再加一种特殊资源(比如 .glsl shader、.proto protobuf)时,知道从哪里开始——照着 wasm.ts 的 161 行骨架抄一份、改几个正则、加一个 helper 函数,就能做出一个新的特殊资源 loader。

与第 6 章(HMR)的深度耦合:importGlobPlugin.hotUpdate 展示了插件如何参与 HMR——不是”接收 HMR 通知”,而是主动给 HMR 系统返回”额外要 invalidate 的模块”。这种双向交互是 Vite HMR 强大的原因之一——第 6 章讲过的模块依赖图基础上,插件可以声明自己关心哪些文件变更并扩展重建范围。Worker 插件的 watchChange 做了类似的事——文件变更时主动标 Worker bundle 为 invalid。

与第 14 章(Rolldown 构建)的互补:本章 bundleWorkerEntry 里在主构建之外启动独立 Rolldown 构建——这是 Vite 构建流水线”主图 + 侧翼图”的典型模式。Worker 的产物不进入主 bundle、有独立的 assets 目录、独立的 chunk 命名规则——它们通过占位符在主 bundle 的 renderChunk 阶段被替换为正确 URL。这条”独立构建 + 占位符占位 + 后期替换”的管线和第 14 章讲的 CSS 代码分割是相同的工程模式——把异质的资源用同一套占位符机制接回主构建流

与 Webpack 的 Asset Modules 对照:Webpack 5 的 asset/resource、asset/inline、asset/source 四种 asset 类型通过 module.rules 配置声明;Vite 通过 URL query 参数(worker / worker&inline / raw / url)声明。两种哲学的根本不同:Webpack 的”config 驱动”让用户显式列每条规则;Vite 的”命名约定驱动”让用户在 import 处表达意图。Webpack 更适合大型项目的复杂规则管理;Vite 更适合快速原型和中小项目的直观心智。本章所有通过 query 分发的插件都是”命名约定”哲学的体现。

与第 3 章(ESM 静态分析)的一致:import.meta.glob、import.meta.url、new Worker(new URL(…))——这些都是 Vite 静态可分析的 API。只要用户用这些标准 ESM API,Vite 就能在编译时做最大化的静态展开。这是 Vite 对 Webpack 的一次反动——Webpack 依赖大量非标准的 require.context、require.ensure 等 API;Vite 坚持只识别 ESM 标准 API + URL query。这条设计让 Vite 的插件代码远比 Webpack 的 loader/plugin 代码简洁——不用处理”用户可能用各种非标准语法”的分支地狱。

这些接合点构成了 Vite 架构的一致性骨架——读完本章你会发现,Vite 对特殊资源的处理不是零散的”加个插件支持 X”,而是基于同一套插件 API + 同一套命名约定 + 同一套静态分析假设,做系统性的组合。这是一个成熟的构建系统在每一条特性上都应该呈现的美学。

17.6.6 本章源码定位索引

为便于读者按图索骥验证本章所有说法,列出各段涉及的源码文件 + 行号锚点:

小节源文件关键函数 / 行号
§17.1.1-8packages/vite/src/node/plugins/worker.ts(674 行)WorkerOutputCache:46 / bundleWorkerEntry:162 / workerFileToUrl:300 / generateEntryUrlPlaceholder:143
§17.1.9同上workerOutputCaches:160 (WeakMap)
§17.1.10同上saveAsset:83(重名警告)
§17.1.11workerImportMetaUrl.ts(297 行)workerImportMetaUrlRE:184 正则 + transform 体
§17.1.12worker.ts内联 Worker 代码生成:440-457
§17.1.13worker.tsworkerType 三分支:408-412
§17.2.4-6packages/vite/src/node/plugins/wasm.ts(161 行)wasmHelper:15 / instantiateFromUrl:42 / instantiateFromFile:63
§17.3.5-6packages/vite/src/node/plugins/dynamicImportVars.ts(297 行)dynamicImportHelper:46
§17.5.2packages/vite/src/node/plugins/importMetaGlob.ts(710 行)importGlobPlugin:37 + hotUpdate hook:101
§17.5.2-bis同上transformGlobImport:405 + ignore 默认值:450
§17.5.3同上parseGlobOptions:140
§17.5.4同上knownOptions:123
§17.5.5workerImportMetaUrl.tsgetWorkerType:135 + parseWorkerOptions:96

源码版本:本章引用的所有代码都来自 vite-latest 本地仓库(packages/vite 子目录)。读者对照时如果版本更新,具体行号可能有偏移,但函数名和正则表达式应保持一致——函数名 + 正则是比行号更稳定的定位锚

给希望深入的读者一个实操建议:clone 一份 vite 仓库、在这五个插件文件里打断点、跑一个最小 repro 项目(带 worker、wasm、glob),观察每一个 resolve/load/transform 钩子是如何在时间轴上排列的。debug 体验能把”源码层面的理解”升华为”直觉层面的熟悉”——以后你在业务代码里写 import.meta.glob('./**/*.json') 时,会自动在脑海里闪过”Vite 调 glob.tinyglobby、过滤 node_modules、按匹配函数数组注册 HMR matcher”的全流程——这种预测力就是”知其然并知其所以然”的价值。

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

作为本章掌握度的自测表,以下 12 个问题在本章内容里都有源码锚点的答案——如果你能不回源码说出原理,这一章你已经读透:

  1. workerOutputCaches 为什么用 WeakMap?(§17.1.9——避免 config 对象泄漏)
  2. 同名 asset 被多次写入为什么不报错?(§17.1.10——isSameContent 先比,内容相同静默覆盖)
  3. new Worker(new URL(..., import.meta.url)) 里 URL 能不能用模板字符串插值?(§17.1.11——不能,会 this.error 抛错)
  4. 内联 Worker 如果 CSP 禁止 Blob URL 会怎样?(§17.1.12——三级 fallback 到 data URL)
  5. 什么时候 Worker 用 iife 格式、什么时候用 es?(§17.1.13——build + target 老浏览器用 iife,否则 module)
  6. Node SSR 下 WASM 怎么加载?(§17.2.5——fs.readFile + new URL(..., import.meta.url)
  7. 为什么 WebAssembly.instantiateStreaming 会默默失败?(§17.2.6——服务器 Content-Type 不是 application/wasm,Vite 自动降级)
  8. import(`./pages/${name}.vue`) 能匹配 ./pages/sub/foo.vue 吗?(§17.3.5——不能,glob 只是单层星号)
  9. dynamicImportHelper 为什么用 queueMicrotask 而不是直接 Promise.reject(§17.3.6——避免 unhandledRejection 误报)
  10. import.meta.glob 默认会扫描 node_modules 吗?(§17.5.2-bis——默认不会,除非 exhaustive: true
  11. 为什么 glob options 不能用变量?(§17.5.3——evalValue 静态求值,因为编译时必须展开)
  12. Worker A import Worker B、B 又 import A 会发生什么?(§17.6.4——Vite 构建期检测并抛 Circular worker imports detected

这 12 个问题的答案都来自本章各节引用的源码细节,是判断是否掌握”为什么这么设计”的自测题。

17.7 小结

本章深入分析了 Vite 对特殊资源的处理机制:

  • Web Worker 插件通过独立的 Rolldown 构建管线为每个 Worker 生成自包含的 bundle。WorkerOutputCache 实现了跨引用的产物缓存和去重,内联模式通过 Blob URL 和 data URL 的组合策略处理 Worker 和 SharedWorker 的差异。URL 占位符在 renderChunk 阶段被替换为相对路径,IIFE Worker 的 import.meta 被替换为 self.location.href

  • WASM 支持通过环境感知的加载策略,在客户端使用 fetch + WebAssembly.instantiateStreaming,在服务端使用 fs.readFile + WebAssembly.instantiate。辅助函数还处理了 data URL 格式和流式实例化的降级逻辑。

  • 动态导入变量被转换为 import.meta.glob 调用,利用文件系统匹配穷举所有可能的模块。运行时辅助函数在匹配失败时提供诊断友好的错误信息。

  • import.meta.glob 在编译时通过 tinyglobby 匹配文件系统,生成包含懒加载函数或静态导入的对象字面量。它与 HMR 系统深度集成:文件的添加和删除会触发使用相关 glob 模式的模块重新转换。picomatch 生成的高效匹配函数和否定模式支持使得这个联动既精确又高效。

这些特殊资源的处理展示了 Vite 插件系统的表达力。每种资源类型都利用了 Vite 插件的不同 Hook 组合(loadtransformrenderChunkgenerateBundlehotUpdate),在开发和构建两种模式下提供一致的行为,同时与 HMR、Source Map、代码分割等核心机制保持协作。