Vite 设计与实现

第8章 依赖预构建

作者 杨艺韬 · 12,907 字 · 发布于 · 更新于

开篇引言

在浏览器原生支持 ES Module 的今天,一个合理的疑问是:既然浏览器能直接通过 import 语句加载模块,为什么 Vite 还需要一个"预构建"步骤?

答案隐藏在 npm 生态的现实中。以 lodash-es 为例,当你执行 import { debounce } from 'lodash-es' 时,浏览器会先请求 lodash-es 的入口文件,然后发现它 re-export 了几百个子模块,每个子模块又可能依赖其他内部模块。一个看似简单的导入,最终可能触发数百个 HTTP 请求。更糟糕的是,大量 npm 包仍然使用 CommonJS 格式发布——module.exports 和 require() 是浏览器完全无法理解的语法。

Vite 的依赖预构建(Dependency Pre-Bundling)正是为解决这两个核心问题而设计的:

  1. 格式转换:将 CommonJS 和 UMD 格式的依赖转换为 ESM
  2. 请求合并:将内部模块众多的依赖打包成单个文件,减少 HTTP 请求数量

本章将深入 optimizer/ 目录的源码,揭示从依赖发现、扫描、打包到缓存的完整实现。

本章要点

  • 依赖预构建的两大动机:CommonJS 转 ESM 和减少请求数
  • scan.ts 如何使用 Rolldown 的 scan API 快速发现项目依赖
  • rolldownDepPlugin.ts 如何处理依赖的打包和外部化
  • 基于 lockfile + config 的两级缓存策略
  • 增量式依赖发现与热重载协调机制
  • optimizer.ts 中的 DepsOptimizer 状态机设计

为什么需要预构建

CommonJS 到 ESM 的转换

npm 上大量流行的包仍然以 CommonJS 格式发布。以 React 为例:

// node_modules/react/index.js
'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

浏览器无法理解 module.exports 和 require()。Vite 的预构建会将其转换为标准的 ESM 格式,并且正确处理 default 导出和命名导出之间的互操作(interop)关系。

减少请求数量

即使是纯 ESM 的包,也可能因为模块拆分过细而导致请求数爆炸——预构建把它们合并成单个文件。而针对前一个问题(CJS/UMD),Vite 源码中定义的 needsInterop 函数负责检测某个依赖的入口是否需要 interop:

// packages/vite/src/node/optimizer/index.ts:1171
function needsInterop(
  environment: Environment,
  id: string,
  exportsData: ExportsData,
  output?: { exports: string[] },
): boolean {
  if (environment.config.optimizeDeps.needsInterop?.includes(id)) {
    return true
  }
  const { hasModuleSyntax, exports } = exportsData
  // 入口文件没有 ESM 语法 -- 很可能是 CJS 或 UMD
  if (!hasModuleSyntax) {
    return true
  }
  // ...
}

ExportsData 通过 es-module-lexer 解析入口文件获得,它包含了模块是否使用了 ESM 语法(import/export)以及导出了哪些名称。

optimizer/ 目录结构

optimizer/
  index.ts              # 核心入口:类型定义、缓存加载、执行打包、hash 计算
  optimizer.ts          # DepsOptimizer 状态机:管理开发模式下的增量发现
  scan.ts               # 依赖扫描:使用 Rolldown scan API 发现裸导入
  rolldownDepPlugin.ts  # 预构建 Rolldown 插件:处理外部化和资源类型
  resolve.ts            # include 选项的解析器和 glob 展开
  pluginConverter.ts    # esbuild 插件到 Rolldown 插件的适配层

这六个文件构成了 Vite 预构建子系统的完整实现。它们之间的关系可以通过下面的架构图来理解:

graph TB
    subgraph "optimizer/ 架构"
        A[optimizer.ts<br/>DepsOptimizer 状态机] --> B[index.ts<br/>核心引擎]
        B --> C[scan.ts<br/>依赖扫描]
        B --> D[rolldownDepPlugin.ts<br/>打包插件]
        B --> E[resolve.ts<br/>路径解析]
        F[pluginConverter.ts<br/>esbuild 插件适配] -.->|config.ts 解析 esbuildOptions.plugins 时调用| B
    end
    A -->|scanImports| C
    A -->|runOptimizeDeps| B
    B -->|rolldownDepPlugin| D
    B -->|createOptimizeDepsIncludeResolver| E
    C -->|rolldownScanPlugin| D2[Rolldown scan API]
    style A fill:#e8f4fd,stroke:#1890ff
    style B fill:#f6ffed,stroke:#52c41a
    style C fill:#fff7e6,stroke:#fa8c16
    style D fill:#fff1f0,stroke:#f5222d

8.1 双维度哈希缓存——lockfile + config 双重保护

打开 packages/vite/src/node/optimizer/index.ts:1332-1345 的 getDepHash——它不是单一哈希、而是三个哈希同时计算:

function getDepHash(environment: Environment): {
  lockfileHash: string
  configHash: string
  hash: string
} {
  const lockfileHash = getLockfileHash(environment)
  const configHash = getConfigHash(environment)
  const hash = getHash(lockfileHash + configHash)
  return {
    hash,
    lockfileHash,
    configHash,
  }
}

三个 hash 各有用途:

  • lockfileHash——反映依赖版本。npm / yarn / pnpm / bun 的 lockfile 类文件(多数是安装后落在 node_modules 下的那一份,见 §8.2)变化就变。
  • configHash——反映 Vite 配置。optimizeDeps.include / rolldownOptions / plugin list 变化就变。
  • hash——两者合并的总哈希、作为缓存的 master key。

为什么不用一个大 hash?——因为 _metadata.json 里同时存三个 hash、启动时做精细化重新优化判断:

if (cachedMetadata.lockfileHash !== getLockfileHash(environment)) {
  // lockfile 变了:依赖版本变了、必须重新优化
  ...
} else if (cachedMetadata.configHash !== getConfigHash(environment)) {
  // config 变了:虽然依赖没变、但打包策略变了
  ...
}

两种变化的信号不同——lockfile 变意味着 "代码本身变了"、config 变意味着 "打包方式变了"。日志里输出不同的信息给用户——一眼就知道是哪种 trigger。

分开的 hash 还让未来可以优化——比如 config 变了但 lockfile 没变时、理论上可以只重新配置、复用依赖解析结果——这是单一 hash 做不到的。vite 现在没这么激进地优化、但架构上保留了可能。

这种 "分维度追踪缓存键" 的模式比"一个大 hash 什么都掺在里面" 更精细——cargo、sccache、Bazel 等构建系统都用类似思路。

8.2 lockfileFormats 的 9 种 lockfile 格式支持

vite 的 lockfile hash 支持 4 种包管理器的 9 种 lockfile 格式(packages/vite/src/node/optimizer/index.ts:1206-1260):

// 为便于阅读压成单行,字段与源码一致
const lockfileFormats = [
  { path: 'node_modules/.package-lock.json', checkPatchesDir: 'patches', manager: 'npm' },
  { path: 'node_modules/.yarn-state.yml', checkPatchesDir: false, manager: 'yarn' }, // Yarn non-PnP
  { path: '.pnp.cjs', checkPatchesDir: '.yarn/patches', manager: 'yarn' },  // Yarn v3+ PnP
  { path: '.pnp.js', checkPatchesDir: '.yarn/patches', manager: 'yarn' },   // Yarn v2 PnP
  { path: 'node_modules/.yarn-integrity', checkPatchesDir: 'patches', manager: 'yarn' }, // yarn 1
  { path: 'node_modules/.pnpm/lock.yaml', checkPatchesDir: false, manager: 'pnpm' }, // Included in lockfile
  { path: '.rush/temp/shrinkwrap-deps.json', checkPatchesDir: false, manager: 'pnpm' },
  { path: 'bun.lock', checkPatchesDir: 'patches', manager: 'bun' },
  { path: 'bun.lockb', checkPatchesDir: 'patches', manager: 'bun' },
].sort((_, { manager }) => {
  return process.env.npm_config_user_agent?.startsWith(manager) ? 1 : -1
})

9 种文件路径 × 4 种包管理器——覆盖几乎所有主流 Node.js 工程化场景。

