Vite 设计与实现

第8章 依赖预构建

作者 杨艺韬 · 11,517 字

第8章 依赖预构建

开篇引言

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

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

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 状态机设计

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

打开 optimizer/index.ts:1342-1380getDepHash——它不是单一哈希、而是三个哈希同时计算

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

三个 hash 各有用途

  • lockfileHash——反映依赖版本。pnpm-lock / package-lock / yarn.lock 变化就变。
  • 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 等构建系统都用类似思路。

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/>路径解析]
        C --> F[pluginConverter.ts<br/>esbuild 插件适配]
    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.4 lockfileFormats 的 9 种包管理器支持

vite 的 lockfile hash 支持 9 种包管理器(index.ts:1216-1267):

const lockfileFormats = [
  { path: 'node_modules/.package-lock.json', manager: 'npm' },
  { path: 'node_modules/.yarn-state.yml', manager: 'yarn' },  // Yarn non-PnP
  { path: '.pnp.cjs', checkPatchesDir: '.yarn/patches' },     // Yarn v3+ PnP
  { path: '.pnp.js', checkPatchesDir: '.yarn/patches' },      // Yarn v2 PnP
  { path: 'node_modules/.yarn-integrity', manager: 'yarn' }, // Yarn 1
  { path: 'node_modules/.pnpm/lock.yaml', manager: 'pnpm' },
  { path: '.rush/temp/shrinkwrap-deps.json', manager: 'pnpm' },
  { path: 'bun.lock', manager: 'bun' },
  { path: 'bun.lockb', manager: 'bun' },
].sort((_, { manager }) => {
  return process.env.npm_config_user_agent?.startsWith(manager) ? 1 : -1
})

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

注意末尾的 .sort——根据 npm_config_user_agent 反向排序——当前使用的包管理器排到最后。这看起来反直觉——为什么当前包管理器排到最后?

因为 lookupFile找到第一个存在的文件就返回——如果你用 yarn 但工程里有 npm 留下的 .package-lock.json 残留、vite 会优先识别为 npm——导致 hash 来源不对。把 当前包管理器的 lockfile 排到最后——意味着先检查其他包管理器的 lockfile、最后才检查 “最应该存在的那个”。

这种反直觉设计只有在多个 lockfile 并存的边缘场景才有价值——典型的是迁移中的项目(从 npm 切到 pnpm 但 package-lock.json 还没删)。通过排序让优先级合理——让 vite 更可能找到”最新/最相关的 lockfile”。

checkPatchesDir 字段——给 patch-package 用户特殊支持——patches 目录的 mtime 也算进 hash——用户修改了 patch 补丁、vite 会重新优化。这个细节只有维护过需要 monkey-patch 上游包的项目的人才会感谢——几分钟的打磨换来几小时的困惑避免。

为什么需要预构建

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.exportsrequire()。Vite 的预构建会将其转换为标准的 ESM 格式,并且正确处理 default 导出和命名导出之间的互操作(interop)关系。

减少请求数量

即使是纯 ESM 的包,也可能因为模块拆分过细而导致请求数爆炸。Vite 源码中定义的 needsInterop 函数负责检测这种情况:

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)以及导出了哪些名称。

8.2.1 getConfigHash 的字段白名单策略

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

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

三层精细处理

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

② 插件列表——config.plugins.map((p) => p.name) 只 hash 插件、不 hash 插件实现。因为插件函数 JSON 序列化会变成 "function () { ... }"、每次 node 重启的函数可能不同(比如闭包捕获了 random)——hash 就不稳定。只用名字保证跨重启的稳定性。

③ 排除高开销字段——optimizeDeps.rolldownOptions 里把 pluginsonLogonwarnchecksoutput.pluginsundefined——因为这些字段往往是函数、JSON 化会产生不稳定字符串、且它们的变化不一定真的影响依赖优化

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

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

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

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

ScanEnvironment

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

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
  }
}

在开发模式下,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.entriesbuild.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 代码:

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() 构建不同,它只执行解析和模块图构建阶段,不生成任何输出文件。这使得依赖扫描的速度极快——通常在几十毫秒内完成。

8.3.5 process.env.npm_config_user_agent 的隐式依赖

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

  • npm run devnpm_config_user_agent = "npm/10.x.x node/v20.x.x ..."
  • yarn devnpm_config_user_agent = "yarn/4.x.x ..."
  • pnpm run devnpm_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) 全部返回 false、原始顺序保留——没问题、只是 fallback 而已。

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

8.3.6 ScanEnvironment 屏蔽 HMR 的边界设计

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

devToScanEnvironment 怎么实现:

