perf(build): 按页字体 CSS 子集化 + gallery 响应式图片阶梯
- 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
This commit is contained in:
1 parent
aa050d8035
commit
1fdd0cc162
3 files changed
+149
-1
No files matched your search
@@ -24,6 +24,10 @@ echo "[build] 构建源: origin/main @ $SHA"
|
|||||||
pnpm install --frozen-lockfile >/dev/null
|
pnpm install --frozen-lockfile >/dev/null
|
||||||
pnpm astro build
|
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"
|
echo "[build] rsync -> $STAGING"
|
||||||
mkdir -p "$STAGING"
|
mkdir -p "$STAGING"
|
||||||
rsync -a --delete "$BUILD_REPO/dist/" "$STAGING/"
|
rsync -a --delete "$BUILD_REPO/dist/" "$STAGING/"
|
||||||
|
|||||||
@@ -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}% 裁减)`);
|
||||||
@@ -101,7 +101,7 @@ const first = cards[0];
|
|||||||
<Image
|
<Image
|
||||||
src={card.image}
|
src={card.image}
|
||||||
alt={card.alt}
|
alt={card.alt}
|
||||||
widths={[320, 560, 720]}
|
widths={[480, 800, 1200]}
|
||||||
sizes="(min-width: 820px) 30vw, 46vw"
|
sizes="(min-width: 820px) 30vw, 46vw"
|
||||||
loading={i < 3 ? 'eager' : 'lazy'}
|
loading={i < 3 ? 'eager' : 'lazy'}
|
||||||
fetchpriority={i < 3 ? 'high' : undefined}
|
fetchpriority={i < 3 ? 'high' : undefined}
|
||||||
|
|||||||
Reference in new issue
Block a user