Vite 设计与实现

第14章 代码分割与产物优化

作者 杨艺韬 · 7,843 字

第14章 代码分割与产物优化

开篇引言

构建工具的最终产物质量,直接决定了用户在浏览器中的加载体验。Vite 在这一环节投入了大量的工程设计:从代码分割策略到 Tree Shaking,从多种压缩引擎的选择到 License 合规提取,从 Manifest 文件的精确映射到 Module Preload 的智能预加载,这些机制共同构成了 Vite 的产物优化体系。

本章将从 Vite 源码出发,逐一剖析这些优化手段的设计原理与实现细节。通过对 terser.tslicense.tsmanifest.tsimportAnalysisBuild.tsmodulePreloadPolyfill.ts 等核心文件的深度解读,我们将理解 Vite 如何在构建阶段将开发者的源代码转化为高性能的生产产物。

本章要点

  • 理解 Vite 基于 Rolldown 的代码分割策略及 chunk 生成逻辑
  • 掌握 Tree Shaking 在 ESM 语义下的工作机制与 preserve_annotations 的设计
  • 深入 Terser 多线程压缩架构与 WorkerWithFallback 惰性初始化模式
  • 分析 License 提取插件的依赖遍历与合规输出设计
  • 理解 Manifest 文件生成的三层插件协作架构
  • 掌握 Module Preload 的并行预加载策略与 Polyfill 注入机制

14.1 代码分割策略

14.1.1 从入口到 Chunk 的拓扑分析

代码分割(Code Splitting)是现代 Web 构建的核心优化手段。Vite 的代码分割建立在 Rolldown 的 chunk 图分析之上,其基本原则是:

  1. 每个入口产生一个 chunk:静态入口点各自生成独立的输出 chunk
  2. 动态导入产生异步 chunkimport() 表达式的目标模块被分割为独立的异步 chunk
  3. 公共模块提取:多个 chunk 共同依赖的模块被提取到共享 chunk 中
graph TD
    A[入口 A] --> C[共享模块 C]
    A --> D[模块 D]
    B[入口 B] --> C
    B --> E[模块 E]
    A -.->|"import()"| F[异步模块 F]
    F --> C

    C --> G["共享 chunk (vendor)"]
    A --> H["chunk A"]
    B --> I["chunk B"]
    F --> J["async chunk F"]

    style G fill:#f9f,stroke:#333
    style J fill:#bbf,stroke:#333

Vite 在 build.ts 中通过 Rolldown 的 preserveEntrySignatures 配置控制入口签名的保留策略。对于应用模式,该选项默认设为 false,允许 Rolldown 自由进行 chunk 合并优化:

const bundle = await rolldown({
  ...rollupOptions,
  input,
  preserveEntrySignatures: false,
  // ...
})

preserveEntrySignatures 设为 false 时,Rolldown 可以将入口 chunk 中的部分代码拆分到共享 chunk 中。这意味着入口模块的原始导出签名可能不再完全保留在单个 chunk 中,但换来的是更优的代码去重和更小的总体积。

14.1.2 CSS 代码分割

CSS 代码分割是 Vite 的一个重要特性。当 build.cssCodeSplittrue(默认值)时,异步 chunk 引用的 CSS 会被提取为独立的 CSS 文件,并在运行时通过动态 <link> 标签加载。

sequenceDiagram
    participant Browser as 浏览器
    participant Main as 主 Chunk
    participant Async as 异步 Chunk
    participant CSS as CSS 文件

    Browser->>Main: 加载主 Chunk
    Main->>Browser: 触发动态 import()
    par 并行加载
        Browser->>Async: 请求异步 Chunk
        Browser->>CSS: 请求关联 CSS
    end
    CSS-->>Browser: CSS 已加载
    Async-->>Browser: JS 已加载
    Browser->>Browser: 渲染完成

这种并行加载策略避免了传统方案中”先加载 JS 再发现 CSS 依赖”的瀑布流问题。Vite 通过 viteMetadata 在 chunk 上标注其关联的 CSS 和静态资源:

chunk.viteMetadata!.importedCss.forEach((file) => {
  mappedChunks.push(joinUrlSegments(base, file))
})
chunk.viteMetadata!.importedAssets.forEach((file) => {
  mappedChunks.push(joinUrlSegments(base, file))
})

viteMetadata 是 Vite 扩展的 Rolldown chunk 元数据,它在构建过程中由 CSS 插件和资源插件填充,记录了每个 JS chunk 关联的所有 CSS 文件和静态资源文件。这个元数据在 Manifest 生成、SSR Manifest 生成、Module Preload 注入等多个场景中被复用。

14.1.3 manualChunks 与自定义分割

Vite 允许通过 build.rollupOptions.output.manualChunks 进行自定义分割。这在需要将特定的第三方库(如 Vue、React)提取为独立 chunk 以优化缓存命中率时特别有用:

// vite.config.ts 示例
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return 'vendor'
          }
        }
      }
    }
  }
}

