task-92: manualChunks 拆包后比较各页面首屏传输大小
This commit is contained in:
@@ -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<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,
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>,
|
||||
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'])
|
||||
})
|
||||
Reference in New Issue
Block a user