字体渲染差异排查全记录
将 Stitch 设计稿还原为 Astro + Tailwind v4 时遇到多处字体粗细和高度偏差。历时 3 小时排查,从浏览器扩展字体注入到 Tailwind v3/v4 字体机制差异,记录完整的定位过程。
字体渲染差异排查全记录
背景
在将 Stitch 设计稿(code.html,使用 Tailwind v3 CDN)还原为 Astro + Tailwind v4 项目时,发现多处视觉差异。本次排查主要围绕”技术探索”卡片中的 TELESCOPE 装饰文字展开——它在实现版本中明显比设计稿更粗,且卡片整体高度存在 4px 偏差。
排查过程耗时约 3 小时,经历了多次错误假设和方向调整,最终定位到两个独立的根因:浏览器扩展字体注入 和 Tailwind v3/v4 字体机制差异。
问题现象
| 对比项 | 设计稿 (code.html) | 实现版 (localhost:4321) |
|---|---|---|
| TELESCOPE 宽度 | 227.58px | 233.53px |
| 视觉粗细 | 较细(和 700 相当) | 明显更粗(真正的 900) |
| 卡片内容区高度 | 519.78px(Inter 加载时) | 519.78px |
| font-weight (computed) | 900 | 900 |
| font-family (computed) | ‘Inter' | 'Inter’ |
两边 computed style 看起来完全一致,但视觉效果不同——这是排查最困难的点。
排查过程
阶段一:字体未加载
现象:实现版 DevTools 显示 Font: 16px ui-sans-serif, system-ui, sans-se...,而设计稿显示 Font: 16px Inter, sans-serif。卡片高度 523px vs 519px。
原因:Tailwind v4 的 preflight 使用 font-family: var(--default-font-family, ui-sans-serif, system-ui...) 的写法。当 --default-font-family 变量未被正确定义时,浏览器 fallback 到 ui-sans-serif。虽然我们在 @theme 里定义了 --font-sans: "Inter", sans-serif,但 Tailwind v4 的 --default-font-family 引用 var(--font-sans) 的作用域优先级不够。
修复:在 html 选择器上显式设置:
html {
--default-font-family: "Inter", sans-serif;
--default-mono-font-family: "JetBrains Mono", monospace;
}
结果:字体加载问题解决,高度对齐到 519px。但 TELESCOPE 粗细差异仍在。
阶段二:国内字体源切换
问题:开发中 Google Fonts(fonts.googleapis.com)在国内访问不稳定。
尝试的镜像源:
fonts.loli.net(中科大镜像)——CSS 可访问,字体文件走gstatic.loli.netfonts.font.im——备选fonts.googlefonts.cn(谷歌中国官方)——备选
最终选择:fonts.loli.net,经测试 CSS 和 woff2 文件都能正常加载。镜像的 CSS 返回内容里 src: url(...) 指向 gstatic.loli.net,CSS 和字体文件都走镜像通道。
阶段三:自托管字体的失败尝试
思路:为彻底规避网络问题,下载字体文件到 public/fonts/ 自托管。
问题链:
- 从 Google Fonts 下载的 woff2 只有 48KB——是单个 weight 的静态子集,不是 variable font
- 从 GitHub(rsms/inter)下载了完整的
InterVariable.woff2(352KB),包含 100-900 全 weight - 在
@font-face中声明font-weight: 100 900
后果:这个 352KB 的 variable font 后来成了排查干扰——它让浏览器能匹配到真正的 900 weight,改变了渲染行为。
教训:自托管字体时必须确保声明的 weight 范围和文件实际内容一致,否则会引入非预期的字体匹配。
阶段四:-webkit-font-smoothing 差异
发现方法:
getComputedStyle(document.querySelector('.font-black')).webkitFontSmoothing
// code.html: 'auto'
// 4321: 'antialiased'
原因:实现版 <body> 有 antialiased class,code.html 没有。
影响:antialiased 关闭亚像素渲染,在高 weight 字体上让笔画显得更”实”更粗。但去掉后粗细差异仍然存在——说明这只是次要因素。
修复:去掉 body 上的 antialiased。
阶段五:最小化测试确认合成加粗
方法:创建纯 HTML 文件,只加载 Inter 400/600/700,测试 900 vs 700:
<link href="https://fonts.loli.net/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<div style="font-family:Inter; font-weight:900; font-size:42px;">TELESCOPE (900)</div>
<div style="font-family:Inter; font-weight:700; font-size:42px;">TELESCOPE (700)</div>
结果:900 明显比 700 粗——浏览器在做合成加粗(faux bold)。但 code.html 同样 900 却不粗——说明有某种机制阻止了合成加粗。
阶段六:document.fonts 对比
方法:
document.fonts.forEach(f => {
if (f.family.includes('Inter'))
console.log(f.weight, f.unicodeRange, f.status)
})
关键发现:4321 页面中出现了多余的 FontFace 条目:
900 normal U+0-10FFFF loaded ← 全范围 unicode,带 font-display: swap
700 normal U+0-10FFFF loaded
800 normal U+0-10FFFF unloaded
...(100-900 全套)
fonts.loli.net 返回的 @font-face 都有具体 unicode-range。这些 U+0-10FFFF(全范围)条目不是来自 web font CSS——来源不明。
阶段七:定位到 Grammarly 扩展(根因)
关键线索:Network 面板过滤 font 类型请求,发现来自浏览器扩展的字体文件:
chrome-extension://kbfnbcaeplbcioakkpcpgfkobkghlhen/src/fonts/inter_Inter-Black.woff 200 OK
chrome-extension://kbfnbcaeplbcioakkpcpgfkobkghlhen/src/fonts/inter_Inter-Bold.woff 200 OK
chrome-extension://kbfnbcaeplbcioakkpcpgfkobkghlhen/src/fonts/inter_Inter-Regular.woff 200 OK
...
扩展身份:kbfnbcaeplbcioakkpcpgfkobkghlhen = Grammarly: AI Writing Assistant
行为:Grammarly 在所有 http/https 页面注入自己的 @font-face 声明,包含 Inter 全套 weight(100-900),unicode-range: U+0-10FFFF,font-display: swap。
验证:禁用 Grammarly → 刷新 4321 → TELESCOPE 变细,和 code.html 完全一致。✅
为什么 code.html 不受影响:以 file:// 协议打开时,Grammarly 的 content script 不匹配,不注入字体。
阶段七补充:为什么 code.html 通过 localhost 打开仍然不粗?
将 code.html 通过 http://localhost:4321/code.html 访问后,Grammarly 同样注入了字体。但 TELESCOPE 仍然不粗。
原因:Tailwind v3 的 font-display class 机制。
code.html 中 TELESCOPE 的 class:font-display text-[42px] leading-none font-black ...
Tailwind v3 config 里:
fontFamily: { "display": ["Inter"] }
fontSize: { "display": ["48px", {"fontWeight": "700"}] }
font-display class 通过 fontSize 复合声明隐式带了 fontWeight: 700,优先级高于 font-black 的 900。最终浏览器实际匹配的是 700 weight 的字体文件。
验证:在 code.html 里通过 Console 执行 document.querySelector('.font-black').classList.remove('font-display') → 文字立即变粗变挤。
Tailwind v4 没有这种 fontFamily + fontSize 复合声明机制,font-black 直接生效为 900。
根因总结
┌─────────────────────────────────────────────────────────────┐
│ 实现版(localhost:4321)的渲染链: │
│ │
│ 1. CSS: font-weight: 900 (font-black) │
│ 2. Grammarly 注入 Inter-Black.woff (weight 900) │
│ 3. 浏览器匹配到真正的 900 字形 │
│ 4. 渲染→更粗更宽 (233.53px) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ code.html(file://协议)的渲染链: │
│ │
│ 1. CSS: font-weight: 900 (font-black) │
│ 2. Grammarly 不在 file:// 页面注入字体 │
│ 3. 只有 Google Fonts 的 400/600/700 可用 │
│ 4. TW3 font-display 复合声明覆盖为实际匹配 700 │
│ 5. 浏览器用 700 字形渲染→正常粗细 (227.58px) │
└─────────────────────────────────────────────────────────────┘
最终解决方案
- 加载所有实际使用的 weight(包括 900):
<link href="https://fonts.loli.net/css2?family=Inter:wght@400;600;700;900&display=swap" rel="stylesheet">
-
去掉 body 上的
antialiased(和 code.html 行为一致) -
开发时禁用 Grammarly 扩展(或使用无痕模式)
这样不管用户是否安装了 Grammarly,页面都用我们自己加载的 900 weight 渲染,效果确定一致。
开发启示
1. 用到的 font-weight 必须全部显式加载
<!-- ❌ 用了 font-black(900) 但没加载 900 -->
<link href="...?family=Inter:wght@400;600;700&display=swap">
<!-- ✅ 用了什么 weight 就加载什么 -->
<link href="...?family=Inter:wght@400;600;700;900&display=swap">
2. 开发时使用无扩展环境对比 UI
| 扩展 | 干扰行为 |
|---|---|
| Grammarly | 注入 Inter 全套字体(100-900) |
| 字体替换扩展 | 替换页面字体 |
| 暗色模式扩展 | 注入颜色覆盖 CSS |
| 广告拦截器 | 可能阻断 CDN 请求 |
用无痕模式(Ctrl+Shift+N)对比——默认禁用所有扩展。
3. 排查字体问题的标准流程
Step 1: getComputedStyle(el).fontFamily → 确认字体名
Step 2: getComputedStyle(el).fontWeight → 确认 weight
Step 3: getComputedStyle(el).webkitFontSmoothing → 确认抗锯齿
Step 4: document.fonts.forEach(...) → 列出所有 FontFace
Step 5: Network 面板过滤 "font" → 看字体文件来源
Step 6: 看 unicode-range → U+0-10FFFF = 非 web font
Step 7: 看请求 URL 前缀 → chrome-extension:// = 扩展注入
4. Tailwind v3 → v4 字体机制差异
| 特性 | Tailwind v3 | Tailwind v4 |
|---|---|---|
| fontFamily + fontSize 复合声明 | ✅ 会隐式覆盖 weight | ❌ 不存在 |
| preflight 字体 | 直接声明 | CSS 变量 + fallback |
| font-black 行为 | 可能被复合声明覆盖 | 直接生效为 900 |
5. 高度差异的常见来源
| 原因 | 影响幅度 |
|---|---|
| 字体未加载(fallback 到系统字体) | ~4px |
-webkit-font-smoothing 不同 | ~0-1px |
小标签缺少 leading-none | 每个 ~2px |
| 扩展注入不同版本字体 | ~6px(宽度) |
错误假设清单
| # | 错误假设 | 实际情况 |
|---|---|---|
| 1 | Google Fonts 被墙导致字体没加载 | TW4 CSS 变量机制问题 |
| 2 | 自托管字体可以解决 | 反而引入 variable font 干扰 |
| 3 | font-weight: 100 900 声明范围问题 | 扩展注入了真正的 900 字形 |
| 4 | antialiased 是主因 | 只是次要因素 |
| 5 | sans-serif fallback 导致合成加粗 | 实测行为相同 |
| 6 | 系统本地安装了 Inter | 来自 Grammarly 扩展注入 |
| 7 | code.html 字体正确因为用了不同版本 | TW3 font-display 复合声明覆盖了 weight |
Grammarly 字体注入机制(附录)
Grammarly 为渲染自己的 UI(下划线、建议弹窗),在页面中注入完整的 Inter 字体:
chrome-extension://kbfnbcaeplbcioakkpcpgfkobkghlhen/src/fonts/
├── inter_Inter-Regular.woff
├── inter_Inter-Italic.woff
├── inter_Inter-SemiBold.woff
├── inter_Inter-SemiBoldItalic.woff
├── inter_Inter-Bold.woff
└── inter_Inter-Black.woff
这些 @font-face 的特征:
font-family: 'Inter'unicode-range: U+0-10FFFF(全覆盖)font-display: swap- 格式为
.woff(非 woff2)
由于浏览器字体匹配优先选择 weight 完全匹配的 FontFace,当页面请求 900 时,Grammarly 的 Inter-Black(精确 900)优先于 web font 的 Inter 700(需要向上匹配)。
这是合法的扩展行为,但对页面字体渲染有意外副作用。全球超过 3000 万用户安装了 Grammarly,这意味着相当比例的用户会遇到类似的字体渲染差异。
防御方案:显式加载所有用到的 weight,让自己的 @font-face 和扩展注入的匹配同一个 weight,渲染结果一致。