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
}

基于 VitePress 构建