LAT: 37.7749° N LONG: 122.4194° W
calendar_today 2025-06-28 schedule 预计阅读 5 分钟 folder 前端
前端排查

字体渲染差异排查全记录

将 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.58px233.53px
视觉粗细较细(和 700 相当)明显更粗(真正的 900)
卡片内容区高度519.78px(Inter 加载时)519.78px
font-weight (computed)900900
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)在国内访问不稳定。

尝试的镜像源

  1. fonts.loli.net(中科大镜像)——CSS 可访问,字体文件走 gstatic.loli.net
  2. fonts.font.im——备选
  3. fonts.googlefonts.cn(谷歌中国官方)——备选

最终选择fonts.loli.net,经测试 CSS 和 woff2 文件都能正常加载。镜像的 CSS 返回内容里 src: url(...) 指向 gstatic.loli.net,CSS 和字体文件都走镜像通道。


阶段三:自托管字体的失败尝试

思路:为彻底规避网络问题,下载字体文件到 public/fonts/ 自托管。

问题链

  1. 从 Google Fonts 下载的 woff2 只有 48KB——是单个 weight 的静态子集,不是 variable font
  2. 从 GitHub(rsms/inter)下载了完整的 InterVariable.woff2(352KB),包含 100-900 全 weight
  3. @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-10FFFFfont-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)                  │
└─────────────────────────────────────────────────────────────┘

最终解决方案

  1. 加载所有实际使用的 weight(包括 900):
<link href="https://fonts.loli.net/css2?family=Inter:wght@400;600;700;900&display=swap" rel="stylesheet">
  1. 去掉 body 上的 antialiased(和 code.html 行为一致)

  2. 开发时禁用 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 v3Tailwind v4
fontFamily + fontSize 复合声明✅ 会隐式覆盖 weight❌ 不存在
preflight 字体直接声明CSS 变量 + fallback
font-black 行为可能被复合声明覆盖直接生效为 900

5. 高度差异的常见来源

原因影响幅度
字体未加载(fallback 到系统字体)~4px
-webkit-font-smoothing 不同~0-1px
小标签缺少 leading-none每个 ~2px
扩展注入不同版本字体~6px(宽度)

错误假设清单

#错误假设实际情况
1Google Fonts 被墙导致字体没加载TW4 CSS 变量机制问题
2自托管字体可以解决反而引入 variable font 干扰
3font-weight: 100 900 声明范围问题扩展注入了真正的 900 字形
4antialiased 是主因只是次要因素
5sans-serif fallback 导致合成加粗实测行为相同
6系统本地安装了 Inter来自 Grammarly 扩展注入
7code.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,渲染结果一致。