manualChunks 函数接收每个模块的 ID,返回该模块应属于的 chunk 名称。Rolldown 会将返回相同名称的所有模块合并到同一个 chunk 中。需要注意的是,不当的 manualChunks 配置可能导致循环 chunk 依赖或代码冗余,因此在生产中需要结合实际的模块依赖图谨慎使用。

14.1.4 库模式的特殊处理

build.lib 配置存在时,Vite 进入库模式。库模式下的代码分割策略与应用模式有显著不同:

  • 默认不进行代码分割,将所有代码打包到单个文件
  • 支持同时输出 ES 和 CJS 两种格式
  • external 配置决定哪些依赖不被打包

这种差异化策略确保了库产物的可预测性和兼容性。

14.2 Tree Shaking

14.2.1 ESM 语义与静态分析

Tree Shaking 的核心在于 ESM 的静态结构特性。ESM 的 import/export 语句在编译时即可确定模块间的依赖关系,这使得构建工具能够在不执行代码的情况下精确标记未被使用的导出。

Vite/Rolldown 的 Tree Shaking 流程可以概括为以下几个阶段:

flowchart TB
    A["解析 ESM 导入导出"] --> B["构建模块依赖图"]
    B --> C["标记已使用的导出 (used exports)"]
    C --> D["识别副作用 (sideEffects)"]
    D --> E{"模块是否有副作用?"}
    E -->|"有"| F["保留模块,移除未使用的导出"]
    E -->|"无 (package.json sideEffects: false)"| G["整体移除未引用的模块"]
    F --> H["生成最终产物"]
    G --> H

sideEffects 字段来自 package.json,它告诉构建工具该包中的模块是否具有副作用。当一个模块被标记为无副作用且其导出未被引用时,整个模块可以被安全移除。这对于像 lodash-es 这样的工具库尤为重要 — 如果应用只使用了 debounce 函数,其他数百个未使用的函数将被完全移除。

14.2.2 Pure Annotation 保留

一个精妙的设计细节出现在 Terser 插件中。当构建目标是 ES 格式的库时,Vite 会强制保留 pure annotation(纯注释标记):

// terser.ts - terserPlugin 中的关键逻辑
const res = await worker.run(terserPath, code, {
  safari10: true,
  ...terserOptions,
  format: {
    ...terserOptions.format,
    // 对于 ES 库模式,保留 pure annotations 以支持下游 Tree Shaking
    preserve_annotations:
      config.build.lib && outputOptions.format === 'es'
        ? true
        : terserOptions.format?.preserve_annotations,
  },
  sourceMap: !!outputOptions.sourcemap,
  module: outputOptions.format.startsWith('es'),
  toplevel: outputOptions.format === 'cjs',
})

/*#__PURE__*/ 注释告诉下游的 Tree Shaker 某个函数调用是无副作用的,可以安全移除。当 Vite 构建的是一个库时,压缩阶段必须保留这些注释,否则下游消费者将失去对这些调用进行 Tree Shaking 的能力。这是一个跨构建工具链的协作设计 — Vite 的产物作为另一个构建工具的输入,两者通过注释约定实现协同优化。

14.2.3 sideEffects 与模块级标记

除了函数级的 /*#__PURE__*/ 标记,模块级的 sideEffects 声明也是 Tree Shaking 的重要输入。Rolldown 在模块解析阶段读取每个包的 package.json 中的 sideEffects 字段:

  • "sideEffects": false — 包中所有文件都没有副作用
  • "sideEffects": ["*.css", "*.global.js"] — 仅指定文件有副作用
  • 不声明 — 默认所有文件可能有副作用

这两级标记体系(模块级 + 表达式级)构成了 Tree Shaking 的完整判断依据。

14.3 Terser 压缩引擎

14.3.1 多线程压缩架构

Vite 的 Terser 压缩插件(plugins/terser.ts)采用了一种精巧的多线程架构。核心在于 WorkerWithFallback 的使用,它来自 artichokie 库:

const makeWorker = () =>
  new WorkerWithFallback(
    () =>
      async (
        terserPath: string,
        code: string,
        options: TerserMinifyOptions,
      ) => {
        // 在 Worker 线程中动态 import terser
        const terser: typeof import('terser') = await import(terserPath)
        try {
          return (await terser.minify(code, options)) as TerserMinifyOutput
        } catch (e) {
          // 错误对象的额外属性在线程间传递时会丢失
          // 需要手动提取 stack 并展开
          throw { stack: e.stack, ...e }
        }
      },
    {
      shouldUseFake(_terserPath, _code, options) {
        // 当选项包含不可序列化的函数时,回退到主线程
        return !!(
          (typeof options.mangle === 'object' &&
            (options.mangle.nth_identifier?.get ||
              (typeof options.mangle.properties === 'object' &&
                options.mangle.properties.nth_identifier?.get))) ||
          typeof options.format?.comments === 'function' ||
          typeof options.output?.comments === 'function' ||
          options.nameCache
        )
      },
      max: maxWorkers,
    },
  )
graph TB
    A["renderChunk 调用"] --> B{"选项是否可序列化?"}
    B -->|"是"| C["分发到 Worker 线程"]
    B -->|"否 (含函数/nameCache)"| D["在主线程执行 (Fake Worker)"]
    C --> E["Worker 线程: import(terserPath)"]
    E --> F["terser.minify(code, options)"]
    D --> F
    F --> G["返回压缩结果"]

    subgraph "WorkerWithFallback"
        C
        D
        E
    end

    style B fill:#ffd,stroke:#333

