Skip to content

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

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 预构建子系统的完整实现。它们之间的关系可以通过下面的架构图来理解:

为什么需要预构建

CommonJS 到 ESM 的转换

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

js
// 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 函数负责检测这种情况:

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

依赖发现扫描(scan.ts)

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

ScanEnvironment

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

typescript
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 函数开始,它按优先级确定入口点:

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

Rolldown 扫描插件

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

typescript
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 钩子实现了完整的依赖分类逻辑:

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

HTML 类型的特殊处理

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

typescript
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 执行:

typescript
import { scan } from 'rolldown/experimental'

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

scan API 与完整的 rolldown() 构建不同,它只执行解析和模块图构建阶段,不生成任何输出文件。这使得依赖扫描的速度极快——通常在几十毫秒内完成。

预构建执行

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

临时目录策略

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

typescript
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 配置:

typescript
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 函数),这样所有输出文件都在同一层目录中,简化了路径管理。

rolldownDepPlugin 的职责

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

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

typescript
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):

typescript
// 默认解析器,优先 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 对象,在访问时打印友好的警告信息:

typescript
// 生产环境返回空对象
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 ...\`)
      }
    }
  }))`,
}

CJS 外部化处理

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

typescript
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 计算

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

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

typescript
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 加载缓存的元数据:

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

browserHash

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

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

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

增量式发现与重新优化

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

DepsOptimizer 状态机

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

typescript
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,
  }
  // ...
}

完整的生命周期

registerMissingImport

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

typescript
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

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

typescript
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 在两者之间建立了桥接层:

typescript
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 的转换管线——预构建解决了第三方依赖的问题,而项目源码的每一个模块请求都需要经过实时转换。

基于 VitePress 构建