注意末尾的 .sort——根据 npm_config_user_agent 排序:比较函数在第二个参数属于当前包管理器时返回 1(即"第一个参数排在它后面")——当前使用的包管理器的 lockfile 排到最前(PR #12700 "load the correct lock file" 引入)。

因为 lookupFile 是在每一层目录里按数组顺序逐个试、找到第一个存在的文件就返回——如果你用 yarn 但工程里有 npm 留下的 node_modules/.package-lock.json 残留、按声明顺序 vite 会先撞上 npm 的文件——导致 hash 来源不对。把 当前包管理器的 lockfile 排到最前——意味着先检查"最应该存在的那个"、其他包管理器的残留只作兜底。

这个排序只有在多个 lockfile 并存的边缘场景才有价值——典型的是迁移中的项目(从 npm 切到 pnpm 但 .package-lock.json 还没删)。通过排序让优先级合理——让 vite 更可能找到"当前真正在用的 lockfile"。

checkPatchesDir 字段——给 patch-package 用户特殊支持——patches 目录的 mtime 也算进 hash(pnpm、Yarn non-PnP 的 patch 信息已经记在 lockfile 里、所以设为 false)——用户修改了 patch 补丁、vite 会重新优化。这个细节只有维护过需要 monkey-patch 上游包的项目的人才会感谢——几分钟的打磨换来几小时的困惑避免。

8.3 getConfigHash 的字段白名单策略

注意 getConfigHash 不是对整个 config 做 hash——只挑影响依赖优化的字段:

// 简化示意:省略了 const { config } = environment 等解构,include/exclude 与 rolldownOptions 的完整写法见 §8.5「缓存策略」
function getConfigHash(environment: Environment): string {
  const content = JSON.stringify({
    define: !config.keepProcessEnv ? process.env.NODE_ENV || config.mode : null,
    root: config.root,
    resolve: config.resolve,
    assetsInclude: config.assetsInclude,
    plugins: config.plugins.map((p) => p.name),  // ← 只 hash 插件名、不 hash 实现
    optimizeDeps: {
      include: ...unique(include).sort()...,
      exclude: ...unique(exclude).sort()...,
      rolldownOptions: { ..., plugins: undefined, onLog: undefined, onwarn: undefined, ... }
    },
    optimizeDepsPluginNames: config.optimizeDepsPluginNames,
  }, (_, value) => {
    if (typeof value === 'function' || value instanceof RegExp) {
      return value.toString()
    }
    return value
  })
  return getHash(content)
}

三层精细处理:

① 白名单字段——只挑 define、root、resolve、assetsInclude、plugins、optimizeDeps 这几个——其他配置改变不 trigger 重新优化。比如 server.port 变了、不影响依赖、不重新优化——这让 "改端口重启" 不会触发几秒的重新优化。

② 插件列表——config.plugins.map((p) => p.name) 只 hash 插件名、不 hash 插件实现。插件对象里是一堆钩子函数,经下面的 replacer 会被 toString() 成整段源码文本——插件实现的任何无关改动都会让 hash 变化。只用名字,hash 只随"装了哪些插件"变化。

③ 排除函数型字段——optimizeDeps.rolldownOptions 里把 plugins、onLog、onwarn、checks、output.plugins 设 undefined——这些字段往往是函数或插件对象;其中两处 plugins 并非被忽略,源码注释写明它们已经由 optimizeDepsPluginNames 按名字计入(packages/vite/src/node/optimizer/index.ts:1284-1294)。

replacer 函数处理 function 和 RegExp——typeof value === 'function' 时用 value.toString()、RegExp 同样——保证 函数和正则也能被 hash、只要 toString 结果稳定。

这种 "白名单 + 字段净化 + 特殊类型处理" 的三层设计让 configHash 既精确又稳定——改端口不重新优化、改 alias 重新优化——符合用户直觉。

8.4 依赖发现扫描(scan.ts)

依赖扫描是预构建的第一步:在服务器启动时,快速发现项目使用了哪些第三方依赖。

ScanEnvironment

扫描运行在一个受限的环境中——ScanEnvironment。它继承了 BaseEnvironment,但故意限制了对模块图和开发服务器的访问:

// packages/vite/src/node/optimizer/scan.ts:41
export class ScanEnvironment extends BaseEnvironment {
  mode = 'scan' as const

  get pluginContainer(): EnvironmentPluginContainer {
    if (!this._pluginContainer)
      throw new Error(
        `${this.name} environment.pluginContainer called before initialized`,
      )
    return this._pluginContainer
  }
  // ... _pluginContainer 字段与 init()(创建插件容器)
}

在开发模式下,devToScanEnvironment 函数会将真正的 DevEnvironment 代理为一个扫描环境,只暴露配置和插件容器,屏蔽模块图和 HMR 通道。

入口点计算

扫描从 computeEntries 函数开始,它按优先级确定入口点:

flowchart TD
    Start[computeEntries] --> A{optimizeDeps.entries<br/>是否配置?}
    A -->|是| B[使用用户指定的 glob 模式]
    A -->|否| C{build.rollupOptions.input<br/>是否配置?}
    C -->|是| D[解析 rollup input 配置]
    C -->|否| E["glob 搜索 **/*.html"]
    B --> F[过滤: 只保留可扫描文件<br/>且文件存在于磁盘]
    D --> F
    E --> F
    F --> G[返回入口列表]

这个设计体现了 Vite"零配置"的理念:默认情况下,扫描器会从项目根目录的 HTML 文件开始爬取依赖。对于非 HTML 入口的项目(如 SSR 应用),可以通过 optimizeDeps.entries 或 build.rollupOptions.input 来指定。

Rolldown 扫描插件

扫描的核心是 rolldownScanPlugin 函数,它返回一组 Rolldown 插件,负责在扫描过程中识别和收集依赖。这组插件被设计为多个独立的小插件,每个处理一种特定场景:

function rolldownScanPlugin(
  environment: ScanEnvironment,
  depImports: Record<string, string>,
  missing: Record<string, string>,
  entries: string[],
): Plugin[] {
  // ...
  return [
    { name: 'vite:dep-scan:resolve-external-url', /* 外部 URL */ },
    { name: 'vite:dep-scan:resolve-data-url',     /* data: URL */ },
    { name: 'vite:dep-scan:local-scripts',         /* 虚拟模块 */ },
    { name: 'vite:dep-scan:resolve',               /* 核心解析逻辑 */ },
    { name: 'vite:dep-scan:load:html',             /* HTML 类型加载 */ },
    // ... JSX 注入和 glob 转换
  ]
}

其中最关键的是 vite:dep-scan:resolve 插件,它的 resolveId 钩子实现了完整的依赖分类逻辑:

flowchart TD
    Import["resolveId(id, importer)"] --> HTML{HTML 类型?<br/>.html/.vue/.svelte}
    HTML -->|是| RHTML[解析路径<br/>继续爬取]
    HTML -->|否| BARE{"裸导入?<br/>例如 'react'"}
    BARE -->|是| EXCL{在 exclude 中?}
    EXCL -->|是| EXT1[标记为 external]
    EXCL -->|否| RESOLVE[通过插件容器解析]
    RESOLVE --> NM{在 node_modules 中?}
    NM -->|是| OPT{可优化?}
    OPT -->|是| RECORD["记录到 depImports<br/>标记为 external"]
    OPT -->|否| EXT2[标记为 external]
    NM -->|否| LINK{是链接包?}
    LINK -->|是| CRAWL[继续爬取]
    LINK -->|否| EXT3[标记为 external]
    BARE -->|否| CSS{CSS 文件?}
    CSS -->|是| EXT4[标记为 external]
    CSS -->|否| OTHER[其他类型处理]
    style RECORD fill:#f6ffed,stroke:#52c41a
    style EXT1 fill:#fff1f0,stroke:#f5222d
    style EXT2 fill:#fff1f0,stroke:#f5222d
    style EXT3 fill:#fff1f0,stroke:#f5222d
    style EXT4 fill:#fff1f0,stroke:#f5222d

核心逻辑很清晰:对于裸导入(bare import),如果它解析到 node_modules 中且是可优化的文件类型(.js、.mjs、.ts 等),就将其记录到 depImports 字典中。CSS、JSON、WASM、已知的资源类型则直接标记为外部依赖,不参与预构建。

HTML 类型的特殊处理

Vue、Svelte、Astro 等框架的单文件组件(SFC)需要特殊处理。htmlTypeOnLoadCallback 函数会解析 <script> 标签,提取其中的 JavaScript 代码:

// packages/vite/src/node/optimizer/scan.ts:99(htmlTypesRE)
// htmlTypeOnLoadCallback 定义在 rolldownScanPlugin 内部(packages/vite/src/node/optimizer/scan.ts:429 起),以下为节选:
// 省略了 type/lang/src 的取值、非 module 脚本的跳过、import.meta.glob 与 Svelte 的特殊分支
const htmlTypesRE = /\.(?:html|vue|svelte|astro|imba)$/

const htmlTypeOnLoadCallback = async (id: string): Promise<string> => {
  let raw = await fsp.readFile(id, 'utf-8')
  raw = raw.replace(commentRE, '<!---->')
  let js = ''
  let scriptId = 0
  const matches = raw.matchAll(scriptRE)
  for (const [, openTag, content] of matches) {
    // 解析 type、lang、src 属性
    const typeMatch = typeRE.exec(openTag)
    const langMatch = langRE.exec(openTag)
    let loader: Loader = 'js'
    if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
      loader = lang
    }
    const srcMatch = srcRE.exec(openTag)
    if (srcMatch) {
      // 外部脚本引用,生成 import 语句
      js += `import ${JSON.stringify(src)}\n`
    } else if (content.trim()) {
      // 内联脚本,创建虚拟模块
      const key = `${id}?id=${scriptId++}`
      scripts[key] = { loader, contents }
      js += `export * from ${JSON.stringify(virtualModulePrefix + key)}\n`
    }
  }
  return js
}