这个设计体现了三个关键决策:

惰性加载 (Lazy Loading):Terser 是可选依赖,仅在实际需要压缩时才动态加载。terserPath 通过 nodeResolveWithVite 先从项目根目录查找,再从 Vite 安装路径查找。Worker 本身也是惰性创建的,通过 worker ||= makeWorker() 确保只在第一个 chunk 需要压缩时才初始化。

序列化降级 (Serialization Fallback):Worker 线程间通信依赖结构化克隆算法,函数无法被序列化。当用户配置了不可序列化的选项(如自定义 comments 过滤函数、nth_identifier 生成器、nameCache 对象),系统通过 shouldUseFake 检测并自动降级到主线程执行。这种”能并行则并行,不能则降级”的策略在高性能工具中非常常见。

Worker 池复用worker 变量在插件生命周期内保持单例。WorkerWithFallback 内部维护了一个有上限的线程池(默认 CPU 核心数 - 1),多个 chunk 的压缩任务会被自动调度到空闲的 Worker 上。在 closeBundle Hook 中,Worker 池被正确释放。

14.3.2 错误增强与定位

Terser 压缩阶段的错误处理也值得关注。当压缩失败时,Vite 会增强错误信息,添加精确的文件位置和代码帧:

catch (e) {
  if (e.line !== undefined && e.col !== undefined) {
    e.loc = {
      file: chunk.fileName,
      line: e.line,
      column: e.col,
    }
  }
  if (e.pos !== undefined) {
    e.frame = generateCodeFrame(code, e.pos)
  }
  throw e
}

generateCodeFrame 是 Vite 的通用工具函数,它从源代码中提取错误位置周围的代码片段,并用下划线标记出具体位置。这种错误增强模式在 Vite 的多个插件中被复用,为开发者提供了一致的调试体验。

14.3.3 构建目标适配

Terser 的行为会根据输出格式和构建目标自动调整:

配置项条件效果
module: true输出格式以 es 开头启用 ES Module 模式,保留 import/export
toplevel: true输出格式为 cjs允许混淆顶层变量名
safari10: true始终开启修复 Safari 10/11 的已知语法 bug
preserve_annotations库模式 + ES 格式保留 /*#__PURE__*/ 注释

14.3.4 applyToEnvironment 的环境过滤

Terser 插件通过 applyToEnvironment 实现精确的环境匹配:

applyToEnvironment(environment) {
  // 即使 minify 不是 'terser',也需要保留该插件
  // 因为 plugin-legacy 会强制使用 terser 处理 legacy chunks
  return !!environment.config.build.minify
}

这段注释揭示了一个重要的设计约束:即使用户选择了 esbuild 或 oxc 作为默认压缩器,Terser 插件仍需保持激活状态,以便 @vitejs/plugin-legacy 在处理遗留兼容 chunk 时能够使用 Terser。

14.4 License 提取

14.4.1 合规性自动化

在生产构建中,正确处理第三方依赖的许可证信息是一项法律合规要求。许多开源协议(如 MIT、Apache 2.0)要求在分发衍生作品时保留原始版权声明。Vite 的 license.ts 插件通过 generateBundle Hook 自动收集并输出许可证文件,将这项繁琐的人工任务自动化。

flowchart TB
    A["generateBundle Hook 触发"] --> B["遍历所有 bundle 中的 chunk"]
    B --> C["遍历每个 chunk 的 moduleIds"]
    C --> D{"模块 ID 以 \\0 开头?"}
    D -->|"是 (虚拟模块)"| C
    D -->|"否"| E{"模块来自 node_modules?"}
    E -->|"否"| C
    E -->|"是"| F["findNearestMainPackageData"]
    F --> G{"name@version 已记录?"}
    G -->|"是 (去重)"| C
    G -->|"否"| H["提取 license 字段"]
    H --> I["查找 LICENSE 文件"]
    I --> J["记录到 licenses 映射表"]
    J --> C
    B --> K{"输出格式?"}
    K -->|".json 后缀"| L["输出 JSON"]
    K -->|"其他"| M["输出 Markdown"]

核心实现的关键路径:

for (const file in bundle) {
  const chunk = bundle[file]
  if (chunk.type === 'asset') continue  // 跳过资源文件

  for (const moduleId of chunk.moduleIds) {
    // 跳过虚拟模块(以 \0 开头)和非 node_modules 模块
    if (moduleId.startsWith('\0') || !isInNodeModules(moduleId)) continue

    // 查找最近的主 package.json(跳过嵌套包的内部 package.json)
    const pkgData = findNearestMainPackageData(
      path.dirname(moduleId),
      packageCache,
    )
    if (!pkgData) continue

    const { name, version = '0.0.0', license } = pkgData.data
    const key = `${name}@${version}`
    if (licenses[key]) continue  // 同一包的同一版本只记录一次

    const entry: LicenseEntry = { name, version }
    if (license) {
      entry.identifier = license.trim()  // SPDX 标识符
    }
    const licenseFile = findLicenseFile(pkgData.dir)
    if (licenseFile) {
      entry.text = fs.readFileSync(licenseFile, 'utf-8').trim()
    }
    licenses[key] = entry
  }
}

