197 lines
6.6 KiB
TypeScript
197 lines
6.6 KiB
TypeScript
/**
|
||
* 构建拆包规划器(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 }
|
||
}
|