《Vite 设计与实现》完整目录
第8章 依赖预构建
开篇引言
在浏览器原生支持 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)正是为解决这两个核心问题而设计的:
- 格式转换:将 CommonJS 和 UMD 格式的依赖转换为 ESM
- 请求合并:将内部模块众多的依赖打包成单个文件,减少 HTTP 请求数量
本章将深入 optimizer/ 目录的源码,揭示从依赖发现、扫描、打包到缓存的完整实现。
:::tip 本章要点
- 依赖预构建的两大动机:CommonJS 转 ESM 和减少请求数
scan.ts如何使用 Rolldown 的scanAPI 快速发现项目依赖rolldownDepPlugin.ts如何处理依赖的打包和外部化- 基于 lockfile + config 的两级缓存策略
- 增量式依赖发现与热重载协调机制
optimizer.ts中的 DepsOptimizer 状态机设计 :::
optimizer/ 目录结构
ini
optimizer/
index.ts # 核心入口:类型定义、缓存加载、执行打包、hash 计算
optimizer.ts # DepsOptimizer 状态机:管理开发模式下的增量发现
scan.ts # 依赖扫描:使用 Rolldown scan API 发现裸导入
rolldownDepPlugin.ts # 预构建 Rolldown 插件:处理外部化和资源类型
resolve.ts # include 选项的解析器和 glob 展开
pluginConverter.ts # esbuild 插件到 Rolldown 插件的适配层
这六个文件构成了 Vite 预构建子系统的完整实现。它们之间的关系可以通过下面的架构图来理解:
DepsOptimizer 状态机] --> B[index.ts
核心引擎] B --> C[scan.ts
依赖扫描] B --> D[rolldownDepPlugin.ts
打包插件] B --> E[resolve.ts
路径解析] C --> F[pluginConverter.ts
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
为什么需要预构建
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.exports 和 require()。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 函数开始,它按优先级确定入口点:
是否配置?} A -->|是| B[使用用户指定的 glob 模式] A -->|否| C{build.rollupOptions.input
是否配置?} C -->|是| D[解析 rollup input 配置] C -->|否| E["glob 搜索 **/*.html"] B --> F[过滤: 只保留可扫描文件
且文件存在于磁盘] D --> F E --> F F --> G[返回入口列表]
这个设计体现了 Vite"零配置"的理念:默认情况下,扫描器会从项目根目录的 HTML 文件开始爬取依赖。对于非 HTML 入口的项目(如 SSR 应用),可以通过 optimizeDeps.entries 或 build.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 钩子实现了完整的依赖分类逻辑:
.html/.vue/.svelte} HTML -->|是| RHTML[解析路径
继续爬取] HTML -->|否| BARE{"裸导入?
例如 'react'"} BARE -->|是| EXCL{在 exclude 中?} EXCL -->|是| EXT1[标记为 external] EXCL -->|否| RESOLVE[通过插件容器解析] RESOLVE --> NM{在 node_modules 中?} NM -->|是| OPT{可优化?} OPT -->|是| RECORD["记录到 depImports
标记为 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 代码:
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 加载缓存的元数据:
返回 undefined"] Force -->|否| READ["读取 _metadata.json"] READ --> PARSE{解析成功?} PARSE -->|否| CLEAR PARSE -->|是| LOCK{lockfileHash
是否匹配?} LOCK -->|否| LOG1["日志: lockfile changed
返回 undefined"] LOCK -->|是| CONF{configHash
是否匹配?} CONF -->|否| LOG2["日志: config changed
返回 undefined"] CONF -->|是| HIT["命中缓存!
返回 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 的基础上加入了运行时发现的依赖信息和时间戳:
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 的原因包括:
- 统一构建引擎:开发和生产使用同一个打包器,减少行为差异
- 更好的 Rollup 兼容性:Rolldown 的插件 API 与 Rollup 兼容,而 esbuild 需要适配层
- CSS 支持路线图 :Rolldown 未来将原生支持 CSS 处理,消除当前
.css被当作.js的临时方案
为什么扫描和构建分离
扫描(scan)和构建(bundle)是两个独立的步骤,而不是合并为一次 Rolldown 运行。原因是:
- 扫描需要尽快返回结果,不需要生成输出文件
- 扫描结果可以与运行时爬取的结果合并后再构建
- 如果合并为一步,扫描器遗漏的依赖将无法在构建前被发现
为什么使用防抖而非立即重新构建
registerMissingImport 使用 100ms 的防抖延迟(debounceMs = 100)。这是因为页面加载通常会在短时间内触发多个新依赖的发现。如果每发现一个就立即重新构建,会导致大量无效的中间构建。防抖策略让系统在一个"安静期"后才执行重新构建,此时大部分新依赖已经被收集完毕。
DepOptimizationMetadata 的三层结构
元数据对象维护三个字典------optimized、discovered 和 chunks------而不是一个扁平的列表。这种分层设计让系统能够区分不同状态的依赖:optimized 是已经完成构建的,discovered 是新发现正在处理的,chunks 是共享的非入口 chunk。状态转换清晰,避免了复杂的状态标志位。
小结
Vite 的依赖预构建是一个精心设计的子系统,它在"快速冷启动"和"零配置"之间取得了平衡。通过 Rolldown 的 scan API 快速发现依赖,通过完整的 rolldown() 构建打包依赖,通过两级哈希缓存避免不必要的重新构建,通过增量发现机制处理运行时新出现的依赖。
DepsOptimizer 状态机是整个系统中最复杂的部分,它需要协调扫描器、构建器、浏览器请求和 HMR 通道之间的时序关系。holdUntilCrawlEnd 策略和 browserHash 机制共同确保了即使在依赖集合发生变化时,用户体验也尽可能流畅。
下一章我们将深入 JavaScript 和 TypeScript 的转换管线------预构建解决了第三方依赖的问题,而项目源码的每一个模块请求都需要经过实时转换。