几个值得关注的设计点:

  1. findNearestMainPackageData 而非普通的 findNearestPackageData:这确保了找到的是包的主 package.json,而非子目录中可能存在的辅助 package.json
  2. name@version 去重:同一个包在多个 chunk 中被引用时,许可证信息只记录一次
  3. packageCache 复用:避免对同一目录反复读取文件系统

14.4.2 许可证文件查找策略

findLicenseFile 函数遵循 npm 的标准约定:

// 参考 npm-packlist 的许可证文件匹配规则
const licenseFiles = [/^license/i, /^licence/i, /^copying/i]

function findLicenseFile(pkgDir: string) {
  const files = fs.readdirSync(pkgDir)
  const matchedFile = files.find((file) =>
    licenseFiles.some((re) => re.test(file)),
  )
  if (matchedFile) {
    return path.join(pkgDir, matchedFile)
  }
}

这个设计兼容了 LICENSELICENSE.mdLICENSE.txtLICENCE(英式拼写)、COPYING(GNU 风格)以及 COPYING.md 等各种命名约定。正则使用了 ^ 锚定和 i 不区分大小写标志,确保只匹配文件名开头部分。

14.4.3 输出格式

License 插件支持两种输出格式,由 fileName 配置的后缀决定:

Markdown 格式(默认 .vite/license.md):

function licenseEntryToMarkdown(licenses: LicenseEntry[]) {
  let text = `# Licenses\n\nThe app bundles dependencies which contain the following licenses:\n`
  for (const license of licenses) {
    const nameAndVersionText = `${license.name} - ${license.version}`
    const identifierText = license.identifier ? ` (${license.identifier})` : ''
    text += `\n## ${nameAndVersionText}${identifierText}\n`
    if (license.text) {
      text += `\n${license.text}\n`
    }
  }
  return text
}

JSON 格式(当文件名以 .json 结尾时):适合程序化处理,可用于构建合规报告或自动化审计。

14.4.4 许可证排序

输出前,许可证条目会通过 sortObjectKeys(licenses) 进行排序。这确保了构建产物的确定性 — 无论模块被遍历的顺序如何,最终输出的许可证列表始终保持字母序,方便版本控制系统进行 diff 比较。

14.5 Manifest 生成

14.5.1 三层插件协作架构

Manifest 插件(plugins/manifest.ts)的设计展示了 Vite 如何在 Rolldown 原生实现与 Vite 兼容层之间协作。它通过 perEnvironmentPlugin 返回一个由三个插件组成的数组:

graph LR
    A["native:manifest-envs<br/>buildStart: 记录环境引用"] --> B["nativeManifestPlugin<br/>(Rolldown 内置)<br/>generateBundle: 生成基础 manifest"]
    B --> C["native:manifest-compatible<br/>generateBundle: 补充 CSS/Assets"]

    style A fill:#e8f5e9
    style B fill:#e3f2fd
    style C fill:#fff3e0

第一层:环境引用捕获

{
  name: 'native:manifest-envs',
  buildStart() {
    envs[environment.name] = this.environment
  },
}

buildStart 时捕获当前环境引用,以便后续插件在 generateBundle 中访问环境特定的 cssEntriesMap

第二层:Rolldown 原生 Manifest 生成

rolldown/experimental 提供的 nativeManifestPlugin,负责基础的 manifest JSON 结构生成。它处理 chunk 的入口信息、文件名映射、静态导入和动态导入关系。

第三层:Vite 兼容层补充

{
  name: 'native:manifest-compatible',
  generateBundle(_, bundle) {
    const asset = bundle[outPath]
    if (asset.type === 'asset') {
      let manifest: Manifest | undefined
      for (const output of Object.values(bundle)) {
        const importedCss = output.viteMetadata?.importedCss
        const importedAssets = output.viteMetadata?.importedAssets
        if (!importedCss?.size && !importedAssets?.size) continue
        manifest ??= JSON.parse(asset.source.toString()) as Manifest
        // 补充 CSS 和资源映射
        if (output.type === 'chunk') {
          const item = manifest[getChunkName(output)]
          if (item) {
            if (importedCss?.size) item.css = [...importedCss]
            if (importedAssets?.size) item.assets = [...importedAssets]
          }
        }
      }
    }
  },
}

这一层读取 Rolldown 生成的基础 manifest,然后补充 viteMetadata 中的 CSS 和资源映射信息。viteMetadata 是 Vite 在构建过程中通过 CSS 插件和资源插件收集的元数据,Rolldown 的原生插件尚不了解这些信息。

14.5.2 Manifest 数据结构

Manifest 文件是一个 JSON 对象,以模块的原始路径为键,ManifestChunk 为值:

