Web 字体加载优化完全指南:从 FOIT 到丝滑体验

HTMLPAGE 团队
12 分钟阅读

深入解析字体加载对性能的影响,详解 font-display、预加载、子集化、可变字体等优化策略,让你的网站字体加载快如闪电。

#字体优化 #性能优化 #Web 字体 #font-display #可变字体

Web 字体加载优化完全指南:从 FOIT 到丝滑体验

你精心设计的网站,用户打开后却看到一片空白文字区域,几秒后才"闪现"出漂亮的字体。这种体验,足以让用户在等待中流失。字体加载,是前端性能优化中最容易被忽视,却又影响深远的环节。

字体加载的"隐形杀手"

FOIT 和 FOUT 是什么

当浏览器发现页面使用了自定义字体,会在字体加载完成前采取两种策略:

FOIT (Flash of Invisible Text) 文字完全不可见,直到字体加载完成。用户看到的是空白区域。

FOUT (Flash of Unstyled Text)
先用系统字体显示,字体加载完成后再替换。用户会看到"闪烁"效果。

FOIT 时间线:
[========空白========] → [自定义字体显示]
        3 秒等待               突然出现

FOUT 时间线:
[系统字体显示] → [切换] → [自定义字体显示]
   可读但丑        闪烁           完美

哪种更好?从用户体验角度,FOUT 显然更友好——至少用户能阅读内容。

字体加载对性能指标的影响

指标影响
LCP如果最大内容是文本,字体阻塞会直接影响 LCP
CLS字体切换时的尺寸变化会导致布局偏移
FCP在某些浏览器中,字体会阻塞首次内容绘制

font-display:你的第一道防线

font-display 属性让你控制字体的加载行为,它是优化字体体验的起点。

五种策略详解

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  
  /* 选择一种策略 */
  font-display: auto | block | swap | fallback | optional;
}

auto(默认) 浏览器自行决定,通常表现为 FOIT,阻塞期约 3 秒。

block 强制 FOIT,阻塞期最长可达 3 秒。适合图标字体等必须等待的场景。

swap(推荐) 立即使用回退字体,字体加载完成后替换。无限等待字体加载。

/* 正文字体推荐使用 swap */
@font-face {
  font-family: 'ArticleFont';
  src: url('/fonts/article.woff2') format('woff2');
  font-display: swap;
}

fallback 短暂阻塞(约 100ms),如果字体 3 秒内未加载完成,放弃使用。

/* 装饰性字体可以使用 fallback */
@font-face {
  font-family: 'FancyTitle';
  src: url('/fonts/fancy.woff2') format('woff2');
  font-display: fallback;
}

optional 极短阻塞(约 100ms),由浏览器决定是否使用字体。适合非关键字体。

策略选择指南

关键正文字体 → swap
- 用户需要立即阅读内容

品牌标题字体 → swap 或 fallback  
- 品牌一致性重要,但内容可读性更重要

图标字体 → block
- 图标显示错误比延迟显示更糟糕

装饰性字体 → optional
- 没有也不影响功能

预加载:让字体抢跑

默认情况下,浏览器只有在解析 CSS 并发现字体引用后才开始加载字体。使用 preload 可以让字体提前加载。

基础预加载

<head>
  <!-- 在 CSS 之前预加载关键字体 -->
  <link 
    rel="preload" 
    href="/fonts/main.woff2" 
    as="font" 
    type="font/woff2" 
    crossorigin
  >
  
  <!-- 然后是样式表 -->
  <link rel="stylesheet" href="/styles/main.css">
</head>

注意事项:

  • crossorigin 属性是必须的,即使字体在同域
  • 只预加载最关键的 1-2 个字体文件
  • 预加载过多字体会适得其反

智能预加载策略

// utils/font-loader.js
export function preloadCriticalFonts() {
  const fonts = [
    { href: '/fonts/main-regular.woff2', weight: '400' },
    { href: '/fonts/main-bold.woff2', weight: '700' },
  ];

  // 只在支持 woff2 的浏览器中预加载
  if (!document.createElement('link').relList?.supports('preload')) {
    return;
  }

  fonts.forEach(font => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'font';
    link.type = 'font/woff2';
    link.crossOrigin = 'anonymous';
    link.href = font.href;
    document.head.appendChild(link);
  });
}

