pnpm Monorepo 下的 Vite 构建链路拆分与共享依赖治理 pnpm Monorepo 下的 Vite 构建链路拆分与共享依赖治理一、Monorepo 的构建膨胀从单包到多包的规模失控Monorepo 架构在组织层面的收益是明确的代码共享、统一版本、原子化提交。但在构建层面如果不做精细化治理多包并存带来的构建膨胀会呈线性甚至超线性增长。一个包含 30 个子包的项目从pnpm install到pnpm build的完整链路未经优化的耗时可能超过 20 分钟。构建膨胀的来源主要有三个方面。第一每个子包独立运行vite build时会重复执行相同的底层操作——ESBuild 的 transform、CSS 的提取和压缩、资源的复制和哈希化。这些操作在 30 个包中重复 30 次。第二共享依赖的体积重复计入。如果 10 个子包都依赖antd但各自独立打包最终的产物中antd的 Tree Shaking 效果大打折扣。第三构建链路缺乏拓扑感知。Vite 的 Library Mode 默认不感知依赖包的构建状态导致即使 A 包依赖 B 包的构建产物也会将 B 包的源码重新编译一遍。flowchart TD subgraph Problem[未经治理的 Monorepo 构建] A[pnpm install] -- B[循环 build 所有包] B -- C1[包 A: 独立 vite build] B -- C2[包 B: 独立 vite build] B -- C3[包 C: 独立 vite build] B -- C4[包 ...: 独立 vite build] C1 -- D[重复编译共享依赖 × N] C2 -- D C3 -- D end subgraph Solution[治理后的构建链路] E[仅 install 顶层依赖] -- F[按拓扑顺序构建] F -- G1[共享依赖: 预构建一次] G1 -- G2[包 A: 引用预构建产物] G1 -- G3[包 B: 引用预构建产物] G1 -- G4[包 C: 引用预构建产物] end二、依赖拓扑分析按顺序构建而非并行构建解决多包构建膨胀的第一步是建立正确的构建顺序。pnpm workspace 中的包之间存在依赖关系图如果不按照拓扑排序构建就会出现构建 A 时引用了尚未构建的 B的错误。// build-graph.ts // 基于 pnpm workspace 的依赖拓扑排序与增量构建 import { readFileSync, existsSync } from fs; import { resolve, dirname } from path; interface PackageNode { name: string; path: string; dependencies: Setstring; // 依赖的其他 workspace 包名 built: boolean; changed: boolean; buildTime?: number; } interface BuildGraph { nodes: Mapstring, PackageNode; order: string[]; } async function analyzeWorkspace(topologyPath: string): PromiseBuildGraph { // 读取 pnpm-lock.yaml 解析 workspace 依赖关系 const lockContent readFileSync(topologyPath, utf-8); const packages parseWorkspacePackages(lockContent); const nodes new Mapstring, PackageNode(); const inDegree new Mapstring, number(); // 初始化所有节点 for (const pkg of packages) { nodes.set(pkg.name, { name: pkg.name, path: pkg.path, dependencies: new Set(pkg.workspaceDependencies), built: false, changed: hasChangesSinceLastBuild(pkg.path), }); inDegree.set(pkg.name, 0); } // 计算入度依赖关系 for (const [, node] of nodes) { for (const dep of node.dependencies) { if (nodes.has(dep)) { inDegree.set(dep, (inDegree.get(dep) || 0) 1); } } } // 拓扑排序Kahn 算法 const queue: string[] []; const order: string[] []; for (const [name, degree] of inDegree) { if (degree 0) queue.push(name); } while (queue.length 0) { const current queue.shift()!; order.push(current); const node nodes.get(current)!; for (const dep of node.dependencies) { const newDegree (inDegree.get(dep) || 1) - 1; inDegree.set(dep, newDegree); if (newDegree 0) queue.push(dep); } } // 检查是否存在循环依赖 if (order.length ! nodes.size) { const unbuilt [...nodes.keys()].filter((n) !order.includes(n)); console.error(检测到循环依赖以下包无法确定构建顺序:, unbuilt); throw new Error(循环依赖导致的构建失败); } return { nodes, order }; } // 检查包自上次构建以来是否有变更 function hasChangesSinceLastBuild(packagePath: string): boolean { const buildMarkerPath resolve(packagePath, .build-marker); if (!existsSync(buildMarkerPath)) return true; // 简化实现比较文件修改时间 // 生产环境应使用 git diff 或文件哈希 const markerTime readFileSync(buildMarkerPath, utf-8).trim(); const lastBuildTime parseInt(markerTime, 10); // 递归检查 src 目录中的文件 const srcPath resolve(packagePath, src); if (!existsSync(srcPath)) return false; const { readdirSync, statSync } require(fs); function checkDir(dir: string): boolean { const entries readdirSync(dir); for (const entry of entries) { const fullPath resolve(dir, entry); const stat statSync(fullPath); if (stat.isDirectory()) { if (checkDir(fullPath)) return true; } else if (stat.mtimeMs lastBuildTime) { return true; } } return false; } return checkDir(srcPath); } function parseWorkspacePackages(lockContent: string): { name: string; path: string; workspaceDependencies: string[]; }[] { // 简化实现实际项目中应从 pnpm-workspace.yaml 和 package.json 中读取 // 这里仅示意返回结构 return [ { name: myapp/shared-utils, path: packages/shared-utils, workspaceDependencies: [], }, { name: myapp/shared-ui, path: packages/shared-ui, workspaceDependencies: [myapp/shared-utils], }, { name: myapp/web-app, path: apps/web, workspaceDependencies: [myapp/shared-utils, myapp/shared-ui], }, ]; }三、共享依赖的治理external 配置与预构建缓存共享依赖治理的核心策略是将大型第三方依赖声明为构建的外部依赖在顶层统一预构建子包通过别名引用预构建产物。在 Vite Library Mode 中build.rollupOptions.external决定了哪些依赖不被打包进当前包的产物。通常所有 workspace 内部包和大型第三方包都应声明为 external// vite.config.shared.ts // Monorepo 中的共享 Vite 配置 import { defineConfig } from vite; import { resolve } from path; // 共享的 external 依赖列表 const SHARED_EXTERNALS [ react, react-dom, react-router-dom, antd, ant-design/icons, dayjs, lodash-es, axios, ]; // 读取当前包的 package.json 来获取 workspace 依赖 function getWorkspaceDeps(): string[] { try { const pkg require(resolve(process.cwd(), package.json)); const deps { ...pkg.dependencies, ...pkg.peerDependencies, }; // 过滤出 workspace 协议workspace:*的依赖 return Object.entries(deps) .filter(([, version]) String(version).startsWith(workspace:)) .map(([name]) name); } catch { return []; } } export function createSharedViteConfig() { const workspaceDeps getWorkspaceDeps(); return defineConfig({ build: { lib: { entry: resolve(process.cwd(), src/index.ts), formats: [es, cjs], }, rollupOptions: { // workspace 内部包和共享第三方包均声明为 external external: [ ...workspaceDeps, ...SHARED_EXTERNALS, // 匹配 workspace 作用域包的正则 /^myapp\//, ], output: { // 保留模块原始结构避免全部打平 preserveModules: true, preserveModulesRoot: src, }, }, // 源码映射方便调试 sourcemap: true, // 不压缩子包产物在顶层应用统一压缩 minify: false, }, resolve: { // 确保优先使用源码而非 dist 产物 conditions: [development, browser], }, }); }对于共享依赖的预构建可以利用 pnpm 的 hoist 机制和 Vite 的optimizeDeps// apps/web/vite.config.ts // 顶层应用的 Vite 配置负责共享依赖的预构建 import { defineConfig } from vite; import react from vitejs/plugin-react; export default defineConfig({ plugins: [react()], optimizeDeps: { // 将 workspace 包也纳入预构建范围 include: [ react, react-dom, antd, myapp/shared-utils, myapp/shared-ui, ], // 排除不需要预构建的包 exclude: [], }, build: { rollupOptions: { output: { // 手动分包将大型共享依赖拆分为独立 chunk manualChunks: { vendor-react: [react, react-dom, react-router-dom], vendor-antd: [antd, ant-design/icons], vendor-utils: [dayjs, lodash-es, axios], }, }, }, }, });四、增量构建与缓存策略只构建变更的部分完整的全量构建应当只在 CI 或发版时执行。本地开发中增量构建是提升效率的关键。// incremental-build.ts // 增量构建引擎仅构建有变更的包及其下游消费者 interface BuildTask { packageName: string; reason: self_changed | dependency_changed; changedDeps: string[]; } async function planIncrementalBuild(graph: BuildGraph): PromiseBuildTask[] { const tasks: BuildTask[] []; const { nodes } graph; // 找出所有发生变更的包 const changedPackages [...nodes.values()].filter((n) n.changed); // 对于每个变更的包递归标记其下游消费者 const affectedSet new Setstring(); function markAffected(packageName: string) { if (affectedSet.has(packageName)) return; affectedSet.add(packageName); // 查找所有依赖此包的其他包 for (const [, node] of nodes) { if (node.dependencies.has(packageName)) { markAffected(node.name); } } } for (const pkg of changedPackages) { markAffected(pkg.name); } // 按照拓扑顺序生成构建任务 for (const name of graph.order) { if (affectedSet.has(name)) { const node nodes.get(name)!; const isSelfChanged node.changed; tasks.push({ packageName: name, reason: isSelfChanged ? self_changed : dependency_changed, changedDeps: [...node.dependencies].filter((d) affectedSet.has(d) ), }); } } return tasks; } // 构建执行器 async function executeBuild(graph: BuildGraph): Promisevoid { const tasks await planIncrementalBuild(graph); if (tasks.length 0) { console.log(没有需要构建的包); return; } console.log(检测到 ${tasks.length} 个包需要构建); const { execSync } require(child_process); for (const task of tasks) { const node graph.nodes.get(task.packageName); if (!node) continue; console.log( 构建 ${task.packageName} (原因: ${task.reason}) ); try { const startTime Date.now(); execSync(pnpm build, { cwd: node.path, stdio: inherit, }); node.buildTime Date.now() - startTime; node.built true; // 写入构建标记 const { writeFileSync } require(fs); const { resolve } require(path); writeFileSync( resolve(node.path, .build-marker), String(Date.now()) ); } catch (error) { console.error(构建 ${task.packageName} 失败:, error); throw error; } } // 输出构建报告 const builtPackages [...graph.nodes.values()].filter((n) n.built); const totalTime builtPackages.reduce( (sum, n) sum (n.buildTime || 0), 0 ); console.log(增量构建完成共 ${builtPackages.length} 个包总耗时 ${totalTime}ms); }五、总结pnpm Monorepo 下的 Vite 构建链路治理需要从三个维度同时切入依赖拓扑排序确保构建顺序的正确性external 配置和手动分包实现共享依赖的去重增量构建机制消除不必要的重复构建。三者组合在 30 个子包的规模下可以将全量构建时间从 20 分钟压缩到 5 分钟以内增量构建时间控制在 30 秒以内。构建链路的治理不是一次性的配置调整它需要随着项目规模的演化持续优化。每次新增子包或引入新的大型依赖时都需要重新审视构建配置的有效性。建议在 CI 中建立构建时间的回归检测当单次构建时间超出基线 20% 时触发告警避免构建性能的缓慢退化。