export interface ManifestChunk {
  src?: string              // 输入文件名
  file: string              // 输出文件名(含 hash)
  css?: string[]            // 关联的 CSS 文件列表
  assets?: string[]         // 关联的资源文件列表(不含 CSS)
  isEntry?: boolean         // 是否为静态入口
  name?: string             // chunk 名称
  isDynamicEntry?: boolean  // 是否为动态入口
  imports?: string[]        // 静态导入的其他 manifest 键
  dynamicImports?: string[] // 动态导入的其他 manifest 键
}

这个数据结构使得后端框架(如 Laravel、Rails)能够根据原始文件路径查找对应的产物文件名,实现正确的 <script><link> 标签注入。

14.5.3 per-environment 状态管理

Manifest 插件使用 perEnvironmentState 实现每环境独立的状态:

const getState = perEnvironmentState(() => {
  return {
    manifest: {} as Manifest,
    outputCount: 0,
    reset() {
      this.manifest = {}
      this.outputCount = 0
    },
  }
})

perEnvironmentState 是 Vite 的通用工具函数,基于 WeakMap<Environment, State> 实现。它确保在多环境构建(如同时构建 client 和 ssr)时,每个环境拥有独立的 manifest 状态,互不干扰。

14.5.4 多输出合并逻辑

当存在多个 output 配置时(例如 @vitejs/plugin-legacy 同时输出现代和遗留两套产物),Manifest 插件需要合并多次 generateBundle 的结果:

const state = getState(this)
state.outputCount++
state.manifest = Object.assign(
  state.manifest,
  manifest ?? JSON.parse(asset.source.toString()),
)
if (state.outputCount >= outputLength) {
  // 最后一次输出,写入完整的 manifest
  asset.source = JSON.stringify(state.manifest, undefined, 2)
  state.reset()
} else {
  // 中间输出,暂时删除 manifest 文件
  delete bundle[outPath]
}

这种”收集 -> 合并 -> 最终写入”的模式避免了中间状态的 manifest 文件被输出到磁盘。

14.6 Module Preload

14.6.1 问题与方案

传统的动态 import() 存在一个性能问题:浏览器只有在解析并执行到 import() 语句时,才会开始请求异步 chunk。而这个异步 chunk 可能还有自己的静态依赖,形成请求瀑布流(waterfall)。

Module Preload 的解决方案是:在执行 import() 之前,通过 <link rel="modulepreload"> 或自定义预加载逻辑,并行预加载该 chunk 及其所有静态依赖和关联 CSS。

sequenceDiagram
    participant Browser as 浏览器
    participant Main as 主模块
    participant A as 异步 Chunk A
    participant B as 依赖 Chunk B
    participant CSS as 关联 CSS

    Note over Browser: 传统方式 (瀑布流)
    Browser->>Main: 加载并执行
    Main->>A: import() 触发请求
    A->>B: 发现静态依赖,请求
    A->>CSS: 发现 CSS 依赖,请求

    Note over Browser: Module Preload (并行)
    Browser->>Main: 加载并执行
    par __vitePreload 并行触发
        Main->>A: 预加载 Chunk A
        Main->>B: 预加载 Chunk B
        Main->>CSS: 预加载 CSS
    end

14.6.2 预加载辅助函数

Vite 在 importAnalysisBuild.ts 中定义了 __vitePreload 辅助函数。在构建时,每个动态 import() 都被包裹为对 __vitePreload 的调用:

// 构建前
const module = await import('./AsyncComponent.vue')

// 构建后
const module = await __vitePreload(
  () => import('./AsyncComponent-abc123.js'),
  ['./assets/dep-xyz789.js', './assets/style-def456.css']
)

__vitePreload 的核心实现:

function preload(
  baseModule: () => Promise<unknown>,
  deps?: string[],
  importerUrl?: string,
) {
  let promise = Promise.resolve()
  if (true               && deps && deps.length > 0) {
    const links = document.getElementsByTagName('link')
    const cspNonce = document.querySelector('meta[property=csp-nonce]')
      ?.nonce || /* fallback */ getAttribute('nonce')

    promise = allSettled(
      deps.map((dep) => {
        dep = assetsURL(dep, importerUrl)
        if (dep in seen) return  // 去重
        seen[dep] = true

        // 检查是否已被 SSR 预加载
        if (isBaseRelative) {
          for (let i = links.length - 1; i >= 0; i--) {
            if (links[i].href === dep) return
          }
        }

        // 创建预加载 <link>
        const link = document.createElement('link')
        link.rel = isCss ? 'stylesheet' : scriptRel
        link.crossOrigin = ''
        link.href = dep
        if (cspNonce) link.setAttribute('nonce', cspNonce)
        document.head.appendChild(link)

        if (isCss) {
          return new Promise((res, rej) => {
            link.addEventListener('load', res)
            link.addEventListener('error', () =>
              rej(new Error(`Unable to preload CSS for ${dep}`)))
          })
        }
      }),
    )
  }
  return promise.then((res) => {
    for (const item of res || []) {
      if (item.status !== 'rejected') continue
      handlePreloadError(item.reason)
    }
    return baseModule().catch(handlePreloadError)
  })
}

