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" ;
3636import { fileURLToPath } from "node:url" ;
3737import { gzipSync } from "node:zlib" ;
3838import { spawnSync } from "node:child_process" ;
3939import { tmpdir } from "node:os" ;
40+ import { measure , rel , fmtB , kB , BudgetError } from "./lib/bundleMeasure.mjs" ;
4041
4142const SELF = fileURLToPath ( import . meta. url ) ;
4243const ROOT = resolve ( dirname ( SELF ) , ".." ) ;
@@ -51,95 +52,11 @@ const DIST = resolve(opt("--dist", join(APP, "dist")));
5152const BUDGET_KB = Number ( opt ( "--budget" , "200" ) ) ;
5253const BUDGET_SOURCE = "docs/standards/performance.md:28" ;
5354
54- const rel = ( p ) => relative ( ROOT , p ) . split ( "\\" ) . join ( "/" ) ;
5555/** 全部失败路径的统一出口:**必须** exit 2(1 只留给「超预算」),并给出可执行的下一条命令。 */
5656function 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 = / i m p o r t \s * \( \s * [ " ' ] [ ^ " ' ] + [ " ' ] \s * \) / g;
66- const RE_STATIC = [ / \b f r o m \s * [ " ' ] \. \/ ( [ ^ " ' / \\ ] + \. j s ) [ " ' ] / g, / \b i m p o r t \s * [ " ' ] \. \/ ( [ ^ " ' / \\ ] + \. j s ) [ " ' ] / 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 ( / < s c r i p t \b [ ^ > ] * > / gi) ?? [ ] ) {
88- if ( ! / \b t y p e \s * = \s * [ " ' ] m o d u l e [ " ' ] / i. test ( tag ) ) continue ;
89- const m = / \b s r c \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 —— 值得人看一眼,不影响判定。 */
14562function preloadUncovered ( dist , eager ) {
@@ -290,10 +207,18 @@ if (!Number.isFinite(BUDGET_KB) || BUDGET_KB <= 0) {
290207 fail ( `--budget 需要正数(实得 "${ opt ( "--budget" , "" ) } ")—— 预算解析失败时绝不能静默按「超标」处理。` ) ;
291208}
292209if ( 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 ) ) ;
0 commit comments