Vite内核解析-第12章 静态资源处理

《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)到音视频格式,再到字体和其他二进制文件:

typescript 复制代码
// 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=2icon.svg?inline 都会被匹配。

除了内置的资源类型列表,Vite 还提供了 assetsInclude 配置选项,允许用户扩展资源类型的识别范围。这对于使用非标准文件格式的项目(如 3D 模型文件 .glb、地理信息文件 .geojson 等)非常有用。

MIME 类型注册

正确的 MIME 类型对于浏览器正确渲染资源至关重要。Vite 使用 mrmime 库来查询文件的 MIME 类型,但这个库对某些常见类型的注册存在偏差。Vite 通过 registerCustomMime 函数进行修正,确保在开发服务器返回资源和构建时生成 Data URL 时使用最佳的 Content-Type:

typescript 复制代码
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 资源处理的核心插件。它通过 resolveIdloadrenderChunkgenerateBundle 四个钩子覆盖了资源处理的完整生命周期。从开发者写下 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 函数的开销:

typescript 复制代码
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 和被代码分割:

typescript 复制代码
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'
或 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:核心决策函数

这个函数的判断逻辑遵循一个清晰的优先级链:显式控制优于语义规则,语义规则优于大小阈值。每一层的检查都代表了特定场景下的最佳实践:

typescript 复制代码
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
是函数?"} P -->|是| Q["调用用户函数判定"] P -->|否| R{"content.length < limit
且非 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 回退到默认的大小阈值判断。这种三值逻辑的设计非常灵活:

javascript 复制代码
// 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 管理大文件的仓库中,如果文件没有被正确下载,工作目录中的文件实际上是一个文本占位符,而非真正的二进制内容。如果不检测这种情况,占位符文本会被错误地内联到产物中:

typescript 复制代码
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 则破坏了这种可压缩性:

typescript 复制代码
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 阶段)再替换为实际路径:

typescript 复制代码
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 进行精准替换,同时支持字符串形式的绝对路径和对象形式的运行时路径计算:

typescript 复制代码
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 开发服务器的一个约定,允许访问文件系统中的任意位置:

typescript 复制代码
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 通过注入时间戳查询参数来破坏浏览器缓存:

typescript 复制代码
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.icorobots.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,...
(嵌入到 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 冲突:

typescript 复制代码
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 模式下,文件变更时对应的缓存条目会被精确删除,确保下次构建使用更新后的文件内容:

typescript 复制代码
watchChange(id) {
  assetCache.get(this.environment)?.delete(normalizePath(id))
},

12.10 generateBundle:产物清理

assetPlugingenerateBundle 钩子执行两个重要的清理任务,确保最终的构建产物是干净和精简的。

第一个任务是移除空的资源入口 chunk。当一个资源文件被配置为入口时,Rolldown 会为其生成一个包含 export default 的 JavaScript chunk。如果这个 chunk 没有被其他模块导入,它就是冗余的------资源本身已经通过 emitFile 输出了,这个 JavaScript 包装器没有存在的必要。

第二个任务是在 SSR 构建中过滤掉资源文件。SSR 环境在服务端运行,不直接服务静态资源(那是 CDN 或静态文件服务器的工作)。因此 SSR 构建默认设置 emitAssets: falsegenerateBundle 阶段会删除所有资源文件------但保留 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 的映射,还包含资源的依赖关系(importsdynamicImportscssassets),使得后端可以正确地注入 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 命名、路径计算、缓存管理等一系列精密操作。

这个系统展现了几个精妙的设计理念:

  1. 延迟解析:通过占位符将路径确定推迟到打包完成后,解耦了模块转换和产物输出的时序依赖
  2. 智能内联:综合考虑查询参数、构建模式、文件类型、文件大小等多维度因素的分层决策
  3. 环境隔离:每个构建环境独立的缓存和处理策略,避免了跨环境的状态污染
  4. SVG 优化:区分简单和复杂 SVG,选择最优的编码方式,在正确性和性能之间取得平衡
  5. 开发一致性:开发模式下模拟构建时的内联行为,消除环境差异,让 "开发时看到的就是构建后的" 成为现实

理解了资源处理的全貌,我们就能更好地优化应用的加载性能------知道何时应该内联、何时应该生成独立文件、何时使用 public 目录。下一章我们将进入 Vite 构建引擎的核心,深入剖析 Rolldown 如何将所有这些资源、脚本和样式打包为优化后的生产产物。

相关推荐
杨艺韬4 小时前
Vite内核解析-第15章 SSR 与模块运行器
agent
杨艺韬4 小时前
Vite内核解析-第13章 Rolldown 构建引擎
agent
杨艺韬4 小时前
Vite内核解析-第18章 设计模式与架构决策
agent
杨艺韬4 小时前
Vite内核解析-第4章 插件系统与 Hook 机制
agent
杨艺韬4 小时前
Vite内核解析-第6章 模块图与依赖追踪
agent
杨艺韬4 小时前
Vite内核解析-第11章 HTML 转换与入口解析
agent
杨艺韬4 小时前
Vite内核解析-前言
agent
杨艺韬4 小时前
Vite内核解析-第7章 HMR 热更新
agent
杨艺韬4 小时前
Vite内核解析-第9章 JavaScript 与 TypeScript 转换
agent