关键设计点:

  1. true 条件编译:在非现代浏览器的 legacy chunk 中,预加载逻辑被完全移除
  2. seen 去重表:全局单例对象,避免同一资源被重复预加载
  3. SSR 标记检测:反向遍历已有的 <link> 标签,避免与 SSR 渲染的预加载标签重复
  4. CSP Nonce 支持:从 <meta property="csp-nonce"> 获取 nonce 值并注入到动态创建的 <link> 标签
  5. allSettled 语义:使用 Promise.allSettled 确保即使某个依赖预加载失败,其他依赖和主模块仍能正常加载
  6. vite:preloadError 事件:通过自定义 DOM 事件允许应用层处理预加载失败,支持 preventDefault 阻止错误冒泡

14.6.3 Polyfill 注入

modulePreloadPolyfill.ts 负责在不支持 modulepreload 的浏览器中注入 Polyfill:

export function modulePreloadPolyfillPlugin(config: ResolvedConfig): Plugin {
  if (config.isBundled) {
    return perEnvironmentPlugin(
      'native:modulepreload-polyfill',
      (environment) => {
        return nativeModulePreloadPolyfillPlugin({
          isServer: environment.config.consumer !== 'client',
        })
      },
    )
  }
  // 开发模式下返回空模块
  return {
    name: 'vite:modulepreload-polyfill',
    load: {
      filter: { id: exactRegex(resolvedModulePreloadPolyfillId) },
      handler(_id) {
        return ''  // 开发模式不需要 polyfill
      },
    },
  }
}

该插件通过 perEnvironmentPlugin 实现环境感知:在客户端环境中注入 Polyfill(用于检测浏览器是否支持 <link rel="modulepreload">),在服务端环境中完全跳过。开发模式下返回空模块,因为开发服务器通过 ESM 原生加载,不需要预加载机制。

14.6.4 预加载代码生成

getPreloadCode 函数根据环境配置生成不同的预加载辅助代码:

function getPreloadCode(environment, renderBuiltUrlBoolean, isRelativeBase) {
  const { modulePreload } = environment.config.build

  // 决定 <link rel="?"> 的值
  const scriptRel =
    modulePreload && modulePreload.polyfill
      ? `'modulepreload'`  // 有 polyfill 保证,直接使用
      : `(${detectScriptRel.toString()})()`  // 运行时检测

  // 资源 URL 解析函数
  const assetsURL =
    renderBuiltUrlBoolean || isRelativeBase
      ? `function(dep, importerUrl) { return new URL(dep, importerUrl).href }`
      : `function(dep) { return ${JSON.stringify(base)}+dep }`

  return `const scriptRel = ${scriptRel};const assetsURL = ${assetsURL};const seen = {};export const ${preloadMethod} = ${preload.toString()}`
}

14.7 设计决策分析

14.7.1 可选依赖与惰性加载

Terser 在 Vite v3 之后被设为可选依赖。这一决策的动机是多方面的:

function loadTerserPath(root: string) {
  if (terserPath) return terserPath  // 缓存

  const resolved =
    nodeResolveWithVite('terser', undefined, { root }) ??
    nodeResolveWithVite('terser', _dirname, { root })
  if (resolved) return (terserPath = resolved)

  throw new Error(
    'terser not found. Since Vite v3, terser has become an optional dependency. You need to install it.',
  )
}
  • 安装速度:减少默认安装的依赖数量,Terser 本身约 3MB
  • 灵活性:大多数项目使用 esbuild 或 oxc 压缩即可,不需要 Terser
  • 错误友好:当用户配置 minify: 'terser' 但未安装时,提供清晰的错误提示

14.7.2 Native 与兼容层的分层策略

Manifest 插件的三层架构展示了 Vite 从 Rollup 向 Rolldown 迁移的过渡策略:

graph TB
    subgraph "迁移路径"
        A["Phase 1: 纯 Vite JS 实现"] --> B["Phase 2: Native + 兼容层"]
        B --> C["Phase 3: 完全 Native (目标)"]
    end

    subgraph "当前状态 (Phase 2)"
        D["Rolldown Native Plugin<br/>高性能核心逻辑"] --> E["Vite 兼容层<br/>补充 viteMetadata 等"]
        D --> F["环境捕获层<br/>multi-environment 支持"]
    end

    style B fill:#fff3e0

这种分层架构使得 Rolldown 可以逐步内化 Vite 的扩展逻辑,最终达到”一次调用,全部完成”的目标。

14.7.3 产物优化的渐进式管线

整个产物优化过程形成一条精确编排的管线:

graph TB
    subgraph "构建阶段"
        A["Tree Shaking<br/>(Rolldown 内置)"] --> B["代码分割<br/>(chunk 图分析)"]
        B --> C["压缩<br/>(Terser / esbuild / oxc)"]
        C --> D["License 提取<br/>(generateBundle)"]
        C --> E["Manifest 生成<br/>(generateBundle)"]
        C --> F["Module Preload 注入<br/>(renderChunk)"]
    end

    subgraph "运行时"
        F --> G["__vitePreload 并行加载"]
        E --> H["后端框架文件映射"]
        D --> I["合规审计"]
    end

    style A fill:#e8f5e9
    style C fill:#fff3e0
    style G fill:#e3f2fd

