Files
crawler-plugin/frontend-vue/src/shared/chunk-transfer-report.ts
T

190 lines
6.3 KiB
TypeScript

/**
* 首屏传输大小报告(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<string, number>
/** 入口 → 入口自身 JS 字节数 */
entrySizes: Record<string, number>
/** 统计的入口数量上限,必须为正数,默认 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<string, number>
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, string[]>()
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<string, number> = {}
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<string, number> = {}
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<string, number[]>()
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<string, Set<string>>()
let sharedBytes = 0
{
const counts = new Map<string, number>()
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<string, number> {
const chunks = planByEntry.get(line.entry) ?? []
const detail: Record<string, number> = {}
for (const chunk of chunks.slice(0, maxChunksPerEntry)) {
detail[chunk] = chunkSizes[chunk]
}
return detail
}
return {
entries: lines,
sharedBytes,
totalBytes,
largest,
smallest,
chunkBytesOf,
}
}