Vite 设计与实现
第12章 静态资源处理
第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 目录中的静态文件。
本章要点
- 理解 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 如何将所有这些资源、脚本和样式打包为优化后的生产产物。
延伸阅读:资源处理的 30 年演化
Web 资源处理——从 1990 年代到 2026 年、经历了长路径。1990 年代——“手动管理”(HTML 里硬编码路径、没有 hash、缓存靠 HTTP 头);2000 年代——“CDN 兴起”(静态资源上 CDN、手动配置 URL);2010 年代早期——“Grunt、Gulp”(JS 构建工具、处理资源合并压缩);2010 年代后期——“Webpack”(资源作为模块、import 图片);2020 年代——“Vite + ESM”(原生模块、按需加载)。
每一代——都让资源处理更自动化、更智能。2026 年的开发者——几乎不用手动处理资源了——Vite 自动做 hash、自动压缩、自动 inline 小文件、自动生成 source map。这让开发者能专注业务、不用操心”文件名怎么改”这类细节。这种”工具吸收复杂度”的演化——是整个工程生态成熟的标志。
延伸阅读:资源内联的阈值之争
“小资源内联到 base64、大资源独立文件”——这个判断的阈值是多少?业界没有统一答案。Vite 默认 4KB——小于 4KB 的图片内联进 HTML/CSS;Webpack 默认 8KB;其他工具可能不同。为什么这个数字?——“权衡 HTTP 请求开销和 base64 膨胀”。每个 HTTP 请求有约 200 字节开销(headers、建立连接)——对小图片、这个开销比图片本身还大;但 base64 编码会让内容膨胀 33%——大文件 inline 就亏了。
这个阈值在 HTTP/2 和 HTTP/3 时代——可能要重新考虑。HTTP/2 支持多路复用、请求开销大幅降低——小文件独立请求可能也不亏;HTTP/3 甚至更快——阈值可能要下调。Vite 默认的 4KB 是基于 HTTP/1.1 时代的经验——未来可能根据协议版本动态调整。这种”随时代演化的默认值”——是好工具的特质。
延伸阅读:SVG 处理的两难
SVG 既是”文本”(可以 inline 到 HTML)、又是”图像”(可以 url())——让它的处理比普通图片复杂。Vite 区分处理——“简单 SVG”(< 4KB、无复杂属性)内联为 data URL;“复杂 SVG”(大文件或含动画、脚本)作为独立文件。这种区分让 SVG 处理兼顾性能(小图内联零请求)和功能(复杂 SVG 能用自己的特性)。
SVG 的处理还有更复杂的可能——“SVG sprite”(多个 SVG 合成一个文件、用 <use> 引用、减少请求)、“React SVG 组件”(用 svg-loader 把 SVG 变成 React 组件、能通过 props 控制颜色)。这些高级用法——在复杂场景下有价值。Vite 的生态有对应插件——让用户按需选择。
延伸阅读:资源的缓存策略
静态资源的 HTTP 缓存——是 Web 性能的核心优化。好的缓存策略——“文件名 hash + 强缓存”(content hash 让文件名唯一、Cache-Control 设一年)——让用户只在真正变化时重新下载。Vite 默认生成 hash 文件名(app.abc123.js)——匹配这种缓存策略。
这种”hash 文件名 + 强缓存”——是 CDN 标准配置。用户第一次访问下载一次、之后的访问都命中浏览器缓存或 CDN 缓存——加载速度接近”零延迟”。这也是为什么”SPA 应用二次访问飞快”——不是浏览器魔法、是构建工具 + 缓存策略的成果。《Vite 源码》第 2 章讨论的构建流程、和本章的资源处理协同——共同构成了”性能优化的基础设施”。
延伸阅读:资源处理的环境隔离
Vite 支持多环境构建(client、ssr、edge)——每个环境的资源处理可以独立。client 环境——资源上 CDN、用户下载;ssr 环境——资源本地引用、服务端生成 HTML;edge 环境——资源可能在 Cloudflare KV、访问受限。同一资源在不同环境有不同处理——这是 Vite Environment API 的价值。
这种”环境感知的资源处理”——适合现代多端部署。一个项目可能同时部署到 Vercel(client + SSR)、Cloudflare Workers(edge)、React Native(移动端)——每端的资源策略都不同。Vite 把这种复杂性抽象到 Environment API——让配置更清晰、不会出错。
延伸阅读:public 目录的哲学
Vite 的 public 目录——“不经过构建、直接复制到输出”——这是一个看似简单但深思的设计。为什么要 public?——有些资源不应该被构建处理。比如——robots.txt(爬虫读取、不应该改名)、favicon.ico(浏览器默认查找、文件名固定)、manifest.json(PWA 规范、路径固定)——这些文件需要”原样”输出。
public 目录——给了开发者”逃生舱口”——某些资源可以绕过构建。这种”合理默认 + 必要时绕过”的设计——让工具既智能又不死板。学这种”默认智能、按需绕过”的设计思路——对做其他工具/框架也有启发。
延伸阅读:资源处理的未来
展望未来——Vite 的资源处理会走向何方?几个可能方向。“AVIF/WebP 自动转换”——让图片自动用更现代格式、在保证兼容性的前提下减小体积。“按视口大小生成多版本”——手机用 400x 的图、PC 用 800x 的图、省带宽。“AI 辅助优化”——LLM 根据图片内容决定压缩等级、智能生成替代格式。“边缘计算集成”——资源处理上 edge、减少对中心服务器的依赖。
这些方向——让”资源处理”从”打包时的一次性优化”变成”持续的、智能的、分布式的优化”。未来几年的前端——“资源处理”会越来越”看不见”——工具自动完成、开发者几乎不用关心。这是”工具吸收复杂度”的持续演化、让前端工程师能把更多精力花在真正有价值的业务上。
延伸阅读:资源的 CDN 策略
生产环境的资源——几乎都走 CDN。原因——“全球分发”(用户就近访问、延迟低)、“带宽便宜”(CDN 成本比自己服务器低)、“DDoS 防护”(CDN 能吸收大量请求)、“缓存优化”(CDN 自带多层缓存)。没有 CDN 的 Web 应用——在全球用户面前体验会很糟。
Vite 支持 CDN 配置——base 选项指定资源的 URL 前缀(比如 https://cdn.example.com/)。构建时所有资源引用都会加这个前缀——部署时直接上 CDN。这种”配置驱动的 CDN 支持”——让”本地开发、CDN 部署”的流程无缝。Cloudflare R2、AWS S3 + CloudFront、阿里云 OSS + CDN——都是常见 CDN 方案。
延伸阅读:资源处理与安全
资源处理也涉及安全——“第三方资源”可能被污染、“恶意脚本”可能混入 bundle、“敏感信息”可能意外打包。Vite 对此有几层保护——SRI(Subresource Integrity)支持(校验外部脚本完整性)、CSP(Content Security Policy)友好(限制资源来源)、构建时扫描(检测明显的敏感信息)。
更严格的安全——可以用专门工具。npm audit(检测依赖漏洞)、Snyk(企业级依赖扫描)、Socket(恶意包检测)——都能集成到 Vite 构建流程。这些工具——在构建时自动检查、发现问题立即报警——让安全成为”自动化”而非”靠自觉”。《MCP 协议源码》第 12 章、《OpenClaw 源码》第 13 章讨论的安全工程——和资源安全思路相通。
延伸阅读:资源预加载的艺术
除了”正常加载”——Vite 还支持”预加载”(preload、prefetch、modulepreload)——让浏览器提前获取资源。preload——“这个资源立即会用、请优先下载”;prefetch——“这个资源未来可能用、闲时下载”;modulepreload——“预加载 ES module”。Vite 默认为首屏资源添加 modulepreload——让冷启动更快。
这些预加载——大幅影响首屏性能。恰当的 preload——让 LCP(Largest Contentful Paint)提前几百毫秒;恰当的 prefetch——让二次访问几乎瞬间完成。但预加载也有代价——预加载错了会浪费带宽。Vite 的自动预加载——默认选最保守最有价值的——不会浪费。需要激进预加载时——用 vite-plugin-preload 等插件定制——让优化策略和业务需求精准匹配。
延伸阅读:资源优化的边际效益
资源优化有”边际效益递减”——简单优化(hash 命名、基本压缩)的收益大、高级优化(细粒度内联阈值、brotli 压缩)的收益小。投入产出比——前 80% 优化带来 80% 收益、后 20% 优化带来 5-10% 收益。
这意味着——对大多数项目、用 Vite 默认配置就够好了。只有”极高流量、极低延迟”的场景(头部电商、媒体网站)——才值得追求极致优化。这种”性能优化的 80/20 法则”——让我们能理性分配投入。不要对中小项目做极端优化——收益小、维护成本高、不值得。这是工程经济学的朴素智慧。
延伸阅读:资源处理和 Core Web Vitals
Google 的 Core Web Vitals(LCP、FID/INP、CLS)——直接受资源处理影响。LCP(最大内容绘制)——首屏主图加载速度;FID/INP(交互延迟)——主线程是否被资源加载阻塞;CLS(累积布局偏移)——图片没预留空间导致的跳动。
Vite 的资源处理——对 Core Web Vitals 优化友好。自动 preload 首屏关键资源(提升 LCP)、代码分割让首屏 JS 更小(降低 INP)、生成图片尺寸信息(避免 CLS)。这些默认优化——让”用 Vite 的应用”天然比”手搓构建的应用”在 SEO 上有优势——Google 排名 +、用户体验 +。
延伸阅读:资源处理的哲学总结
回顾本章——Vite 的资源处理、体现了几个设计哲学。“合理默认 + 灵活覆盖”——大多数场景默认配置够用、特殊场景可以覆盖;“零配置可用、深度可定制”——新手能跑起来、高手能精调;“性能优先、但不牺牲开发体验”——构建产物快、但 dev 模式也丝滑;“和 Web 标准协同”——用原生 HTML/CSS/JS 机制、不发明轮子。
这些哲学——贯穿 Vite 整个设计。读完本章、你对 Vite 的设计风格应该有了体感——这种”有品味的工程”、比单纯的”功能列表”更值得学习——让你写自己的工具时也能有相同的审美。
延伸阅读:资源处理和 AI 结合
2026 年——AI 正在改变资源处理。LLM 能根据内容生成 alt 文字(无障碍)、自动选择最佳压缩格式、生成 responsive 图片的多尺寸版本、甚至自动生成缺失的 favicon。一些新兴工具(Imgix、Cloudinary、Vercel Image Optimization)——都在集成 AI 能力。
未来的 Vite 资源处理——可能和 AI 深度集成。比如构建时自动调用 LLM 生成图片描述、自动裁剪聚焦主体、自动生成多语言 alt 文字。这些能力——让”资源处理”从”纯技术优化”升级到”智能内容管理”。前端工程师的工作——也会相应进化——从”手动优化”到”审查 AI 的优化”。
延伸阅读:资源处理的测试
资源处理的正确性——需要测试保证。测试维度——“功能正确”(图片 import 后路径对、hash 匹配、inline/external 符合预期)、“性能达标”(bundle 体积不超预算、首屏资源压缩到位)、“兼容性 OK”(老浏览器能加载、polyfill 够用)。
Vite 生态有对应工具——vitest(单元测试)、Playwright(E2E 测试)、size-limit(体积预算)、bundle-analyzer(体积分析)。把这些集成到 CI——每次 PR 自动检查——让资源处理的质量有保障。这种”自动化质量保证”——是生产级项目的标配——不应该被视为”额外工作”、而应该是”基础投入”。
延伸阅读:从资源处理看前端工程
资源处理虽小——但背后是整个前端工程的缩影。从 HTML 资源引用、到 CSS 加载策略、到 JS 打包优化、到资源缓存、到 CDN 部署——串起来就是”前端工程的核心流程”。理解资源处理——等于对前端工程有了完整认知。
这也是为什么这一章虽然看起来”偏技术细节”、但价值很大——它是”前端工程”的一个微观模型。搞懂这个微观模型——你对整个前端工程的理解会上一个台阶。读源码、读技术书——最大的价值不在记住”这个 API 怎么用”、而在”通过一个案例理解一类问题”——这种学习方式让知识能跨场景迁移。
延伸阅读:资源处理的开源生态
除了 Vite 本身——资源处理领域有大量开源生态。图片——imagemin(压缩)、sharp(处理)、squoosh(Web 化的压缩服务);字体——fontsource(自托管字体)、subset-font(子集化);SVG——svgo(优化)、svg-sprite(合成);视频——ffmpeg.wasm(浏览器里处理视频)。
这些工具——合起来构成了”前端资源处理”的瑞士军刀。Vite 插件生态——让大部分工具都能通过配置集成。学会用这些工具——让你能在资源处理上做到”业界最佳”水平。这是深度前端工程师和普通前端工程师的区别——深度的人有完整工具箱、普通的人只会用默认——工具箱的丰富度往往决定你能解决多少问题。
延伸阅读:资源处理的工程回报
资源处理——表面看是”技术细节”、实际上对业务有巨大影响。电商网站——资源加载快 1 秒、转化率提升 5-7%(Amazon 研究);媒体网站——首图加载慢 3 秒、跳出率 40%(Google 数据);SaaS 工具——登录页首屏慢、用户放弃注册。这些数字——让”资源优化”从”技术问题”变成”业务问题”。
理解这层——让你能和产品、业务沟通性能优化的价值——不是”工程师想折腾”、是”直接影响收入”。这种”业务感知的技术视角”——是资深工程师的标志——能让技术投入获得业务支持。希望本章帮读者建立这种视角——让你的性能优化工作被团队重视、得到应有的资源投入。
延伸阅读:资源处理能力的自我评估
读完本章、你应该能回答几个问题——1、“我的项目里有哪些资源?“(图片、字体、视频、SVG、JSON、文本);2、“每类资源是怎么被 Vite 处理的?“(hash、inline、public、复制);3、“我的资源缓存策略是什么?“(CDN、强缓存、内容 hash);4、“我能衡量资源处理的效果吗?“(Lighthouse、Core Web Vitals、RUM)。
能回答这些问题——说明你对资源处理有”系统理解”——不只是”会用”。如果答不出——这也没关系、本章给了知识框架——对着实际项目摸一遍、就能回答上了。这种”理论 + 实战”的反复——是掌握任何技术的正确方法。