对于 TypeScript 的 <script> 块,扫描器还会通过 extractImportPaths 函数额外追加 import 语句,防止转译器在编译 TS 时将看似未使用的导入删除——这些导入可能在模板中被使用。

调用 Rolldown scan API

最终的扫描通过 Rolldown 的实验性 scan API 执行:

import { scan } from 'rolldown/experimental'

async function build() {
  await scan({
    ...rolldownOptions,
    transform: transformOptions,
    input: entries,
    logLevel: 'silent',
    plugins,
  })
}

scan API 与完整的 rolldown() 构建不同,它只执行解析和模块图构建阶段,不生成任何输出文件(rolldown 源码 packages/rolldown/src/api/experimental.ts 的注释:只执行 scan/build 阶段)。省掉代码生成与写盘,依赖扫描因此很快。

8.4.1 process.env.npm_config_user_agent 的隐式依赖

前面 lockfile sort 用了 npm_config_user_agent——这个环境变量由包管理器启动脚本时设置:

  • npm run dev → npm_config_user_agent = "npm/10.x.x node/v20.x.x ..."
  • yarn dev → npm_config_user_agent = "yarn/4.x.x ..."
  • pnpm run dev → npm_config_user_agent = "pnpm/8.x.x ..."

用户不需要显式配置 vite 用哪个包管理器——vite 自动从环境变量识别。

但这有个隐含限制——用户必须通过 npm/yarn/pnpm run 启动、直接执行 node node_modules/.bin/vite 会缺 env var、vite fallback 到默认(可能顺序错)。

直接 vite 二进制的场景——env var undefined、?.startsWith(manager) 得到 undefined、比较函数恒返回 -1。这是个不自洽的比较函数,结果取决于引擎的排序实现:在 Node 24(V8)上实测,数组会被整体反转(bun 的两项排最前、npm 排最后),并不是"保留原始顺序"。不过顺序只在多个 lockfile 并存时才起作用,单 lockfile 的项目不受影响——只是 fallback 而已。

这就是"约定优于配置" 的 double-edge——大部分场景自动识别、少数场景需要用户理解机制。vite 没把这个写死、也没暴露配置让用户手动指定——相信用户会用推荐姿势启动。

8.4.2 ScanEnvironment 屏蔽 HMR 的边界设计

ScanEnvironment 继承 BaseEnvironment、但故意不暴露 moduleGraph 和 HMR 通道。scan 阶段只需要配置和插件容器——其他是污染。

看 devToScanEnvironment 怎么实现(packages/vite/src/node/optimizer/scan.ts:70-95):

// Restrict access to the module graph and the server while scanning
export function devToScanEnvironment(
  environment: DevEnvironment,
): ScanEnvironment {
  return {
    mode: 'scan',
    get name() {
      return environment.name
    },
    getTopLevelConfig() {
      return environment.getTopLevelConfig()
    },
    get config() {
      return environment.config
    },
    get logger() {
      return environment.logger
    },
    get pluginContainer() {
      return environment.pluginContainer
    },
    get plugins() {
      return environment.plugins
    },
  } as unknown as ScanEnvironment
}

白名单式包装——不是 Proxy、而是一个只有 mode / name / getTopLevelConfig / config / logger / pluginContainer / plugins 七个成员的新对象。moduleGraph、hot、depsOptimizer 这些字段根本不存在——运行时访问得到 undefined、在 TypeScript 层则直接是类型错误(ScanEnvironment 上没有这些属性)。

为什么要隔离?——因为 scan 阶段不该修改 moduleGraph(scan 是只读的依赖发现、不是真实构建)、也不该触发 HMR(还没开始 serve、HMR 没意义)。

如果插件在 scan 阶段用了 this.environment.moduleGraph——拿到的是 undefined、紧接着的属性访问就会抛 TypeError,插件作者能在第一时间发现。这比"插件能用但产生奇怪副作用" 友好得多。

这种 "按阶段限制环境能力" 的设计是 vite 架构的精髓——同一个 Environment 接口在不同阶段暴露不同子集——用户代码的可能破坏面被物理隔离。

8.5 预构建执行、缓存与增量重优化

扫描完成后,runOptimizeDeps 函数负责实际的打包工作。

8.5.1 临时目录策略

预构建使用临时目录来保证原子性:

// packages/vite/src/node/optimizer/index.ts:511
export function runOptimizeDeps(
  environment: Environment,
  depsInfo: Record<string, OptimizedDepInfo>,
) {
  const depsCacheDir = getDepsCacheDir(environment)
  const processingCacheDir = getProcessingDepsCacheDir(environment)

  // 在临时目录中工作,避免损坏现有缓存
  fs.mkdirSync(processingCacheDir, { recursive: true })

  // 写入 package.json 让 Node.js 将所有文件识别为 ES Module
  fs.writeFileSync(
    path.resolve(processingCacheDir, 'package.json'),
    `{\n  "type": "module"\n}\n`,
  )
  // ...
}

这个设计避免了在打包过程中直接写入缓存目录可能导致的损坏。只有当打包成功完成并通过 commit() 方法确认后,临时目录才会通过重命名操作替换正式缓存目录。在 Windows 上,Vite 把 fs.renameSync 换成带重试的 safeRename——源码注释说明杀毒软件等可能锁住刚写入文件的目录、让 rename 报 EACCES/EPERM,于是最多重试 5 秒(packages/vite/src/node/optimizer/index.ts:1427-1466)。

8.5.2 Rolldown 打包配置

prepareRolldownOptimizerRun 函数构建完整的 Rolldown 配置:

// packages/vite/src/node/optimizer/index.ts:822(位于内部的 build() 函数中)
// 简化:省略了用户 rolldownOptions.transform / resolve / moduleTypes / output 的展开合并,
// 以及 jsxLoader 时把 '.js' 映射为 'jsx' 的分支
const bundle = await rolldown({
  ...rolldownOptions,
  input: flatIdDeps,    // 扁平化的依赖 ID 作为入口
  logLevel: 'silent',
  plugins,
  platform,              // 'browser' 或 'node'
  transform: {
    target: ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET,
    define,
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.jsx', '.js', '.css', '.json'],
  },
  moduleTypes: {
    '.css': 'js',        // 将 CSS 当作 JS 处理(暂时禁用 Rolldown CSS 支持)
  },
})

const result = await bundle.write({
  format: 'esm',
  sourcemap: true,
  dir: processingCacheDir,
  entryFileNames: '[name].js',
})

值得注意的是 flatIdDeps 的设计——依赖 ID 中的 / 被替换为 _(通过 flattenId 函数),这样所有输出文件都在同一层目录中,简化了路径管理。

8.5.3 flattenId 函数的路径扁平化

flattenId(id)(packages/vite/src/node/utils.ts:69-84 里定义)——把 @scope/pkg/sub 这样的带斜杠 ID 转成 @scope_pkg_sub:

const replaceSlashOrColonRE = /[/:]/g
const replaceDotRE = /\./g
const replaceNestedIdRE = /\s*>\s*/g
const replaceHashRE = /#/g
const replacePlusRE = /\+/g
export const flattenId = (id: string): string => {
  const flatId = limitFlattenIdLength(
    id
      .replace(replaceSlashOrColonRE, '_')
      .replace(replaceDotRE, '__')
      .replace(replaceNestedIdRE, '___')
      .replace(replaceHashRE, '____')
      .replace(replacePlusRE, '_____'),
  )
  return flatId
}