// 检测字体加载状态
export function onFontLoad(fontFamily) {
  return document.fonts.ready.then(() => {
    return document.fonts.check(`16px ${fontFamily}`);
  });
}

字体子集化:只加载需要的字符

一个完整的中文字体可能有 20MB+,但你的页面可能只用到了几百个汉字。子集化就是只保留需要的字符。

使用 unicode-range

/* 只加载中文常用字 */
@font-face {
  font-family: 'ChineseFont';
  src: url('/fonts/chinese-common.woff2') format('woff2');
  unicode-range: U+4E00-9FFF; /* CJK 统一表意文字 */
  font-display: swap;
}

/* 数字和英文用另一个字体 */
@font-face {
  font-family: 'ChineseFont';
  src: url('/fonts/latin.woff2') format('woff2');
  unicode-range: U+0000-00FF; /* 基本拉丁字母 */
  font-display: swap;
}

Google Fonts 的分片策略

Google Fonts 已经内置了分片机制,这是它的工作原理:

/* Google Fonts 返回的 CSS 示例 */
/* cyrillic-ext */
@font-face {
  font-family: 'Roboto';
  src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F;
}
/* cyrillic */
@font-face {
  font-family: 'Roboto';
  src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2) format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
  font-family: 'Roboto';
  src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA;
}

使用工具生成子集

# 使用 pyftsubset(fonttools)
pip install fonttools brotli

# 从原始字体生成子集
pyftsubset "SourceHanSansCN-Regular.otf" \
  --text-file=chars.txt \
  --output-file="subset.woff2" \
  --flavor=woff2 \
  --layout-features='*'

# chars.txt 包含你页面用到的所有字符
// 提取页面用到的字符
const content = document.body.textContent;
const uniqueChars = [...new Set(content)].join('');
console.log(uniqueChars);

可变字体:一个文件,无限可能

可变字体(Variable Fonts)是字体技术的革命。一个字体文件包含了从细到粗、从窄到宽的所有变体。

基础使用

@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter-VariableFont.woff2') format('woff2-variations');
  font-weight: 100 900; /* 支持 100-900 的任意权重 */
  font-display: swap;
}

/* 使用任意权重值 */
.thin { font-weight: 100; }
.regular { font-weight: 400; }
.medium { font-weight: 550; } /* 传统字体做不到! */
.bold { font-weight: 700; }
.black { font-weight: 900; }

可变字体的轴

可变字体支持多个可变轴:

.text {
  font-family: 'Roboto Flex', sans-serif;
  
  /* 使用 font-variation-settings 控制各个轴 */
  font-variation-settings: 
    'wght' 450,  /* 字重 */
    'wdth' 100,  /* 字宽 */
    'slnt' -10,  /* 倾斜度 */
    'GRAD' 50,   /* 灰度(可选轴) */
    'opsz' 24;   /* 光学尺寸 */
}

/* 或使用对应的 CSS 属性 */
.text {
  font-weight: 450;
  font-stretch: 100%;
  font-style: oblique -10deg;
}

可变字体的性能优势

场景传统字体可变字体
Regular + Bold2 个文件 (~50KB)1 个文件 (~30KB)
4 种权重4 个文件 (~100KB)1 个文件 (~30KB)
8 种权重8 个文件 (~200KB)1 个文件 (~30KB)
/* 如果只需要特定权重范围,可以指定 */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
  /* 只声明实际使用的范围,帮助浏览器优化 */
  font-weight: 400 700;
}

回退字体优化:减少 CLS

字体切换时,自定义字体和回退字体的尺寸差异会导致布局偏移。

使用 size-adjust

/* 调整回退字体大小,匹配自定义字体 */
@font-face {
  font-family: 'Adjusted Arial';
  src: local('Arial');
  size-adjust: 106%; /* 调整大小 */
  ascent-override: 90%;
  descent-override: 20%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;
}

body {
  font-family: 'CustomFont', 'Adjusted Arial', sans-serif;
}

