自托管中文字体的两周:从 Lighthouse 到 CLReq
一、触发
起因是朋友某天发来一张截图。截图里是博客的哪一篇文章,现在我已经记不清,截图本身的细节也无从复述——只是那一眼就够了:在 Windows 上,我这个博客看起来确实很丑。
要把这份「丑」全归罪于微软雅黑,也许并不公平。我并非字体专业人士,说不出究竟是哪一笔的粗细、哪一处的 hinting,还是哪一种字间距让人不适。原因不止一个:微软雅黑这个字体本身、Windows 的字体渲染机制、加上非高分辨率屏幕——朋友用的显示器并非廉价低分屏,但和我日常使用的 6K 屏一比,像素密度自然差出一截。
这件事让我直面了一个之前从未真正想过的问题
这并不和我一向「写文章不考虑受众」的态度冲突
那一天我就开了一张 ticket。听起来有些可笑——给自己的博客也走这一道流程——但我向来如此,过度工程化是我的默认模式。一周后,我开始动手。
二、第一阶段:自托管与子集化的逐步打磨
那张 ticket 开下去的时候,我并没有想到自己将要做的事会有多麻烦。在我当时的预期里,这是一个周末下午就能干完的活:找一个像样的中文字体,扔给一个现成的子集化方案,配上 @font-face 与 preload,仅此而已。
为了 GDPR 合规,远端托管字体——例如 Google Fonts——一开始就在排除之列,因为它会把每个读者的 IP 报告给第三方;剩下的就是自托管。事后回头看,即便没有这层硬约束,预切好的托管字体也并不能满足我后来才意识到自己需要的那种自定义程度。
字体选了思源黑体(Source Han Sans
子集化方案上,我研究了 cn-font-split。结论倒不是说它不行——它的工艺其实相当讲究——只是我发现了一个更根本的问题:当你的自定义需求多到一定程度,再优秀的现成方案也会开始与你的意图在设计层面打架,每解决一个问题,都要绕过它替你做好的两个决定。到那个时候,自己写反而更省事。我于是放弃了 cn-font-split,转而基于 subset-font 自己搭一条管线。
这是 rabbit hole 的入口,只是当时我没看出来。
我的 CI 一直跑着 Lighthouse,Performance 分数早在这次工程开始之前就是流水线的一部分。接下来一整个星期,我都在对着这个分数做加法。先把字体装上,发现首页性能严重退化;于是按字重切,把 400/500/600/700 各自拆成 WOFF2;分数有起色,但长文还是慢;再按页面切,每个页面只下载它实际用到的字符;列表页和详情页用到的字重本就不同,那字重也按页面类型分开;高频字在所有页面都要用,那就抽出一个跨页面共享的 common pool,统一 preload 一次,后续页面靠 HTTP 缓存命中;为了避免读者重复下载本机已经装过的字体,@font-face 的 src 里 local(...) 列在 url(...) 之前,分系统、分字重写到 PostScript 一级1——只要本机装了 PingFang 或思源任意一种,除了 preload 的 floor 那一份,CJK 链路一个字节都不下载。这一长串决定看起来步步都有道理,但每一步都不是事先规划好的,而是上一步留下的尾巴逼出来的。
到那个星期末,主要页面的 Lighthouse Performance 分数大致回到了 90 上下。合并 PR 之前,我自己其实已经看到了第一道裂缝——但还是先把这层基础合了进去。
三、两道裂缝
那道裂缝就是覆盖率。我合并 PR 前做了一次检查:静态页面没问题——所有用到的字符都在子集里。但动态生成的内容则不可能:AI 搜索这类功能可以产生任何字符,build-time scanner 看不见。一旦页面里出现没被扫到的字符,浏览器会退回到系统字体;思源黑体和微软雅黑在同一句话里并排时,肉眼一下就看出来。
摆在面前的有两条路:要么让动态区域整体退回系统字体,全局统一;要么重做子集化方案,让字体能覆盖到的 CJK 字符都进切片。我选了后者——不能接受字体不统一。说实话这个判断没有什么客观尺度,是「我看着不舒服」拍的板;我对排版的所有主观判断一直如此。
这是第一道裂缝。第二道几乎在同一时间露头——和覆盖率看起来风马牛不相及,要到下一节才看出它们其实是同一回事。
由于我之前接入时的失误,赫蹏的 JS 部分在我博客上从来没运行过——而 CSS 部分倒是完美生效,版心、行高、引用、列表这些结构性样式效果都很好,所以我从外观上完全没察觉赫蹏不完整。但赫蹏的 JS 负责的恰恰是 CJK 排版里那些复杂的细节:相邻标点挤压、CJK/Latin 间距。这些细节对非设计师本就容易忽视——更巧的是,其中本来最容易看出问题的 CJK/Latin 间距,在我这里又被 VSCode 的 autocorrect 插件遮住了:插件在保存时自动给 CJK/Latin 之间塞空格,Markdown 源文件里本就带着真空格,浏览器渲染出来于是看着「正常
这促成了我另一个决定:与其再修一次运行时接入,不如把原本靠 JS 在浏览器里做的文本改写整体搬到构建期。
我把这两件事一起塞进了第二阶段的 PR:覆盖率方案重做、不再依赖赫蹏库,改用构建时方案。当时我以为这只是把两个独立问题打包;动手之后才慢慢看出,它们其实是 CJK 排版同一个连续问题的两面。
四、第二阶段:从按页面切到 unicode-range slices,并废弃赫蹏
开第二阶段 PR 时,我把一篇 50 万字符级别的长文加进了 Lighthouse 的评估列表。这其实是一个自己选难度的游戏——别人 4×100 分的博客,可能只测了默认的首页,而我挑的是这种页面。
字体子集化的思路从「按页面切」改成「按字符频率全站切片」——把整套字体切成多个包,让浏览器按页面实际用到的字符懒加载。具体落到实现上:按全站字符频率排序,floor 和 cjk-common 各一片、cjk-extended 按 codepoint 范围分桶切多片,每片各自带 unicode-range;浏览器只要在页面上遇到没加载过的字符,就自动 fetch 命中那一片。三个动机依次是:动态内容里出现的字符只要字体本身覆盖,就能命中对应切片,这是首要的;切片文件名稳定,跨页面 HTTP cache 命中;构建时不再需要维护 page key、prop drilling 这一套脆弱的抽象。
切片不是一视同仁。floor + 400(常规字重)这一片(基础标点、ASCII、拉丁补集)跟着 HTML 首发,进 critical CSS2 并 preload;cjk-common 那一片只进 critical CSS、不 preload,让浏览器看到 unicode-range 命中再去 fetch;其他切片走 deferred,idle 之后才生效。local() 优先 url() 的策略不变——slice 多了以后,本地命中反而更显眼:macOS 上装了 PingFang 的读者,除了 preload 的 floor 那一份,整条 CJK 链路零字节。
原本由赫蹏 JS 处理的相邻标点挤压改写成 rehype 插件3,在 HAST 树里识别 7 类标点的相邻关系,按全宽对全宽、半宽对全宽两类关系判断挤压量,分别使用 half 与 quarter,输出 <x-h> 包裹元素与对应 class。不走 Markdown 渲染的静态文本(例如文章标题、TOC)通过一个小的 HetiAdjacentText.astro 组件获得相同效果。赫蹏自己的版心、行高、列表、引用、标题等结构性 CSS 整套 vendored 进了项目。
CJK 与 Latin 之间的间距没有走构建时方案。第三节里说过——autocorrect 已经把真空格塞进 Markdown 源文件——这次只是把它追认为唯一的 source of truth。原本赫蹏的运行时 JS 会把这些空格 trim 掉,再用 margin 假装空格;新方案直接什么都不做。git diff、grep、复制粘贴的语义于是全都一致。
把覆盖率重构这一步做完的那一刻,这篇长文的 Lighthouse 分数从第二阶段 PR 合并前 main 上的 89 跌到了 35。从分数看,前面两周的字体工作仿佛白费——但 Lighthouse 衡量的只有请求的传输大小,megabyte 级的 CJK 字体本身就是绕不过去的代价;切片策略真正保住的东西——覆盖率、动态内容、回退路径——这套指标看不见。要把分数拉回去,剩下的工作只能落到字体之外。这些调整加上之后,分数回升,最终稳定在 69 到 91 之间——多次运行里最高和最低相差 22 分。
22 分的波动也让我开始反思指标驱动型优化本身。Lighthouse 模拟的是慢速移动网络;它的测试环境本身没有任何 CJK 字体,所以会把我子集字体的全部下载量算进去。但真实的移动端读者并不是这样:Android 自带 Noto Sans CJK,iOS 自带苹方,两者都在我的 local() 列表里——local() 命中之后,url() 那一份子集字体压根不会被下载。也就是说,我在为一个真实读者那里其实并不存在的瓶颈做加法。Goodhart 定律说,当一个度量变成目标,它就不再是好度量。这条规律在这件事上的轮廓慢慢清晰,我开始想 yak shaving 和合理优化之间的边界究竟在哪——这个问题我没有答案。
第二阶段花了一周,连同第一阶段总共两周。
即便如此,最终的性能分数仍不算让我满意。还有一个方向我打算之后单独做:把长文做二次加载,初次加载先渲染前几屏,剩下的在首屏完成后立即追加;副作用是首屏加载窗口里读者一旦按 End 键就会触及竞态,看到一次闪烁。但这是另一个故事,这里不展开。
不过比性能曲线更让我意外的,是这一周里我顺手翻了 CLReq 和几份 W3C 草案——这才意识到,我一直以为自己在做的事情,远不只是字体问题。
五、中文排版从来不止字体
所谓 CLReq,全名是《Requirements for Chinese Text Layout》——W3C 国际化工作组维护的一份文档,定位是「描述中文排版需要做的事
里面写的都不是稀奇的东西——标点挤压、行首行末禁则4、标点悬挂、CJK 与拉丁字母的间距、引号本地化、ruby5、着重号、竖排(包括纵中横6与横竖排切换)……我熟悉的一套,加上我之前不知道还存在的另一套,结构性地摊在那里。每一条单独看都不复杂;合起来——再考虑它们之间相互的影响——就是一本书。
其中很多并不是 Web 才出现的问题。中文排版在纸媒时代就已经是一个独立的工艺:版式、字号、字间距、行间距、悬挂、避头尾、异体字与古字的处理、繁简共排、横竖排互转——印刷工人与设计师跟这些规则相处的历史,比 HTML 长得多。Web 只是把它们重新搬上来的最新一台机器。
一个具体的尺度:CSS Text 4 的 text-spacing-trim 试图实现的,本来就是中文铅排在 1950 年代就标准化的规则——浏览器到 2024 年才开始实现。从印刷规范到屏幕实现,差不多 70 年。这中间发生了什么,受限于篇幅这里不便展开。
回看赫蹏,它在我认知里的位置也因此调整了。我之前默认它是一个「中文排版增强」的工具——加点标点挤压、加点空格——属于可有可无的层。意识到 CLReq 的存在之后,赫蹏的真正价值变得更清楚一些:它覆盖了古文、竖排这类我个人博客根本用不上的 niche 场景;那部分是这个项目几年来积累下来的、对中文排版规范的回应。我废弃赫蹏,改用自己写的构建时方案替代,是因为我的需求很窄,不是因为赫蹏不必要。
但即便我只取最常见的那一小部分——句中标点挤压、CJK 与拉丁字母的间距——我面前真正的问题也不是「我能不能在 CSS + 构建时方案里把它实现」——而是更结构性的:浏览器本身,在多大程度上准备好了帮我做这件事。
六、Web 平台对 CJK 的欠债
答案是:准备得并不算充分。
近几年 W3C 的 CSS Text 4、Inline 3、Ruby 1 等草案陆续把一批 CJK 排版规则纳入规范,浏览器引擎也在陆续实现——但实现的状况非常碎。简单举几条:text-spacing-trim(相邻全宽标点的挤压)只有 Chromium 实现;hanging-punctuation(标点悬挂)只有 WebKit 实现;word-break: auto-phrase(按短语断行)只有 Chromium 实现,且只对 lang="ja" 生效;text-transform: full-width / full-size-kana(字符转全宽形 / 小假名缩放ruby-overhang(ruby 注音溢出基础文本)只有 Safari;text-emphasis-skip(着重号跳过特定字符)三家都没有。
不是某一家单方面落后。三家各自领先一些、也各自落后一些,方向还彼此不重合——Chromium 在标点挤压上走在前,WebKit 在悬挂和 ruby 上走在前,Firefox 缺了几项对这条管线重要的能力;但这不是关键。关键是没有任何一家给出了完整的 CJK 排版能力。
任何想在三个引擎上都给出一致中文排版的作者,都得为缺失的那部分自己写 polyfill 或 wrapper。无论选择优化哪一个引擎都要写——并不是「为某一家落后买单
所以在新方案里,我没有在「全自定义」与「全交给 CSS 标准」之间二选一。我选了渐进式增强:wrapper 与 CSS 同时挂上。在没有 text-spacing-trim 的引擎里,wrapper 用 margin 把相邻全宽标点的挤压做出来;在有 text-spacing-trim 的引擎里,CSS 通过 @supports (text-spacing-trim: normal) 把 wrapper 的 margin 归零,让原生属性接管。不支持的浏览器自动忽略对应的 CSS 块;支持的浏览器拿到一份等价的、原生执行的版本。等到某一天三家都补齐了实现,rehype 插件、wrapper、为它们兜底的 @supports 块一起退场,留下的就是一条原生 CSS 规则。
中文网站在 Lighthouse 上常见的低分有几个具体来源叠加。CJK 字体本体比 Latin 字体重一两个数量级——即便按字重、切片拆开之后,全站累计仍是 megabyte 级;中文排版需要的后处理——wrapper、rehype 插件、polyfill JS——会让 HTML 体积和 DOM 节点都被推高;加上前面说过的测试机没 CJK 字体那一层,中文站想拿 4×100 在结构上就比英文站难得多。
这其实属于 Goodhart 定律的延伸——单一指标变成目标已经会失真,整个指标系统对 CJK 不友好则把这种失真放大。但我并不打算把这件事归咎到 Lighthouse。相反,它的「不友好」恰恰说明了它的公平性:Lighthouse 没有为某种语言或文字做特殊照顾,它只是如实地把 Web 平台对 CJK 的基础假设反映了出来。问题不在工具,在更上游的地方。
「上游」长什么样,举一个具体例子。HTML 的 <em> 标签在浏览器默认样式表里被渲染成 italic——但 italic 是西文里的斜体字形,中文里强调一句话的传统做法是着重号或加粗,把字斜过来并不是自然的默认值。这条 CSS 默认是 Western-first 的,CJK 站要 reset 才能拿回语义正确的强调。默认值不是中性的,它带着标准设计者母语里的那套字形习惯。
表面上看,这件事是给一个中文博客做字体优化;走完一圈再看,难度真正的来源不是字体,而是 Web 平台对 CJK 的支持还没补齐。剩下的部分——为什么要做、怎么算够、什么时候停——是几个我自己也没有答案的问题。
Footnotes
PostScript: 此处指 PostScript name——字体在系统里用于精确匹配的内部标识,比家族名一级更精细;
local()写到这一级,可以避免被同名但版本不同的字体误命中。 ↩︎critical CSS: 首屏要用的那一小部分 CSS——HTML 解析阶段就需要就位,通常直接内联进 HTML 或跟首屏一起发送,以免阻塞绘制。 ↩︎
rehype: unified 生态里处理 HTML AST 的工具链,通常作为 Markdown 渲染管线的后处理层;配套的 HAST 即 HTML Abstract Syntax Tree——把 HTML 摊成一棵可以被程序化遍历的树。 ↩︎
行首行末禁则:中文排版里负责哪些标点或字符不能落在行首、哪些不能落在行末的一套规则(也叫「避头尾
」 ) ;浏览器对此的支持程度参差。 ↩︎ruby: 排版意义上指附在汉字旁的注音小字,相当于日文里的振假名——与编程语言 Ruby 同名而无关。 ↩︎
纵中横:竖排版面里把少量横排字符(数字、字母缩写之类)作为一个横向小块嵌进去的做法,CSS 里对应
text-combine-upright。 ↩︎