五种替换:

  • / / : → _——@scope/pkg 变 @scope_pkg、npm:pkg 变 npm_pkg
  • . → __——pkg.v1 变 pkg__v1、避免 . 被 Rolldown 识别为扩展名分隔
  • > → ___——pkg > nested 变 pkg___nested(用于 nested dependency)
  • # → ____、+ → _____——处理 id 里可能出现的 # 和 +(如 pkg/sub#frag、vue+jsx 这类写法)

外面再包一层 limitFlattenIdLength——扁平 ID 超过 170 字符时截断并追加 8 位 hash、避免撞到文件系统的文件名长度上限。

为什么要扁平化?——源码注释(packages/vite/src/node/optimizer/index.ts:760-765)给的理由是:带斜杠的入口会让 bundler 以"最近公共祖先"为基准输出嵌套目录,入口与产物的对应关系难以预测;扁平化后所有预构建输出都在同一个目录里(node_modules/.vite/deps/),比如 @radix-ui_react-dialog.js 就是一个独立文件、不需要创建子目录。

_ vs __ vs ___ 的层级编码——让不同种类的分隔符在结果里保持可区分:如果都用 _ 替换,@scope/pkg.v1 会和 @scope/pkg/v1 撞成同一个名字。但这套编码并不严格可逆:/ 和 : 共用 _,原 id 里本来就有的下划线也会混进来(@scope/pkg.v1 与字面量 @scope_pkg__v1 扁平化结果相同),超长 id 还会被截断加 hash——好在 Vite 从不做反向解析,实际用到的是 OptimizedDepInfo 里记录的 id ↔ file 映射。

> 的特殊意义——Vite 允许用户在 optimizeDeps.include 写 "pkg-a > dep-b" 表示 "预优化 pkg-a 的嵌套依赖 dep-b"——这种语法需要保留 >、所以用 ___ 这种独特 separator 编码。

这个不到 20 行的小函数在 vite 里被每个依赖都调用一次——看似琐碎、但扁平化是整个预构建缓存目录结构的基础。

rolldownDepPlugin 的职责

rolldownDepPlugin 是预构建过程中最核心的插件,它返回两个 Rolldown 插件:

vite:dep-pre-bundle-assets:处理资源类型文件的外部化。当一个依赖引用了 CSS、图片等非 JS 资源时,需要将这些引用转换为正确的导入路径。对于 require() 调用,还需要一个额外的中间层来将其转换为 import 语句:

const resolveAssets = (resolved: string, kind: ImportKind) => {
  if (kind === 'require-call') {
    // 不直接设为 external,而是通过命名空间转换 require 为 import
    return {
      id: externalWithConversionNamespace + resolved,
    }
  }
  return {
    id: resolved,
    external: 'absolute' as const,
  }
}

vite:dep-pre-bundle:核心的依赖解析插件。它使用两套解析器——一个优先 ESM(用于 import),一个优先 Node.js 风格(用于 require):

// 默认解析器,优先 ESM
const _resolve = createBackCompatIdResolver(environment.getTopLevelConfig(), {
  asSrc: false,
  scan: true,
  packageCache: esmPackageCache,
})

// CJS 解析器,优先 Node
const _resolveRequire = createBackCompatIdResolver(
  environment.getTopLevelConfig(),
  {
    asSrc: false,
    isRequire: true,
    scan: true,
    packageCache: cjsPackageCache,
  },
)

该插件还处理了浏览器外部化(browser externals)和可选的 peer dependencies。对于在浏览器中不可用的 Node.js 内置模块,它会生成一个 Proxy 对象,在访问时打印友好的警告信息:

// 生产环境返回空对象
if (isProduction) {
  return { code: 'module.exports = {}' }
}
// 开发环境返回 Proxy,访问时打印警告
return {
  code: `module.exports = Object.create(new Proxy({}, {
    get(_, key) {
      if (key !== '__esModule' && key !== '__proto__' && ...) {
        console.warn(\`Module "${path}" has been externalized ...\`)
      }
    }
  }))`,
}

8.5.4 esmPackageCache vs cjsPackageCache 的双缓存

rolldownDepPlugin 里维护两个独立的 packageCache:

const _resolve = createBackCompatIdResolver(..., {
  packageCache: esmPackageCache,   // ESM 路径优先
})

const _resolveRequire = createBackCompatIdResolver(..., {
  packageCache: cjsPackageCache,   // CJS 路径优先
})

为什么要两套缓存?——因为同一个 pkg name 在 ESM 和 CJS 下解析路径可能不同:

  • import 'foo' → 走 package.json 的 exports["."]["import"] 或 module 字段
  • require('foo') → 走 exports["."]["require"] 或 main 字段

解析结果的差异是 Node.js 双包 dual-package 的基础——同一个包可以有两个不同的入口文件、一个 ESM 一个 CJS。

不过要注意,v8.0.3 里即使共用一个 packageCache 也不会串味——解析结果的缓存键本身已经带上了 isRequire,连同 conditions、extensions、mainFields 一起拼进 key(packages/vite/src/node/packages.ts:227-237 的 getResolveCacheKey)。这两个 Map 在 vite:dep-pre-bundle 插件的 buildEnd 钩子里一起清空(packages/vite/src/node/optimizer/rolldownDepPlugin.ts:220-223);源码注释对它们给出的理由是另一层:optimizer 用独立于 Vite 主进程的 packageCache,因为它缓存的是 node_modules 周边的路径,而主进程之后不大会再走进 node_modules(packages/vite/src/node/optimizer/rolldownDepPlugin.ts:75-78)。至于为什么 ESM、CJS 各一份,这是 PR #12512 引入时就有的写法,源码没有另作说明。

代价——同一个 pkg 的数据可能在两个 Map 里各存一份;但两个 Map 只活在一次预构建里,构建结束即清空。

ESM/CJS 互通是现代 JS 生态最麻烦的问题之一——在 v8.0.3 里,真正把两种解析上下文分开的是缓存键里的 isRequire 与 conditions(webpack 用 resolve.conditionNames 表达的也是同一类"按条件解析"),而不是 Map 的个数。

8.5.5 Proxy 警告——浏览器外部化的 DX 兜底

前面源码里的 Proxy 警告机制值得细品:

// 开发环境
return {
  code: `module.exports = Object.create(new Proxy({}, {
    get(_, key) {
      if (key !== '__esModule' && key !== '__proto__' && ...) {
        console.warn(\`Module "${path}" has been externalized ...\`)
      }
    }
  }))`,
}

什么场景触发?——用户在浏览器代码里意外 import 了 Node.js 内置模块(fs、path 等)。vite 不能把这些包进 bundle(浏览器没这些 API)、就 externalize 成一个 Proxy。

关键是 warning 的时机——访问 Proxy 的属性时才报警——不是在 import 时报。这避免了大量"我 import 但没用" 的情况误报——只有实际用了的情况才 warn。

过滤的 key 列表——__esModule、__proto__、constructor、splice 这四个 key 是 bundler 的 interop 代码和浏览器 devtools 自己会访问的属性——用户代码没写也会访问、不该当成误用报警。vite 白名单这些 key、避免 false positive。

为什么 Object.create(new Proxy({}, ...)) 而不是直接 new Proxy({}, ...)?——源码注释给出的原因是 esbuild 的 CJS → ESM interop 流程:它先用 Object.create(Object.getPrototypeOf(module.exports)) 建一个空对象、再把 module.exports 的自有属性拷过去、最后把新对象交给 ESM 侧。如果 module.exports 直接是 Proxy、第一步得到的是普通空对象、第二步又没有自有属性可拷——Proxy 就被丢掉了。把 Proxy 放在原型上、第一步创建出来的对象原型就是这个 Proxy——属性访问才能被拦截到。

production 环境 return module.exports = {} 空对象——不带 warning。因为 production 假设用户已经在 dev 修过所有警告、warning 只会污染生产日志。

这种 "dev 警告、prod 静默" 的 dual-mode 是现代前端工具的共识——Vite 把它做到了 externalization 层。

CJS 外部化处理

rolldownCjsExternalPlugin 解决了一个微妙的问题:当外部依赖通过 require() 被引用时,Rolldown 不会自动将其转换为 import 语句。在浏览器平台上,这会导致运行时错误。该插件通过创建一个 facade 模块来完成转换:

