Vite 设计与实现
第17章 Web Worker 与特殊资源
第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.glob(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{"开发模式?"}
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}
);
}`
}
}
这段代码展示了三个关键设计:
- 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 阶段,这些占位符被替换为实际的相对路径:
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 负责在后处理阶段进行替换:
// 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 层嵌套语法。关键点:
\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&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'
这段代码把三种状态压缩成了一组条件:
- dev(
config.isBundled === false):永远用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。用户如果配置了 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-Type 是 application/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.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/*.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(...)) 模式,分别标记为 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 类型:
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 的根本性质——它必须在编译时处理,因为:
- Glob 匹配需要访问文件系统,运行时(浏览器)不具备这个能力
- 匹配结果决定了需要打包哪些模块,影响构建图
- 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-182 的 getWorkerType 是一个展示 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 必须在编译时处理,因为:
- 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.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。
webWorkerPostPlugin 在 renderChunk 阶段扫描 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-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(297 行) | dynamicImportHelper:46 |
| §17.5.2 | packages/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.5 | workerImportMetaUrl.ts | getWorkerType: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 个问题在本章内容里都有源码锚点的答案——如果你能不回源码说出原理,这一章你已经读透:
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 如果 CSP 禁止 Blob URL 会怎样?(§17.1.12——三级 fallback 到 data URL)
- 什么时候 Worker 用 iife 格式、什么时候用 es?(§17.1.13——build + target 老浏览器用 iife,否则 module)
- Node SSR 下 WASM 怎么加载?(§17.2.5——
fs.readFile+new URL(..., import.meta.url)) - 为什么
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——避免 unhandledRejection 误报) import.meta.glob默认会扫描node_modules吗?(§17.5.2-bis——默认不会,除非exhaustive: true)- 为什么 glob options 不能用变量?(§17.5.3——
evalValue静态求值,因为编译时必须展开) - 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 组合(load、transform、renderChunk、generateBundle、hotUpdate),在开发和构建两种模式下提供一致的行为,同时与 HMR、Source Map、代码分割等核心机制保持协作。