Skip to content

Commit c29a1b8

Browse files
committed
refactor(scripts): 拆出 bundleMeasure 度量模块
1 parent 2ed5c83 commit c29a1b8

2 files changed

Lines changed: 147 additions & 92 deletions

File tree

‎scripts/check-bundle-budget.mjs‎

Lines changed: 17 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@
3131
* `--json` 与默认构建同用会把 vite 输出混进 stdout;机器消费请配 `--no-build`。
3232
* 退出码:0 = 达标 · 1 = 超预算 · 2 = 构建失败 / 产物缺失 / 自检失败。
3333
*/
34-
import { readFileSync, readdirSync, existsSync, statSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
35-
import { join, resolve, dirname, relative } from "node:path";
34+
import { readFileSync, existsSync, statSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
35+
import { join, resolve, dirname } from "node:path";
3636
import { fileURLToPath } from "node:url";
3737
import { gzipSync } from "node:zlib";
3838
import { spawnSync } from "node:child_process";
3939
import { tmpdir } from "node:os";
40+
import { measure, rel, fmtB, kB, BudgetError } from "./lib/bundleMeasure.mjs";
4041

4142
const SELF = fileURLToPath(import.meta.url);
4243
const ROOT = resolve(dirname(SELF), "..");
@@ -51,95 +52,11 @@ const DIST = resolve(opt("--dist", join(APP, "dist")));
5152
const BUDGET_KB = Number(opt("--budget", "200"));
5253
const BUDGET_SOURCE = "docs/standards/performance.md:28";
5354

54-
const rel = (p) => relative(ROOT, p).split("\\").join("/");
5555
/** 全部失败路径的统一出口:**必须** exit 2(1 只留给「超预算」),并给出可执行的下一条命令。 */
5656
function fail(msg) {
5757
console.error(`❌ 首屏预算守卫:${msg}`);
5858
process.exit(2);
5959
}
60-
const fmtB = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
61-
const kB = (n) => (Math.round(n / 10) / 100).toFixed(2); // 半进位:与 vite 打印口径一致(11745 B:toFixed 给 11.74、vite 给 11.75)
62-
const gzBytes = (buf) => gzipSync(buf, { level: 6 }).length;
63-
64-
/** 动态形态先整段剥掉;静态形态要求引号紧跟 `from` / `import`(产物里就是 `from"./x.js"`)。 */
65-
const RE_DYNAMIC = /import\s*\(\s*["'][^"']+["']\s*\)/g;
66-
const RE_STATIC = [/\bfrom\s*["']\.\/([^"'/\\]+\.js)["']/g, /\bimport\s*["']\.\/([^"'/\\]+\.js)["']/g];
67-
68-
/** 一段 chunk 代码里的静态 ESM 依赖(basename 集合);`export … from` 与裸 `import` 都算静态边。 */
69-
function staticDeps(code) {
70-
const text = code.replace(RE_DYNAMIC, "");
71-
const out = new Set();
72-
for (const re of RE_STATIC) {
73-
re.lastIndex = 0;
74-
let m;
75-
while ((m = re.exec(text)) !== null) out.add(m[1]);
76-
}
77-
return out;
78-
}
79-
80-
/** 从 index.html 取入口 chunk 的 basename;解析不到即产物问题 ⇒ exit 2(属性顺序无关)。 */
81-
function entryChunkName(dist) {
82-
const htmlPath = join(dist, "index.html");
83-
if (!existsSync(htmlPath) || !statSync(htmlPath).isFile()) {
84-
fail(`找不到 ${rel(htmlPath)} —— 产物缺失。先跑「cd app; npm run build」,或去掉 --no-build 让本脚本代跑。`);
85-
}
86-
const html = readFileSync(htmlPath, "utf8");
87-
for (const tag of html.match(/<script\b[^>]*>/gi) ?? []) {
88-
if (!/\btype\s*=\s*["']module["']/i.test(tag)) continue;
89-
const m = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(tag);
90-
const base = m ? m[1].split(/[?#]/)[0].split("/").pop() : "";
91-
if (base && base.endsWith(".js")) return base;
92-
}
93-
fail(`${rel(htmlPath)} 里没有带 src 的 <script type="module"> —— 空/损坏的 dist 走这条,不是「零字节首屏」。`);
94-
}
95-
96-
/** 首屏闭包:从入口 BFS,**只沿静态边**;动态可达的 chunk 不会进来。返回 Map<basename,{bytes,gzip}>。 */
97-
function firstScreenChunks(dist) {
98-
const entry = entryChunkName(dist);
99-
const assets = join(dist, "assets");
100-
if (!existsSync(assets) || !statSync(assets).isDirectory()) fail(`找不到 ${rel(assets)} 目录 —— 产物缺失。`);
101-
const eager = new Map();
102-
const queue = [entry];
103-
while (queue.length) {
104-
const name = queue.shift();
105-
if (eager.has(name)) continue;
106-
const p = join(assets, name);
107-
if (!existsSync(p) || !statSync(p).isFile()) fail(`首屏 chunk 静态引用了 ${name},但 ${rel(p)} 不存在 —— 产物残缺。`);
108-
const bytes = readFileSync(p);
109-
eager.set(name, { bytes: bytes.length, gzip: gzBytes(bytes) });
110-
for (const dep of staticDeps(bytes.toString("utf8"))) if (!eager.has(dep)) queue.push(dep);
111-
}
112-
return eager;
113-
}
114-
115-
/** 产物资产清单(vite 把 chunk / CSS / 字体平铺在 dist/assets 下)。 */
116-
function listAssets(dist) {
117-
const assets = join(dist, "assets");
118-
if (!existsSync(assets) || !statSync(assets).isDirectory()) return [];
119-
return readdirSync(assets)
120-
.map((name) => join(assets, name))
121-
.filter((p) => statSync(p).isFile())
122-
.map((p) => ({ name: p.slice(assets.length + 1), path: p, bytes: statSync(p).size }));
123-
}
124-
125-
/** 唯一的度量实现(报表与 JSON 共用):量一次产物,返回全部读数。 */
126-
function measure(dist) {
127-
const assets = listAssets(dist);
128-
const eager = firstScreenChunks(dist);
129-
const gzFile = (p) => gzBytes(readFileSync(p));
130-
const shaped = (a) => { const g = gzFile(a.path); return { name: a.name, bytes: a.bytes, gzipBytes: g, gzipKb: Number(kB(g)) }; };
131-
const htmlPath = join(dist, "index.html");
132-
return {
133-
dist,
134-
eager,
135-
eagerBytes: [...eager.values()].reduce((a, c) => a + c.gzip, 0),
136-
rawBytes: [...eager.values()].reduce((a, c) => a + c.bytes, 0),
137-
lazy: assets.filter((a) => a.name.endsWith(".js") && !eager.has(a.name)).map(shaped),
138-
excluded: assets.filter((a) => !a.name.endsWith(".js")).map(shaped),
139-
htmlBytes: existsSync(htmlPath) ? statSync(htmlPath).size : 0,
140-
htmlGzip: existsSync(htmlPath) ? gzFile(htmlPath) : 0,
141-
};
142-
}
14360

14461
/** 参考项:HTML 用 modulepreload 声明但静态闭包没覆盖的 .js —— 值得人看一眼,不影响判定。 */
14562
function preloadUncovered(dist, eager) {
@@ -290,10 +207,18 @@ if (!Number.isFinite(BUDGET_KB) || BUDGET_KB <= 0) {
290207
fail(`--budget 需要正数(实得 "${opt("--budget", "")}")—— 预算解析失败时绝不能静默按「超标」处理。`);
291208
}
292209
if (has("--dist") && !NO_BUILD) fail(`--dist 与构建互斥:--dist 用于既有产物或自检夹具,请配 --no-build。`);
293-
if (SELF_TEST) selfTest();
294-
if (!NO_BUILD) {
295-
console.log(`▶ cd app && npm run build(--no-build 可跳过;产物写入 app/dist)`);
296-
const r = spawnSync("npm", ["run", "build"], { cwd: APP, stdio: "inherit", shell: process.platform === "win32" });
297-
if (r.status !== 0) fail(`npm run build 失败(exit ${r.status})—— 构建不绿时「首屏 gzip」没有意义,先修构建。`);
210+
// 度量已搬进 `./lib/bundleMeasure.mjs`:那边的 `fail()` **只抛** `BudgetError`(度量模块不得持有进程
211+
// 出口),这里把它桥回本件的统一出口(`console.error` + `exit 2`,文案逐字不变)。用**专属类型**是为了
212+
// 让其余异常继续原样上抛 —— 拆前它们就是未捕获栈 + exit 1,无差别转 exit 2 才是真的行为变化。
213+
try {
214+
if (SELF_TEST) selfTest();
215+
if (!NO_BUILD) {
216+
console.log(`▶ cd app && npm run build(--no-build 可跳过;产物写入 app/dist)`);
217+
const r = spawnSync("npm", ["run", "build"], { cwd: APP, stdio: "inherit", shell: process.platform === "win32" });
218+
if (r.status !== 0) fail(`npm run build 失败(exit ${r.status})—— 构建不绿时「首屏 gzip」没有意义,先修构建。`);
219+
}
220+
finish(measure(DIST));
221+
} catch (e) {
222+
if (e instanceof BudgetError) fail(e.message);
223+
throw e;
298224
}
299-
finish(measure(DIST));

‎scripts/lib/bundleMeasure.mjs‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @ai-context 首屏预算守卫的**度量实现**(批 7 T8 从 `scripts/check-bundle-budget.mjs` 纯搬迁而来)。
3+
*
4+
* Why 独立成件:主件是**唯一**判首屏的门禁,而它当时 **299/300 行(余 1)** —— 批 7 还要往主件加
5+
* 懒侧字节门禁(C9.6 第 4 条逐字「先拆件再加门禁」)⇒ 先把「度量」与「CLI / 报表 / 退出码」
6+
* 物理分离。⚠️ 本件**不在** `scripts/line-limits.mjs:23` 的 `SOURCE_EXT = /\.(ts|tsx|rs)$/` 视野内
7+
* ⇒ 「≤150 行」是自设预算、**不是**绑定约束(`.mjs` 不受行数门禁管辖)。
8+
*
9+
* 口径(与主件文件头的「口径」一节是**同一个**口径,勿在此另立一套;真伪由主件 `--self-test` 的
10+
* 负样本夹具证):首屏 = `dist/index.html` 里 `<script type="module">` 指向的入口 chunk,
11+
* **加上从入口出发只沿静态 ESM `import` / `export … from` 可达的全部 `.js` chunk**;
12+
* `import(…)` 动态边是断点;gzip = `zlib.gzipSync(buf, { level: 6 })`;kB = 十进制 ÷1000
13+
* (vite 口径,不是 KiB ÷1024)。
14+
*
15+
* 副作用:**只读**文件系统(`dist` 与其 `assets/`);**无进程出口**(见下方 `fail` 的形态)。
16+
* 边界:vite 的 chunk 一律平铺在 `dist/assets/` 下(本件按 basename 解析);不解析 sourcemap、
17+
* 不解析 CSS 里的 `url()`、不认带 query 的说明符 —— 三者在本仓产物中都不存在。
18+
*/
19+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
20+
import { join, resolve, dirname, relative } from "node:path";
21+
import { fileURLToPath } from "node:url";
22+
import { gzipSync } from "node:zlib";
23+
24+
/** ⚠️ 本件住 `scripts/lib/` ⇒ 仓库根上溯**两级**;搬迁前主件住 `scripts/` 只上溯一级,改目录结构须同步改此行。 */
25+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
26+
27+
/**
28+
* 本件唯一的失败信号:主件 `catch` 到它才转 `fail()`(exit 2)。
29+
* Why 专属类型而非裸 `Error`:拆前的 `fail()` 立即 `process.exit(2)`、**不吞别的异常**;若主件无差别
30+
* 地把任何异常都转成 exit 2,会把「未预期 IO 异常」的退出形态(未捕获栈 + exit 1)静默改掉 —— 那才是
31+
* 真的行为变化。故只认这一个类型,其余异常原样上抛。
32+
*/
33+
export class BudgetError extends Error {}
34+
35+
const rel = (p) => relative(ROOT, p).split("\\").join("/");
36+
/**
37+
* 与主件 `fail()` **同名同参**,但**只抛不退出** —— 度量模块不得持有进程出口(拆前它在这里直接
38+
* `process.exit(2)`,等于把「产物缺失」变成库的副作用)。🔴 这是拆件中**唯一**一处语义改写:4 个调用点
39+
* 与消息文本逐字未动,主件在同名出口里保留 `console.error` + `process.exit(2)` 并由
40+
* `e instanceof BudgetError` 桥接 ⇒ stderr 文案与退出码逐字相同(对拍证据在 `tmp/t8/`)。
41+
*/
42+
function fail(msg) {
43+
throw new BudgetError(msg);
44+
}
45+
const fmtB = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
46+
const kB = (n) => (Math.round(n / 10) / 100).toFixed(2); // 半进位:与 vite 打印口径一致(11745 B:toFixed 给 11.74、vite 给 11.75)
47+
const gzBytes = (buf) => gzipSync(buf, { level: 6 }).length;
48+
49+
/** 动态形态先整段剥掉;静态形态要求引号紧跟 `from` / `import`(产物里就是 `from"./x.js"`)。 */
50+
const RE_DYNAMIC = /import\s*\(\s*["'][^"']+["']\s*\)/g;
51+
const RE_STATIC = [/\bfrom\s*["']\.\/([^"'/\\]+\.js)["']/g, /\bimport\s*["']\.\/([^"'/\\]+\.js)["']/g];
52+
53+
/** 一段 chunk 代码里的静态 ESM 依赖(basename 集合);`export … from` 与裸 `import` 都算静态边。 */
54+
function staticDeps(code) {
55+
const text = code.replace(RE_DYNAMIC, "");
56+
const out = new Set();
57+
for (const re of RE_STATIC) {
58+
re.lastIndex = 0;
59+
let m;
60+
while ((m = re.exec(text)) !== null) out.add(m[1]);
61+
}
62+
return out;
63+
}
64+
65+
/** 从 index.html 取入口 chunk 的 basename;解析不到即产物问题 ⇒ exit 2(属性顺序无关)。 */
66+
function entryChunkName(dist) {
67+
const htmlPath = join(dist, "index.html");
68+
if (!existsSync(htmlPath) || !statSync(htmlPath).isFile()) {
69+
fail(`找不到 ${rel(htmlPath)} —— 产物缺失。先跑「cd app; npm run build」,或去掉 --no-build 让本脚本代跑。`);
70+
}
71+
const html = readFileSync(htmlPath, "utf8");
72+
for (const tag of html.match(/<script\b[^>]*>/gi) ?? []) {
73+
if (!/\btype\s*=\s*["']module["']/i.test(tag)) continue;
74+
const m = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(tag);
75+
const base = m ? m[1].split(/[?#]/)[0].split("/").pop() : "";
76+
if (base && base.endsWith(".js")) return base;
77+
}
78+
fail(`${rel(htmlPath)} 里没有带 src 的 <script type="module"> —— 空/损坏的 dist 走这条,不是「零字节首屏」。`);
79+
}
80+
81+
/** 首屏闭包:从入口 BFS,**只沿静态边**;动态可达的 chunk 不会进来。返回 Map<basename,{bytes,gzip}>。 */
82+
function firstScreenChunks(dist) {
83+
const entry = entryChunkName(dist);
84+
const assets = join(dist, "assets");
85+
if (!existsSync(assets) || !statSync(assets).isDirectory()) fail(`找不到 ${rel(assets)} 目录 —— 产物缺失。`);
86+
const eager = new Map();
87+
const queue = [entry];
88+
while (queue.length) {
89+
const name = queue.shift();
90+
if (eager.has(name)) continue;
91+
const p = join(assets, name);
92+
if (!existsSync(p) || !statSync(p).isFile()) fail(`首屏 chunk 静态引用了 ${name},但 ${rel(p)} 不存在 —— 产物残缺。`);
93+
const bytes = readFileSync(p);
94+
eager.set(name, { bytes: bytes.length, gzip: gzBytes(bytes) });
95+
for (const dep of staticDeps(bytes.toString("utf8"))) if (!eager.has(dep)) queue.push(dep);
96+
}
97+
return eager;
98+
}
99+
100+
/** 产物资产清单(vite 把 chunk / CSS / 字体平铺在 dist/assets 下)。 */
101+
function listAssets(dist) {
102+
const assets = join(dist, "assets");
103+
if (!existsSync(assets) || !statSync(assets).isDirectory()) return [];
104+
return readdirSync(assets)
105+
.map((name) => join(assets, name))
106+
.filter((p) => statSync(p).isFile())
107+
.map((p) => ({ name: p.slice(assets.length + 1), path: p, bytes: statSync(p).size }));
108+
}
109+
110+
/** 唯一的度量实现(报表与 JSON 共用):量一次产物,返回全部读数。 */
111+
function measure(dist) {
112+
const assets = listAssets(dist);
113+
const eager = firstScreenChunks(dist);
114+
const gzFile = (p) => gzBytes(readFileSync(p));
115+
const shaped = (a) => { const g = gzFile(a.path); return { name: a.name, bytes: a.bytes, gzipBytes: g, gzipKb: Number(kB(g)) }; };
116+
const htmlPath = join(dist, "index.html");
117+
return {
118+
dist,
119+
eager,
120+
eagerBytes: [...eager.values()].reduce((a, c) => a + c.gzip, 0),
121+
rawBytes: [...eager.values()].reduce((a, c) => a + c.bytes, 0),
122+
lazy: assets.filter((a) => a.name.endsWith(".js") && !eager.has(a.name)).map(shaped),
123+
excluded: assets.filter((a) => !a.name.endsWith(".js")).map(shaped),
124+
htmlBytes: existsSync(htmlPath) ? statSync(htmlPath).size : 0,
125+
htmlGzip: existsSync(htmlPath) ? gzFile(htmlPath) : 0,
126+
};
127+
}
128+
129+
/** 对外接口 = 主件所需 + T9 的懒侧门禁复用(**同一个**度量实现才保证两个读数同口径)⇒ `rel` 一并导出。 */
130+
export { rel, fmtB, kB, gzBytes, staticDeps, entryChunkName, firstScreenChunks, listAssets, measure };

0 commit comments

Comments
 (0)