load: {
  filter: { id: prefixRegex(cjsExternalFacadeNamespace) },
  handler(id) {
    const idWithoutNamespace = id.slice(cjsExternalFacadeNamespace.length)
    return {
      code: `\
import * as m from ${JSON.stringify(nonFacadePrefix + idWithoutNamespace)};
module.exports = ${isNodeBuiltin(idWithoutNamespace) ? 'm.default' : '{ ...m }'};
`,
    }
  },
},

缓存策略

Vite 的预构建缓存是其"快速冷启动"体验的关键。缓存策略基于两级哈希:

Hash 计算

flowchart LR
    subgraph "lockfileHash"
        L1[node_modules/.package-lock.json] --> LH
        L2[".yarn-state.yml / .pnp.cjs / .yarn-integrity"] --> LH
        L3[node_modules/.pnpm/lock.yaml] --> LH
        L4[bun.lock / bun.lockb] --> LH
        L5[patches/ 目录 mtime] --> LH[lockfileHash]
    end
    subgraph "configHash"
        C1[process.env.NODE_ENV] --> CH
        C2[config.root] --> CH
        C3[config.resolve] --> CH
        C4[config.plugins 名称列表] --> CH
        C5[optimizeDeps.include/exclude] --> CH
        C6[rolldownOptions] --> CH[configHash]
    end
    LH --> H["hash = getHash(lockfileHash + configHash)"]
    CH --> H
    H --> BH["browserHash = getHash(hash + deps + timestamp)"]
    style H fill:#e8f4fd,stroke:#1890ff
    style BH fill:#f6ffed,stroke:#52c41a

getDepHash 函数计算两个独立的哈希值:

function getDepHash(environment: Environment) {
  const lockfileHash = getLockfileHash(environment)
  const configHash = getConfigHash(environment)
  const hash = getHash(lockfileHash + configHash)
  return { hash, lockfileHash, configHash }
}

lockfileHash 基于项目的包管理器锁文件内容。它支持所有主流包管理器:npm、Yarn Classic/Berry、pnpm、Bun。如果项目使用了 patch-package,还会将 patches 目录的修改时间纳入计算。

configHash 基于影响依赖优化的配置项子集——而非全部配置。这种精确的范围界定避免了不相关配置变更触发不必要的重新构建:

function getConfigHash(environment: Environment): string {
  const content = JSON.stringify({
    define: !config.keepProcessEnv
      ? process.env.NODE_ENV || config.mode : null,
    root: config.root,
    resolve: config.resolve,
    assetsInclude: config.assetsInclude,
    plugins: config.plugins.map((p) => p.name),
    optimizeDeps: {
      include: optimizeDeps.include
        ? unique(optimizeDeps.include).sort() : undefined,
      exclude: optimizeDeps.exclude
        ? unique(optimizeDeps.exclude).sort() : undefined,
      rolldownOptions: { /* 去除 plugins/onLog/onwarn 等不可序列化项 */ },
    },
    optimizeDepsPluginNames: config.optimizeDepsPluginNames,
  })
  return getHash(content)
}

缓存加载与失效

服务器启动时,loadCachedDepOptimizationMetadata 函数尝试从 node_modules/.vite/deps/_metadata.json 加载缓存的元数据:

flowchart TD
    Start[loadCachedDepOptimizationMetadata] --> Force{--force 标志?}
    Force -->|是| CLEAR["清除缓存目录<br/>返回 undefined"]
    Force -->|否| READ["读取 _metadata.json"]
    READ --> PARSE{解析成功?}
    PARSE -->|否| CLEAR
    PARSE -->|是| LOCK{lockfileHash<br/>是否匹配?}
    LOCK -->|否| LOG1["日志: lockfile changed<br/>返回 undefined"]
    LOCK -->|是| CONF{configHash<br/>是否匹配?}
    CONF -->|否| LOG2["日志: config changed<br/>返回 undefined"]
    CONF -->|是| HIT["命中缓存!<br/>返回 metadata"]
    style HIT fill:#f6ffed,stroke:#52c41a
    style CLEAR fill:#fff1f0,stroke:#f5222d
    style LOG1 fill:#fff1f0,stroke:#f5222d
    style LOG2 fill:#fff1f0,stroke:#f5222d

注意缓存失效的粒度:lockfileHash 和 configHash 是分别检查的,这样日志消息能准确告诉用户是什么变化触发了重新构建。

browserHash

除了用于冷启动缓存的 hash,还有一个 browserHash 用于浏览器端的缓存失效。它在 hash 的基础上加入了运行时发现的依赖信息和时间戳:

function getOptimizedBrowserHash(
  hash: string,
  deps: Record<string, string>,
  timestamp = '',
) {
  return getHash(hash + JSON.stringify(deps) + timestamp)
}

预构建后的依赖通过 ?v=browserHash 查询参数在浏览器中缓存。当依赖集合发生变化时,browserHash 也会变化,从而使浏览器缓存自动失效。

增量式发现与重新优化

预构建最复杂的部分不在于初次构建,而在于运行时的增量发现。当开发者在浏览器中导航到新页面时,可能会触发新的依赖导入——这些依赖在初始扫描中未被发现。

DepsOptimizer 状态机

optimizer.ts 中的 createDepsOptimizer 函数创建了一个精密的状态机来管理这个过程:

// packages/vite/src/node/optimizer/optimizer.ts:40(节选:状态变量在源码中分散声明,这里集中列出)
export function createDepsOptimizer(
  environment: DevEnvironment,
): DepsOptimizer {
  let debounceProcessingHandle: NodeJS.Timeout | undefined
  let waitingForCrawlEnd = false
  let currentlyProcessing = false
  let firstRunCalled = false
  let newDepsDiscovered = false

  const depsOptimizer: DepsOptimizer = {
    init,
    metadata,
    registerMissingImport,
    run: () => debouncedProcessing(0),
    isOptimizedDepFile: createIsOptimizedDepFile(environment),
    isOptimizedDepUrl: createIsOptimizedDepUrl(environment),
    getOptimizedDepId: (depInfo: OptimizedDepInfo) =>
      `${depInfo.file}?v=${depInfo.browserHash}`,
    close,
    options,
  }
  // ...
}

完整的生命周期

sequenceDiagram
    participant Dev as 开发服务器
    participant DOpt as DepsOptimizer
    participant Scan as Scanner
    participant Bundle as Rolldown
    Dev->>DOpt: init()
    alt 有缓存
        DOpt->>DOpt: loadCachedDepOptimizationMetadata
        DOpt-->>Dev: 使用缓存,跳过扫描
    else 无缓存(冷启动)
        DOpt->>DOpt: addManuallyIncludedOptimizeDeps
        DOpt->>Scan: discoverProjectDependencies
        Note over Scan: 后台并行执行
        DOpt->>Dev: waitForRequestsIdle -> onCrawlEnd
        Scan-->>DOpt: 扫描结果 deps
        DOpt->>Bundle: runOptimizeDeps(初次,后台)
        alt holdUntilCrawlEnd
            Note over DOpt: 结果先扣住,等静态导入爬取完成再比对<br/>发现漏扫依赖就丢弃结果重跑
        else
            Bundle-->>Dev: 扫描完即放行结果
        end
    end
    Dev->>DOpt: registerMissingImport("新依赖")
    DOpt->>DOpt: addMissingDep + debouncedProcessing
    Note over DOpt: 等待 100ms 收集更多依赖
    DOpt->>Bundle: runOptimizeDeps(增量)
    alt 文件哈希未变
        Bundle-->>Dev: 无需重载
    else 文件哈希变化
        Bundle-->>Dev: full-reload
    end

registerMissingImport

当 importAnalysis 插件在转换模块时遇到一个未被预构建的依赖,它会调用 registerMissingImport:

// packages/vite/src/node/optimizer/optimizer.ts:555
function registerMissingImport(
  id: string,
  resolved: string,
): OptimizedDepInfo {
  // 检查是否已经在优化列表中
  const optimized = metadata.optimized[id]
  if (optimized) return optimized

  const chunk = metadata.chunks[id]
  if (chunk) return chunk

  let missing = metadata.discovered[id]
  if (missing) return missing

  // 添加为新发现的依赖
  missing = addMissingDep(id, resolved)

  if (!waitingForCrawlEnd) {
    // 触发防抖处理
    debouncedProcessing()
  }

  return missing
}

关键设计点:即使依赖尚未构建完成,函数也会立即返回一个 OptimizedDepInfo 对象,其中包含了预期的输出文件路径和一个 processing Promise。请求该模块的代码会 await 这个 Promise,等待构建完成后才继续加载。

重新优化的智能判断

