task-91: 按页面拆分 Element Plus 与公共业务 chunk 规划器

This commit is contained in:
2026-08-30 23:05:08 +08:00
parent 6804b2dea6
commit 72998b7d22
2 changed files with 406 additions and 0 deletions
@@ -0,0 +1,196 @@
/**
* 构建拆包规划器(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<string, string[]>
/** 每个入口实际包含的 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<string, Array<{ chunk: string; patterns: string[] }>>()
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<string, string[]> {
const config: Record<string, string[]> = {}
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 }
}