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 如何将所有这些资源、脚本和样式打包为优化后的生产产物。

相关推荐
KaneLogger3 分钟前
如何提升模型编码能力
agent·ai编程
louiX9 分钟前
初级 AI Agent 工程师
langchain·agent·客户端
阿珊和她的猫1 小时前
从实践中提炼的架构设计与工程规范
ai·agent·llama·cli·mcp
大山同学2 小时前
Feynman—证据驱动的 AI 研究代理
人工智能·agent·智能体
欧雷殿2 小时前
跨设备自动化:家庭 AI 工作台的首个小目标
后端·agent·aiops
DigitalOcean2 小时前
AI变智能体,传统云不够用了:成本降67%,延迟降40%的新解法
aigc·agent
python零基础入门小白4 小时前
从0到1:手把手教你用Coze打造AI Agent,小白也能转行AI!
人工智能·学习·程序员·大模型·agent·产品经理·ai大模型
johnny2335 小时前
多智能体协作:Edict、Open Multi-Agent、Agora
agent
后端小肥肠6 小时前
白嫖小云雀 API 200 秒免费额度,封装 Skill,玩转 Seedance2.0 视频
人工智能·agent
大模型真好玩6 小时前
LangChain DeepAgents 速通指南(八)—— DeepAgents流式输出详解
人工智能·langchain·agent