当增量构建完成后,runOptimizer 函数不会盲目触发页面重载。它会比较新旧构建的 fileHash:

const needsReload =
  needsInteropMismatch.length > 0 ||
  metadata.hash !== newData.hash ||
  Object.keys(metadata.optimized).some((dep) => {
    return (
      metadata.optimized[dep].fileHash !== newData.optimized[dep].fileHash
    )
  })

如果所有已知依赖的输出文件保持不变(即新依赖的加入没有影响共享 chunk 的内容),就可以避免全页重载——只要新依赖没有改动已有依赖的共享 chunk,这个条件就成立。

holdUntilCrawlEnd 策略

holdUntilCrawlEnd(默认启用,packages/vite/src/node/config.ts:859;选项声明见 packages/vite/src/node/optimizer/index.ts:165)是一种优化策略,它在冷启动时延迟将预构建结果交给浏览器,直到所有静态导入都被爬取完毕。这样可以最大程度地减少"扫描遗漏依赖 -> 重新构建 -> 全页重载"的情况:

// packages/vite/src/node/optimizer/optimizer.ts:634(`// ...` 处为删节,其余逐字)
async function onCrawlEnd() {
  // switch after this point to a simple debounce strategy
  waitingForCrawlEnd = false
  // ... debug 日志、closed 提前返回 ...
  // Await for the scan+optimize step running in the background
  await depsOptimizer.scanProcessing

  if (optimizationResult && !options.noDiscovery) {
    const afterScanResult = optimizationResult.result
    optimizationResult = undefined // signal that we'll be using the result

    const result = await afterScanResult
    currentlyProcessing = false

    const crawlDeps = Object.keys(metadata.discovered)
    const scanDeps = Object.keys(result.metadata.optimized)
    // ... 两侧都为空时直接提交结果并 return ...

    const needsInteropMismatch = findInteropMismatches(
      metadata.discovered,
      result.metadata.optimized,
    )
    const scannerMissedDeps = crawlDeps.some((dep) => !scanDeps.includes(dep))
    const outdatedResult =
      needsInteropMismatch.length > 0 || scannerMissedDeps

    if (outdatedResult) {
      // Drop this scan result, and perform a new optimization to avoid a full reload
      result.cancel()
      // ... 把 scanner 发现、爬取时没见过的 dep 补进 discovered ...
      debouncedProcessing(0)
    } else {
      startNextDiscoveredBatch()
      runOptimizer(result)
    }
  }
}

注意守卫是 outdatedResult 而不是 scannerMissedDeps:「扫描器漏了依赖」只是重跑的两个触发条件之一,另一个是 findInteropMismatches 发现 CJS/ESM interop 判定与扫描结果不一致(packages/vite/src/node/optimizer/optimizer.ts:677-680)。只盯着前者会以为 interop 变化不会导致重新预构建。

esbuild 插件适配层

pluginConverter.ts 提供了 convertEsbuildPluginToRolldownPlugin 函数,将 esbuild 格式的插件转换为 Rolldown 兼容的插件。这个适配层确保了与 Vite 生态中大量使用 esbuild 插件 API 的工具的向后兼容性。

适配的核心挑战在于两种插件 API 的差异:esbuild 使用正则过滤器的 onResolve/onLoad 回调模式,而 Rolldown 使用标准的 resolveId/load 钩子。pluginConverter 在两者之间建立了桥接层:

// packages/vite/src/node/optimizer/pluginConverter.ts:191(节选:省略了 importer 的
// namespace 拆分,以及 errors / warnings / watchFiles 的处理)
function createResolveIdHandler(
  options: esbuild.OnResolveOptions,
  callback: EsbuildOnResolveCallback,
): ResolveIdHandler {
  return async function (id, importer, opts) {
    // 检查命名空间和过滤器是否匹配
    if (options.namespace !== undefined &&
        options.namespace !== importerNamespace) return
    if (options.filter !== undefined &&
        !options.filter.test(id)) return

    // 调用 esbuild 回调,转换参数和返回值格式
    const result = await callback({
      path: id,
      importer: importerWithoutNamespace ?? '',
      namespace: importerNamespace,
      resolveDir: dirname(importerWithoutNamespace ?? ''),
      kind: importerWithoutNamespace === undefined
        ? 'entry-point'
        : opts.kind === 'new-url' || opts.kind === 'hot-accept'
          ? 'dynamic-import'
          : opts.kind,
      pluginData: {},
      with: {},
    })
    if (!result) return
    return {
      id: result.namespace
        ? `${result.namespace}:${result.path}`
        : result.path,
      external: result.external,
      moduleSideEffects: result.sideEffects,
    }
  }
}

设计决策

为什么用 Rolldown 而不是 esbuild

在 Vite 早期版本中,预构建完全基于 esbuild。迁移到 Rolldown 的原因包括:

  1. 统一构建引擎:开发和生产使用同一个打包器,减少行为差异
  2. 更好的 Rollup 兼容性:Rolldown 的插件 API 与 Rollup 兼容,而 esbuild 需要适配层
  3. CSS 支持路线图:Rolldown 未来将原生支持 CSS 处理,消除当前 .css 被当作 .js 的临时方案

为什么扫描和构建分离

扫描(scan)和构建(bundle)是两个独立的步骤,而不是合并为一次 Rolldown 运行。原因是:

  1. 扫描需要尽快返回结果,不需要生成输出文件
  2. 扫描结果可以与运行时爬取的结果合并后再构建
  3. 如果合并为一步,扫描器遗漏的依赖将无法在构建前被发现

为什么使用防抖而非立即重新构建

registerMissingImport 使用 100ms 的防抖延迟(debounceMs = 100)。这是因为页面加载通常会在短时间内触发多个新依赖的发现。如果每发现一个就立即重新构建,会导致大量无效的中间构建。防抖策略让系统在一个"安静期"后才执行重新构建,此时大部分新依赖已经被收集完毕。

DepOptimizationMetadata 的三层结构

元数据对象维护三个字典——optimized、discovered 和 chunks——而不是一个扁平的列表。这种分层设计让系统能够区分不同状态的依赖:optimized 是已经完成构建的,discovered 是新发现正在处理的,chunks 是共享的非入口 chunk。状态转换清晰,避免了复杂的状态标志位。

8.6 跨章节呼应

与 hyper/tower 专栏第 14 章 keep-alive 与超时的呼应——依赖预构建的 lockfileHash + configHash 两维缓存和 hyper 的 Dur::Default/Configured 两态枚举(外加 Time::Empty)——都是把"来源不同的信息" 用类型/字段精确表达、避免信息丢失。

与 LangGraph 专栏第 13 章 Streaming 的呼应——vite 的 ScanEnvironment 屏蔽 moduleGraph / HMR 通道、LangGraph 的 TAG_NOSTREAM 屏蔽流式——都是"按阶段/场景限制接口可见性" 来防止误用。

与 Serde 专栏第 16 章扩展属性的呼应——vite 的 getConfigHash 白名单 + JSON 序列化用 replacer 处理 function/RegExp、serde_derive 的 quote_spanned! 精准错误定位——都是"对复杂输入的序列化/hash 做精细化处理"的工程细节。

8.7 十二条工程原则收束

从本章的源码观察提炼出 Vite 依赖预构建子系统的 12 条原则:

① 双维度缓存 hash(§8.1)——lockfile + config 分开、判断更精细。

② 包管理器 lockfile 全覆盖(§8.2)——9 种 lockfile 格式 + 排序策略。

③ 白名单 hash 字段(§8.3)——只 hash 影响优化的配置、避免 noise。

④ 环境变量隐式识别(§8.4.1)——npm_config_user_agent 自动识别包管理器。

⑤ 按阶段限制 Environment(§8.4.2)——ScanEnvironment 屏蔽 moduleGraph / HMR。

⑥ 临时目录保证原子性(§8.5.1)——处理成功后才 rename 替换缓存。

⑦ flattenId 扁平化路径(§8.5.3)——不同分隔符用不同长度的下划线编码、保证扁平且可区分。

⑧ ESM / CJS 双 packageCache(§8.5.4)——不同解析上下文不互相污染。

⑨ Proxy 警告 externalized modules(§8.5.5)——dev 友好提示、prod 静默。

⑩ browserHash 叠加依赖集合——hash + 依赖列表 + 时间戳,依赖集合一变,浏览器缓存随之失效。

⑪ holdUntilCrawlEnd 协调时序——冷启动时扣住扫描后的预构建结果,爬完静态导入再放行,尽量避免 full-reload。

