构建期瘦身
图片管线与多语言字典两条瘦身路线
构建期瘦身
静态站的”胖”有两种。
看得见的:几十张几 MB 的原图,读者浏览器一半流量花在图上。
看不见的:head 里内联的 200KB 翻译字典,每个页面都背一份。
本文记录两条瘦身路线:图片管线与多语言字典。它们共用的思路是把转换成本全部前置到构建期,运行时只享受结果。
两条瘦身线分开讲,它们共享同一套方法论:单一来源、构建期转换、增量缓存、单点判断。
上半场:图片管线
图片优化是个”每次构建都重做一遍太蠢、不做又不行”的活。
这个站的做法是把优化前置到构建前的批处理脚本:全站扫描、双格式生成、内容哈希缓存、manifest 分发。
管线的形状
prebuild 脚本在 astro build 之前跑,图片这一步干四件事:
- 扫描
public/下所有.png/.jpg/.jpeg(跳过字体、KaTeX、Live2D 等无需优化的目录); - 双格式生成:每张图用 sharp 输出 AVIF(quality 60)和 WebP(quality 75)两份;
- 内容哈希缓存:以”路径 + 文件大小 + 修改时间”的 SHA1 为键,产物已存在就跳过。日常构建只处理新增/变动的图,缓存命中率随时间趋近 100%;
- 写 manifest:
{ 原图路径 → { avif, webp, width, height } }的映射表,交给页面组件运行时查表分发。
实际跑起来的日志长这样:图片 262 张、原图若干 MB 压到 AVIF+WebP 合计零头。
构建一次,全站受益,之后每次构建几乎零成本。
几个参数取舍的理由
为什么是 AVIF + WebP 双格式而不是只出 AVIF?
AVIF 压缩率最好,但解码慢(尤其移动端大图)、且老浏览器不支持。WebP 兼容性近乎全面、解码快。
组件按浏览器支持顺序尝试 AVIF → WebP → 原图,三层兜底。
AVIF 的 quality 压到 60 是因为 AVIF 对低质量档的编码效率异常好。60 的 AVIF 观感通常好过 75 的 WebP,文件还更小。
WebP 保 75 是它的体感舒适区。
为什么用”路径+大小+mtime”做哈希键而不是文件内容?
内容哈希要读全文件再 SHA1,全站扫一遍成本高。
路径+元信息的组合键对”没变的图”零读取,对”变了的图”必然失配(任何编辑都会改变大小或 mtime)。
这是缓存键设计里”用近似的唯一性换零成本探测”的典型取舍。错的概率是”文件内容变了但大小和 mtime 都没变”,实践中几乎不存在。
为什么宽高进 manifest?
拿到图片尺寸后组件能直接输出 width/height,浏览器在图片加载前就排好版。
这是 CLS 优化的地基(图片没有尺寸属性是布局偏移的第一大来源,见渲染性能篇)。
一个 manifest 字段同时服务格式分发和布局稳定,几乎没有额外成本。
还有一条”没做”的边界值得记下来:只转格式、不缩放。
每张图只有一个宽度的 AVIF/WebP,srcset 里也就一个候选 URL。
多宽度响应式意味着”每张图 × N 个宽度”的产物膨胀和缓存键复杂度,而这个站正文图的显示宽度是固定的,浏览器缩放大图的代价由解码端一次付清。
对个人站,多宽度的收益撑不起它的复杂度。哪天图片尺寸跨度变大(缩略图与全屏大图混用),这一层再补不迟。
与 Astro 组件的配合
prebuild 只负责”备货”,消费端是一个 rehype 插件。
Markdown 里的原始图片引用在渲染管线里被查 manifest 替换成带 srcset/尺寸属性的现代格式标签。
作者写文章时完全无感。继续引用原图路径,管线负责把”你写的”变成”浏览器该拿的”。
改写出来的标签结构值得看一眼,因为格式选择权在浏览器手里:
<picture> <source type="image/avif" srcset="同一张图的 .avif"> <source type="image/webp" srcset="同一张图的 .webp"> <img src="原图" width="1920" height="1080" loading="lazy" decoding="async" alt=" "></picture><source> 只声明”我有这种格式”,浏览器按支持度自上而下挑一个,全不支持就落到 <img> 的原图。
没有一行 JS 参与,静态标签自己完成协商。
width/height 写死像素值,浏览器据此在加载前就排好纵横比占位。
loading="lazy" 让折叠线以下的图滚动到附近才拉。
消费端还有几个安静的边界处理:远程图和 data URL 原样放行。manifest 里查不到的图(漏跑脚本的新图、被排除目录的图)不会报错,插件现场用 sharp 读一次元数据补上宽高,行为退化为”只有尺寸优化、没有格式优化”。
缓存的使命就是把这种”现场读”压到接近零。
这个分层的价值在职责清晰:脚本管批处理,插件管替换,组件管兜底。
任何一层的策略调整(比如以后加质量参数、改目录规则)都不波及其他层。
首屏与正文
管线只处理 Markdown 正文里的 <img>,组件层的图(题图、OG 图)不经过这条路。
这个边界恰好和懒加载的边界对齐:正文图绝大多数在折叠线以下,loading="lazy" 的收益全拿。题图往往是 LCP 元素,懒加载它反而拖慢首屏,让浏览器立刻去拉才对。
所以”全站图片一律加 lazy”是个粗糙策略,“正文 lazy、首屏 eager”才是对的形状。
而管线天然的分层恰好落在这个形状上。
为什么不用框架内置的图片方案
Astro 自带 astro:assets,也能在构建期转格式、量尺寸,但它只认 import 进来或放在 src/ 里的图。
本站的图片全部住在 public/,文章里写的是根绝对路径。内容(Markdown)和资产(图片)各自独立存放,图片 URL 不随构建产物变化。
这个组织方式下内置方案帮不上忙,而批处理脚本对 public/ 里的任何图都有效。
另一个考虑是可移植性:prebuild 脚本加 rehype 插件不绑任何框架,静态站生成器换了,管线原样搬走。
构建期压缩让位边缘压缩
管线最早还引入了 @playform/compress 在构建期再压一遍 HTML/CSS/JS。
后来关掉了大半,只留 HTML 级别的轻处理,理由写在配置注释里:Cloudflare 和 Vercel 都已在边缘实时做 gzip/brotli 压缩。
构建期再压一份是重复劳动,而且是全构建链路里最慢的环节之一(sharp 重压图片那一项尤其夸张)。
决策原则:同一件事,平台已经做了,就不要在构建期再做。
构建时间也是钱,CI 排队比几十字节的 HTML 空白金贵。
这个决策也划清了边界:只有”格式转换”(原图 → AVIF/WebP)必须在构建期做(边缘服务不提供转码),而”无损压缩”(文本类资源)交给边缘。
判断一个优化属于哪边,看它需不需要领域知识:转码需要,压缩不需要。
转码为什么不能像压缩那样交给请求时处理,值得把账摊开算。
AVIF 的编码端极其昂贵。一张大图编码一次可能要几百毫秒到几秒,而解码端相对便宜。
若在边缘按请求转码:要么每个访问者都付一次编码成本(慢到不可用),要么边缘缓存转码结果(那等于把缓存失效、存储成本的问题从构建期搬到了运行期,复杂度反而高)。
构建期方案里,编码成本只付一次、产物静态分发、CDN 全程缓存。
昂贵且确定的事情适合一次性预付,便宜且无状态的事情可以交给边缘实时做。
孤儿产物与对账
管线跑久了会积累一类垃圾:源图被删除或改名后,旧图生成的 .avif/.webp 还躺在产物目录里。
没有页面引用它们,但也没有任何机制删除它们。
这类”孤儿产物”不报错、只默默占空间,混进部署目录还会被一起上传。
对策是把 manifest 当正本清单:产物目录里任何不在 manifest 登记的文件都是孤儿。
值得加一道对账步骤:每次批处理扫完,遍历产物目录,凡不在本次 manifest 里的就删除。
逻辑一行不复杂,省掉的是”部署目录越长越大、没人敢删”的慢性病。
同理,缓存键里”路径 + 大小 + mtime”的组合在改名场景下会 miss(路径变了),旧的产物就成了孤儿。对账步骤一并兜住。
与 HTTP 缓存的配合
产物文件名若带内容哈希,HTTP 层可以直接下发 Cache-Control: immutable 的长缓存。
URL 不会变,缓存命中即正确。原图 URL 恒定不变,用中等时长加 revalidate。
这套配合让”更新了图但用户看到旧的”的坑只剩一个入口:替换了原图内容却没换文件名。
而那是使用纪律问题(内容变了就走一遍批处理、产出新哈希),不是管线缺陷。
管线的职责就是把“内容变”翻译成“URL 变”,让 HTTP 缓存的所有正确性假设都成立。
原图入库纪律
管线的收益上限由原图质量决定,几条入库纪律值得写下来。
入库前先缩:手机直出的照片动辄 4000px 宽,而正文显示宽度固定,长边超过两三千像素的部分纯属浪费传输和存储。入库脚本(或手动)先压到目标长边。
统一色彩空间:混合 P3 与 sRGB 的图在部分浏览器上会有色差,统一转 sRGB 最省心(有专业色彩需求的站点另说)。
EXIF 方向:手机拍摄的照片靠 EXIF 标记记录旋转,不带处理直接转码会出”横竖颠倒”的产物。sharp 这类库支持按 EXIF 自动转正,管线里要显式确认这一步开着。
目录与命名约定:本站按文章建目录、图名用序号(如 /260927/1.png),URL 短且稳定,也方便按目录做排除和清理。
这些纪律不写进代码,但决定了管线跑十年之后产物目录还干不干净。
质量参数怎么定
AVIF 60 / WebP 75 这两个数不该是抄来的,定法也不神秘。一轮有纪律的盲测。
选三类代表性样张(照片、截图、图表各几张:三类的内容结构完全不同,最优档位差异很大),各输出一排不同质量的产物,文件名去掉质量信息、打乱顺序,并排对比,挑”看不出差别”的最低档。
几个经验规律:照片的容错远高于图表。照片 AVIF 40 都常常无感,图表、带文字的截图压狠了先糊的就是笔画。“放大到 200% 看”是比”看着还行”更严格的验收标准,文字边缘是第一个暴露压缩痕迹的地方。选定后把参数写进脚本注释并注明测试日期,将来换编码器版本时重测一轮即可。
还有一个反向思路值得记下:先用最高的质量档跑通管线,再逐级压。
调参阶段最大的坑是”压缩伪影”与”管线 bug”互相背锅。先确保一张图从源文件到页面显示全链路正确,再引入压缩损失,问题才能定位。
质量参数是管线里唯一需要“人眼验收”的旋钮。
可复用的清单
要把这套管线搬到别的静态站,骨架只需要五件事:
- 扫描目录(带排除表);
- 以内容哈希为键的产物缓存(增量构建的关键);
- 双格式 + 质量参数(AVIF 60 / WebP 75 起步);
- manifest 记录产物路径与宽高;
- 渲染管线里查表替换 + 原图兜底。
合计两百来行脚本,收益是全站图片流量砍半起步、构建时间可控。
对一个内容站来说,这是投入产出比最高的性能基建。
下半场:内联字典瘦身
图片的胖看得见,字典的胖看不见。
双语站点要把界面文案两份携带、交互组件要在浏览器里即时取词,于是翻译字典得进 HTML。
这个站的字典全量展开后接近 200KB。如果每个页面都背一份,等于用户每次导航都白下一本词典。
瘦身的对象同样在构建期。
问题的形状
把站里的文案按消费方分类,会发现它们根本不该同等待遇:
- 核心文案:导航、页脚、按钮、表单提示。每页都用,量小(几 KB),必须随页面内联,否则组件首帧就取不到词;
- 小部件文案:284 个游戏/工具组件内部的上千条文案。只有对应页面用,量大(百 KB 级),但游戏页确实需要(组件要即时切换语言);
- 服务端专属文案:详情页的「玩法与操作」正文。只在构建期由 Astro 服务端渲染进 HTML,浏览器里的任何代码都不读它。
第三类就是瘦身的靶子。
它有 568 个 slug 条目、两个语言合计约 200KB,此前混在字典里被一起内联。
但正文早就在 HTML 里以可见文本的形式存在了,客户端再背一份纯属双重存储。
第一刀:把 guides 子树从客户端字典里剥离(一个 stripGuides 函数,序列化前把这几个顶层 key 删掉),只留在服务端字典里。
第二刀是分级注入:普通页面(博客、归档、关于)只注入核心字典;只有访问 /games/**、/tools/** 时才注入全量字典(核心 + widgets + games + tools)。
判断就在布局层一行路径正则。大部分页面的 head 体积立刻瘦回几 KB。
那行正则值得贴出来,因为它的边界处理就是分级注入的全部细节:
const needsFullI18n = /(^|\/)(games|tools)(\/|$)/.test(Astro.url.pathname);三段各有职责:(^|\/) 保证 nogames/ 这种前缀撞车不误判。(games|tools) 是两个全量目录。(\/|$) 兜住路径结尾(/games 本身和 /games/2048/ 都要命中)。
写漏任何一段,症状都是”某类页面背了不该背的字典”或反过来”游戏页没字典”。而且只在特定 URL 上复现,极难靠抽查发现。
与 new URL(pathname, base).pathname 这类规范化写法不同,这个正则跑在原始字符串上,要小心 trailing slash 的差异。本地 dev 和部署环境的斜线习惯可能不同。
结果:普通页面不再背任何小部件文案;即便在小部件页,服务端正文也不再双份存储。
全站 head 里内联 JSON 的总量下降一个数量级。
设计约束
有人会问:为什么不把字典做成 JSON 文件按需加载?
技术上可行,但会引入一个静态站的经典时序问题:组件首帧渲染早于 fetch 返回。
用户会看到一堆 key 名闪现,或者组件要等待字典就绪才能水合。
对”切页即玩”的游戏站,这个等待不可接受。
内联字典随 HTML 到达、同步可读,是静态多语言站最稳的选择。只是必须配上面两刀把体积管住。
缓存角度内联也不吃亏:HTML 本来就走 SWR 缓存(见 SW 缓存篇),字典随页面被浏览器和 SW 双层缓存,二次访问零成本。
取词链路的全貌
瘦身动的是字典的体量,链路的形状也值得交代一遍,因为两刀都是沿着它切的。
字典的顶层是语言。zh 和 en 两个子树,BaseLayout 渲染时把当前页面需要的部分序列化成 JSON,内联进 <script> 挂到 window.__FROSTI_I18N__。
组件里的 ts(key) 先按语言选子树,再按点分路径逐级下钻:ts("games.2048.title") 在 zh 子树里找 games → 2048 → title。
取不到词时的回退链是当前语言 → 英文 → key 本身,这是有意设计的失败信号:上线新组件时,key 打错或字典漏条目,页面上出现的不是空白,而是英文文案或 key 名。
一眼可见,不需要开控制台。
踩坑一节的”哨兵检查”能成立,前提正是这条回退链:字典注入失败时所有 ts() 整齐地退化成 key 名,症状藏不住。
变量插值走 {name} 占位符,翻译里写”找到路径,长度 {n}“,代码里传 n。
语言本身不写进 key、也不读 localStorage:ts() 用的语言来自 SSR 写进 <html lang> 的值,岛屿水合时读一次,ClientRouter 换页后在 astro:after-swap 里重新读。
URL 换了语言,取词子树跟着换。
URL 是唯一权威源,字典只是它的随从。
踩坑:define:vars 的双重序列化
剥离过程中踩了一个极隐蔽的坑,值得单独记录。
字典内联的标准做法:
<script is:inline define:vars={{ i18nDictJson }}> window.__DICT__ = JSON.parse(i18nDictJson);</script>define:vars 会对字符串再做一层 JSON 序列化,保证在 HTML 里字面安全。
我在上层曾手动做了 JSON.stringify(dict).replace(/</g, "\\u003c") 的 XSS 加固。
两道处理叠加后,\u003c 被再次转义成 \\u003c,浏览器端 JSON.parse 遇到这个非法转义直接抛错,整本字典注入失败。
症状极具迷惑性:页面看似正常(服务端渲染的部分完好),但所有客户端组件的文案变成了 key 名。games.2048.title 满屏飞。
教训拆成两条:
- XSS 转义与序列化框架二选一:要么信任框架的
define:vars,要么自己全权处理(is:inline手写 script + 自己转义)。两层都想做,就会互相打架; - 字典注入要加哨兵检查:
JSON.parse外面套 try-catch,失败时 console.error 一条明确的日志。这个坑从上线到发现可能隔很久。没有哨兵日志,你只会看到”文案变 key 名”这个无从下手的症状。
体积守门
瘦身的敌人是回潮:三个月后某个组件顺手把长正文塞回客户端字典,没人会发现。
页面照常工作,只是每个访问者多背 30KB。
所以守门要做成自动的,两个层级:
- 构建期报告:序列化字典的地方顺手
console.log各子树的字节数(或写入构建摘要文件),每次构建都能看到”guides 0KB / widgets 84KB / …”。体积成为被看见的数字,才谈得上控制; - 预算阈值:给每棵子树定预算(比如 widgets 子树 ≤ 100KB),超预算让构建警告或失败。数字不用精确。预算的意义是”任何人往里加东西时,必须有意识地越过一道门槛”。
配合一条代码习惯:往字典加新顶层 key 前先回答”它的消费方是客户端还是服务端”。
回答不出来,就先按服务端专属处理,需要时再搬进客户端字典。
这道前置检查做得越认真,事后需要把条目搬出客户端字典的清理就越少。
如果要加第三种语言
现在的机制对 zh/en 是硬编码的:字典顶层按语言分子树,取词链路是”当前语言 → 英文 → key”。
加第三种语言时机制本身能承接(字典里多一个子树、SUPPORTED 数组加一项),但回退链要重新设计。
当前语言缺词回退英文,意味着日语页面会混出中文没有、英文也没有时的英文文案。
双语时代”回退到英文”约等于”回退到另一门完整语言”。三语之后,回退策略变成一个产品决策(回英文?回源语言?显示 key 催补翻译?),值得在加语言之前想清楚,而不是让默认行为替你决定。
内联体积的账也要重算:每种语言都是一份全量子树,三级语言的字典体积直接乘三。
分级注入从”优化”升级成”必需”。
两个已知局限
这套”YAML 嵌套 + 点分 key + {n} 插值”的方案简单可靠,但有两个边界要心里有数。
嵌套结构的利弊。
按”页面/组件 → 元素”嵌套(games.2048.title),YAML 可读、可整棵挪动、可与组件目录对齐。代价是注入前要序列化整棵对象、深层改名要全局搜 key。
扁平结构("games.2048.title": "2048" 一行一个)对 grep 和 diff 更友好,传输体积略小(省掉对象括号),但失去”按子树剥离”的操作粒度。
本文的 stripGuides 剥离就依赖嵌套。
两者都能跑,选哪个取决于你更需要”搬子树”还是”搜 key”。
复数与语法形态。
{n} 插值只能替换数字,处理不了”1 file / 2 files”这种复数屈折。
文案里写 “found {n} files” 在 n=1 时就是错的。
中文没有屈折变形,本站侥幸不踩。英文要认真做就得引入带复数规则的格式(ICU MessageFormat 一类),字典值从字符串升级成带条件的对象。
这个局限不影响小型双语站,但值得在”文案里出现计数”时提前想到。要么把文案写成复数中性的(“found: {n}”),要么一开始就选带复数机制的方案。
键名规范与文件组织
字典的键名是给人看的 API,规范要在第一天定死。
本站的约定是”路径对齐”:key 的前缀与消费组件的位置对应(games.2048.title 只会被 2048 的组件用)。
好处是找 key 即找文件。看到组件就知道它的 key 长什么样,看到 key 就知道改哪个组件。副作用是组件搬家时 key 要跟着改。
另一种流派是语义命名(common.save、errors.network),复用性好,但容易长出”万能桶”。什么都往 common 里塞,最后谁也不敢动。
小站二选一都行,大站建议路径对齐 + 少量显式共享区(label. 这类全局词),并写进约定。
文件组织同理是个两难。
按语言拆(zh.yaml 一整份、en.yaml 一整份):翻译对照方便、漏翻一眼可比对,代价是单文件超长、多人协作容易冲突。
按模块拆(translations.yaml / games.yaml / tools.yaml,本站的形态):子树与文件一一对应、剥离和分级注入直接受益(stripGuides 就是在文件级操作的),代价是”对照检查漏翻”要跨文件拼。
拆分方案天然配合前文的分级注入。
文件边界 = 注入边界。这是“按模块拆”在架构上最实质的回报。
共同的方法论
两条线跑完,把重叠的部分抽出来,就是静态站瘦身的方法论本体:
- 单一来源:图片的正本是
public/里的原图,文案的正本是几份 YAML。所有衍生品都从正本生成,不手改产物; - 构建期预付:昂贵的转换(AVIF 编码、字典序列化)只做一次,做在构建期,运行时只分发结果;
- 增量与分级:没变的图跳过重压(哈希键),不变的页面少背字典(分级注入)。“按需”两个字省下的都是真流量;
- 单点判断:图片的”变没变”由一个哈希键决定,页面的”背不背全量字典”由一个布尔量决定。同一件事永远只有一个判断点,才不会单边漂移;
- 失败必须可见:孤儿产物靠对账暴露,字典注入失败靠哨兵日志暴露。静默失败是瘦身工程最大的敌人。
第三处(字体子集、JSON 压缩,或下一个随便什么资产)按同样的思路处理即可。