// 概念性
function devToScanEnvironment(env: DevEnvironment): ScanEnvironment {
  return new Proxy(env, {
    get(target, prop) {
      if (prop === 'moduleGraph') throw new Error('moduleGraph not accessible during scan');
      if (prop === 'hot') throw new Error('HMR not available during scan');
      return target[prop];
    }
  })
}

Proxy 拦截——尝试访问 moduleGraph 或 hot 时抛错、而不是 silent 返回 undefined——让错误在最早暴露

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

如果插件错误地在 scan 阶段用了 moduleGraph——立刻报错、插件作者能看到精确的错误位置。这比**“插件能用但产生奇怪副作用” 友好得多**。

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

预构建执行

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

临时目录策略

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

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 甚至使用了 safeRename 来保证跨进程的安全性。

Rolldown 打包配置

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

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.4.3 flattenId 函数的路径扁平化

flattenId(id)shared/utils.ts 里定义)——把 @scope/pkg/sub 这样的带斜杠 ID 转成 @scope_pkg_sub

function flattenId(id: string): string {
  return id
    .replace(/[/:]/g, '_')  // ← 替换 / 和 : 为 _
    .replace(/[.]/g, '__')  // ← 替换 . 为 __
    .replace(/(\s*>\s*)/g, '___')
}

三种替换

  • / / :_——@scope/pkg@scope_pkgnpm:pkgnpm_pkg
  • .__——pkg.v1pkg__v1、避免 . 被 Rolldown 识别为扩展名分隔
  • >___——pkg > nestedpkg___nested(用于 nested dependency)

为什么要扁平化?——因为预构建输出要放在同一个目录里(node_modules/.vite/deps/)——不能用 / 嵌套目录。扁平化后 @radix-ui_react-dialog.js 是一个独立文件、不需要创建子目录。

_ vs __ vs ___ 的层级编码——让不同种类的斜杠保持可区分。如果都用 _ 替换、@scope/pkg.v1@scope_pkg__v1 会和 @scope_pkg.v1 冲突。三种 separator 保证扁平化是可逆的(虽然 Vite 目前不做反向解析)——未来需要也不会出问题。

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

这个 15 行的小函数在 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.4.5 esmPackageCache vs cjsPackageCache 的双缓存

rolldownDepPlugin 里维护两个独立的 packageCache

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

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

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

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

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

如果共用一个 packageCache——第一次 import 'foo' 缓存了 ESM 路径、后续 require('foo') 从 cache 拿到 ESM 路径——。两个独立 cache 保证不同解析上下文不互相污染

代价——同一个 pkg 可能被解析两次(ESM 一次、CJS 一次)——内存翻倍。但 packageCache 的 entry 通常很小(几 KB)——接受。

这种 “按使用上下文分开 cache 的模式在处理 multi-context 解析时很常见——Webpack 的 resolve.conditionNames、Bun 的 resolve.conditions 都有类似机制。ESM/CJS 互通是现代 JS 生态最麻烦的问题、这个双缓存是 vite 对 Node.js 这套复杂规则的 faithful 实现。

8.4.6 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 内置模块fspath 等)。vite 不能把这些包进 bundle(浏览器没这些 API)、就 externalize 成一个 Proxy。

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

过滤的 key 列表——__esModule__proto__ 等是 JS runtime 自己会访问的属性——用户代码没写也会访问、不该当成误用报警。vite 白名单这些 key、避免 false positive。

为什么 Object.create(new Proxy({}, ...)) 而不是直接 new Proxy({}, ...)?——因为某些库会通过原型链访问属性Object.create 保证原型链查找也走 Proxy——更完整的 get 覆盖

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

这种 dev 警告、prod 静默 的 dual-mode 是现代前端工具的共识——Vite 把它做到了 externalization 层、比 webpack 的 require.resolve fallback 更友好。

CJS 外部化处理

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

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

缓存策略

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

Hash 计算

