《Vite 设计与实现》完整目录
第12章 静态资源处理
Web 应用不仅仅由 JavaScript 和 CSS 构成。图片、字体、音视频、JSON 文件、纯文本等静态资源构成了应用的血肉。在原始的开发流程中,开发者需要手动管理这些资源的路径、大小优化和缓存策略——复制文件到正确的目录、为文件名添加 hash、配置 CDN 路径、决定哪些小图片应该内联为 Data URL 以减少 HTTP 请求。这些繁琐但重要的工作在 Vite 中被自动化了。
Vite 的静态资源处理系统让这一切变得透明而高效:你只需 import 一个图片文件,Vite 就会在开发时返回正确的 URL,在构建时自动决定是内联为 Data URL 还是生成带 hash 的独立文件。这种 "import 即使用" 的体验消除了资源管理的心智负担,让开发者专注于业务逻辑。
本章将深入 plugins/asset.ts,剖析 Vite 如何识别资源文件、处理不同的导入模式、基于大小阈值决定内联策略、生成 hash 文件名、管理资源清单,以及处理 public 目录中的静态文件。
:::tip 本章要点
- 理解 Vite 资源插件的注册与工作原理
- 掌握 URL、raw、inline 三种资源导入模式的实现细节
- 深入
assetsInlineLimit的判定逻辑与回调扩展 - 了解 hash 文件名生成与
__VITE_ASSET__占位符机制 - 掌握 manifest 资源清单的生成与使用
- 理解 public 目录与项目资源的处理差异 :::
12.1 资源类型识别
默认资源类型
Vite 维护了一个详尽的默认资源文件扩展名列表,定义在 constants.ts 中。这个列表涵盖了 Web 开发中最常见的静态资源类型,从图片格式(包括现代的 AVIF 和 WebP)到音视频格式,再到字体和其他二进制文件:
// constants.ts
export const DEFAULT_ASSETS_RE: RegExp = new RegExp(
`\\.(` +
// 图片:涵盖了从传统 PNG/JPEG 到现代 AVIF/WebP 的所有常见格式
'apng|bmp|gif|ico|cur|jpg|jpeg|jfif|pjpeg|pjp|png|svg|tif|tiff|webp|avif|' +
// 媒体:音视频格式,包括字幕文件 VTT
'mp4|webm|ogg|mp3|wav|flac|aac|opus|mov|m4a|vtt|' +
// 字体:所有主流 Web 字体格式
'woff2?|eot|ttf|otf|' +
// 其他:Web 应用清单、PDF、纯文本
'webmanifest|pdf|txt' +
`)(\\?.*)?$`
)
export const DEFAULT_ASSETS_INLINE_LIMIT = 4096 // 4 KiB
这个正则表达式末尾的 (\\?.*)?$ 部分确保了带有查询参数的资源引用也能被正确识别。例如 logo.png?v=2 或 icon.svg?inline 都会被匹配。
除了内置的资源类型列表,Vite 还提供了 assetsInclude 配置选项,允许用户扩展资源类型的识别范围。这对于使用非标准文件格式的项目(如 3D 模型文件 .glb、地理信息文件 .geojson 等)非常有用。
MIME 类型注册
正确的 MIME 类型对于浏览器正确渲染资源至关重要。Vite 使用 mrmime 库来查询文件的 MIME 类型,但这个库对某些常见类型的注册存在偏差。Vite 通过 registerCustomMime 函数进行修正,确保在开发服务器返回资源和构建时生成 Data URL 时使用最佳的 Content-Type:
export function registerCustomMime(): void {
// ico 文件应使用 image/x-icon 而非 IANA 注册的 image/vnd.microsoft.icon
// 这是因为 image/x-icon 有更好的浏览器兼容性
mrmime.mimes['ico'] = 'image/x-icon'
// cur 是光标文件,与 ico 共享相同的文件格式
mrmime.mimes['cur'] = 'image/x-icon'
// flac 无损音频格式
mrmime.mimes['flac'] = 'audio/flac'
// eot 是一种旧的嵌入式 OpenType 字体格式
mrmime.mimes['eot'] = 'application/vnd.ms-fontobject'
}
关于 .ico 文件的 MIME 类型选择值得说明:虽然 IANA 正式注册的类型是 image/vnd.microsoft.icon,但 image/x-icon 在实践中有更广泛的浏览器支持,HTML5 Boilerplate 等知名项目也推荐使用后者。
12.2 资源插件架构
assetPlugin 是 Vite 资源处理的核心插件。它通过 resolveId、load、renderChunk 和 generateBundle 四个钩子覆盖了资源处理的完整生命周期。从开发者写下 import logo from './logo.png' 的那一刻起,到最终产物中出现正确的资源路径或内联 Data URL,每一步都由这个插件精心编排。
graph TD
A["import logo from './logo.png'"] --> B["resolveId"]
B --> C{"是否为资源文件?"}
C -->|否| D["跳过,交给其他插件"]
C -->|是| E["load"]
E --> F{"查询参数?"}
F -->|?raw| G["读取文件,返回文本字符串"]
F -->|?url 或默认| H{"开发 or 构建?"}
H -->|开发| I["fileToDevUrl"]
I --> I1["返回开发服务器 URL"]
H -->|构建| J["fileToBuiltUrl"]
J --> K{"应该内联?"}
K -->|是| L["assetToDataURL"]
L --> L1["返回 data: URI"]
K -->|否| M["emitFile 注册资源"]
M --> N["返回 __VITE_ASSET__ 占位符"]
N --> O["renderChunk"]
O --> P["替换占位符为最终路径"]
style A fill:#e1f5fe
style L1 fill:#e8f5e9
style P fill:#e8f5e9
resolveId:资源识别门户
resolveId 钩子是资源处理管线的第一道关卡。它利用 Rolldown 的 filter 机制进行高效的预筛选——只有匹配资源模式的模块 ID 才会进入处理逻辑,其他模块在正则匹配阶段就被快速排除。这种过滤器设计对于大型项目非常重要,因为它避免了对每个模块 ID 调用 JavaScript 函数的开销:
resolveId: {
filter: {
id: [urlRE, DEFAULT_ASSETS_RE, .../* 用户自定义资源模式 */],
},
handler(id) {
if (!config.assetsInclude(cleanUrl(id)) && !urlRE.test(id)) {
return
}
// 处理 public 目录中的资源引用
const publicFile = checkPublicFile(id, config)
if (publicFile) {
return id
}
},
},
对于 public 目录中的文件引用,resolveId 直接返回原始 ID,让后续的 load 钩子来处理路径转换。这是因为 public 目录中的文件不参与模块解析——它们的路径在最终产物中保持不变。
load:资源加载的核心逻辑
load 钩子是资源处理的灵魂。根据查询参数和运行环境的不同,它采取完全不同的处理策略。所有的资源最终都被转换为一个 JavaScript 模块,导出一个字符串——要么是 URL,要么是文件内容。这种统一的抽象使得资源可以像普通模块一样被 import、被 tree-shaking 和被代码分割:
load: {
filter: {
id: {
include: [rawRE, urlRE, DEFAULT_ASSETS_RE, .../* 用户自定义 */],
exclude: /^\0/, // 排除虚拟模块(以 \0 开头的 ID 是 Rollup 约定的虚拟模块标识)
},
},
async handler(id) {
// raw 模式:返回文件内容字符串
if (rawRE.test(id)) {
const file = checkPublicFile(id, config) || cleanUrl(id)
this.addWatchFile(file)
return {
code: `export default ${JSON.stringify(await fsp.readFile(file, 'utf-8'))}`,
moduleType: 'js',
}
}
// URL 或默认模式
if (!urlRE.test(id) && !config.assetsInclude(cleanUrl(id))) return
id = removeUrlQuery(id)
let url = await fileToUrl(this, id)
// 开发模式下继承 HMR 时间戳,确保文件变更时浏览器重新请求
if (!url.startsWith('data:') && this.environment.mode === 'dev') {
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod && mod.lastHMRTimestamp > 0) {
url = injectQuery(url, `t=${mod.lastHMRTimestamp}`)
}
}
return {
code: `export default ${JSON.stringify(encodeURIPath(url))}`,
moduleSideEffects: config.command === 'build' && this.getModuleInfo(id)?.isEntry
? 'no-treeshake' : false,
moduleType: 'js',
}
},
},
12.3 三种导入模式
Vite 为静态资源提供了三种导入模式,每种模式适用于不同的使用场景。开发者通过在导入路径上附加查询参数来选择模式。这种基于查询参数的模式选择是一个优雅的设计——它不需要额外的配置文件,意图直接表达在代码中,一目了然:
graph LR
subgraph "导入方式"
A["import img from './photo.png'"] --> A1["默认:URL 模式"]
B["import img from './photo.png?url'"] --> B1["显式 URL 模式"]
C["import text from './data.txt?raw'"] --> C1["Raw 文本模式"]
D["import img from './icon.svg?inline'"] --> D1["强制内联模式"]
E["import img from './big.png?no-inline'"] --> E1["禁止内联模式"]
end
subgraph "返回值"
A1 --> R1["'/assets/photo-a1b2c3.png'<br/>或 data:image/png;base64,..."]
B1 --> R1
C1 --> R2["文件原始内容字符串"]
D1 --> R3["data:image/svg+xml,..."]
E1 --> R4["'/assets/big-d4e5f6.png'"]
end
style A1 fill:#e1f5fe
style C1 fill:#fff3e0
style D1 fill:#e8f5e9
URL 模式(默认)
这是最常用的导入模式。导入一个资源文件时,Vite 返回该资源的 URL。在开发模式下这是开发服务器的路径(如 /src/assets/logo.png),在构建模式下则根据 assetsInlineLimit 阈值决定是返回 Data URL 还是输出文件的路径(如 /assets/logo-a1b2c3.png)。这种模式适用于需要在 JavaScript 中引用资源路径的场景,最典型的就是图片的 src 属性。
Raw 模式
通过 ?raw 查询参数导入文件的原始文本内容。这种模式将文件内容读取为 UTF-8 字符串并导出。它适用于需要在 JavaScript 中处理文件文本内容的场景,如加载 GLSL 着色器代码、Markdown 文件、SQL 查询语句等。注意 addWatchFile 调用确保了文件变更时能触发 HMR 更新,即使文件内容的变化不会自动被 Vite 的文件监听器捕获。
内联控制
?inline 和 ?no-inline 提供了对内联行为的精确控制,覆盖了默认的大小阈值判断。?inline 强制将资源内联为 Data URL,适用于确信需要内联的小图标或 SVG;?no-inline 则强制生成独立文件,适用于虽然文件很小但不适合内联的场景(例如需要被缓存策略管理的文件)。
12.4 内联阈值判定
内联决策是资源处理中最关键的决策之一。内联小文件可以减少 HTTP 请求数量,提升首屏加载速度;但过度内联会增大 JavaScript 包体积,反而影响性能。Vite 的 shouldInline 函数综合考虑了多个因素来做出最优决策。
shouldInline:核心决策函数
这个函数的判断逻辑遵循一个清晰的优先级链:显式控制优于语义规则,语义规则优于大小阈值。每一层的检查都代表了特定场景下的最佳实践:
function shouldInline(
environment: Environment,
file: string,
id: string,
content: Buffer,
buildPluginContext: PluginContext | undefined,
forceInline: boolean | undefined,
): boolean {
// 第一优先级:显式查询参数控制
if (noInlineRE.test(id)) return false
if (inlineRE.test(id)) return true
// 第二优先级:构建模式特殊规则
if (buildPluginContext) {
if (environment.config.build.lib) return true // 库模式全部内联
if (buildPluginContext.getModuleInfo(id)?.isEntry) return false // 入口不内联
}
// 第三优先级:外部传入的强制标志
if (forceInline !== undefined) return forceInline
// 第四优先级:文件类型检查
if (file.endsWith('.html')) return false
if (file.endsWith('.svg') && id.includes('#')) return false
// 第五优先级:大小阈值判定
let limit: number
const { assetsInlineLimit } = environment.config.build
if (typeof assetsInlineLimit === 'function') {
const userShouldInline = assetsInlineLimit(file, content)
if (userShouldInline != null) return userShouldInline
limit = DEFAULT_ASSETS_INLINE_LIMIT
} else {
limit = Number(assetsInlineLimit)
}
return content.length < limit && !isGitLfsPlaceholder(content)
}
下面的流程图完整展示了这个决策过程。理解这个流程有助于排查 "为什么某个资源被内联了" 或 "为什么某个资源没有被内联" 的问题:
flowchart TD
A["shouldInline(file, id, content)"] --> B{"?no-inline 查询参数"}
B -->|有| C["return false"]
B -->|无| D{"?inline 查询参数"}
D -->|有| E["return true"]
D -->|无| F{"库模式构建?"}
F -->|是| G["return true"]
F -->|否| H{"是入口文件?"}
H -->|是| I["return false"]
H -->|否| J{"forceInline 参数?"}
J -->|有值| K["return forceInline"]
J -->|无| L{".html 文件?"}
L -->|是| M["return false"]
L -->|否| N{".svg 且带 #fragment?"}
N -->|是| O["return false"]
N -->|否| P{"assetsInlineLimit<br/>是函数?"}
P -->|是| Q["调用用户函数判定"]
P -->|否| R{"content.length < limit<br/>且非 Git LFS 占位?"}
R -->|是| S["return true (内联)"]
R -->|否| T["return false (生成文件)"]
style A fill:#e1f5fe
style S fill:#e8f5e9
style C fill:#ffebee
style T fill:#ffebee
几个决策细节值得特别说明。库模式下全部内联是因为库不知道最终被集成到什么应用中,无法预设资源的服务路径,内联消除了对外部路径的依赖。入口文件不内联是因为入口资源通常需要独立的缓存控制。带有 #fragment 的 SVG 不内联是因为 fragment 标识符用于引用 SVG 中的特定元素(如 icon.svg#arrow),内联后 fragment 引用将失效。
函数式 assetsInlineLimit
assetsInlineLimit 支持函数形式,为用户提供了完全自定义的内联控制能力。函数接收文件路径和内容 Buffer 作为参数,返回 true 强制内联、false 强制不内联、undefined 回退到默认的大小阈值判断。这种三值逻辑的设计非常灵活:
// vite.config.js - 自定义内联策略示例
export default {
build: {
assetsInlineLimit: (filePath, content) => {
if (filePath.endsWith('.svg')) return true // SVG 总是内联
if (content.length > 10240) return false // 大于 10KB 不内联
return undefined // 其他使用默认阈值
},
},
}
Git LFS 占位文件检测
这是一个容易被忽视但非常重要的安全检查。在使用 Git LFS 管理大文件的仓库中,如果文件没有被正确下载,工作目录中的文件实际上是一个文本占位符,而非真正的二进制内容。如果不检测这种情况,占位符文本会被错误地内联到产物中:
const GIT_LFS_PREFIX = Buffer.from('version https://git-lfs.github.com')
function isGitLfsPlaceholder(content: Buffer): boolean {
if (content.length < GIT_LFS_PREFIX.length) return false
return GIT_LFS_PREFIX.compare(content, 0, GIT_LFS_PREFIX.length) === 0
}
12.5 Data URL 编码
当决定内联时,资源被编码为 Data URL。非 SVG 文件统一使用 base64 编码,而 SVG 文件则有一套更精细的优化策略。
SVG 的特殊优化
SVG 本质上是 XML 文本,与二进制图片不同,它有独特的优化机会。Base64 编码会将数据膨胀约 33%,这对文本格式的 SVG 来说是很大的浪费。Vite 对简单 SVG 使用 URL 编码,这通常能产生更小的 Data URL,而且在 HTTP 传输层的 gzip/brotli 压缩后效果更佳——因为 URL 编码保留了文本的重复模式,而 base64 则破坏了这种可压缩性:
function svgToDataURL(content: Buffer): string {
const stringContent = content.toString()
// 包含复杂内容的 SVG 使用 base64(安全但体积略大)
if (
stringContent.includes('<text') ||
stringContent.includes('<foreignObject') ||
nestedQuotesRE.test(stringContent)
) {
return `data:image/svg+xml;base64,${content.toString('base64')}`
} else {
// 简单 SVG 使用 URL 编码(体积更小,压缩效果更好)
return 'data:image/svg+xml,' +
stringContent.trim()
.replaceAll(/>\s+</g, '><') // 移除标签间空白
.replaceAll('"', "'") // 双引号转单引号(避免转义)
.replaceAll('%', '%25') // 百分号必须首先编码
.replaceAll('#', '%23') // 片段标识符
.replaceAll('<', '%3c') // 标签定界符
.replaceAll('>', '%3e')
.replaceAll(/\s+/g, '%20') // 空白编码(srcset 需要)
}
}
包含 <text> 或 <foreignObject> 的 SVG 被视为 "复杂 SVG",回退到 base64 编码。这是因为这些元素的内容中可能包含各种特殊字符,URL 编码可能导致解析问题。嵌套引号的情况也同样回退——当 SVG 属性值中同时存在单引号和双引号时,任何转换都可能破坏引号的配对关系。
12.6 资源占位符与路径解析
_VITE_ASSET_ 占位符机制
在构建过程中,资源的最终文件名取决于其内容 hash。但在模块转换阶段,资源的内容可能还在处理中(例如图片优化插件可能改变内容),因此文件名无法提前确定。Vite 使用占位符机制来解决这个 "鸡与蛋" 的问题——先生成一个临时标识符,在后期(renderChunk 阶段)再替换为实际路径:
async function fileToBuiltUrl(pluginContext, id, skipPublicCheck, forceInline) {
// ... 缓存检查和内联判断 ...
// 非内联资源:注册到 Rolldown 的资源系统
const referenceId = pluginContext.emitFile({
type: 'asset',
name: path.basename(file),
originalFileName: normalizePath(path.relative(environment.config.root, file)),
source: content,
})
// 返回占位符字符串,后续会被替换为实际路径
url = `__VITE_ASSET__${referenceId}__${postfix ? `$_${postfix}__` : ``}`
cache.set(id, url)
return url
}
占位符的格式经过精心设计:__VITE_ASSET__ 前缀确保不会与正常代码冲突,referenceId 是 Rolldown 分配的唯一资源标识符,可选的 $_<postfix>__ 部分保留了原始导入路径中的查询参数信息(如 hash fragment)。
renderChunk:占位符的最终替换
在所有模块都处理完毕、资源文件名确定之后,renderChunk 阶段负责将所有占位符替换为实际的输出路径。这个过程同时处理项目内资源(__VITE_ASSET__)和 public 目录资源(__VITE_PUBLIC_ASSET__)两种占位符:
sequenceDiagram
participant Source as 源代码
participant Load as load 钩子
participant Rolldown as Rolldown 打包器
participant Render as renderChunk
participant Output as 最终产物
Source->>Load: import logo from './logo.png'
Load->>Load: 调用 fileToBuiltUrl()
Load->>Rolldown: emitFile({ type: 'asset', source: Buffer })
Rolldown-->>Load: referenceId = "ref_id_001"
Load-->>Source: export default "VITE_ASSET_PLACEHOLDER_ref_id_001"
Note over Rolldown: 打包、Tree-shaking、代码分割
Rolldown->>Render: renderChunk(code, chunk)
Render->>Render: 正则匹配 VITE_ASSET_PLACEHOLDER
Render->>Rolldown: getFileName("ref_id_001")
Rolldown-->>Render: "assets/logo-d4e5f6.png"
Render->>Render: 替换占位符为实际路径
Render-->>Output: export default "/assets/logo-d4e5f6.png"
renderAssetUrlInJS 函数的实现使用 MagicString 进行精准替换,同时支持字符串形式的绝对路径和对象形式的运行时路径计算:
export function renderAssetUrlInJS(pluginContext, chunk, opts, code) {
const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime(
opts.format, environment.config.isWorker,
)
assetUrlRE.lastIndex = 0
while ((match = assetUrlRE.exec(code))) {
s ||= new MagicString(code)
const [full, referenceId, postfix = ''] = match
const file = pluginContext.getFileName(referenceId)
chunk.viteMetadata!.importedAssets.add(cleanUrl(file))
const replacement = toOutputFilePathInJS(
environment, filename, 'asset', chunk.fileName, 'js', toRelativeRuntime,
)
const replacementString = typeof replacement === 'string'
? JSON.stringify(encodeURIPath(replacement)).slice(1, -1)
: `"+${replacement.runtime}+"`
s.update(match.index, match.index + full.length, replacementString)
}
return s
}
注意 chunk.viteMetadata!.importedAssets.add() 这一行——它记录了每个 chunk 引用了哪些资源文件,这个信息后续会被 HTML 插件和 manifest 插件使用。
12.7 开发模式下的资源处理
fileToDevUrl:开发时的路径策略
开发模式下的路径计算需要处理三种不同的文件位置:public 目录中的文件保持原始路径;项目根目录内的文件使用相对于根目录的路径;项目根目录外的文件使用特殊的 /@fs/ 前缀,这是 Vite 开发服务器的一个约定,允许访问文件系统中的任意位置:
export async function fileToDevUrl(environment, id, asFileUrl = false) {
const config = environment.getTopLevelConfig()
const publicFile = checkPublicFile(id, config)
// 显式内联请求:无论开发还是构建都生成 Data URL
if (inlineRE.test(id)) {
const file = publicFile || cleanUrl(id)
const content = await fsp.readFile(file)
return assetToDataURL(environment, file, content)
}
// SVG 的特殊处理:保持开发与构建行为一致
if (cleanedId.endsWith('.svg')) {
const content = await fsp.readFile(file)
if (shouldInline(environment, file, id, content, undefined, undefined)) {
return assetToDataURL(environment, file, content)
}
}
// 路径计算
let rtn: string
if (publicFile) {
rtn = id // public 目录:保持原始路径
} else if (id.startsWith(withTrailingSlash(config.root))) {
rtn = '/' + path.posix.relative(config.root, id) // 项目内:相对路径
} else {
rtn = path.posix.join(FS_PREFIX, id) // 项目外:/@fs/ 前缀
}
const base = joinUrlSegments(config.server.origin ?? '', config.decodedBase)
return joinUrlSegments(base, removeLeadingSlash(rtn))
}
这里有一个精妙的设计细节:SVG 文件在开发模式下也会根据构建时的内联规则判断是否内联。这确保了开发和构建的行为一致性——如果 SVG 在构建时会被内联为 Data URL,那么在开发时也应该返回 Data URL。否则,由于 Data URL 和普通 URL 在引号处理、基础路径解析等方面的差异,可能导致开发时正常但构建后出问题。
HMR 时间戳注入
当资源文件发生变更时,Vite 通过注入时间戳查询参数来破坏浏览器缓存:
if (!url.startsWith('data:') && this.environment.mode === 'dev') {
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod && mod.lastHMRTimestamp > 0) {
url = injectQuery(url, `t=${mod.lastHMRTimestamp}`)
}
}
已经内联为 Data URL 的资源不需要时间戳——Data URL 的内容是直接嵌入在代码中的,代码本身的变更已经通过 HMR 机制传播。
12.8 public 目录处理
public vs 项目资源的本质差异
public 目录和项目内资源代表了两种根本不同的资源管理哲学。项目内资源参与模块系统——它们被 import、被打包、被 hash 命名、被 tree-shaking 分析。public 目录资源则完全绕过模块系统——它们在构建时被原样复制到输出目录,文件名保持不变,内容不经过任何处理。
这种区别的设计意图是明确的:项目内资源享受构建优化的全部好处,适合那些在代码中显式引用的资源;public 目录适合那些需要保持固定路径的资源,如 favicon.ico、robots.txt、社交媒体分享图等——这些资源的 URL 可能被外部系统硬编码,不能添加 hash 后缀。
graph TD
subgraph "public 目录"
A["public/favicon.ico"]
B["public/robots.txt"]
end
subgraph "项目资源"
C["src/assets/logo.png"]
D["src/assets/icon.svg"]
end
A -->|"原样复制,保持路径"| E["dist/favicon.ico"]
B -->|"原样复制,保持路径"| F["dist/robots.txt"]
C -->|"打包处理 + hash 命名"| G["dist/assets/logo-a1b2c3.png"]
D -->|"内联判断"| H{"size < 4KB?"}
H -->|是| I["data:image/svg+xml,...<br/>(嵌入到 JS 中)"]
H -->|否| J["dist/assets/icon-d4e5f6.svg"]
style E fill:#e8f5e9
style F fill:#e8f5e9
style G fill:#fff3e0
style I fill:#fff3e0
publicFileToBuiltUrl:占位符策略
Public 资源在构建时也使用占位符,但与项目内资源不同。项目内资源通过 Rolldown 的 emitFile API 注册,其 referenceId 由 Rolldown 管理;public 资源使用 URL 的 hash 值作为标识符,存储在一个独立的 publicAssetUrlCache 映射中。这种分离确保了两类资源不会产生 ID 冲突:
export function publicFileToBuiltUrl(url: string, config: ResolvedConfig): string {
if (config.command !== 'build') {
return joinUrlSegments(config.decodedBase, url)
}
const hash = getHash(url)
let cache = publicAssetUrlCache.get(config)
if (!cache) {
cache = new Map<string, string>()
publicAssetUrlCache.set(config, cache)
}
cache.set(hash, url)
return `__VITE_PUBLIC_ASSET__${hash}__`
}
12.9 资源缓存机制
资源处理可能涉及文件读取、内容编码、hash 计算等开销较大的操作。Vite 通过多级缓存避免对同一资源的重复处理。缓存以 Environment 为键使用 WeakMap,这个设计有两个好处:一是确保不同构建环境之间的缓存隔离(client 和 SSR 环境可能对同一资源有不同的处理结果),二是当环境对象被垃圾回收时缓存自动释放,不会造成内存泄漏。
在 watch 模式下,文件变更时对应的缓存条目会被精确删除,确保下次构建使用更新后的文件内容:
watchChange(id) {
assetCache.get(this.environment)?.delete(normalizePath(id))
},
12.10 generateBundle:产物清理
assetPlugin 的 generateBundle 钩子执行两个重要的清理任务,确保最终的构建产物是干净和精简的。
第一个任务是移除空的资源入口 chunk。当一个资源文件被配置为入口时,Rolldown 会为其生成一个包含 export default 的 JavaScript chunk。如果这个 chunk 没有被其他模块导入,它就是冗余的——资源本身已经通过 emitFile 输出了,这个 JavaScript 包装器没有存在的必要。
第二个任务是在 SSR 构建中过滤掉资源文件。SSR 环境在服务端运行,不直接服务静态资源(那是 CDN 或静态文件服务器的工作)。因此 SSR 构建默认设置 emitAssets: false,generateBundle 阶段会删除所有资源文件——但保留 SSR manifest 和 source map,因为它们是服务端渲染流程所需的元数据。
12.11 Hash 文件名与缓存策略
内容 hash 实现了 "内容寻址" 的缓存策略,这是现代 Web 性能优化的基石。文件内容不变时 hash 不变,URL 不变,浏览器可以永久缓存;文件内容更新时 hash 变化,URL 变化,浏览器自动请求新版本。这使得可以为资源设置最激进的缓存策略(Cache-Control: max-age=31536000, immutable),在保证缓存有效性的同时实现即时更新。
构建输出的命名模式根据构建类型有所不同:应用模式使用 assets/[name]-[hash].[ext],库模式使用 [name].[ext](不添加 hash,因为库的版本管理通常通过 npm 完成),SSR 模式使用 [name].js(服务端代码不需要浏览器缓存策略)。
12.12 Manifest 资源清单
Vite 可以生成 .vite/manifest.json 文件,记录源文件到输出文件的完整映射关系。Manifest 对于不使用 Vite 生成 HTML 的场景至关重要——当使用 PHP、Ruby on Rails、Django 等后端框架的模板引擎时,后端代码通过读取 manifest 来获取正确的带 hash 的资源路径。
当前版本中,manifest 插件利用了 Rolldown 提供的原生 viteManifestPlugin,这意味着清单的生成在 Rust 层完成,性能更优。生成的清单不仅包含 JavaScript 和 CSS 的映射,还包含资源的依赖关系(imports、dynamicImports、css、assets),使得后端可以正确地注入 preload 和 prefetch 标签。
12.13 设计决策分析
为什么使用占位符而非立即解析
在 Rolldown 打包过程中存在一个时序矛盾:资源路径需要在模块转换阶段就确定(因为模块代码中引用了这个路径),但资源的最终文件名(包含 hash)取决于内容,而内容可能在后续的插件处理中被修改。占位符机制优雅地解耦了这个矛盾——转换阶段生成占位符,等一切尘埃落定后再替换为实际路径。
为什么 SVG 有特殊的编码策略
这是一个经过深思熟虑的性能优化。对于简单 SVG,URL 编码相比 base64 能减少约 20-30% 的 Data URL 体积(因为省去了 base64 膨胀),而且经过 HTTP 压缩后差距更大(URL 编码保留了 XML 的重复模式,压缩率更高)。但对于包含富文本(<text>, <foreignObject>)的复杂 SVG,引号和特殊字符的处理可能出错,因此回退到稳定可靠的 base64。
为什么环境隔离的缓存
一个直觉上的疑问是:同一个图片文件,client 和 SSR 环境的处理结果不是一样的吗?答案是不一定。SSR 环境可能不输出资源(emitAssets: false),库模式强制内联,不同环境的 assetsInlineLimit 可能不同。环境隔离的缓存确保了每个环境独立做出正确的决策。
12.14 小结
Vite 的静态资源处理系统将复杂的资源管理封装为简洁的开发体验。从一个简单的 import 语句开始,背后的系统自动完成了类型识别、模式选择、内联决策、hash 命名、路径计算、缓存管理等一系列精密操作。
这个系统展现了几个精妙的设计理念:
- 延迟解析:通过占位符将路径确定推迟到打包完成后,解耦了模块转换和产物输出的时序依赖
- 智能内联:综合考虑查询参数、构建模式、文件类型、文件大小等多维度因素的分层决策
- 环境隔离:每个构建环境独立的缓存和处理策略,避免了跨环境的状态污染
- SVG 优化:区分简单和复杂 SVG,选择最优的编码方式,在正确性和性能之间取得平衡
- 开发一致性:开发模式下模拟构建时的内联行为,消除环境差异,让 "开发时看到的就是构建后的" 成为现实
理解了资源处理的全貌,我们就能更好地优化应用的加载性能——知道何时应该内联、何时应该生成独立文件、何时使用 public 目录。下一章我们将进入 Vite 构建引擎的核心,深入剖析 Rolldown 如何将所有这些资源、脚本和样式打包为优化后的生产产物。