⑫ 防抖聚合新依赖——100ms 窗口把页面加载期的多次发现合并为一次重新构建。

这 12 条原则贯穿 vite 从 scan → optimize → cache → serve 的完整预构建链路——每一条都解决一个工程细节、合起来就是 "Vite 的冷启动为什么这么快" 的答案。

8.8 预构建系统在其他工具里的对应

把 Vite 的预构建思路和其他前端工具对比:

工具 预构建机制 缓存 key 何时触发
Vite scan + rolldown 打包 lockfile + config hash 启动时 / 运行时新发现
webpack 无独立预构建阶段 模块级持久化缓存的 etag/version 默认 dev 是内存缓存(webpack@5.109.2 的 lib/config/defaults.js:402-403,production 下 cache: false),所以默认每次启动重来;显式配 cache: { type: 'filesystem' } 后跨启动复用(lib/cache/PackFileCacheStrategy.js)
Parcel 无 全链条 content hash 每次启动 + 文件变化
esbuild 无显式预构建 基于 content hash 每次启动
Turbopack 持久化依赖图 depth-based invalidation 启动时 / 文件变化
Bun 无 依靠 Bun 的快速 transpile 每次请求

在上表列出的工具里,只有 Vite 把"预构建 = 独立阶段"做得这么彻底——其他工具要么每次启动都重新构建(慢)、要么没有显式的预构建边界(cache key 设计复杂)。

这个选择的根源是 Vite 对 "开发服务器冷启动时间" 的极致追求——预构建让 node_modules 几 MB 的依赖变成几个 bundle、浏览器加载时只有几个 HTTP 请求——秒级冷启动。

代价——第一次启动要额外付出 scan + build 的时间——但二次启动(用户日常场景)几乎瞬时(缓存命中)——用户体验曲线非常陡峭——付一次代价、之后长期受益。

这就是 Vite 在 "开发服务器" 这个维度相对 webpack 的 dev server 的根本优势——架构上的优势、不是实现细节。

8.9 DepsOptimizer 状态机的真实复杂度

DepsOptimizer 的真正难点在于 多个异步过程的时序协调:

启动 ──┬── 扫描(async)─── 发现初始依赖集合 A
       ├── 预构建 A(async)── 生成 bundle
       ├── 启动 HTTP server(async)
       └── 处理第一个请求 ───── 可能发现新依赖 B(A 没有)
                                 ├─ 触发 registerMissingImport(B)
                                 ├─ 100ms debounce 等更多依赖
                                 ├─ 重新预构建 A+B(不再重新扫描)
                                 └─ 产物 fileHash 变了才通知浏览器 full-reload

问题:在 "预构建 A" 还在跑的时候、用户发过来请求了 B——怎么办?

解决方案——holdUntilCrawlEnd 策略:

  • 第一个请求到达时、如果 optimizeDeps 还没跑完、hold 住这个请求
  • 等预构建完成后再处理——用户看到加载慢、但不会看到"依赖没准备好"的错误

更棘手的场景——hold 的过程中发现了新依赖、需要重新优化——此时该怎么办?

vite 的策略是:hold 期间(静态导入爬取结束前)如果发现了扫描器漏掉的依赖、或 interop 判定不一致,就丢弃这次扫描后的预构建结果、带上新依赖立刻重跑一次,再把结果交给浏览器——源码注释原话是 "to avoid a full reload"(packages/vite/src/node/optimizer/optimizer.ts:677-703)。只有爬取结束之后才冒出来的新依赖,才走 100ms 防抖重跑,并且只在产物 fileHash(或总 hash、interop 判定)变化时才 full-reload(packages/vite/src/node/optimizer/optimizer.ts:364-371)。

这套状态机的复杂度——约 800 行 optimizer.ts——仅次于近 1500 行的 index.ts。没有这套状态机、预构建会在无数边缘场景下挂掉——什么时候 hold、什么时候 release、什么时候 full-reload——每个决策都要精确。

这也是为什么 vite 团队把 optimizer 做成独立模块——复杂度太高、必须和其他逻辑隔离。类似的设计在其他大型工具里也常见——webpack 的 watch mode state machine、Turbopack 的 graph invalidation engine——关键复杂度必须有专用模块承载。

8.10 冷启动的 "首次代价" 值不值

讨论一个价值判断——Vite 冷启动时付出的预构建时间、真的值吗?

正面论据:

  • 二次启动快——日常开发多数时候走缓存命中、跳过扫描和打包
  • HMR 极快——源码改动直接走 transform + HMR boundary、不重新构建依赖
  • 浏览器只加载少量依赖 chunk——而不是把 node_modules 里数百个内部模块逐个请求、HTTP 请求成本低得多

负面论据:

  • 第一次启动慢——新手试用 / CI 清理后重启 / 切换分支、都会遇到几秒预构建
  • 增量发现时可能 full-reload——新 import 的依赖首次出现时、浏览器可能需要 reload
  • 复杂场景调试——预构建缓存出问题时、.vite/deps 目录里的文件名扁平化让人看不懂

Vite 的选择是牺牲偶尔的冷启动换稳态的丝滑——在 "大多数人大多数时间的体验" 上胜过 webpack-dev-server。

但这个权衡不是适合所有人——在 CI 经常清理缓存、每次都是冷启动的场景下,预构建的首次代价会反复出现,此时 turbopack/rspack 这类替代品也值得评估。

没有最优的工具、只有最适合场景的工具——这是所有工具选型的真理。Vite 做的是 "让大多数场景变得非常好"——剩下的场景可以用其他工具补——"生态分工" 的成熟度。

8.11 从 scan 到 commit——预构建的 6 步完整生命周期

压缩本章内容、一次预构建的 6 步完整流程:

