From 1fdd0cc162ca1324c8e31016c3e3f3746554c1c0 Mon Sep 17 00:00:00 2001 From: wait4xx Date: Sun, 25 Oct 2026 21:05:15 +0800 Subject: [PATCH] =?UTF-8?q?perf(build):=20=E6=8C=89=E9=A1=B5=E5=AD=97?= =?UTF-8?q?=E4=BD=93=20CSS=20=E5=AD=90=E9=9B=86=E5=8C=96=20+=20gallery=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=BC=8F=E5=9B=BE=E7=89=87=E9=98=B6=E6=A2=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subset-font-css.mjs post-build:扫每页文本(含 attr)+ ui.ts 全 词条 + Latin/标点永保三防线,命中 unicode-range 才留 @font-face, 子集按内容哈希去重共享,孤儿原 CSS 清出——全站字体 CSS 6.7→2.6MB (61%),mian-sha 声明 101→48/家族 - build.sh 挂载(存在性守卫:车道在途文件不断发布链——20:31 事故 纪律) - GalleryPage srcset 阶梯 320/560/720→480/800/1200(首图 fetchpriority 维持) - Lighthouse:mian-sha 61→78、gallery 74→79、en 70→83、terminal 74→88(A11y/BP/SEO 持平;unused-css 370→36KB) - 过程坑:v1 重写 href 丢前导斜杠致嵌套页 404——v2 只换文件名 车道:op-gitea(A/B 渲染等价证明:serif 916 字 0-miss;分数复采 入 /tmp/e2e-shots/lighthouse-ap/);收编验收:serif 字形 fonts.check 实测 true、srcset 产出、E2E 31/31 验证: pnpm astro build + subset 跑通 35 页;E2E 31/31 PASS; staging 部署后 stamp 复核 Spec: none (构建管线性能二连:post-build 子集化+图片阶梯) Co-authored-by: GLM-5.3 --- infra/deploy/build.sh | 4 + scripts/build-perf/subset-font-css.mjs | 144 +++++++++++++++++++++++++ src/components/pages/GalleryPage.astro | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 scripts/build-perf/subset-font-css.mjs diff --git a/infra/deploy/build.sh b/infra/deploy/build.sh index bae4eb8..d24a640 100755 --- a/infra/deploy/build.sh +++ b/infra/deploy/build.sh @@ -24,6 +24,10 @@ echo "[build] 构建源: origin/main @ $SHA" pnpm install --frozen-lockfile >/dev/null pnpm astro build +# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链; +# 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉 +[ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过" + echo "[build] rsync -> $STAGING" mkdir -p "$STAGING" rsync -a --delete "$BUILD_REPO/dist/" "$STAGING/" diff --git a/scripts/build-perf/subset-font-css.mjs b/scripts/build-perf/subset-font-css.mjs new file mode 100644 index 0000000..5409327 --- /dev/null +++ b/scripts/build-perf/subset-font-css.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * 按页字体 CSS 子集化(车道 A 任务 1):post-build 对 dist 每页扫描文本, + * 仅保留 unicode-range 命中页面实际用字的 @font-face 声明。 + * + * 为什么裁声明而非字体文件:woff2 切片本就由浏览器按 unicode-range 按需拉取, + * 裁掉的是 CSS 字节与主线程解析成本(@fontsource CJK 每家族 ~100 条全量下发)。 + * + * 动态文本三道防线(宁多勿缺): + * ① aria-label/placeholder/title 等 attr 一并入扫(去掉 script/style/svg 后全串扫描) + * ② src/i18n/ui.ts 全部词条字符并入(命令面板/常驻 UI) + * ③ Basic Latin/Latin-1/通用标点区(U+0020-024F, U+2000-206F)的 @font-face 永保 + * ——终端输出/命令面板的动态拉丁内容不可枚举 + */ +import { readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { createHash } from 'node:crypto'; + +const distRoot = process.argv[2] ?? 'dist'; +const repoRoot = process.cwd(); +const SAFE_LO = 0x20, SAFE_HI = 0x24f; // Latin + Latin-1 + 拉丁扩展 +const SAFE2_LO = 0x2000, SAFE2_HI = 0x206f; // 通用标点 +const skipRe = /<(script|style|svg)\b[\s\S]*?<\/\1>/gi; + +/** 解析 unicode-range 值 → [lo,hi] 数组(含 U+3??? 通配形态) */ +function parseRanges(spec) { + const out = []; + for (const tok of spec.split(',')) { + const m = tok.trim().match(/^u\+([0-9a-f?]+)(?:-([0-9a-f]+))?$/i); + if (!m) continue; + if (m[1].includes('?')) { + const q = m[1].split('?').length - 1; + const base = parseInt(m[1].replace(/\?/g, '0'), 16); + out.push([base, base + (1 << (4 * q)) - 1]); + } else { + const lo = parseInt(m[1], 16); + const hi = m[2] ? parseInt(m[2], 16) : lo; + out.push([lo, hi]); + } + } + return out; +} + +/** 收集"需要保真渲染"的字符集(CJK 等非安全区字符才有判定价值) */ +function collectChars(text) { + const s = new Set(); + for (const ch of text) { + const c = ch.codePointAt(0); + if (c > SAFE2_HI) s.add(c); // 安全区以下/之内的拉丁与标点走永保规则 + } + return s; +} + +function rangeHit(ranges, chars) { + for (const [lo, hi] of ranges) { + if ((lo <= SAFE_HI && hi >= SAFE_LO) || (lo <= SAFE2_HI && hi >= SAFE2_LO)) return true; // 防线③ + for (const c of chars) if (c >= lo && c <= hi) return true; + } + return false; +} + +function walkHtml(dir) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...walkHtml(p)); + else if (e.name.endsWith('.html')) out.push(p); + } + return out; +} + +// ---------- 主流程 ---------- +const uiPath = join(repoRoot, 'src/i18n/ui.ts'); +const uiChars = existsSync(uiPath) ? collectChars(readFileSync(uiPath, 'utf8')) : new Set(); + +const htmls = walkHtml(distRoot); +// 字体 CSS 识别:含 unicode-range 的 @font-face +const astroDir = join(distRoot, '_astro'); +const fontCss = readdirSync(astroDir).filter((f) => f.endsWith('.css')) + .filter((f) => { + const t = readFileSync(join(astroDir, f), 'utf8'); + return t.includes('@font-face') && t.includes('unicode-range'); + }); + +const parsed = new Map(); // cssFile → { blocks: [{block, ranges}], other } +for (const f of fontCss) { + const text = readFileSync(join(astroDir, f), 'utf8'); + const blocks = []; + let idx = 0; + const re = /@font-face\s*\{[^}]*\}/g; + let m; + while ((m = re.exec(text))) { + const block = m[0]; + const ur = block.match(/unicode-range:\s*([^;}]+)/i); + blocks.push({ block, ranges: ur ? parseRanges(ur[1]) : null }); + idx = m.index + m[0].length; + } + // 非声明部分(注释/其他规则)原样保留一份 + const other = text.replace(re, '\u0000').split('\u0000'); + parsed.set(f, { blocks, other }); +} + +const written = new Map(); // 内容 hash → 新文件名(同子集去重共享) +let totalBefore = 0, totalAfter = 0; + +for (const html of htmls) { + const raw = readFileSync(html, 'utf8'); + const text = raw.replace(skipRe, ' '); + const chars = collectChars(text); + for (const c of uiChars) chars.add(c); + if (chars.size === 0) continue; + + let out = raw; + for (const f of fontCss) { + const hrefs = [...new Set(raw.match(new RegExp(`[^"'\\s]*${f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^"'\\s]*`, 'g')) ?? [])]; + if (hrefs.length === 0) continue; + const { blocks, other } = parsed.get(f); + const keep = blocks.filter((b) => !b.ranges || rangeHit(b.ranges, chars)); + if (keep.length === blocks.length) continue; // 全命中无需子集 + const body = other[0] ?? ''; + const css = keep.map((b) => b.block).join('') + body; + const h = createHash('sha256').update(css).digest('hex').slice(0, 8); + const name = `fs-${f.replace(/\.css$/, '').replace(/[^a-z0-9-]/gi, '')}-${h}.css`; + if (!written.has(h)) { + writeFileSync(join(astroDir, name), css); + written.set(h, name); + } + for (const href of hrefs) out = out.split(href).join(href.replace(f, written.get(h))); + totalBefore += readFileSync(join(astroDir, f), 'utf8').length; + totalAfter += css.length; + } + if (out !== raw) writeFileSync(html, out); +} + +// 原字体 CSS 已无引用则清出 dist(构建仓 rsync 整目录,孤儿只会白占体积) +for (const f of fontCss) { + const stillUsed = htmls.some((h) => readFileSync(h, 'utf8').includes(f)); + if (!stillUsed) unlinkSync(join(astroDir, f)); +} + +const pages = htmls.length; +console.log(`[subset-font-css] pages=${pages} fontCss=${fontCss.length} ` + + `subsets=${written.size} bytes ${totalBefore} -> ${totalAfter} ` + + `(${totalBefore ? Math.round((1 - totalAfter / totalBefore) * 100) : 0}% 裁减)`); diff --git a/src/components/pages/GalleryPage.astro b/src/components/pages/GalleryPage.astro index 1b2c673..076381c 100644 --- a/src/components/pages/GalleryPage.astro +++ b/src/components/pages/GalleryPage.astro @@ -101,7 +101,7 @@ const first = cards[0];