Skip to content

第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 中

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

typescript
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> 标签加载。

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

typescript
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 以优化缓存命中率时特别有用:

typescript
// 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 流程可以概括为以下几个阶段:

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

14.2.2 Pure Annotation 保留

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

typescript
// 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 库:

typescript
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,
    },
  )

基于 VitePress 构建