使用 @font-face-metric-override

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;
  
  /* 度量覆盖 */
  ascent-override: 92%;
  descent-override: 22%;
  line-gap-override: 0%;
}

自动计算工具

// 使用 fontpie 自动计算回退字体调整值
// npm install fontpie

const { generateFallbackCSS } = require('fontpie');

const css = await generateFallbackCSS({
  font: './fonts/CustomFont.woff2',
  fallback: 'Arial',
});

console.log(css);
// 输出调整后的 @font-face 规则

自托管 vs CDN

自托管优势

# nginx 配置:启用缓存和压缩
location /fonts/ {
  expires 1y;
  add_header Cache-Control "public, immutable";
  
  # 预压缩
  gzip_static on;
  brotli_static on;
}
  • 完全控制缓存策略
  • 无第三方依赖
  • 隐私合规(GDPR)
  • 可以与页面资源同域,减少连接开销

CDN 优势

<!-- Google Fonts 使用示例 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
  • 自动子集化和分片
  • 全球 CDN 加速
  • 跨站点缓存共享
  • 无需维护

混合策略

<head>
  <!-- 首屏关键字体自托管,预加载 -->
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
  
  <!-- 非关键字体使用 CDN -->
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Fira+Code&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
</head>

实战:完整优化方案

Next.js 优化示例

// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google';
import localFont from 'next/font/local';

// Google 字体(自动优化)
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

// 代码字体
const firaCode = Fira_Code({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-fira-code',
});

// 本地中文字体
const notoSansSC = localFont({
  src: [
    {
      path: '../public/fonts/NotoSansSC-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: '../public/fonts/NotoSansSC-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
  display: 'swap',
  variable: '--font-noto-sc',
});

export default function RootLayout({ children }) {
  return (
    <html lang="zh" className={`${inter.variable} ${firaCode.variable} ${notoSansSC.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Nuxt 优化示例

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        // 预加载关键字体
        {
          rel: 'preload',
          href: '/fonts/inter-var.woff2',
          as: 'font',
          type: 'font/woff2',
          crossorigin: 'anonymous',
        },
      ],
    },
  },
  
  // 使用 @nuxtjs/fontaine 自动优化回退字体
  modules: ['@nuxtjs/fontaine'],
  
  fontMetrics: {
    fonts: [
      {
        family: 'Inter',
        fallbacks: ['Arial'],
      },
    ],
  },
});
/* assets/css/fonts.css */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var-cyrillic.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0400-045F, U+0490-0491;
}

性能检测与监控

使用 Font Loading API

// 检测字体加载状态
document.fonts.ready.then(() => {
  console.log('所有字体加载完成');
});

// 检测特定字体
document.fonts.load('16px Inter').then(() => {
  console.log('Inter 字体加载完成');
  document.body.classList.add('fonts-loaded');
});

// 监控字体加载时间
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.initiatorType === 'css' && entry.name.includes('.woff2')) {
      console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
    }
  }
});
observer.observe({ entryTypes: ['resource'] });

Lighthouse 字体审计

// 检查字体是否阻塞渲染
// Lighthouse 会报告以下问题:
// - Ensure text remains visible during webfont load
// - Avoid enormous network payloads
// - Minimize main-thread work

检查清单

在上线前,用这个清单检查你的字体优化:

  • 所有字体都设置了 font-display: swap
  • 关键字体使用了 preload
  • 字体文件使用 WOFF2 格式
  • 中文字体进行了子集化
  • 配置了合适的回退字体
  • 回退字体使用了 size-adjust 减少 CLS
  • 字体文件启用了长期缓存
  • 使用可变字体替代多个字重文件
  • 非关键字体延迟加载

结语

字体加载优化,是一个投入产出比很高的优化方向。通过本文介绍的技术:

  1. font-display 消除 FOIT
  2. preload 提前加载
  3. 子集化 减小体积
  4. 可变字体 一个文件走天下
  5. 回退字体优化 消除 CLS

你可以让用户在几百毫秒内看到完美的排版,而不是盯着空白页面发呆。

字体是设计的灵魂,加载速度是用户体验的基石。两者兼得,才是真正的优秀。