Step 1 - Entry 发现(computeEntries):

  • 优先 optimizeDeps.entries glob
  • 其次 build.rollupOptions.input
  • 最后 **/*.html 兜底
  • 输出:入口文件列表

Step 2 - Scan(scanImports):

  • 启动 Rolldown 的 scan API
  • 用 rolldownScanPlugin 收集所有裸导入
  • 区分:记录 to depImports / 标 external / 继续 crawl
  • 输出:depImports: {id → path} 字典

Step 3 - Hash 计算(getDepHash):

  • lockfileHash + configHash + hash
  • 对比 .vite/deps/_metadata.json 里的老 hash
  • 输出:是否需要重新优化

Step 4 - Rolldown 打包(runOptimizeDeps + prepareRolldownOptimizerRun):

  • 临时目录 .vite/deps_temp_<suffix>
  • 写入 package.json {"type": "module"}
  • rolldown({...flat ids as inputs...}).write({format: "esm"})
  • 输出:bundle 文件

Step 5 - Metadata 生成:

  • 对每个 chunk 计算 needsInterop、file path、hash
  • 填入 DepOptimizationMetadata.optimized dict
  • 输出:完整 metadata

Step 6 - Commit(原子替换):

  • 先把旧的 deps/ 挪到临时名,再 fs.renameSync(processingCacheDir, depsCacheDir)(Windows 上换成带重试的 safeRename)
  • 打包失败则删掉 processing 目录、不污染现有缓存
  • 输出:.vite/deps/*.js 可用

每一步都能独立失败——vite 每一步都有回滚/错误处理。这就是生产级工程代码的密度——错误处理和 happy path 一样占篇幅——两者加起来才是一个鲁棒的系统。

读完本章、看 packages/vite/src/node/optimizer/index.ts 的近 1500 行代码会觉得合理多了——每一块都在完成上述某一步的细节。

8.12 getTempSuffix 的随机后缀——防止并发冲突

看 packages/vite/src/node/optimizer/index.ts:949 的 getTempSuffix:

function getTempSuffix() {
  return (
    '_temp_' +
    getHash(
      `${process.pid}:${Date.now().toString()}:${Math.random()
        .toString(16)
        .slice(2)}`,
    )
  )
}

每次预构建用 "pid + 时间戳 + 随机数" 的 8 位 hash 做后缀(getHash 默认截 8 位)——processingCacheDir = depsCacheDir + getTempSuffix()——产生 .vite/deps_temp_abc12345/ 这样的目录。

为什么要随机?——防止两个 vite 进程并发预构建时目录冲突:

  • 开发者打开两个 vite 终端(比如 dev + storybook)、都在同一个项目
  • 两个进程可能几乎同时启动预构建
  • 如果都用同一个 processing 目录名——互相覆盖、结果损坏

随机后缀保证每个进程有自己的 processing 目录——互不干扰。结束时都 rename 到 depsCacheDir——commit() 先把旧的 deps/ 挪到另一个临时名、再把自己的 processing 目录 rename 过去、最后在后台删掉旧目录——后到的覆盖先到的;deps/ 处于不一致状态的窗口被压缩到两次 rename 之间(源码注释:"minimize the time where the deps folder isn't in a consistent state")。

为什么三种来源都掺进去?——process.pid 区分并发进程;Date.now() 区分同一个进程的多次预构建(第一次初始、后续 re-optimize)——pid 不变、时间戳变;Math.random() 兜底同一毫秒内的碰撞。三者拼起来再过一次 getHash、得到定长的 8 位后缀——一致的长度便于目录管理。

这个 10 行的 helper 是并发安全的关键——没它、双进程场景会互相覆盖同一个 processing 目录——有了它、各自的构建过程互不干扰、用户甚至不知道有这个问题。

8.13 未来演进——Rolldown 取代 esbuild 之后

Vite 预构建早期(直到 Vite 7)用 esbuild(Go 实现、极快但功能有限)、Vite 8 起切换到 Rolldown(Rust 实现、同样快但 API 更完整)。这次切换带来几个变化:

① 共享 bundler——生产构建用 Rolldown、预构建也用 Rolldown——一套构建代码两处用、减少维护负担。

② CSS 支持更完整——esbuild 的 CSS 功能有限、Rolldown 可以更完整地处理 CSS(虽然目前 moduleTypes['.css'] = 'js' 还绕过了)。

③ 行为一致性——依赖在开发(预构建)和生产(构建)里由同一个 bundler 处理——减少两套 bundler 在 CJS interop、tree-shaking 上的行为差异。

④ 性能不是切换的主要理由——Rolldown 与它替下来的 esbuild 在依赖预构建上孰快孰慢,目前没有可复现的公开基准,这里不下结论。

这次切换的过渡期——pluginConverter.ts 把 esbuild 插件 API 转成 Rolldown 插件 API——让生态插件不需要重写。这种向后兼容层在大型架构迁移里是必备的——保护用户投资、减少破坏性。

8.14 isDepOptimizationDisabled 的边界场景

在 packages/vite/src/node/optimizer/index.ts:195 有一个小 helper:

export function isDepOptimizationDisabled(
  optimizeDeps: DepOptimizationOptions,
): boolean {
  return (
    optimizeDeps.disabled === true ||
    optimizeDeps.disabled === 'dev' ||
    (!!optimizeDeps.noDiscovery && !optimizeDeps.include?.length)
  )
}

三种情况 disabled:

  • disabled: true——显式关掉
  • disabled: 'dev'——只在 dev 关掉。disabled 的类型是 boolean | 'build' | 'dev'(packages/vite/src/node/optimizer/index.ts:147), 默认 'build'(packages/vite/src/node/config.ts:856)——也就是 默认只在 build 期关闭,而 build 期的预构建本来就在 Vite 5.1 被移除了, 所以默认值等于"不关"
  • noDiscovery: true + include 空/undefined——"不自动发现"且"没显式列出依赖"——等于没预构建

值得注意的是 disabled 自己带着 @deprecated:源码注释写明"Deps optimization during build was removed in Vite 5.1. This option is now redundant and will be removed in a future version. Switch to using optimizeDeps.noDiscovery and an empty or undefined optimizeDeps.include."——换句话说,前两个分支是为兼容留下的, 第三个才是现在推荐的写法。这也解释了为什么三个分支要合成一个 helper: 调用方不需要知道哪个是历史包袱。

noDiscovery 的场景——用户完全知道自己的依赖集合(比如纯 SSR 场景、没有运行时发现)——用 include 列出来、vite 就不扫描。如果连 include 都没写、说明用户也没提供依赖——直接不预构建。

disabled: true 的场景——用户在某些非标准环境下、希望完全绕过预构建(比如 Deno / Bun 的 Vite 适配器——它们自己的 runtime 能处理 CJS → ESM)。

三种判别合成一个函数——清晰表达 "在什么情况下完全跳过预构建" 的定义——代码其他地方用这个 helper 决定 "是否要创建 DepsOptimizer"。

这种 "把复杂判断封装成语义明确的 helper" 是保持代码可读的关键——用到的地方只看到 if (!isDepOptimizationDisabled(...)) 一行、不会被 || / && 的多重判断淹没。

8.15 getDepsCacheSuffix:按 environment 隔离依赖缓存目录

看 getDepsCacheSuffix:

function getDepsCacheSuffix(environment: Environment): string {
  return environment.name === 'client' ? '' : `_${environment.name}`
}

按 environment 名加 suffix:

  • client env——默认、无 suffix、目录是 .vite/deps/
  • ssr 或其他自定义 env——加 _${name}、目录是 .vite/deps_ssr/、.vite/deps_worker/ 等

(早期版本还有一个 _build 后缀区分 build 期的预构建、Vite 5.1 移除 build 期预构建后这一分支也随之消失——移除发生在 PR #15184 refactor: remove build time pre-bundling,git tag --contains 显示它最早出现在 v5.1.0。v8.0.3 的实现见 packages/vite/src/node/optimizer/index.ts:933-938。)

为什么要分 env 的目录?——因为不同 env 的依赖优化策略不同:

  • client——面向浏览器、externalize Node.js 内置模块、ESM 输出
  • ssr——面向 Node.js、保留 require / process / fs
  • 自定义 env(比如 workerd、edge runtime)——externalize 不同集合的 API

如果共用一个缓存目录——SSR 环境预构建完后 client 的预构建会被覆盖、反之亦然——每次切换都要重新预构建——体验崩。

分目录后——每个 env 独立缓存、切换不触发重新构建——用户在 client 和 SSR 之间切换顺滑。

代价——磁盘占用翻几倍(deps + deps_ssr + ...)——但现代硬盘几 GB 不是问题、交换了用户体验值得。

这种 "按使用场景分目录缓存" 的模式和 Cargo 的 target/ 分 debug/、release/ 是同一种思路——不同用途的产物不能共缓存。

8.16 预构建的 "不信任" 防御——多层校验

Vite 预构建从头到尾贯穿不信任的防御态度——几个关键校验点:

① _metadata.json 格式校验——parseDepsOptimizerMetadata 解析时检查 chunks 字段存在、且每个 optimized 项都带 fileHash——缺了就视为旧格式(源码注释 "outdated _metadata.json version")、作废重来。

② lockfileHash / configHash 精细对比——§8.1 讲过、不仅看总 hash、还分维度比较。

③ 临时目录的原子 rename——§8.5.1 讲过、commit 时原子替换、失败回滚。

④ getTempSuffix 并发隔离——§8.12 讲过、防止并发进程冲突。

⑤ flatIdDeps 扁平化入口——把每个依赖 id 扁平化(去掉斜杠)后作为打包入口,入口文件由插件自己读入以保留原路径,不去依赖打包器按公共祖先目录生成的、不可预测的输出结构(源码注释,packages/vite/src/node/optimizer/index.ts:760-765)。

⑥ Proxy 警告的 externalized modules——§8.5.5 讲过、用户意外 import Node.js 模块时提示。

⑦ needsInterop 检测入口文件格式——误把 CJS 当 ESM 会产出错的 bundle、needsInterop 防这种误判。

7 层防御——每一层都针对某种真实可能发生的失败模式。

从"第一次代码能跑起来"到"工业级可靠"——中间相差的正是这类防御。笔者认为,新的工具(turbopack、rspack)要替代 vite、不只是要更快、还要在这类防御上至少做到同等水准——否则生态接受度会卡住。

这也解释了为什么写一个新的构建工具很难——不是算法难、是这些防御代码的积累难——靠时间 + 社区反馈慢慢补。

小结

Vite 的依赖预构建是一个精心设计的子系统,它在"快速冷启动"和"零配置"之间取得了平衡。通过 Rolldown 的 scan API 快速发现依赖,通过完整的 rolldown() 构建打包依赖,通过两级哈希缓存避免不必要的重新构建,通过增量发现机制处理运行时新出现的依赖。

DepsOptimizer 状态机是整个系统中最复杂的部分,它需要协调扫描器、构建器、浏览器请求和 HMR 通道之间的时序关系。holdUntilCrawlEnd 策略和 browserHash 机制共同确保了即使在依赖集合发生变化时,用户体验也尽可能流畅。

下一章我们将深入 JavaScript 和 TypeScript 的转换管线——预构建解决了第三方依赖的问题,而项目源码的每一个模块请求都需要经过实时转换。