Vite 设计与实现
第17章 Web Worker 与特殊资源
开篇引言
现代 Web 应用不仅包含 JavaScript 和 CSS,还需要处理各种特殊类型的资源:Web Worker 提供了多线程计算能力,WebAssembly 带来了接近原生的执行性能,JSON 导入需要与 Tree Shaking 协作,动态导入变量需要在构建时被静态化,import.meta.glob 则提供了文件系统级的批量导入能力。
这些特殊资源的处理是 Vite 插件系统的高级应用。每一种资源类型都需要在开发和构建两种模式下提供一致的行为,同时还要与 HMR、Source Map、代码分割等核心机制协作。
本章将深入分析 Vite 对这些特殊资源的处理实现,重点关注 Worker 插件(packages/vite/src/node/plugins/worker.ts)、WASM 支持(packages/vite/src/node/plugins/wasm.ts)、动态导入变量(packages/vite/src/node/plugins/dynamicImportVars.ts)和 import.meta.glob(packages/vite/src/node/plugins/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 运行在独立的线程中,拥有独立的全局作用域。这给构建工具带来了独特的挑战:
- Worker 脚本需要被打包为独立的入口文件
- Worker 脚本可能依赖其他模块,需要递归处理
- 开发模式和构建模式下 Worker 的加载方式不同
- 内联 Worker 需要将代码转换为 Blob URL
- SharedWorker 不能使用 Blob URL(会导致多实例)
- IIFE 格式的 Worker 不支持
import.meta
17.1.2 Worker 插件架构
Worker 插件由两个主要部分组成:webWorkerPlugin(主插件)和 webWorkerPostPlugin(后处理插件)。
graph TB
A["import MyWorker from './worker?worker'"] --> B{"开发模式(非 bundled)?"}
B -->|"是"| C["fileToUrl: 生成开发服务器 URL"]
B -->|"否"| D{"内联模式?"}
D -->|"是 (?inline)"| E["bundleWorkerEntry: 打包并内联"]
D -->|"否"| F["workerFileToUrl: 打包为独立文件"]
C --> G["返回 Worker 构造函数代码"]
E --> H["返回 Blob URL Worker 代码"]
F --> I["返回带占位符的 Worker 代码"]
J["webWorkerPlugin.renderChunk: 替换占位符为实际路径"]
subgraph "webWorkerPostPlugin(Worker 子构建内)"
K["IIFE Worker: 替换 import.meta"]
end
I --> J
E --> K
F --> 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 文件只被打包一次,即使它在多个地方被引用:
// packages/vite/src/node/plugins/worker.ts:162(签名在源码里是多行带类型)
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 构建处理:
// 简化自 packages/vite/src/node/plugins/worker.ts:186-243:省略了 onLog、moduleTypes、
// experimental 的展开、assetFileNames、try/catch,以及排在 format 之前的 ...workerConfig
// (用户 worker.rollupOptions.output 可覆盖文件名等,但覆盖不了 format / sourcemap)
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 通过 ?worker&inline(或 ?sharedworker&inline)方式导入时,Worker 代码会被内联到主 bundle 中:
// 简化自 packages/vite/src/node/plugins/worker.ts:422-471:源码是一个按 workerConstructor
// 分支的三元表达式,并用 new ${workerConstructor}(...) 生成构造调用;这里拆成 if/else,
// 省略了 addWatchFile 与 return
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}
);
}`
}
}
这段代码展示了三个关键设计:
- Blob URL 优先,data URL 回退:Blob URL 性能更好,但创建失败时回退到 data URL
- 自动 revoke:Worker 启动后通过注入的代码自动调用
revokeObjectURL,避免内存泄漏。对于classic类型使用self.location.href,对于module类型使用import.meta.url - 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 阶段,这些占位符被替换为实际的相对路径:
// 简化自 packages/vite/src/node/plugins/worker.ts:562-619:省略了 s 的声明、返回值组装,
// 以及 filename 查不到时 this.warn 后 continue 的分支
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.meta,webWorkerPostPlugin 负责在一个 order: 'post' 的 transform 钩子里进行替换(packages/vite/src/node/plugins/worker.ts:330-381;config.isBundled 时改由 Rolldown 的 nativeWebWorkerPostPlugin() 承担,见 packages/vite/src/node/plugins/worker.ts:319-328):
// packages/vite/src/node/plugins/worker.ts:330-381
// 外层是 `{ name: 'vite:worker-post', transform: { filter: { code: 'import.meta' },
// order: 'post', async handler(code, id) { ... } } }`,下面从 handler 体开始逐字:
async handler(code, id) {
// import.meta is unavailable in the IIFE worker, so we need to replace it
if (this.environment.config.worker.format === 'iife') {
await init
let imports: readonly ImportSpecifier[]
try {
imports = parse(code)[0]
} catch {
// ignore if parse fails
return
}
let injectedImportMeta = false
let s: MagicString | undefined
for (const { s: start, e: end, d: dynamicIndex } of imports) {
// is import.meta
if (dynamicIndex === -2) {
const prop = code.slice(end, end + 4)
if (prop === '.url') {
s ||= new MagicString(code)
s.overwrite(start, end + 4, 'self.location.href')
} else {
s ||= new MagicString(code)
if (!injectedImportMeta) {
s.prepend(
'const _vite_importMeta = { url: self.location.href };\n',
)
injectedImportMeta = true
}
s.overwrite(start, end, '_vite_importMeta')
}
}
}
if (!s) return
return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary', source: id }),
}
}
}
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 实例怎么查找这个关键问题没展开。打开 packages/vite/src/node/plugins/worker.ts:160:
const workerOutputCaches = new WeakMap<ResolvedConfig, WorkerOutputCache>()
为什么是 WeakMap?每次真正打包 Worker,bundleWorkerEntry 都会用 config.worker.plugins(newBundleChain) 解析出一份新的 Worker ResolvedConfig(packages/vite/src/node/config.ts:1747-1777),给它解析插件时又会调用一次 webWorkerPlugin,往 workerOutputCaches 里再登记一项;测试或编程式 API 反复创建 config 也是同理——每个 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 引用——这是正确的去重复用,不需要警告。同名不同内容要么是哈希冲突(极小概率),要么是用户通过 worker.rollupOptions.output 把文件名配成了不带 [hash] 的固定名字(bundleWorkerEntry 里 ...workerConfig 排在默认文件名之后,可以覆盖它们,worker.ts:240),这时才需要用户注意。
2、警告而不 throw。哈希冲突极不可能,但真发生时用户的构建不会被打断——只是显式告知。笔者的理解是,这体现了 Vite 在"严格性 vs 可用性"之间的取舍:先告警让用户注意,不硬阻拦让用户继续工作。
17.1.11 源码核对: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)) 时。
打开正则表达式 packages/vite/src/node/plugins/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 层嵌套语法。关键点:
\bword 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 的动态导入变量,如 import(`./workers/${name}.js?worker`)——§17.3 的动态导入变量插件会把它展开成带 query: '?worker' 的 import.meta.glob,把候选文件在构建期全部打包(packages/vite/src/node/plugins/dynamicImportVars.ts:89-104)。
这个正则和它后面的静态识别路径,是 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 回退链——三层容错
§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? 层 1 没通过时 blob 为假,objURL 也随之为假,if (!objURL) throw '' 主动抛错进入 catch;createObjectURL 或 new Worker(objURL, ...) 同步抛出异常时,同样落进 catch。
层 3:data URL 兜底。'data:text/javascript;charset=utf-8,' + encodeURIComponent(jsContent) 直接把代码编码在 URL 里,不依赖 Blob。代价是 URL 会大很多(data URL 包含整段代码)、且有些浏览器对长 URL 有限制;另外它也不是无条件可用——CSP 同样可以禁止 data: 来源的 Worker。
worker.addEventListener("error", () => revokeObjectURL(objURL)) 这行容易被漏看——它是 Worker 运行期出错时自动清理 Blob URL 的保险。Worker 正常启动后,Worker 自己的代码里第一行 URL.revokeObjectURL(import.meta.url); 会 revoke 自己的 URL(防内存泄漏);但如果 Worker 代码解析就失败(语法错误),runner 内部那行 revoke 永远跑不到——外层的 error listener 接手做这件事。
三层容错 + 两套 revoke 机制一起保证:缺少 Blob、或 Blob URL 这条路同步失败时,内联 Worker 仍有 data URL 可退;只有当 data URL 也被环境禁掉时才没有退路。这种"边界尽量想到"的细节是 Vite 在生产可靠性上的投入。
17.1.13 源码核对:Worker 打包 format 的三种分支
packages/vite/src/node/plugins/worker.ts:408-412 定义了 Worker 的 format 选择:
const workerType = config.isBundled
? format === 'es'
? 'module'
: 'classic'
: 'module'
这段代码把三种状态压缩成了一组条件:
- 非 bundled 的 dev(
config.isBundled === false,即没开experimental.bundledDev——isBundled的定义是config.experimental?.bundledDev || isBuild,见packages/vite/src/node/config.ts:1892):永远用type: 'module'Worker——现代浏览器标配,直接 import ES 模块 - build + format=es:
type: 'module'Worker——和 dev 一致 - build + format=iife:
type: 'classic'Worker——IIFE 不能用 ES module 语法
为什么不统一用 module?因为 format=iife 是给不支持 module worker 的旧浏览器准备的——比如需要兼容到 Safari < 15、Chrome < 80。注意 worker.format 的默认值就是 'iife'(packages/vite/src/node/config.ts:1822 的 format: config.worker?.format || 'iife',不会随 build.target 自动切换;docs/config/worker-options.md 也写着 Default: 'iife')——想在构建产物里得到 module worker、要显式配置 worker: { format: 'es' }。
这条分支逻辑导出了一条实用选择题:你的 worker 要兼容到哪些浏览器?
- 现代浏览器 only(Chrome 80+、Safari 15+、Firefox 114+):显式配置
worker: { format: 'es' }、产物是type: 'module'Worker - 需要兼容老版本:保持默认的
format: 'iife'+ 接受 IIFE Worker 的import.meta不可用限制(通过 §17.1.7 的 polyfill 绕过)
笔者预计这条分支迟早会被 deprecated——iife worker 场景会随浏览器升级而减少,module worker 成为主流选择。Vite 的设计允许你今天做保守选择、明天无痛升级到纯 module。
17.2 WASM 支持
17.2.1 两种加载策略
packages/vite/src/node/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)
// (此处省略 wasm.ts:101-104:SSR 下把 __VITE_ASSET__ 占位符改名为
// __VITE_WASM_INIT__,供 §17.2.3 的 renderChunk 识别)
return `
import initWasm from "${wasmHelperId}"
export default opts => initWasm(opts, ${JSON.stringify(url)})
`
},
},
辅助函数还处理了 data URL 格式的 WASM(Base64 编码),这在某些打包场景中会出现:
// packages/vite/src/node/plugins/wasm.ts:15(简化:源码签名是 (opts = {}, url: string),
// Buffer 分支还要求 typeof Buffer.from === 'function',并有两者都缺时抛错的 else,完整版见 §17.2.4)
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 的相对路径:
// packages/vite/src/node/plugins/wasm.ts:112 起(简化:省略了 match / s 的声明与
// s ??= meta.magicString ?? new MagicString(code) 的初始化、lastIndex 复位和返回值组装)
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 和浏览器的细节。打开 packages/vite/src/node/plugins/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
}
两条同义路径同时存在是因为浏览器没有 Buffer,只能靠 atob;Node 一直有 Buffer(全局 atob 是后来才补上的遗留 API):
- Node 环境(SSR、worker_threads):
Buffer.from(x, 'base64')一步直接得到字节数组。 - 浏览器环境:
atob先把 base64 decode 成一个二进制字符串(每个 char 的 charCode < 256),再用for循环构造 Uint8Array。多绕了一层字符串、还要逐字节循环,但这是浏览器上通用的路径。 - 都没有:比如某些极端的 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 场景下 WASM 文件就躺在服务端产物旁边,没有一个现成的 HTTP 源可以拿来请求相对路径——Node SSR 本身就是服务器、向谁发请求?读本地文件才是正确的语义。
这也解释了为什么 fileUrl 构造用 new URL(fileUrlString, import.meta.url)——把相对路径解析成 absolute file:// URL,不管 Vite 把 WASM 放在哪个目录,import.meta.url 能定位到当前模块,基于这个去找 WASM 文件就永远正确。/** #__KEEP__ */ 注释是给 Vite 自身的构建看的:packages/vite/rolldown.config.ts 里的 buildTimeImportMetaUrlPlugin 会把 src/ 下的 import.meta.url 换成构建期的值,带这个注释的会被跳过——这段函数要 toString() 后注入用户产物,必须保留 import.meta.url 的运行时求值语义。
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-Type 是 application/wasm——不是 octet-stream、不是空——严格 application/wasm。很多默认的静态文件服务器不发这个 MIME(Node 的 http.createServer 默认没配、某些 CDN 没配)——用户部署后 instantiateStreaming 就会因 MIME 不对而拒绝(reject),WASM 加载失败。
Vite 的解决方案不是"教用户配 MIME"(那会有大量 issue),而是自动检测 Content-Type,如果不对就降级到 arrayBuffer + instantiate 的慢路径。慢路径的代价是 WASM 文件要完整下载再 parse(不能流式),但功能永远不坏。
注释里贴了 MDN 的 issue 链接,把这条分支的由来记录在代码里——读源码时顺着链接就能查到原因。
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["运行时根据路径查找匹配的模块"]
核心转换逻辑:
// 简化自 packages/vite/src/node/plugins/dynamicImportVars.ts:114-167(类型标注省略)
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)}` : ''
// ...(省略 :148-158:把 rawPattern 规整成相对 importer、以 ./ 或 ../ 开头的 newRawPattern)
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 模式
在 bundled 模式下(config.isBundled:build,或开了 experimental.bundledDev 的 dev),动态导入变量使用 Rolldown 的内置实现:
// packages/vite/src/node/plugins/dynamicImportVars.ts:176
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 检查。打开 packages/vite/src/node/plugins/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 是编译时算出的路径段数——transform 里用 rawPattern.split('/').length 求出(dynamicImportVars.ts:283),插值原样留在串里:模板串 ./pages/${name}.vue 按 / 切成 .、pages、${name}.vue,段数是 3。运行时如果用户传的 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?源码没有注释,引入这个 helper 的提交(80d113b1d,feat: rework dynamic-import-vars (#7756),2022-05)也没写明理由。能确定的是效果:拒绝被推迟到 queueMicrotask(或 setTimeout)的回调里才发生,失败是异步报告的。它并不是为了避免 unhandledRejection 误报——调用方同步 await 返回值时,Promise.reject() 同样会在微任务检查点之前挂上处理器,不会被报告。
setTimeout 是对没有 queueMicrotask 的老环境的兜底。
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.ts 在 transform 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 Object.assign({
'./pages/About.vue': () => import('./pages/About.vue'),
'./pages/Contact.vue': () => import('./pages/Contact.vue'),
'./pages/Home.vue': () => import('./pages/Home.vue'),
})
对于 eager 模式,生成静态导入:
// 输入
const modules = import.meta.glob('./pages/*.vue', { eager: true })
// 输出(匹配结果按路径排序)
import * as __vite_glob_0_0 from './pages/About.vue'
import * as __vite_glob_0_1 from './pages/Contact.vue'
import * as __vite_glob_0_2 from './pages/Home.vue'
const modules = /* #__PURE__ */ Object.assign({
'./pages/About.vue': __vite_glob_0_0,
'./pages/Contact.vue': __vite_glob_0_1,
'./pages/Home.vue': __vite_glob_0_2,
})
17.4.4 选项处理
parseGlobOptions 函数解析和验证 glob 选项:
const knownOptions = {
as: ['string'],
eager: ['boolean'],
import: ['string'],
exhaustive: ['boolean'],
query: ['object', 'string'],
base: ['string'],
}
// 简化自 packages/vite/src/node/plugins/importMetaGlob.ts:140-235:省略了 evalValue 的
// try/catch(见 §17.5.3)、base 报错里的 `, but got "..."` 后缀,以及 query / as 的后续处理与 return
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 模式的模块需要被重新转换:
// packages/vite/src/node/plugins/importMetaGlob.ts:101
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):
// packages/vite/src/node/plugins/importMetaGlob.ts:74
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(...)) 模式,分别标记为 onlyKeys 和 onlyValues,以便在代码生成阶段进行优化——例如 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 类型:
// packages/vite/src/node/plugins/worker.ts:516-557(简化:原注释换成中文说明,workerFileMatch 判空、
// moduleGraph 的取法(dev 环境才取 environment.moduleGraph)省略,ignore 分支的 isBundled 判断改写成取反)
transform: {
filter: { id: workerFileRE },
async handler(raw, id) {
const workerFileMatch = workerFileRE.exec(id) // :519,匹配不上就整段跳过
const workerType = workerFileMatch[1] as WorkerType // 'classic' | 'module' | 'ignore'
let injectEnv = ''
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 || ''
}
}
// :548-555——真正把 env 代码顶到文件头的是这一段
if (injectEnv) {
const s = new MagicString(raw)
s.prepend(injectEnv + ';\n')
return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary' }),
}
}
},
}
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 需要重新扫描? 答案在 packages/vite/src/node/plugins/importMetaGlob.ts:46-115:
打开 importGlobPlugin,它维护一个双层 Map:
const importGlobMaps = new Map<
Environment, // 外层 key
Map<string, Array<(file: string) => boolean>> // 外层 value
>()
- 外层 Map key:
Environment——对应不同的环境(如 client / ssr) - 内层 Map key:
id——使用了import.meta.glob的源文件 id - 内层 Map value:
Array<(file: string) => boolean>——每个glob调用编译后得到的匹配函数
这层抽象让 HMR 变得极简——hotUpdate 钩子拿到变更文件、遍历所有 glob 匹配函数、找出哪些源文件的 glob 匹配到变更路径、把它们加入待重建模块列表:
// packages/vite/src/node/plugins/importMetaGlob.ts:101
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 是按文件名匹配的,内容改变不影响匹配结果。这个短路让普通的保存不必遍历所有 glob 匹配函数,也不会把 glob 了该文件的模块额外拉进 HMR 更新范围。
2、匹配函数的构造逻辑体现了 glob 的联合语义。看 packages/vite/src/node/plugins/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。大型项目有几十个 import.meta.glob 调用、每次文件变更要遍历所有 glob——这份预编译 matcher 的价值会被放大。
3、Environment 级别的 scope 清空。buildStart() 里 importGlobMaps.clear()——插件管线启动时清空所有 glob 状态,避免沿用上一轮陈旧的 matcher。清空 cache 保证下一次 transform 重新注册 matcher。注意这整套 JS 实现(包括 hotUpdate)只在非 bundled 的 dev 下生效:config.isBundled 时 importGlobPlugin 直接返回 Rolldown 的 nativeImportGlobPlugin(importMetaGlob.ts:38-44)。
这条"预编译 matcher + HMR 精准定位 + buildStart 清空"的三段设计让 import.meta.glob 既支持热更新、又性能高、又不泄漏状态——是 Vite 在"方便性 + 性能 + 正确性"三维上的一次合格设计。
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 的根本性质——它必须在编译时处理,因为:
- Glob 匹配需要访问文件系统,运行时(浏览器)不具备这个能力
- 匹配结果决定了需要打包哪些模块,影响构建图
- Eager 模式需要展开为多个 import 语句、lazy 模式生成 dynamic import——编译时就要决定生成哪种
用户如果写 const opts = { eager: true }; import.meta.glob('./**', opts),Vite 会抛 "not static" 错误。这条规则是 Vite 为了让编译时静态分析能成立付的代价——牺牲了一点表达力,换来构建期能完全展开。
这和第 1 章讲过的基于原生 ESM 做静态分析的路线一脉相承:任何需要编译时展开的东西,都必须编译时静态可确定。动态和静态的分界在 Vite 里是明确的——像 import.meta.glob、new Worker(new URL(..., import.meta.url)) 这类需要编译期展开的写法,都走静态分析路径,都有"参数必须字面量"的约束。
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 的格式进一步约束——必须以 /、./、../ 之一开头:
// packages/vite/src/node/plugins/importMetaGlob.ts:175
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 包下的文件,只能用相对路径写出来,并留意 glob 默认带 **/node_modules/** 忽略规则(exhaustive: true 时才去掉,packages/vite/src/node/plugins/importMetaGlob.ts:450)。
这条约束看似繁琐,但笔者认为它让 glob 模式的语义始终指向本项目的文件系统——不会因为 npm 包被 deduped 到不同位置而有歧义。这是把"可能的混乱"提前在 API 层消除的典型做法。
17.5.5 源码核对:getWorkerType 的两段解析——字符串 substring + AST parse
packages/vite/src/node/plugins/workerImportMetaUrl.ts:135-182 的 getWorkerType 是一个展示 Vite 解析策略分层逻辑的极佳样本。它的任务:给定一段 new Worker(new URL(...), { type: "module" }) 代码和第一个括号位置,返回这个 Worker 的 type(module / classic / ignore)。
它不直接调 AST parser——因为"跑一次 parse 只为了拿一个 type 字段"太贵。而是走三级降级:
第一级:字符串级别找逗号(workerImportMetaUrl.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
只花一次线性字符串扫描就处理掉最简单的一类 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, workerImportMetaUrl.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 调用止步于字符串扫描或 evalValue——代价很小;少数复杂场景才付 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 必须在编译时处理,因为:
- Glob 匹配需要访问文件系统,运行时(浏览器)不具备这个能力
- 匹配结果决定了需要打包哪些模块,影响构建图
- 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.4 源码核对:webWorkerPostPlugin 的 IIFE import.meta 改写——_vite_importMeta polyfill
§17.1.7 提了一句 IIFE Worker 不支持 import.meta,要替换。真实的 webWorkerPostPlugin 改写代码在 packages/vite/src/node/plugins/worker.ts:330-381 的 transform(order: 'post') 钩子里——不是 renderChunk;而且这条 JS 路径只在非 bundled 时走,config.isBundled(build / bundledDev)下改由 packages/vite/src/node/plugins/worker.ts:319-328 的 nativeWebWorkerPostPlugin() 承担。还要注意:这个插件只挂在 Worker 子构建的插件列表里(packages/vite/src/node/build.ts:517,config.isWorker 时才加),而 Worker 子构建(bundleWorkerEntry)只会从 isBundled 分支触发,所以在 8.0.3 里实际生效的是原生版,下面这段 JS 实现可以当作参考实现来读。值得拆开来看完整机制。
当用户用 worker.format: 'iife' 打包 Worker,生成的代码不是 ES module——没有 import.meta 语法可用。但用户的 Worker 源码里可能用了 import.meta.url(Web 标准),直接输出会导致运行时 ReferenceError。
webWorkerPostPlugin 在这个 order: 'post' 的 transform 里扫描 import.meta 引用(钩子本身还带 filter: { code: 'import.meta' },代码里没这个字面量就根本不进 handler),根据后跟的属性决定替换策略:
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? IIFE 格式的 Worker 是 classic script,拿不到 import.meta,必须替换成别的对象;而 import.meta.url 是最常用的 import.meta 属性(定位当前模块、做相对 fetch、配合 new URL() 等),Worker 脚本自身的地址正好就是 self.location.href,所以直接用它填上。
2、为什么不把 polyfill 写成 { url: self.location.href, get xxx() {...} } 完整对象? 源码没有解释,笔者的理解是:import.meta 还有 resolve 等成员,各工具也会各自扩展,逐个模拟不现实。与其 enumerate,不如只保证最常用的 url 能正常工作,其他属性读到的是 undefined。笔者把它概括为"最小可用 polyfill"原则。
17.6.5 源码核对: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 是独立线程,循环创建会让线程数不断增长。源码只说明不支持、没有解释原因,笔者的理解是:部分支持的边界比完全禁止更容易出 bug,所以 Vite 选择了"明确不支持"。
这条决策和《React 19 内核探秘》专栏第 5 章 Reconciliation 的「不做最优 LIS 移动」、Serde 专栏第 9 章 serde_derive「不识别 Option」是同一个哲学分支——当一个特性能支持但会带来极大心智成本时,果断不支持比勉强支持更好。
17.6.6 本章与其他 Vite 章节的系统性关联
特殊资源这一章看似和 Vite 的主流程(依赖预构建、模块解析、HMR)隔得较远,实际上它和前几章的机制紧密咬合——读完本章能把几个看似独立的概念重新串成一条线:
与第 4 章(插件系统)的接合点:本章讲的 5 个插件(worker、wasm、dynamicImportVars、importMetaGlob、workerImportMetaUrl)都是第 4 章介绍的标准插件 API 的实例化。resolveId + load + transform + renderChunk + hotUpdate + watchChange 六个 hook 在这 5 个插件的源码里都用到了——Vite 的特殊资源支持不是"框架特性",是"一组插件的组合"。这条认识让你以后再加一种特殊资源(比如 .glsl shader、.proto protobuf)时,知道从哪里开始——照着 wasm.ts 的 161 行骨架抄一份、改几个正则、加一个 helper 函数,就能做出一个新的特殊资源 loader。
与第 7 章(HMR)的深度耦合:importGlobPlugin.hotUpdate 展示了插件如何参与 HMR——不是"接收 HMR 通知",而是主动给 HMR 系统返回"额外要 invalidate 的模块"。这种双向交互是 Vite HMR 强大的原因之一——第 6、7 章讲过的模块图与 HMR 基础上,插件可以声明自己关心哪些文件变更并扩展重建范围。Worker 插件的 watchChange 做了类似的事——文件变更时主动标 Worker bundle 为 invalid。
与第 13、14 章(Rolldown 构建与代码分割)的互补:本章 bundleWorkerEntry 里在主构建之外启动独立 Rolldown 构建——这是 Vite 构建流水线"主图 + 侧翼图"的典型模式。Worker 的代码不并入主 chunk,而是由主构建在 generateBundle 里作为独立 asset 发出(同样落在 build.assetsDir 下,按 [name]-[hash].js 命名)——主 bundle 里对它的引用先写成占位符,在 renderChunk 阶段被替换为正确 URL。这条"独立构建 + 占位符占位 + 后期替换"的管线和第 14 章讲的 CSS 代码分割是相同的工程模式——把异质的资源用同一套占位符机制接回主构建流。
与 Webpack 的 Asset Modules 对照:Webpack 5 的 asset、asset/resource、asset/inline、asset/source 等 asset 类型通过 module.rules 配置声明;Vite 通过 URL query 参数(worker / worker&inline / raw / url)声明。两种哲学的根本不同:Webpack 的"config 驱动"让用户显式列每条规则;Vite 的"命名约定驱动"让用户在 import 处表达意图。本章所有通过 query 分发的插件都是"命名约定"哲学的体现。
与第 1 章(原生 ESM 路线)的一致:import.meta.glob、import.meta.url、new Worker(new URL(...))——这些都是 Vite 静态可分析的 API。其中 import.meta.url 与 new Worker(new URL(...)) 是标准写法,import.meta.glob 虽是 Vite 自己的扩展,也挂在 ESM 标准的 import.meta 入口上。只要用户用这些基于 ESM 的 API,Vite 就能在编译时做最大化的静态展开。这是 Vite 对 Webpack 的一次反动——Webpack 依赖大量非标准的 require.context、require.ensure 等 API;Vite 把识别面收敛在 ESM 语法(含 import.meta 上的扩展)+ URL query。这条设计让 Vite 的插件代码远比 Webpack 的 loader/plugin 代码简洁——不用处理"用户可能用各种非标准语法"的分支地狱。
这些接合点构成了 Vite 架构的一致性骨架——读完本章你会发现,Vite 对特殊资源的处理不是零散的"加个插件支持 X",而是基于同一套插件 API + 同一套命名约定 + 同一套静态分析假设,做系统性的组合。这是一个成熟的构建系统在每一条特性上都应该呈现的美学。
17.6.7 本章源码定位索引
为便于读者按图索骥验证本章所有说法,列出各段涉及的源码文件 + 行号锚点:
| 小节 | 源文件 | 关键函数 / 行号 |
|---|---|---|
| §17.1.1-8 | packages/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.11 | workerImportMetaUrl.ts(297 行) |
workerImportMetaUrlRE:184 正则 + transform 体 |
| §17.1.12 | worker.ts |
内联 Worker 代码生成:440-457 |
| §17.1.13 | worker.ts |
workerType 三分支:408-412 |
| §17.2.4-6 | packages/vite/src/node/plugins/wasm.ts(161 行) |
wasmHelper:15 / instantiateFromUrl:42 / instantiateFromFile:63 |
| §17.3.5-6 | packages/vite/src/node/plugins/dynamicImportVars.ts(298 行) |
dynamicImportHelper:46 |
| §17.5.2 | packages/vite/src/node/plugins/importMetaGlob.ts(710 行) |
importGlobPlugin:37 + hotUpdate hook:101 |
| §17.8.1 | 同上 | transformGlobImport:405 + ignore 默认值:450 |
| §17.5.3 | 同上 | parseGlobOptions:140 |
| §17.5.4 | 同上 | knownOptions:123 |
| §17.5.5 | workerImportMetaUrl.ts |
getWorkerType:135 + parseWorkerOptions:96 |
源码版本:vite v8.0.3(commit a248a41),代码位于 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.8 读完本章能回答的具体问题清单
作为本章掌握度的自测表,以下 12 个问题在本章内容里都有源码锚点的答案——如果你能不回源码说出原理,这一章你已经读透:
workerOutputCaches为什么用 WeakMap?(§17.1.9——避免 config 对象泄漏)- 同名 asset 被多次写入为什么不报错?(§17.1.10——
isSameContent先比,内容相同静默覆盖) new Worker(new URL(..., import.meta.url))里 URL 能不能用模板字符串插值?(§17.1.11——不能,会this.error抛错)- 内联 Worker 用不了 Blob URL 时会怎样?(§17.1.12——三级 fallback 到 data URL)
- 什么时候 Worker 用 iife 格式、什么时候用 es?(§17.1.13——dev 永远 module;build 看
worker.format、默认 iife、显式'es'才是 module) - Node SSR 下 WASM 怎么加载?(§17.2.5——
fs.readFile+new URL(..., import.meta.url)) - 为什么 Vite 不总是用
WebAssembly.instantiateStreaming?(§17.2.6——服务器 Content-Type 不是application/wasm时它会失败,Vite 先检查再自动降级) import(`./pages/${name}.vue`)能匹配./pages/sub/foo.vue吗?(§17.3.5——不能,glob 只是单层星号)- dynamicImportHelper 为什么用
queueMicrotask而不是直接Promise.reject?(§17.3.6——效果是把拒绝推迟成异步报告;源码与引入提交都没写明动机) import.meta.glob默认会扫描node_modules吗?(§17.8.1——默认不会,除非exhaustive: true)- 为什么 glob options 不能用变量?(§17.5.3——
evalValue静态求值,因为编译时必须展开) - Worker A import Worker B、B 又 import A 会发生什么?(§17.6.5——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.url被替换为self.location.href,其余import.meta访问改指注入的_vite_importMeta对象。 -
WASM 支持通过环境感知的加载策略,在客户端使用
fetch+WebAssembly.instantiateStreaming,在服务端使用fs.readFile+WebAssembly.instantiate。辅助函数还处理了 data URL 格式和流式实例化的降级逻辑。 -
动态导入变量被转换为
import.meta.glob调用,利用文件系统匹配穷举所有可能的模块。运行时辅助函数在匹配失败时提供诊断友好的错误信息。 -
import.meta.glob 在编译时通过
tinyglobby匹配文件系统,生成包含懒加载函数或静态导入的对象字面量。它与 HMR 系统深度集成:文件的添加和删除会触发使用相关 glob 模式的模块重新转换。picomatch生成的高效匹配函数和否定模式支持使得这个联动既精确又高效。
这些特殊资源的处理展示了 Vite 插件系统的表达力。每种资源类型都利用了 Vite 插件的不同 Hook 组合(load、transform、renderChunk、generateBundle、hotUpdate),在开发和构建两种模式下提供一致的行为,同时与 HMR、Source Map、代码分割等核心机制保持协作。
17.8 源码核对
本章正文为了讲清主干做过简化。这一节把正文里每一处简化,逐条对回真实源码—— 路径、行号、以及正文没有展开的边界条件。想核对的读者可以照着逐条验证。
17.8.1 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*.js') 几乎不会期望匹配到 node_modules 里的上万个文件——那会让 Vite 做爆炸性的工作
exhaustive: true 表明意图同一行还有 dot: !!options.exhaustive——默认不匹配点开头的文件(.env、.git/**)。exhaustive 模式一切照收。这套 "默认做合理的事、exhaustive 一键切换严格模式"的设计让 import.meta.glob 在绝大多数场景下"开箱即用",少数高级用户有显式 opt-in 选项。
还有一条更隐蔽的处理:.filter((file) => file !== id)——排除源文件自己。防止用户在 src/foo.ts 里写 import.meta.glob('./*.ts') 时把自己也匹配进去——那会导致循环 import。这条过滤看似简单,但避免了一类极难调试的"为什么我的 glob 里有自己"bug。
.sort() 让结果稳定有序——相同源码不同操作系统(不同文件系统遍历顺序)下生成的 bundle 应该一致。这是 reproducible build 的基础要求,sort() 这 6 个字符是它的兑现。