diff --git a/frontend-vue/src/shared/build-chunk-planner.ts b/frontend-vue/src/shared/build-chunk-planner.ts new file mode 100644 index 00000000..20f02b6f --- /dev/null +++ b/frontend-vue/src/shared/build-chunk-planner.ts @@ -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 + /** 每个入口实际包含的 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 } +} diff --git a/frontend-vue/tests/build-chunk-planner.test.ts b/frontend-vue/tests/build-chunk-planner.test.ts new file mode 100644 index 00000000..99d81ce4 --- /dev/null +++ b/frontend-vue/tests/build-chunk-planner.test.ts @@ -0,0 +1,210 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createChunkPlanner } from '../src/shared/build-chunk-planner.ts' + +test('test_task_091_chunk_normal_default_path', () => { + const planner = createChunkPlanner({ + entries: ['publish', 'dedupe', 'convert'], + vendorRules: [ + { chunk: 'element-plus', patterns: ['element-plus', '@element-plus'] }, + { chunk: 'shared', patterns: ['/src/shared/'] }, + { chunk: 'vue-vendor', patterns: ['/node_modules/vue/', '/node_modules/@vue/', 'vue-router'] }, + ], + perEntryRules: [ + { entry: 'publish', chunk: 'publish-page', patterns: ['/src/pages/publish/'] }, + { entry: 'dedupe', chunk: 'dedupe-page', patterns: ['/src/pages/dedupe/'] }, + { entry: 'convert', chunk: 'convert-page', patterns: ['/src/pages/convert/'] }, + ], + }) + const config = planner.manualChunksConfig() + assert.deepEqual( + Object.keys(config).sort(), + ['convert-page', 'dedupe-page', 'element-plus', 'publish-page', 'shared', 'vue-vendor'].sort(), + '默认路径产出全部计划 chunk', + ) + // Element Plus 模块进入独立 chunk + assert.equal( + planner.assign('D:/repo/node_modules/element-plus/es/components/button/index.mjs', 'publish'), + 'element-plus', + ) + // 公共业务模块进入公共业务 chunk + assert.equal(planner.assign('D:/repo/src/shared/api/java-modules.ts', 'publish'), 'shared') + // 页面私有模块进入页面 chunk + assert.equal(planner.assign('D:/repo/src/pages/publish/components/Table.vue', 'publish'), 'publish-page') + // vue 依赖进入 vendor chunk + assert.equal(planner.assign('D:/repo/node_modules/vue/dist/vue.runtime.esm-bundler.js', 'publish'), 'vue-vendor') + // 未匹配模块不强制拆包,交给 Vite 默认处理 + assert.equal(planner.assign('D:/repo/src/main.ts', 'publish'), undefined) + const stats = planner.stats() + assert.equal(stats.entryCount, 3) + assert.equal(stats.chunkCount, 6) + assert.equal(stats.sharedChunkCount, 3) + assert.equal(stats.unmatchedCount, 1) +}) + +test('test_task_091_chunk_normal_multiple_items', () => { + const entries = ['publish', 'dedupe', 'convert', 'split', 'delete-brand'] + const planner = createChunkPlanner({ + entries, + vendorRules: [{ chunk: 'element-plus', patterns: ['element-plus'] }], + perEntryRules: entries.map((entry) => ({ + entry, + chunk: `${entry}-page`, + patterns: [`/src/pages/${entry}/`], + })), + }) + // 批量分配:多个模块同一 chunk,结果不丢失且顺序稳定 + for (let i = 0; i < 3; i++) { + assert.equal(planner.assign(`D:/repo/node_modules/element-plus/es/components/table/index.mjs`, entries[i]), 'element-plus') + } + for (const entry of entries) { + assert.equal(planner.assign(`D:/repo/src/pages/${entry}/index.vue`, entry), `${entry}-page`) + } + const plan = planner.planByEntry() + assert.equal(plan.length, 5) + assert.equal(plan[0].entry, 'publish') + assert.equal(plan[0].chunks[0], 'element-plus') + assert.equal(plan[0].chunks[1], 'publish-page') + assert.equal(plan[4].chunks[1], 'delete-brand-page') + // 每入口均含 element-plus + 自己的页面 chunk + for (const item of plan) { + assert.ok(item.chunks.includes('element-plus')) + assert.ok(item.chunks.includes(`${item.entry}-page`)) + assert.equal(item.chunks.length, 2) + } +}) + +test('test_task_091_chunk_normal_repeated_operation_is_idempotent', () => { + const options = { + entries: ['publish', 'dedupe'], + vendorRules: [ + { chunk: 'element-plus', patterns: ['element-plus'] }, + { chunk: 'shared', patterns: ['/src/shared/'] }, + ], + perEntryRules: [ + { entry: 'publish', chunk: 'publish-page', patterns: ['/src/pages/publish/'] }, + ], + } + const planner = createChunkPlanner(options) + const once = planner.manualChunksConfig() + const twice = planner.manualChunksConfig() + assert.deepEqual(once, twice, '重复生成配置结果一致') + // 重复登记同 chunk 同名规则:合并去重,不产生重复 chunk + const merged = createChunkPlanner({ + entries: ['publish', 'dedupe'], + vendorRules: [ + { chunk: 'element-plus', patterns: ['element-plus'] }, + { chunk: 'element-plus', patterns: ['@element-plus'] }, + { chunk: 'shared', patterns: ['/src/shared/'] }, + ], + perEntryRules: [ + { entry: 'publish', chunk: 'publish-page', patterns: ['/src/pages/publish/'] }, + ], + }) + assert.deepEqual(merged.manualChunksConfig()['element-plus'].sort(), ['@element-plus', 'element-plus']) + assert.equal(merged.stats().chunkCount, 3) + // 重复 assign 同一模块:结果不变,命中统计幂等 + assert.equal(planner.assign('x/node_modules/element-plus/a.js', 'publish'), 'element-plus') + assert.equal(planner.assign('x/node_modules/element-plus/a.js', 'publish'), 'element-plus') + assert.equal(planner.stats().unmatchedCount, 0) +}) + +test('test_task_091_chunk_boundary_empty_input', () => { + const planner = createChunkPlanner({ entries: [] }) + assert.deepEqual(planner.manualChunksConfig(), {}, '无入口产空配置') + assert.deepEqual(planner.planByEntry(), []) + assert.equal(planner.stats().entryCount, 0) + assert.equal(planner.stats().chunkCount, 0) + assert.equal(planner.assign('x/module.js', 'publish'), undefined) +}) + +test('test_task_091_chunk_boundary_single_item', () => { + const planner = createChunkPlanner({ + entries: ['withdraw'], + vendorRules: [{ chunk: 'element-plus', patterns: ['element-plus'] }], + perEntryRules: [{ entry: 'withdraw', chunk: 'withdraw-page', patterns: ['/src/pages/withdraw/'] }], + }) + assert.equal(planner.assign('/repo/node_modules/element-plus/es/index.mjs', 'withdraw'), 'element-plus') + assert.equal(planner.assign('/repo/src/pages/withdraw/index.vue', 'withdraw'), 'withdraw-page') + assert.equal(planner.assign('/repo/src/pages/withdraw/index.vue', 'publish'), undefined, '入口不匹配时页面规则不生效') + const plan = planner.planByEntry() + assert.equal(plan.length, 1) + assert.equal(plan[0].chunks.length, 2) +}) + +test('test_task_091_chunk_boundary_limit_and_overflow', () => { + const planner = createChunkPlanner({ + entries: ['a', 'b', 'c'], + vendorRules: [1, 2, 3, 4, 5].map((n) => ({ chunk: `vendor-${n}`, patterns: [`/vendor-${n}/`] })), + perEntryRules: [ + { entry: 'a', chunk: 'a-page', patterns: ['/src/pages/a/'] }, + { entry: 'b', chunk: 'b-page', patterns: ['/src/pages/b/'] }, + { entry: 'c', chunk: 'c-page', patterns: ['/src/pages/c/'] }, + ], + maxRules: 3, + maxEntries: 2, + }) + const stats = planner.stats() + assert.equal(stats.chunkCount, 5, '规则超限后剩余规则被拒绝') + assert.equal(stats.rejectedCount, 2, 'vendor-4/vendor-5 被拒绝') + assert.equal(stats.entryCount, 2, '入口超过 maxEntries 只登记前 2 个') + assert.equal(planner.assign('x/vendor-1/a.js'), 'vendor-1') + assert.equal(planner.assign('x/vendor-4/a.js'), undefined, '被拒绝的规则不再生效') + const plan = planner.planByEntry() + assert.equal(plan.length, 2) +}) + +test('test_task_091_chunk_invalid_input_rejected', () => { + assert.throws(() => createChunkPlanner({ entries: 'publish' as never }), /entries 必须是数组/) + assert.throws( + () => createChunkPlanner({ entries: ['a'], vendorRules: [{ chunk: '', patterns: ['x'] }] }), + /chunk 名不能为空/, + ) + assert.throws( + () => createChunkPlanner({ entries: ['a'], vendorRules: [{ chunk: 'v', patterns: [] }] }), + /patterns 必须是非空数组/, + ) + assert.throws( + () => createChunkPlanner({ entries: ['a'], vendorRules: [{ chunk: 'v', patterns: 'x' as never }] }), + /patterns 必须是非空数组/, + ) + assert.throws(() => createChunkPlanner({ entries: ['a'], maxRules: 0 }), /maxRules 必须为正数/) + assert.throws(() => createChunkPlanner({ entries: ['a'], maxEntries: -1 }), /maxEntries 必须为正数/) + assert.throws( + () => createChunkPlanner({ entries: ['a'], perEntryRules: [{ entry: '', chunk: 'x', patterns: ['y'] }] }), + /entry 名不能为空/, + ) +}) + +test('test_task_091_chunk_dependency_failure_releases_resources', () => { + let broken = true + const poisonedPatterns = new Proxy(['element-plus'], { + get(target, prop, receiver) { + if (broken && prop === '0') throw new Error('pattern getter down') + return Reflect.get(target, prop, receiver) + }, + ownKeys(target) { + if (broken) throw new Error('pattern ownKeys down') + return Reflect.ownKeys(target) + }, + }) + // 依赖(规则读取)失败:构造期抛错,规划器不产生部分状态 + assert.throws( + () => + createChunkPlanner({ + entries: ['publish'], + vendorRules: [{ chunk: 'element-plus', patterns: poisonedPatterns as unknown as string[] }], + }), + /pattern/, + ) + // 错误可恢复:依赖恢复后同一配置成功 + broken = false + const planner = createChunkPlanner({ + entries: ['publish'], + vendorRules: [{ chunk: 'element-plus', patterns: poisonedPatterns as unknown as string[] }], + }) + assert.equal(planner.assign('/repo/node_modules/element-plus/a.js', 'publish'), 'element-plus') + assert.deepEqual(planner.manualChunksConfig()['element-plus'], ['element-plus']) + assert.equal(planner.stats().rejectedCount, 0, '失败不产生拒绝计数') + assert.equal(planner.stats().chunkCount, 1) +})