flowchart LR
    subgraph "lockfileHash"
        L1[package-lock.json] --> LH
        L2[yarn.lock] --> LH
        L3[pnpm-lock.yaml] --> LH
        L4[bun.lock] --> 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 函数创建了一个精密的状态机来管理这个过程:

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) =>
      `${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

        alt holdUntilCrawlEnd
            Note over DOpt: 等待静态导入爬取完成
        else
            DOpt->>Bundle: runOptimizeDeps(初次)
            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

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 的内容),就可以避免全页重载。这在实践中是很常见的——大多数新发现的依赖与已有依赖没有共享模块。

holdUntilCrawlEnd 策略

holdUntilCrawlEnd(默认启用)是一种优化策略,它在冷启动时延迟将预构建结果交给浏览器,直到所有静态导入都被爬取完毕。这样可以最大程度地减少”扫描遗漏依赖 -> 重新构建 -> 全页重载”的情况:

async function onCrawlEnd() {
  waitingForCrawlEnd = false

  await depsOptimizer.scanProcessing

  if (optimizationResult && !options.noDiscovery) {
    const afterScanResult = optimizationResult.result
    const result = await afterScanResult

    const scanDeps = Object.keys(result.metadata.optimized)
    const crawlDeps = Object.keys(metadata.discovered)
    const scannerMissedDeps = crawlDeps.some(
      (dep) => !scanDeps.includes(dep)
    )

    if (scannerMissedDeps) {
      // 扫描器遗漏了依赖,丢弃结果,重新运行
      result.cancel()
      debouncedProcessing(0)
    } else {
      // 扫描器发现了所有依赖,直接使用结果
      startNextDiscoveredBatch()
      runOptimizer(result)
    }
  }
}

esbuild 插件适配层

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

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

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 的三层结构

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

小结

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

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

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

8.12 跨章节呼应

与 Vite 第 15 章 SSR Module Runner 的呼应——预构建的 flattenId 把路径扁平化存缓存、Module Runner 的 ensureBuiltins 也是启动期协商——都体现 “启动期一次性付代价、稳态零开销 的模式。

与 hyper 第 11 章 HTTP/1 wire 的呼应——依赖预构建的 lockfileHash + configHash 两维缓存和 hyper 的 Dur::Default/Configured/Empty 三态枚举——都是把”来源不同的信息 用类型/字段精确表达、避免信息丢失。

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

与 serde 第 16 章 derive 的呼应——vite 的 getConfigHash 白名单 + JSON 序列化用 replacer 处理 function/RegExp、serde_derive 的 quote_spanned! 精准错误定位——**都是”对 “复杂输入的序列化/hash” 做精细化处理” 的工程细节。

8.13 十二条工程原则收束

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

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

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

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

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

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

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

⑦ flattenId 扁平化路径(§8.4.3)——多层斜杠用不同下划线编码、保证扁平且可区分。

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

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

browserHash 的三级 hash——optimized 依赖 + chunks 精细缓存失效。

⑪ holdUntilCrawlEnd 协调时序——避免半缓存状态被前端看到。

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

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

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

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

工具预构建机制缓存 key何时触发
Vitescan + rolldown 打包lockfile + config hash启动时 / 运行时新发现
webpack无(每次 build 重新打包)全部 chunk hash每次启动
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.15 DepsOptimizer 状态机的真实复杂度

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

启动 ──┬── 扫描(async)─── 发现初始依赖集合 A
       ├── 预构建 A(async)── 生成 bundle
       ├── 启动 HTTP server(async)
       └── 处理第一个请求 ───── 可能发现新依赖 B(A 没有)
                                 ├─ 触发 registerMissingImport(B)
                                 ├─ 100ms debounce 等更多依赖
                                 ├─ 重新扫描 + 预构建 A+B
                                 └─ 通知浏览器 reload(因为缓存失效)

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

解决方案——holdUntilCrawlEnd 策略:

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

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

vite 的策略是 等初次优化完、让用户先看到页面、后台再重新优化、重新优化完再 full-reload——分三步让用户永远看到稳定状态。

这套状态机的复杂度——约 1000 行 optimizer.ts——比所有其他部分加起来还多。没有这套状态机、预构建会在无数边缘场景下挂掉——什么时候 hold、什么时候 release、什么时候 full-reload、什么时候 soft-reload——每个决策都要精确。

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

读完这一章、你应该对 “为什么 vite 的开发服务器不是 200 行代码” 有新的理解——它背后是几千行精心设计的状态协调代码。

8.16 冷启动的 “首次代价” 值不值

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

正面论据

  • 二次启动瞬时——用户日常 90% 场景走缓存命中、几百毫秒启动
  • HMR 极快——源码改动直接走 transform + HMR boundary、不重新构建依赖
  • 浏览器只加载 N 个依赖 chunk——vs webpack 的数百个小文件、HTTP 请求成本低得多

负面论据

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

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

但这个权衡不是适合所有人——monorepo 大型项目 + CI 经常清理场景下、Vite 的预构建可能比 webpack 还慢(因为 webpack 可以并行处理、而 vite scan 是序列的)。此时 npm run dev --force 或者 turbopack/rspack 这种替代品可能更合适。

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

读完本章、希望你对预构建的技术真相场景适用性都有清晰的认识——这样你在下次选型时、选 Vite 是因为它适合你的场景、不是因为它 trendy——这是工程师的成熟标志。

8.17 从 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(原子替换)

  • safeRename(processingCacheDir, depsCacheDir)
  • Windows 上用特殊的跨进程安全 rename
  • 失败回滚、不污染现有缓存
  • 输出:.vite/deps/*.js 可用

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

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

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

index.ts:959getTempSuffix

function getTempSuffix() {
  return (
    '_temp_' +
    Math.random().toString(36).slice(2).padEnd(8, '0').slice(0, 8)
  )
}

每次预构建用一个 8 字符随机后缀——processingCacheDir = depsCacheDir + getTempSuffix()——产生 .vite/deps_temp_abc12345/ 这样的目录。

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

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

随机后缀保证每个进程有自己的 processing 目录——互不干扰。结束时都尝试 rename 到 depsCacheDir——谁先成功谁赢、后者的临时目录被删。

padEnd(8, '0').slice(0, 8)——保证 8 字符长度。Math.random().toString(36).slice(2) 有时长度不够 8、padEnd 补 0——一致的长度便于目录管理

随机后缀 vs PID——为什么不用 process.pid?因为 同一个进程可能多次触发预构建(第一次初始、后续 re-optimize)——pid 不变、会冲突。随机后缀每次都不同、更安全。

这个 5 行的 helper 是并发安全的关键——没它、双进程场景会 silent corruption——而有它、并发完全安全、用户甚至不知道有这个问题。

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

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

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

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

③ Tree-shaking 一致性——开发和生产用同一个 bundler——tree-shaking 结果一致——避免 “生产 build 后少了一个 function、dev 正常” 的诡异 bug

④ 性能略微下降——Rust vs Go 的 bundler 性能目前还是互有胜负——Rolldown 在某些场景可能比 esbuild 慢 10-20%——但对预构建场景(几秒 vs 几秒)用户感知不强。

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

读到此——你看懂了 Vite 预构建 “为什么要这么复杂”——不是过度工程、是真实场景下每一条路径都要覆盖。下一章我们看 Vite 的源码转换管线——比预构建更频繁(每次 HMR 都跑)对性能更敏感——会是另一次工程密度的享受。

8.20 isDepOptimizationDisabled 的边界场景

index.ts:195 有一个小 helper:

export function isDepOptimizationDisabled(optimizeDeps: {...}): boolean {
  return optimizeDeps.disabled === true
    || (optimizeDeps.noDiscovery
        && (!optimizeDeps.include || optimizeDeps.include.length === 0))
}

两种情况 disabled

  • disabled: true——显式关掉
  • noDiscovery: true + include 空/undefined——“不自动发现”且”没显式列出依赖”——等于没预构建

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

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

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

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

8.21 depsCacheSuffix 的环境隔离

getDepsCacheSuffix

function getDepsCacheSuffix(environment: Environment): string {
  let suffix = ''
  const config = environment.getTopLevelConfig()
  // Create an extra cache directory for ssr and worker environment
  if (environment.name !== 'client') {
    suffix += `_${environment.name}`
  }
  if (config.command === 'build') {
    suffix += '_build'
  }
  return suffix
}

三种情况加 suffix

  • client env——默认、无 suffix、目录是 .vite/deps/
  • ssr / worker env——加 _ssr / _worker、目录是 .vite/deps_ssr/
  • build 命令——加 _build、目录是 .vite/deps_build/

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

  • client——面向浏览器、externalize Node.js 内置模块、ESM 输出
  • ssr——面向 Node.js、保留 require / process / fs
  • worker——面向 Web Worker、externalize 不同集合的 API
  • build——面向生产构建、可能启用更多优化

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

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

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

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

8.22 预构建的 “不信任” 防御——多层校验

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

metadata.json 版本校验——读取 cache 时先看 version 字段、如果 Vite 版本不匹配就作废重来。

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

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

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

flatIdDeps 输入去重——构建前对依赖去重、避免重复处理。

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

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

7 层防御——每一层都 address 某种 真实世界出现过的失败模式。这些防御不是纸上的 best practice、是 vite 发布几年来从 issue tracker 里积累的补丁——每一条背后可能是一个被修过的 user-reported bug。

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

这也解释了为什么写一个新的构建工具很难——不是算法难、是这些防御代码的积累难——靠时间 + 社区反馈慢慢补。vite 从 2020 年到 2026 年、才积累成现在的样子。这就是 “时间 + 社区 = 可靠性” 的不二法则——所有成熟工具背后都有这条规律。任何新工具如果想赶上 vite 的位置、必须走过这段路——没有捷径、只有勤奋补课。下次你听到某个新工具宣传比 vite “更快”——先问它有没有走过这几千个 issue 的修补——这才是真实的技术深度衡量标准——速度只是第一条门槛、可靠性才是生产级工具的真正考验