14.7.4 本章六大机制在源码里的真实尺寸:1131 行 + build.ts 1933 行

把本章六大优化机制对应的实际文件按行数实测——

机制文件备注
代码分割 / Tree Shaking 编排src/node/build.ts1933不是单插件、是 build 顶层编排——调用 Rolldown + 控制 chunk 拆分逻辑、importedBindings 提取、entry 信号保留
资源处理(asset)src/node/plugins/asset.ts617本章未覆盖——图片/字体/二进制资源的 hash + emit + URL 改写——是 plugins/ 第三大文件(仅次 css 和 html)
Manifest 生成src/node/plugins/manifest.ts188§14.5 主角
License 提取src/node/plugins/license.ts150§14.4 主角
Terser 压缩src/node/plugins/terser.ts139§14.3 主角——139 行实现整个多线程压缩
Module Preload Polyfillsrc/node/plugins/modulePreloadPolyfill.ts37§14.6 polyfill 部分(实际预加载逻辑不在这里、是构建期注入到 chunk 里的运行时代码)

三条值得记住的物理事实——

  1. build.ts 1933 行是最重的优化文件——本章 §14.1 讨论的代码分割编排其实不在 plugins/ 任何文件里——而是在这个 1933 行的顶层 build orchestrator——它调用 Rolldown、设置 output.manualChunks、传 chunk hooks(renderChunk 用于上面 5 个插件的 hook 链)——理解 Vite 构建产物优化、build.ts 是必读
  2. terser.ts 139 行就把多线程压缩实现完了——本章 §14.3.1 讨论的”WorkerWithFallback”实际来自第三方包 artichokie(Vite 团队自己写的小工具库、专门解决 “worker 不可序列化时回退到主线程” 这个问题);139 行里 shouldUseFake 函数列出 5 个触发回退的场景:(a) mangle.nth_identifier.get、(b) mangle.properties.nth_identifier.get、(c) format.comments 是函数、(d) output.comments 是函数、(e) nameCache——全是闭包或函数引用、无法跨 worker boundary 序列化
  3. modulePreloadPolyfill.ts 仅 37 行——但 §14.6 用一节讨论 Module Preload——是因为真正的 __vitePreload 辅助函数代码不在这个文件里——它是作为字符串模板被嵌入到运行时代码里、注入到每个使用动态 import 的 chunk——这种 “代码生成 vs 文件存放” 的分离是 Vite 处理 polyfill 的常用手法

asset.ts 617 行是本章完全没讲的板块——是 plugins/ 第三大文件(仅次 css.ts 3552 / html.ts 1605)——负责图片/字体/二进制资源的 import → hash → emit → URL 改写全流程。下一版章节应补一节专门讲 asset 处理。

14.7.5 补上 asset.ts:产物优化不只有 JS chunk

如果只讲 Tree Shaking、压缩和 chunk 拆分,Vite 的产物优化会被误解成”只优化 JavaScript”。asset.ts 说明另一条同样重要的路径:图片、字体、WASM、二进制文件从 import 语句进入构建图,最后变成 hash 文件、data URL 或 public 路径。

fileToBuiltUrl 是这条路径的入口之一。源码在 ../vite-latest/packages/vite/src/node/plugins/asset.ts:421-470:它先检查是否命中 public 文件;如果是 public 且不是 ?inline,直接走 publicFileToBuiltUrl。否则读取文件内容,调用 shouldInline 判断是否内联;不内联时通过 emitFile({ type: 'asset', ... }) 交给打包器输出,并保留 originalFileName

flowchart TB
    A["import './logo.svg'"] --> B{"public 文件?"}
    B -->|"是且非 inline"| C["publicFileToBuiltUrl"]
    B -->|"否"| D["readFile"]
    D --> E{"shouldInline?"}
    E -->|"是"| F["assetToDataURL"]
    E -->|"否"| G["emitFile(type: asset)"]
    G --> H["hash 文件 + referenceId"]

shouldInline 也不是简单的”小文件就 base64”。它先排除 HTML,再排除带 fragment 的 SVG,因为这类 SVG 往往要被复用,内联会破坏引用语义;之后才读取 build.assetsInlineLimit,并允许用户传函数自行决定(../vite-latest/packages/vite/src/node/plugins/asset.ts:552-565)。这体现了 Vite 的一贯策略:默认值覆盖常见情况,但关键判断点留给用户。

这条资产路径和 Manifest / Module Preload 也会合流。assetUrlRE 替换阶段会把最终文件加入 chunk.viteMetadata.importedAssets../vite-latest/packages/vite/src/node/plugins/asset.ts:99-109);Manifest 插件随后读取 viteMetadata.importedAssets 补齐清单(../vite-latest/packages/vite/src/node/plugins/manifest.ts:114-127)。也就是说,asset 不是构建的旁路,而是 chunk 元数据的一部分。

14.7.6 Module Preload 的关键边界:CSS 依赖不能被用户删掉

