task-91: 按页面拆分 Element Plus 与公共业务 chunk 规划器
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user