工程排版

为什么这个博客没有用现成的生成器

一次从零开始的取舍记录

目录

选择自己写一套静态站点生成器,通常是个坏决定。Hugo、Astro、Eleventy 都成熟、快、生态完整,而自己写的那套永远缺功能、有 bug、只有一个用户。

所以值得先说清楚:什么情况下这个坏决定会变成对的。

现成方案不解决的两件事#

第一件是中文排版。 主流生成器的排版能力围绕拉丁文字构建。中西文间距、标点挤压、着重号、避头尾——这些要么没有,要么依赖运行时的 JavaScript 在页面加载后再改一遍 DOM。后者意味着读者会先看到没处理过的版面,然后眼睁睁看着文字跳动一次。

这类工作本该在构建期完成。文章内容是静态的,处理结果也是静态的,没有任何理由推迟到读者的浏览器里做。

第二件是中文字体。 思源黑体覆盖三万余字,原始文件接近 17 MB。这个体积不可能直接上生产。

通行的做法是按 Unicode 区段切成上百个分片,浏览器按需下载。这是个聪明的方案,但它把字体托管绑定到了特定的 CDN 上。

另一条路是:既然文章内容在构建时就完全确定,那么全站到底用到哪些字也完全确定。只把这些字打进字体文件即可。

按用字子集化Python
used = set()
for page in rendered_pages:
    used.update(strip_markup(page))

subsetter = subset.Subsetter(options=options)
subsetter.populate(text="".join(sorted(used)))
subsetter.subset(font)

这套站点上的实际效果:

字体 原始 子集后 压缩比
思源黑体 16.9 MB 约 110 KB 99.4%
Inter 859 KB 约 92 KB 89.3%
JetBrains Mono 297 KB 约 41 KB 86.2%

子集化本身不难,难的是三条不能破的底线:

变量轴
子集之后 wghtopsz 轴必须还在。否则一套字体退化成若干静态字重,体积反而更大,也失去了大字紧、小字松的光学补偿。
排版特性
palthalt 决定标点能否挤压,kerncalt 决定西文的字偶距与连字。子集化工具默认会丢掉大部分 OpenType 特性,必须显式保留。
系统兜底
字体栈里始终留着 PingFang SCMicrosoft YaHei 这一层。万一某个字没进子集,读者看到的只是字形略有出入,而不是一个方框。

一个容易踩的坑#

子集是按用字算的,用字变了,文件内容就变了,内容指纹跟着变,缓存全部失效。也就是说,每发一篇新文章,所有回访读者都要重新下载一遍中文字体。

解决办法是让字符集只增不减:把历次构建用到的字累积记在一个账本里,删掉旧文章不会让字体缩小。

var/charset.jsonJSON
["一", "丁", "七", "万", "丈", "三", "上", "下", "不", "与", ...]

这样文章写到二三十篇之后,新增文章几乎不会引入新字,字体指纹自然稳定下来,缓存长期有效。

关于动效的克制#

页面上有几处动效:导航栏滚动时从透明变成毛玻璃,内容分节在进入视口时轻微上浮,页面之间跳转时标题会形变。

它们有两个共同点。

其一,全部由 CSS 完成。滚动驱动动画用的是 animation-timeline,页面切换用的是 View Transitions,没有一个滚动监听器。这不只是代码量的问题——JavaScript 驱动的滚动动画会和主线程抢时间,页面一忙就掉帧,而 CSS 的滚动时间轴跑在合成器上,不受影响。

其二,位移量都很小。入场上浮只有 18 像素。

  • 缓动曲线用真实的阻尼弹簧解算,不用 ease-out
  • 单次动效时长控制在 400 毫秒以内
  • prefers-reduced-motion 下全部关闭
  • 视差滚动——不打算做

第三条不是可选项。动效在一部分人身上会引发前庭不适,这是无障碍要求,不是偏好设置。

那么这个坏决定值不值#

如果目标是尽快把文章发出去,用 Hugo,半小时搞定,不要多想。

如果排版本身也是想做的事情之一——那么自己写一套的理由就成立了。中西文混排、标点、着重号、字体子集,这些都不是配置项能解决的,它们要求你对内容处理的每一步都有控制权。

代价是要自己维护。收益是,页面上每一处细节都是有理由的。

esc