diff --git a/frontend-vue/src/shared/chunk-transfer-report.ts b/frontend-vue/src/shared/chunk-transfer-report.ts new file mode 100644 index 00000000..c8c895b0 --- /dev/null +++ b/frontend-vue/src/shared/chunk-transfer-report.ts @@ -0,0 +1,189 @@ +/** + * 首屏传输大小报告(Task 92)。 + * + * 在 manualChunks 拆包后比较各页面首屏传输大小:每个入口的首屏字节 = + * 入口自身 JS + 其依赖的全部 chunk 字节(公共 chunk 在多个入口之间共享, + * 计入各自首屏但只在报告总览里计一次)。 + * + * 有界统计:maxEntries 限制统计的入口数量;maxChunksPerEntry 限制每个入口 + * 计入的 chunk 数量(超出部分忽略,防止页面私有 chunk 过多导致报告失真)。 + * 缺失入口的拆包计划或尺寸、缺失 chunk 尺寸、尺寸为负数均 fail-fast 抛错, + * 不产生部分结果;同一输入重复计算幂等,输入对象永不修改。 + */ +export interface ChunkPlanEntry { + entry: string + chunks: string[] +} + +export interface ChunkTransferReportOptions { + /** 页面入口列表(顺序即报告顺序);超过 maxEntries 的部分被跳过 */ + entries: string[] + /** 每个入口的 chunk 清单(来自拆包规划) */ + plans: ChunkPlanEntry[] + /** chunk 名 → 字节数(构建产物尺寸) */ + chunkSizes: Record + /** 入口 → 入口自身 JS 字节数 */ + entrySizes: Record + /** 统计的入口数量上限,必须为正数,默认 100 */ + maxEntries?: number + /** 每个入口计入的 chunk 数量上限,必须为正数,默认 100 */ + maxChunksPerEntry?: number +} + +export interface ChunkTransferLine { + entry: string + entryBytes: number + chunkBytes: number + transferBytes: number + chunkCount: number +} + +export interface ChunkTransferReport { + /** 按入口顺序排列的首屏传输明细 */ + entries: ChunkTransferLine[] + /** 被多个入口共享的 chunk 总字节(每个共享 chunk 只计一次) */ + sharedBytes: number + /** 全部入口 transferBytes 之和 */ + totalBytes: number + /** 首屏最大的入口(空输入为 undefined) */ + largest: ChunkTransferLine | undefined + /** 首屏最小的入口(空输入为 undefined) */ + smallest: ChunkTransferLine | undefined + /** 单个入口的 chunk 字节明细(chunk 名 → 字节数,不含被截断的 chunk) */ + chunkBytesOf: (line: ChunkTransferLine) => Record +} + +function isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value != null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +export function createChunkTransferReport(options: ChunkTransferReportOptions): ChunkTransferReport { + if (!Array.isArray(options.entries)) { + throw new Error('entries 必须是数组') + } + if (!Array.isArray(options.plans)) { + throw new Error('plans 必须是数组') + } + if (!isRecord(options.chunkSizes)) { + throw new Error('chunkSizes 必须是对象') + } + if (!isRecord(options.entrySizes)) { + throw new Error('entrySizes 必须是对象') + } + const maxEntries = options.maxEntries ?? 100 + const maxChunksPerEntry = options.maxChunksPerEntry ?? 100 + if (!(maxEntries > 0)) { + throw new Error('maxEntries 必须为正数: ' + maxEntries) + } + if (!(maxChunksPerEntry > 0)) { + throw new Error('maxChunksPerEntry 必须为正数: ' + maxChunksPerEntry) + } + + const entries = options.entries.slice(0, maxEntries) + const planByEntry = new Map() + for (const plan of options.plans) { + planByEntry.set(plan.entry, plan.chunks) + } + for (const entry of entries) { + if (!planByEntry.has(entry)) { + throw new Error('缺少 entry 的拆包计划: ' + entry) + } + if (!(entry in options.entrySizes)) { + throw new Error('缺少 entry 尺寸: ' + entry) + } + } + + const chunkSizes: Record = {} + for (const key of Object.keys(options.chunkSizes)) { + const size = options.chunkSizes[key] + if (!(size >= 0)) { + throw new Error('尺寸不能为负数: ' + key + '=' + size) + } + chunkSizes[key] = size + } + const entrySizes: Record = {} + for (const key of Object.keys(options.entrySizes)) { + const size = options.entrySizes[key] + if (!(size >= 0)) { + throw new Error('尺寸不能为负数: ' + key + '=' + size) + } + entrySizes[key] = size + } + + const chunkPerEntry = new Map() + for (const entry of entries) { + const chunks = planByEntry.get(entry) ?? [] + const sizes: number[] = [] + for (const chunk of chunks.slice(0, maxChunksPerEntry)) { + if (!(chunk in chunkSizes)) { + throw new Error('缺少 chunk 尺寸: ' + chunk) + } + sizes.push(chunkSizes[chunk]) + } + chunkPerEntry.set(entry, sizes) + } + + // 被多个入口共享的 chunk 总字节(每个共享 chunk 只计一次) + const chunkCountByEntry = new Map>() + let sharedBytes = 0 + { + const counts = new Map() + for (const entry of entries) { + for (const chunk of planByEntry.get(entry) ?? []) { + counts.set(chunk, (counts.get(chunk) ?? 0) + 1) + } + } + for (const [chunk, count] of counts) { + if (count > 1 && chunk in chunkSizes) { + sharedBytes += chunkSizes[chunk] + } + } + } + + const lines: ChunkTransferLine[] = [] + for (const entry of entries) { + const sizes = chunkPerEntry.get(entry) ?? [] + const entryBytes = entrySizes[entry] + const chunkBytes = sizes.reduce((sum, size) => sum + size, 0) + lines.push({ + entry, + entryBytes, + chunkBytes, + transferBytes: entryBytes + chunkBytes, + chunkCount: sizes.length, + }) + chunkCountByEntry.set(entry, new Set(planByEntry.get(entry) ?? [])) + } + + let totalBytes = 0 + for (const line of lines) totalBytes += line.transferBytes + let largest: ChunkTransferLine | undefined = undefined + let smallest: ChunkTransferLine | undefined = undefined + for (const line of lines) { + if (!largest || line.transferBytes > largest.transferBytes) largest = line + if (!smallest || line.transferBytes < smallest.transferBytes) smallest = line + } + + function chunkBytesOf(line: ChunkTransferLine): Record { + const chunks = planByEntry.get(line.entry) ?? [] + const detail: Record = {} + for (const chunk of chunks.slice(0, maxChunksPerEntry)) { + detail[chunk] = chunkSizes[chunk] + } + return detail + } + + return { + entries: lines, + sharedBytes, + totalBytes, + largest, + smallest, + chunkBytesOf, + } +} diff --git a/frontend-vue/tests/chunk-transfer-report.test.ts b/frontend-vue/tests/chunk-transfer-report.test.ts new file mode 100644 index 00000000..30df8dfa --- /dev/null +++ b/frontend-vue/tests/chunk-transfer-report.test.ts @@ -0,0 +1,236 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createChunkTransferReport } from '../src/shared/chunk-transfer-report.ts' + +const plans = (items: Array<{ entry: string; chunks: string[] }>) => items + +test('test_task_092_chunk_normal_default_path', () => { + const report = createChunkTransferReport({ + entries: ['publish', 'dedupe', 'convert'], + plans: plans([ + { entry: 'publish', chunks: ['element-plus', 'vue-vendor', 'publish-page'] }, + { entry: 'dedupe', chunks: ['element-plus', 'vue-vendor', 'dedupe-page'] }, + { entry: 'convert', chunks: ['element-plus', 'vue-vendor', 'convert-page'] }, + ]), + chunkSizes: { 'element-plus': 300_000, 'vue-vendor': 80_000, 'publish-page': 20_000, 'dedupe-page': 25_000, 'convert-page': 30_000 }, + entrySizes: { publish: 10_000, dedupe: 12_000, convert: 11_000 }, + }) + const reportEntries = report.entries + assert.equal(reportEntries.length, 3) + assert.deepEqual( + reportEntries.map((e) => e.entry), + ['publish', 'dedupe', 'convert'], + '入口顺序稳定', + ) + assert.equal(reportEntries[0].entryBytes, 10_000) + assert.equal(reportEntries[0].chunkBytes, 400_000) + assert.equal(reportEntries[0].transferBytes, 410_000) + assert.equal(reportEntries[0].chunkCount, 3) + // 公共 chunk 只统计一次 + assert.equal(report.sharedBytes, 380_000, 'element-plus + vue-vendor 合计') + assert.equal(report.largest?.entry, 'convert') + assert.equal(report.largest?.transferBytes, 421_000) + assert.equal(report.smallest?.entry, 'publish') + assert.equal(report.totalBytes, 410_000 + 417_000 + 421_000) +}) + +test('test_task_092_chunk_normal_multiple_items', () => { + const entries = ['a', 'b', 'c', 'd', 'e'] + const report = createChunkTransferReport({ + entries, + plans: plans(entries.map((entry) => ({ entry, chunks: ['element-plus', `${entry}-page`] }))), + chunkSizes: { + 'element-plus': 300_000, + 'a-page': 10_000, + 'b-page': 20_000, + 'c-page': 30_000, + 'd-page': 40_000, + 'e-page': 50_000, + }, + entrySizes: { a: 5_000, b: 6_000, c: 7_000, d: 8_000, e: 9_000 }, + }) + assert.equal(report.entries.length, 5) + // 批量结果不丢失、顺序稳定 + assert.deepEqual(report.entries.map((e) => e.entry), entries) + for (let i = 0; i < entries.length; i++) { + const item = report.entries[i] + assert.equal(item.transferBytes, (i + 1) * 10_000 + 300_000 + (i + 5) * 1_000) + } + assert.equal(report.sharedBytes, 300_000, 'element-plus 出现在全部入口,只计一次') + assert.deepEqual(report.entries.map((e) => e.chunkCount), [2, 2, 2, 2, 2]) +}) + +test('test_task_092_chunk_normal_repeated_operation_is_idempotent', () => { + const options = { + entries: ['publish', 'dedupe'], + plans: plans([ + { entry: 'publish', chunks: ['element-plus', 'publish-page'] }, + { entry: 'dedupe', chunks: ['element-plus', 'dedupe-page'] }, + ]), + chunkSizes: { 'element-plus': 300_000, 'publish-page': 20_000, 'dedupe-page': 25_000 }, + entrySizes: { publish: 10_000, dedupe: 12_000 }, + } + const once = createChunkTransferReport(options) + const twice = createChunkTransferReport(options) + assert.deepEqual(once.entries, twice.entries, '同一输入两次计算结果一致') + assert.equal(once.sharedBytes, twice.sharedBytes) + assert.equal(once.totalBytes, twice.totalBytes) + assert.deepEqual(once.largest, twice.largest) + assert.deepEqual(once.smallest, twice.smallest) + // 输入对象不被修改 + assert.equal(options.chunkSizes['element-plus'], 300_000) + assert.equal(options.plans[0].chunks.length, 2) +}) + +test('test_task_092_chunk_boundary_empty_input', () => { + const report = createChunkTransferReport({ entries: [], plans: [], chunkSizes: {}, entrySizes: {} }) + assert.deepEqual(report.entries, []) + assert.equal(report.sharedBytes, 0) + assert.equal(report.totalBytes, 0) + assert.equal(report.largest, undefined) + assert.equal(report.smallest, undefined) +}) + +test('test_task_092_chunk_boundary_single_item', () => { + const report = createChunkTransferReport({ + entries: ['withdraw'], + plans: plans([{ entry: 'withdraw', chunks: ['element-plus', 'withdraw-page'] }]), + chunkSizes: { 'element-plus': 300_000, 'withdraw-page': 15_000 }, + entrySizes: { withdraw: 8_000 }, + }) + assert.equal(report.entries.length, 1) + assert.equal(report.entries[0].transferBytes, 323_000) + assert.equal(report.sharedBytes, 0, 'chunk 只出现在一个入口时不算公共') + assert.equal(report.largest?.entry, 'withdraw') + assert.equal(report.smallest?.entry, 'withdraw') + assert.equal(report.largest?.transferBytes, report.smallest?.transferBytes) +}) + +test('test_task_092_chunk_boundary_limit_and_overflow', () => { + const report = createChunkTransferReport({ + entries: ['a', 'b', 'c', 'd'], + plans: plans( + ['a', 'b', 'c', 'd'].map((entry) => ({ + entry, + chunks: ['element-plus', 'vue-vendor', 'shared', `${entry}-page`, `extra-${entry}`], + })), + ), + chunkSizes: { + 'element-plus': 300_000, + 'vue-vendor': 80_000, + shared: 50_000, + 'a-page': 10_000, + 'b-page': 10_000, + 'c-page': 10_000, + 'd-page': 10_000, + 'extra-a': 5_000, + 'extra-b': 5_000, + 'extra-c': 5_000, + 'extra-d': 5_000, + }, + entrySizes: { a: 5_000, b: 5_000, c: 5_000, d: 5_000 }, + maxEntries: 2, + maxChunksPerEntry: 3, + }) + assert.equal(report.entries.length, 2, '超过 maxEntries 只统计前 2 个入口') + assert.deepEqual( + report.entries.map((e) => e.entry), + ['a', 'b'], + ) + assert.equal(report.entries[0].chunkCount, 3, '每入口最多统计 3 个 chunk') + assert.equal(report.entries[0].chunkBytes, 430_000, 'element-plus + vue-vendor + shared') + assert.equal(report.entries[0].transferBytes, 435_000) + // 超出部分的 chunk 尺寸不计入 + assert.ok(!('extra-a' in report.chunkBytesOf(report.entries[0])), '被截断的 chunk 不计入') + // 入口缺失 plan 或尺寸:fail-fast 抛错 + assert.throws( + () => createChunkTransferReport({ + entries: ['a', 'x'], + plans: plans([{ entry: 'a', chunks: ['element-plus'] }]), + chunkSizes: { 'element-plus': 100 }, + entrySizes: { a: 1 }, + }), + /缺少 entry 的拆包计划: x/, + ) + assert.throws( + () => createChunkTransferReport({ + entries: ['a'], + plans: plans([{ entry: 'a', chunks: ['element-plus'] }]), + chunkSizes: { 'element-plus': 100 }, + entrySizes: {}, + }), + /缺少 entry 尺寸: a/, + ) +}) + +test('test_task_092_chunk_invalid_input_rejected', () => { + assert.throws(() => createChunkTransferReport({} as never), /entries 必须是数组/) + assert.throws( + () => createChunkTransferReport({ entries: ['a'], plans: 'x' as never, chunkSizes: {}, entrySizes: {} }), + /plans 必须是数组/, + ) + assert.throws( + () => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: null as never, entrySizes: {} }), + /chunkSizes 必须是对象/, + ) + assert.throws( + () => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: null as never }), + /entrySizes 必须是对象/, + ) + assert.throws( + () => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: new Date() as never }), + /entrySizes 必须是对象/, + ) + assert.throws( + () => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: {}, maxEntries: 0 }), + /maxEntries 必须为正数/, + ) + assert.throws( + () => createChunkTransferReport({ entries: ['a'], plans: [], chunkSizes: {}, entrySizes: {}, maxChunksPerEntry: -1 }), + /maxChunksPerEntry 必须为正数/, + ) + // 尺寸为负数拒绝 + assert.throws( + () => createChunkTransferReport({ + entries: ['a'], + plans: plans([{ entry: 'a', chunks: ['c'] }]), + chunkSizes: { c: -1 }, + entrySizes: { a: 1 }, + }), + /尺寸不能为负数/, + ) + // 缺失 chunk 尺寸拒绝 + assert.throws( + () => createChunkTransferReport({ + entries: ['a'], + plans: plans([{ entry: 'a', chunks: ['c'] }]), + chunkSizes: {}, + entrySizes: { a: 1 }, + }), + /缺少 chunk 尺寸: c/, + ) +}) + +test('test_task_092_chunk_dependency_failure_releases_resources', () => { + let broken = true + const poisonedSizes = new Proxy({ 'element-plus': 300_000, 'a-page': 20_000 }, { + get(target, prop, receiver) { + if (broken && prop === 'element-plus') throw new Error('size getter down') + return Reflect.get(target, prop, receiver) + }, + }) + const options = { + entries: ['a'], + plans: plans([{ entry: 'a', chunks: ['element-plus', 'a-page'] }]), + chunkSizes: poisonedSizes as Record, + entrySizes: { a: 5_000 }, + } + // 依赖(尺寸读取)失败:计算抛错,不产生部分结果 + assert.throws(() => createChunkTransferReport(options), /size getter down/) + // 错误可恢复:依赖恢复后同一输入计算成功,输入未被修改 + broken = false + const report = createChunkTransferReport(options) + assert.equal(report.entries.length, 1) + assert.equal(report.entries[0].transferBytes, 325_000) + assert.deepEqual(options.plans[0].chunks, ['element-plus', 'a-page']) +})