/** * 首屏传输大小报告(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, } }