/** * 构建拆包规划器(Task 91)。 * * 为多页应用(MPA)生成 manualChunks 配置,按页面拆分公共业务 chunk: * - vendorRules:全局 vendor 规则(Element Plus、Vue 运行时等),所有入口共享; * - perEntryRules:按入口隔离的页面私有 chunk(匹配需同时给出 entryName, * 未匹配入口的模块不会被误拆到其他页面 chunk); * - 同名 chunk 的规则自动合并去重;未匹配模块返回 undefined,交由 Vite * 默认拆包逻辑处理,不强制拆包。 * * 有界配置:maxRules 限制 vendor 规则数量(超限拒绝计数,规则不生效); * maxEntries 限制登记的入口数量(超出部分连同其 per-entry 规则一并跳过)。 * 校验失败(空 chunk 名、空 patterns、非法上限)fail-fast 抛错且零状态变更; * 规则读取抛错(依赖故障)时调用失败,规划状态不被污染,恢复后可继续使用。 */ export interface ChunkRule { /** chunk 名,不能为空 */ chunk: string /** 匹配模块 id 的字符串 pattern 列表,不能为空 */ patterns: string[] } export interface EntryChunkRule extends ChunkRule { /** 关联的入口名,不能为空 */ entry: string } export interface ChunkPlannerOptions { /** 页面入口列表;超过 maxEntries 的部分被跳过 */ entries: string[] /** 全局 vendor 拆包规则;数量超过 maxRules 的部分被拒绝 */ vendorRules?: ChunkRule[] /** 按入口隔离的页面 chunk 规则 */ perEntryRules?: EntryChunkRule[] /** vendor 规则数量上限,必须为正数,默认 100 */ maxRules?: number /** 登记的入口数量上限,必须为正数,默认 100 */ maxEntries?: number } export interface ChunkPlan { entry: string chunks: string[] } export interface ChunkPlannerStats { entryCount: number chunkCount: number sharedChunkCount: number unmatchedCount: number rejectedCount: number } export interface ChunkPlanner { /** 分配模块到 chunk;未匹配返回 undefined(交给 Vite 默认拆包) */ assign: (moduleId: string, entryName?: string) => string | undefined /** 生成 manualChunks 配置对象(chunk 名 → patterns) */ manualChunksConfig: () => Record /** 每个入口实际包含的 chunk 清单(公共 vendor + 页面私有) */ planByEntry: () => ChunkPlan[] stats: () => ChunkPlannerStats } export function createChunkPlanner(options: ChunkPlannerOptions): ChunkPlanner { if (!Array.isArray(options.entries)) { throw new Error('entries 必须是数组') } const maxRules = options.maxRules ?? 100 const maxEntries = options.maxEntries ?? 100 if (!(maxRules > 0)) { throw new Error('maxRules 必须为正数: ' + maxRules) } if (!(maxEntries > 0)) { throw new Error('maxEntries 必须为正数: ' + maxEntries) } const entries = options.entries.slice(0, maxEntries) const entrySet = new Set(entries) const vendorRules: Array<{ chunk: string; patterns: string[] }> = [] const perEntryRulesByEntry = new Map>() const chunkNames: string[] = [] let rejectedCount = 0 let unmatchedCount = 0 function validateRule(rule: ChunkRule) { if (typeof rule.chunk !== 'string' || rule.chunk.length === 0) { throw new Error('chunk 名不能为空') } if (!Array.isArray(rule.patterns) || rule.patterns.length === 0) { throw new Error('patterns 必须是非空数组') } } function mergePatterns(target: string[] | undefined, patterns: string[]): string[] { const result = target ? [...target] : [] for (const pattern of patterns) { if (!result.includes(pattern)) result.push(pattern) } return result } function addVendorRule(rule: ChunkRule) { validateRule(rule) if (vendorRules.length >= maxRules) { rejectedCount += 1 return } const existing = vendorRules.find((item) => item.chunk === rule.chunk) if (existing) { existing.patterns = mergePatterns(existing.patterns, rule.patterns) } else { vendorRules.push({ chunk: rule.chunk, patterns: [...rule.patterns] }) chunkNames.push(rule.chunk) } } function addPerEntryRule(rule: EntryChunkRule) { if (typeof rule.entry !== 'string' || rule.entry.length === 0) { throw new Error('entry 名不能为空') } validateRule(rule) if (!entrySet.has(rule.entry)) return const rules = perEntryRulesByEntry.get(rule.entry) ?? [] const existing = rules.find((item) => item.chunk === rule.chunk) if (existing) { existing.patterns = mergePatterns(existing.patterns, rule.patterns) } else { rules.push({ chunk: rule.chunk, patterns: [...rule.patterns] }) perEntryRulesByEntry.set(rule.entry, rules) chunkNames.push(rule.chunk) } } for (const rule of options.vendorRules ?? []) { addVendorRule(rule) } for (const rule of options.perEntryRules ?? []) { addPerEntryRule(rule) } function matches(patterns: string[], moduleId: string): boolean { for (const pattern of patterns) { if (moduleId.includes(pattern)) return true } return false } function assign(moduleId: string, entryName?: string): string | undefined { for (const rule of vendorRules) { if (matches(rule.patterns, moduleId)) return rule.chunk } if (entryName !== undefined) { const rules = perEntryRulesByEntry.get(entryName) if (rules) { for (const rule of rules) { if (matches(rule.patterns, moduleId)) return rule.chunk } } } unmatchedCount += 1 return undefined } function manualChunksConfig(): Record { const config: Record = {} for (const rule of vendorRules) { config[rule.chunk] = mergePatterns(config[rule.chunk], rule.patterns).sort() } for (const rules of perEntryRulesByEntry.values()) { for (const rule of rules) { config[rule.chunk] = mergePatterns(config[rule.chunk], rule.patterns).sort() } } return config } function planByEntry(): ChunkPlan[] { const vendorNames = vendorRules.map((rule) => rule.chunk) return entries.map((entry) => ({ entry, chunks: [...vendorNames, ...(perEntryRulesByEntry.get(entry)?.map((rule) => rule.chunk) ?? [])], })) } function stats(): ChunkPlannerStats { return { entryCount: entries.length, chunkCount: chunkNames.length, sharedChunkCount: vendorRules.length, unmatchedCount, rejectedCount, } } return { assign, manualChunksConfig, planByEntry, stats } }