importAnalysisBuild.ts__vitePreload 逻辑不是只为了 JS 预加载,它还承载了 CSS 并行加载的职责。getPreloadCode 会根据 modulePreload.polyfill 决定 scriptRel,并根据 renderBuiltUrl 或相对 base 生成不同的 assetsURL 函数(../vite-latest/packages/vite/src/node/plugins/importAnalysisBuild.ts:170-194)。这意味着同一个 helper 必须同时适配绝对 base、相对 base 和自定义 URL 渲染。

更细的边界在动态 import 依赖收集处。源码会把 chunk.viteMetadata.importedCss 加入 deps(../vite-latest/packages/vite/src/node/plugins/importAnalysisBuild.ts:381-392),随后构造 depsArray。即使用户把 modulePreload 关掉,CSS deps 也不能随便移除;源码注释明确说 CSS deps “aren’t really preloads”,只是复用同一套 helper 机制(../vite-latest/packages/vite/src/node/plugins/importAnalysisBuild.ts:416-427)。

这点很容易被误解:modulePreload: false 不是”动态 import 相关资源全都别处理”,而是”不要额外做 JS module preload”。异步 chunk 关联的 CSS 仍然要被带上,否则浏览器会先执行 JS,渲染时才发现 CSS 还没加载,出现闪烁或样式延迟。

资源类型是否受 modulePreload: false 影响原因
JS 依赖 chunk这是 module preload 的直接对象
CSS 依赖不能简单删除它关系到异步组件渲染时的样式完整性
通过 resolveDependencies 自定义的依赖受用户函数影响但仍要尊重 CSS 边界

因此 Vite 的产物优化不是单点技巧,而是一套跨插件协作:asset 插件产出文件和元数据,CSS 插件记录样式依赖,import analysis 生成 preload helper,manifest 插件把映射交给后端框架。少看任何一环,都会把”为什么最终 HTML 能正确加载资源”讲得不完整。

14.7.7 与第 12 章资源处理的边界分工

第 12 章讲资源模块时,重点是”一个 import 如何变成 URL”;本章讲产物优化时,重点是”这个 URL 如何参与最终加载性能”。二者的边界可以这样划:

问题第 12 章回答本章回答
文件是否内联assetsInlineLimit、Git LFS、SVG fragment内联会不会影响首屏体积和缓存
文件名如何确定emitFile、hash、占位符替换Manifest 和后端框架如何找到 hash 文件
CSS/图片如何关联 JS chunkviteMetadata.importedAssets 的来源Module Preload 和 Manifest 如何消费这些元数据
public 资源如何处理public 路径和普通 asset 分流产物映射中如何避免重复 emit

这个分工能避免一个常见误读:认为资源处理只是 loader 功能。实际上,资源 loader 产生的是构建图里的事实;产物优化阶段要把这些事实组织成浏览器和后端都能消费的加载计划。asset.ts 负责把文件放进 bundle,importAnalysisBuild.ts 负责把动态 import 的依赖转成运行时代码,manifest.ts 负责把最终文件映射落盘。三者的协作,才是 Vite 生产构建”开箱即能部署到后端框架”的原因。

如果你调试生产资源加载问题,也应该按这条链路排查:先看 asset 是否被 emit,再看 chunk 的 viteMetadata 是否记录 CSS / asset,最后看 manifest 或 preload helper 是否消费了这些元数据。只盯最终 HTML,往往只能看到症状,看不到是哪一环丢了关联。

14.8 小结

本章深入分析了 Vite 产物优化的六大核心机制:

  • 代码分割利用 Rolldown 的模块图分析实现入口分割、动态分割和公共模块提取,CSS 代码分割与 JS chunk 保持关联以支持并行加载。preserveEntrySignaturesmanualChunks 提供了灵活的控制手段。
  • Tree Shaking 基于 ESM 的静态语义实现死代码消除,通过模块级 sideEffects 和表达式级 /*#__PURE__*/ 两级标记体系提供精确的消除依据。Vite 在库模式下特别保留 pure annotation 以支持下游的二次 Tree Shaking。
  • Terser 压缩采用 WorkerWithFallback 实现多线程并行压缩,通过 shouldUseFake 检测优雅处理不可序列化配置的主线程回退。惰性加载策略确保了 Terser 作为可选依赖的零成本。
  • License 提取通过遍历 chunk 的 moduleIds,自动从 node_modules 中提取许可证信息,使用 name@version 去重,支持 Markdown 和 JSON 两种输出格式。
  • Manifest 生成采用三层插件协作架构,在 Rolldown native 实现和 Vite 兼容层之间实现平滑过渡,perEnvironmentState 确保了多环境构建的状态隔离。
  • Module Preload 通过构建时分析依赖图并注入 __vitePreload 辅助函数,将动态导入的瀑布流请求转化为并行预加载。去重、CSP Nonce、SSR 检测、错误事件等细节设计展示了生产级代码的严谨性。

物理事实:本章六大机制对应源码 1131 行(plugins/)+ build.ts 1933 行——其中 build.ts 是代码分割编排的真正所在;terser.ts 仅 139 行实现整个多线程压缩、依赖第三方 artichokie 的 WorkerWithFallback;asset.ts 617 行是 plugins/ 第三大文件但本章未覆盖。