Appearance
第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(后处理插件)。
17.1.3 WorkerOutputCache
Worker 插件使用 WorkerOutputCache 管理构建产物的缓存和去重:
typescript
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 文件只被打包一次,即使它在多个地方被引用:
typescript
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 构建处理:
typescript
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,
})17.1.5 内联 Worker 与 Blob URL
当 Worker 使用 ?inline 查询参数或通过 ?worker&inline 方式导入时,Worker 代码会被内联到主 bundle 中:
typescript
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:
typescript
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 阶段,这些占位符被替换为实际的相对路径:
typescript
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 负责在后处理阶段进行替换:
typescript
// 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 文件变更与缓存失效
typescript
watchChange(file) {
if (isWorker) return
workerOutputCaches
.get(config)!
.invalidateAffectedBundles(normalizePath(file))
}当文件发生变更时,Worker 插件通过 watchChange Hook 检查该文件是否被某个 Worker bundle 引用。如果是,则将对应的 bundle 标记为失效,下次构建时重新打包。
17.2 WASM 支持
17.2.1 两种加载策略
plugins/wasm.ts 为 .wasm?init 导入提供支持。WASM 模块在客户端和服务端使用不同的加载策略:
typescript
// 客户端:通过 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 提供辅助函数:
typescript
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 编码),这在某些打包场景中会出现:
typescript
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 的相对路径:
typescript
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.3 动态导入变量
17.3.1 问题描述
动态导入的参数如果包含变量,构建工具在编译时无法确定实际的模块路径:
javascript
const module = await import(`./pages/${name}.vue`)Vite 的 dynamicImportVars.ts 插件将这类模式转换为 import.meta.glob,利用文件系统匹配来穷举所有可能的模块。
17.3.2 转换流程
核心转换逻辑:
typescript
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 在运行时查找匹配的模块:
typescript
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 的内置实现:
typescript
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.4 import.meta.glob
17.4.1 功能概览
import.meta.glob 是 Vite 的标志性特性之一,它允许使用 glob 模式批量导入文件:
javascript
// 懒加载所有 .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 调用:
17.4.3 代码生成
对于非 eager 模式,import.meta.glob 被转换为一个对象字面量,每个匹配的文件对应一个懒加载函数:
javascript
// 输入
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 模式,生成静态导入:
javascript
// 输入
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 选项:
typescript
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 模式的模块需要被重新转换:
typescript
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
}Glob matchers 使用 picomatch 库生成高效的匹配函数,支持否定模式(!pattern):
typescript
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 时不需要生成导入语句,只需要键列表。
typescript
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 类型:
typescript
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 || ''
}
}
},
}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 的不兼容
SharedWorker 的共享机制基于 URL 匹配。Blob URL 每次调用 createObjectURL 都会生成不同的 URL,导致无法复用同一个 SharedWorker 实例。因此 Vite 对 SharedWorker 使用 data URL,确保内容相同的 Worker 共享同一个 URL。
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、代码分割等核心机制保持协作。