欢迎来到“别让用户看到丑陋的文本”研讨会
各位好,我是你们的领路人。
今天我们要聊的点,听起来可能有点枯燥——字体。但别急着划走,因为如果你是 React 开发者,或者你正在维护一个 React 项目,你会发现字体加载是前端性能优化中最容易被忽视,却又最容易搞砸的“暗礁”。
想象一下:你精心设计了 UI,颜色搭配绝了,布局完美,但是当用户打开页面时,正文区域是一片白板,等个几百毫秒,突然“哗啦”一下,文字跳了出来。这就是 FOIT(无样式文本闪烁)。或者更糟,文字先以系统默认字体显示,然后突然变样了,像变脸一样。这就是 FOUT(无样式文本闪烁)。
这两种情况,用户体验都很像是在被耍。今天,我们要干一件“硬核”的事:封装一套逻辑,在 React 组件渲染之前,就把字体“喂”饱,让用户看到的就是你想要的样子,而且一点都不卡顿。
我们将深入探讨:子集化、预加载,以及如何在 React 的生命周期里,利用 useLayoutEffect 这个“隐秘高手”,在浏览器绘制屏幕之前搞定一切。
第一部分:字体的“减肥”与“预加载”艺术
首先,我们要搞清楚浏览器是怎么加载字体的。
1. 字体文件的“肥胖症”
大多数设计师给你的字体文件,动辄 2MB、5MB,甚至更大。为什么?因为它们包含了全世界所有的字符、所有的变体(细体、粗体、斜体)。
但是,你的网站真的需要这些吗?如果你的网站只有中文,你真的需要加载拉丁字符集吗?如果你只有几个特定的英文单词,你真的需要加载整个英文字体库吗?
答案是:不需要。
这就是 子集化。这就像是把一个巨大的自助餐厅,只给你切一块你爱吃的牛排。你不需要整头牛,你只需要那块肉。
为什么要子集化?
- 体积小: 2MB 的字体,可能只需要 50KB。这能减少 25 倍的流量。
- 加载快: 文件越小,浏览器下载越快,解析越快。
- 渲染快: 字体解析引擎处理的数据量减少了,浏览器渲染页面的时间自然就缩短了。
2. 预加载:给浏览器下“通牒”
字体加载是异步的。浏览器在解析 HTML 时,发现 font-family: 'MyFont',但它手里没有这个字体文件。它得去下载。
下载是需要时间的。在这个等待的时间里,浏览器会怎么做?它不知道该用什么字体显示文字,于是它可能会:
- 等待: 一直等到字体下载完(这就是 FOIT)。
- 降级: 使用系统默认字体(这就是 FOUT)。
为了打破这个僵局,我们需要告诉浏览器:“嘿,兄弟,你先把这个文件下载下来,存到缓存里,别等我要用的时候再找。”
这就需要用到 <link rel="preload">。这是浏览器的一个 API,允许我们在页面渲染的早期阶段,优先加载关键资源。
第二部分:React 里的“时间旅行”
React 的渲染周期是单向流动的:Mount -> Render -> Commit -> Layout -> Paint。
- Render: 计算 DOM。
- Commit: 将 DOM 插入页面。
- Layout/Paint: 浏览器根据 DOM 绘制像素。
问题来了: 字体文件是异步的。如果在 Render 阶段,字体还没下载完,React 就开始渲染,它只能使用系统字体。等到 Commit 之后,字体下载完了,React 不会自动重新渲染(除非你手动触发)。
这时候,DOM 已经画在屏幕上了,然后突然变了。这就是闪烁。
解决方案:
我们需要在 DOM 插入之前,确保字体已经加载完毕。React 提供了 useLayoutEffect。它的名字听着有点吓人,但它的作用很明确:同步执行。
在 useLayoutEffect 里,我们可以:
- 检查字体是否加载。
- 如果没加载,强制使用系统字体(避免 FOIT)。
- 如果加载了,应用自定义字体。
- 修正 DOM。
注意: 我们不能在 useEffect 里做这件事,因为 useEffect 是在 Paint 之后才运行的,那时候用户已经看到闪烁了。
第三部分:封装逻辑 —— 打造 useFont Hook
好了,理论讲完了,我们开始动手。我们要封装一个名为 useFont 的自定义 Hook。这个 Hook 将成为我们项目的“字体管家”。
它的职责是:
- 接收字体 URL 和字符集(用于子集化)。
- 自动注入
<link rel="preload">。 - 监听字体加载状态。
- 在组件渲染前切换字体类名。
代码示例 1:基础版 Hook
import { useLayoutEffect, useState } from 'react';
export const useFont = ({ url, subsets = [], weight = '400' }) => {
const [isLoaded, setIsLoaded] = useState(false);
useLayoutEffect(() => {
// 1. 创建字体预加载标签
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.type = 'font/woff2'; // 假设是 woff2
link.crossOrigin = 'anonymous';
// 这里可以拼接子集化参数,比如 ?subset=abc123
link.href = url;
document.head.appendChild(link);
// 2. 创建 @font-face 规则(可选,取决于策略)
// 如果我们只想预加载,不强制应用,可以不创建 @font-face
// 但为了更精确的控制,我们通常会在 CSS 中定义 @font-face,
// 这里我们只负责告诉浏览器“它来了”。
// 3. 监听加载完成
const handleLoad = () => {
setIsLoaded(true);
};
link.addEventListener('load', handleLoad);
return () => {
document.head.removeChild(link);
link.removeEventListener('load', handleLoad);
};
}, [url]);
return isLoaded;
};
这个 Hook 很简单,但它解决了“预加载”的问题。但是,它没有解决“渲染前应用字体”的问题。
代码示例 2:结合 CSS 类名的进阶版
为了在渲染前应用字体,我们需要在 useLayoutEffect 里直接操作 DOM 的 style 属性,或者操作 DOM 的 classList。
假设我们的 CSS 是这样写的:
/* 默认样式 */
body {
font-family: system-ui, -apple-system, sans-serif;
}
/* 加载完成后的样式 */
body.font-loaded {
font-family: 'MyCustomFont', sans-serif;
}
export const useFont = ({ url, subsets = [], weight = '400' }) => {
const [isLoaded, setIsLoaded] = useState(false);
useLayoutEffect(() => {
// ... (上面的预加载代码) ...
const handleLoad = () => {
setIsLoaded(true);
};
link.addEventListener('load', handleLoad);
// 核心逻辑:强制应用字体
const applyFont = () => {
document.body.classList.add('font-loaded');
// 或者直接操作 style
// document.body.style.fontFamily = "'MyCustomFont', sans-serif";
};
// 如果 link 已经加载了(比如缓存),立即应用
if (link.complete) {
applyFont();
} else {
link.addEventListener('load', applyFont);
}
return () => {
document.head.removeChild(link);
link.removeEventListener('load', handleLoad);
// 清理工作
document.body.classList.remove('font-loaded');
};
}, [url]);
return isLoaded;
};
代码示例 3:处理子集化
子集化通常是在服务器端或者构建工具(如 Webpack 的 file-loader 或 url-loader)完成的。但在 React 组件里,我们可以动态构建 URL。
假设我们有一个工具函数 getSubsetUrl(url, subsets),它会返回过滤后的 URL。
// 假设我们有一个简单的正则替换逻辑来模拟子集化
const getSubsetUrl = (base, chars) => {
// 实际项目中,这里会调用 fontmin 等工具
// 这里只是演示:在 URL 后面加 query 参数
const subsetQuery = chars.length > 0 ? `?subset=${chars.join(',')}` : '';
return `${base}${subsetQuery}`;
};
export const useFont = ({ url, subsets = [], weight = '400' }) => {
// ... 状态定义 ...
useLayoutEffect(() => {
// 生成子集化后的 URL
const finalUrl = getSubsetUrl(url, subsets);
// ... 创建 link ...
link.href = finalUrl;
// ... 监听加载 ...
}, [url, subsets]); // 依赖 subsets 变化
return isLoaded;
};
第四部分:Next.js 的挑战与 SSR 兼容性
如果你在用 Next.js,事情会变得稍微复杂一点。因为 Next.js 默认是服务端渲染(SSR)的。
问题:
- 服务端没有 DOM: 在
getServerSideProps或getInitialProps里,document对象是 undefined。你不能在这里写useLayoutEffect,也不能在这里操作document.body。 - Hydration 不匹配: 服务端渲染出来的 HTML,默认使用的是系统字体。客户端渲染时,如果字体还没加载完,React 会看到 DOM 结构一致(都是系统字体),但
classList可能不一致(如果我们在客户端加了.font-loaded),从而导致 Hydration 错误。
解决方案:
我们需要在服务端和客户端分别处理。
- 服务端: 我们只需要把
<link rel="preload">注入到 HTML 的<head>里。 - 客户端: 使用
useLayoutEffect来监听加载并切换类名。
代码示例 4:Next.js 集成
// hooks/useFont.js
import { useEffect, useState } from 'react';
export const useFont = ({ url, subsets = [], weight = '400' }) => {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
// 注意:在 Next.js 的客户端组件中,useEffect 是可以用的
// 但 useLayoutEffect 更好,因为我们想避免 FOIT
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.type = 'font/woff2';
link.crossOrigin = 'anonymous';
link.href = url; // 这里建议使用子集化后的 URL
document.head.appendChild(link);
const handleLoad = () => {
setIsLoaded(true);
document.body.classList.add('font-loaded');
};
link.addEventListener('load', handleLoad);
// 处理缓存情况
if (link.complete) {
handleLoad();
}
return () => {
document.head.removeChild(link);
document.body.classList.remove('font-loaded');
};
}, [url]);
return isLoaded;
};
// pages/_document.js (Next.js 旧版)
// 或者直接在 _app.js 中处理
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="zh-CN">
<Head>
{/*
关键点:在服务端渲染的 HTML 中,直接插入预加载链接。
这样首屏渲染时,浏览器就已经开始下载字体了。
*/}
<link
rel="preload"
href="/fonts/my-font-subset.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</Head>
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
);
}
为什么要在 _document.js 里写?
因为 useFont 是一个客户端 Hook,它会在页面加载后运行。如果在组件里写,字体可能已经下载了一半,用户还是能看到闪烁。而在 _document.js 里写,是同步的,浏览器在解析 HTML 标签时就会立即下载。
第五部分:更优雅的 CSS-in-JS 方案
上面的方案是基于全局 body 的。但在现代 React 开发中,我们很少直接操作 body,而是倾向于局部应用字体。
如果我们想给一个特定的组件(比如一个 Hero 组件)应用自定义字体,该怎么办?
我们可以结合 CSS-in-JS 库(如 Styled Components 或 Emotion)。
代码示例 5:基于 CSS-in-JS 的字体切换
假设我们有一个 FontProvider 组件,负责管理全局字体状态。
import React, { createContext, useContext, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components';
// 定义字体变量
export const FontContext = createContext({
isFontLoaded: false,
setFontLoaded: () => {},
});
export const FontProvider = ({ children }) => {
const [isLoaded, setIsLoaded] = React.useState(false);
useEffect(() => {
// 1. 预加载逻辑
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.type = 'font/woff2';
link.crossOrigin = 'anonymous';
link.href = '/fonts/custom-font.woff2';
document.head.appendChild(link);
const handleLoad = () => {
setIsLoaded(true);
};
link.addEventListener('load', handleLoad);
if (link.complete) handleLoad();
return () => {
document.head.removeChild(link);
};
}, []);
return (
<FontContext.Provider value={{ isFontLoaded, setFontLoaded: setIsLoaded }}>
{children}
</FontContext.Provider>
);
};
// 创建一个样式组件,用于切换字体
const CustomFont = css`
font-family: 'CustomFont', sans-serif;
`;
const DefaultFont = css`
font-family: system-ui, -apple-system, sans-serif;
`;
// 我们可以封装一个组件,根据上下文自动切换类名
export const FontSwitcher = ({ children }) => {
const { isFontLoaded } = useContext(FontContext);
// 使用 useLayoutEffect 确保在绘制前应用样式
React.useLayoutEffect(() => {
if (isFontLoaded) {
document.body.classList.add('use-custom-font');
} else {
document.body.classList.remove('use-custom-font');
}
}, [isFontLoaded]);
return <>{children}</>;
};
// 然后在你的页面中使用
const Page = () => {
return (
<FontProvider>
<FontSwitcher>
<h1>这是标题</h1>
<p>这是正文。</p>
</FontSwitcher>
</FontProvider>
);
};
这种方式的优点是:
- 作用域明确: 只在特定的 Provider 下生效。
- 解耦: 字体加载逻辑和业务逻辑分离。
- 可控性: 可以根据路由、用户设置动态切换字体。
第六部分:处理 document.fonts.ready 的陷阱
除了 <link rel="preload">,我们还有一个 API 叫 document.fonts.ready。它返回一个 Promise,当所有通过 CSS @font-face 加载的字体都准备好了,这个 Promise 才会 resolve。
什么时候用 document.fonts.ready?
当你使用 CSS @font-face 定义字体,并且希望确保所有字体都加载完毕后再显示内容时。
什么时候用 <link rel="preload">?
当你希望尽早开始下载,不阻塞页面渲染,并且允许在字体加载过程中使用回退字体时。
陷阱:
document.fonts.ready 是异步的,而且可能比 link.onload 慢。因为 document.fonts.ready 是基于 CSS 规则解析的,而 link.onload 是基于网络请求的。
最佳实践:
通常,我们结合使用两者。
- 在 HTML
<head>中插入preload链接,开始下载。 - 在
useLayoutEffect中监听link.onload,或者等待document.fonts.ready。 - 如果两者都用了,要注意避免重复加载。
代码示例 6:双重保险
export const useFont = ({ url }) => {
const [isLoaded, setIsLoaded] = useState(false);
useLayoutEffect(() => {
// 1. 预加载
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.href = url;
document.head.appendChild(link);
// 2. 双重检查机制
const checkFonts = async () => {
try {
// 等待 document.fonts 中的该字体加载
// 注意:document.fonts 里的字体对象可以通过 font-family 查询
const fontFace = document.fonts.get(url); // 这里的 url 需要匹配 @font-face 中的 name
if (fontFace && fontFace.loaded) {
await fontFace.loaded;
} else {
// 如果没在 CSS 里定义 @font-face,则依赖 link.onload
await new Promise(resolve => link.addEventListener('load', resolve, { once: true }));
}
setIsLoaded(true);
} catch (e) {
console.error('Font load failed', e);
setIsLoaded(true); // 失败也强制显示,避免一直白屏
}
};
checkFonts();
return () => {
document.head.removeChild(link);
};
}, [url]);
return isLoaded;
};
第七部分:性能监控与故障排查
写了这么多代码,怎么知道它好不好用?我们需要工具。
1. Chrome DevTools Network
- 打开 Network 面板。
- 勾选 “Disable cache”。
- 刷新页面。
- 找到你的字体文件。看它的 Size。如果很大,那就是子集化没做好。
- 看 “Time to Interactive” (TTI)。字体加载对 TTI 有很大影响。
2. Chrome DevTools Rendering
- 勾选 “Font Rendering”。
- 你可以看到哪些元素使用了自定义字体,哪些还在等待。
3. Web Vitals
- 关注 LCP (Largest Contentful Paint)。字体加载是影响 LCP 的关键因素之一。如果你的 LCP 被一个巨大的字体文件拖慢了,赶紧子集化!
第八部分:终极封装 —— 一个生产级的 useFont Hook
好了,现在是展示真正实力的时刻。我们将把上述所有的点(子集化、预加载、SSR 兼容、useLayoutEffect、CSS-in-JS 风格的类名切换)整合到一个 Hook 中。
这个 Hook 将支持:
- 多个字体文件并行加载。
- 智能的
font-display策略。 - 自动注入 CSS 变量。
- SSR 友好。
/**
* useFont Hook
* 功能:
* 1. 预加载字体文件
* 2. 处理子集化 (通过 URL 参数)
* 3. 在渲染前应用字体 (防止 FOIT)
* 4. 支持 SSR
*
* @param {Object} fontConfig
* @param {string} fontConfig.url - 字体文件 URL
* @param {string[]} fontConfig.subsets - 需要包含的字符集 (用于生成子集化 URL)
* @param {string} fontConfig.family - 字体族名称
* @param {string} fontConfig.weight - 字体粗细
* @param {boolean} fontConfig.async - 是否异步加载 (默认 true,即 font-display: swap)
*/
export const useFont = ({ url, subsets = [], family = 'CustomFont', weight = '400', display = 'swap' }) => {
const [isLoaded, setIsLoaded] = React.useState(false);
const fontLoadedRef = React.useRef(false); // 防止重复应用样式
// 构建子集化 URL
// 假设有一个后端接口支持子集化,或者构建工具支持
const getFontUrl = (base) => {
if (subsets.length === 0) return base;
// 简单的模拟:将字符集拼接到 URL 后
// 实际项目中,请使用 fontmin 等工具生成真正的 .subset.woff2 文件
const chars = subsets.join(',');
return `${base}?subset=${chars}`;
};
React.useLayoutEffect(() => {
const fontUrl = getFontUrl(url);
// 1. 创建 @font-face 规则 (为了配合 document.fonts API)
// 注意:在生产环境中,通常在 CSS 文件中定义 @font-face
// 这里我们动态创建,仅用于演示 document.fonts 的检测能力
const fontFace = new FontFace(
family,
`url(${fontUrl}) format('woff2')`
);
// 2. 创建 preload 标签
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.type = 'font/woff2';
link.crossOrigin = 'anonymous';
link.href = fontUrl;
// 设置 display 策略
// swap: 立即显示文本,后台加载字体
// optional: 如果字体加载快,显示字体;否则显示回退字体
// fallback: 显示回退字体,后台加载字体
// block: 阻止文本显示,直到字体加载完成 (最差体验)
link.setAttribute('data-display', display);
document.head.appendChild(link);
// 3. 加载逻辑
const loadFont = async () => {
try {
// 尝试加载字体
await fontFace.load();
// 将字体添加到文档,使其可被 CSS 选中
document.fonts.add(fontFace);
// 更新状态
if (!fontLoadedRef.current) {
setIsLoaded(true);
fontLoadedRef.current = true;
// 应用样式到 body 或特定容器
// 这里我们选择应用到 body,因为这是最通用的方案
document.body.style.setProperty('--current-font-family', family);
// 如果你有 CSS 类名策略
document.body.classList.add('font-loaded');
}
} catch (error) {
console.warn(`Failed to load font ${family}:`, error);
// 即使失败,也标记为已加载,避免一直等待
setIsLoaded(true);
}
};
// 检查字体是否已经在 document.fonts 中
const existingFont = document.fonts.get(family);
if (existingFont && existingFont.status === 'loaded') {
loadFont();
} else {
loadFont();
}
return () => {
document.head.removeChild(link);
// 注意:不要从 document.fonts.remove 中移除字体,这会导致页面其他地方样式丢失
};
}, [url, subsets, family, weight, display]);
return isLoaded;
};
// 组件使用示例
const App = () => {
useFont({
url: '/fonts/Inter-Regular.woff2',
subsets: ['chinese', 'latin'], // 假设我们要包含中文字符
family: 'Inter',
weight: '400',
display: 'swap'
});
return (
<div>
<h1>Hello World</h1>
<p>这是使用了自定义字体的段落。</p>
</div>
);
};
第九部分:总结与避坑指南
写到这里,相信大家已经掌握了在 React 中处理字体加载的核心逻辑。但是,工欲善其事,必先利其器。这里有几个“血泪经验”送给大家:
- 不要过度子集化: 虽然子集化能减小体积,但如果你把字符集切得太细(比如每个汉字都单独一个文件),会导致 HTTP 请求过多,反而拖慢速度。通常包含 20%-50% 的常用字符就足够了。
- Web Font vs. System Font: 尽量避免在关键路径上使用 Web Font。如果你的字体文件太大,直接用系统字体,通过
@font-face加载备用字体。记住,系统字体加载是瞬时的。 font-display: swap是双刃剑: 它能防止 FOIT,但可能导致 FOUT(文字先以系统字体出现,再跳变)。如果你的品牌对字体形状极其敏感,请使用optional或fallback,并做好加载失败的回退方案。- Next.js 的 Head 管理: 在 Next.js 中,
useFontHook 负责客户端的交互,而next/head或_document.js负责服务端的静态注入。两者缺一不可。 - Hydration 错误: 如果你发现控制台报错说样式不匹配,检查一下你的服务端渲染 HTML 里的 class 是否和客户端一致。如果不一致,先在服务端也加上预加载,或者使用
suppressHydrationWarning(但这通常是最后手段)。
好了,今天的讲座就到这里。希望大家回去后,都能给自己的项目加上这个“字体管家”,让用户的阅读体验从此丝般顺滑。记住,代码不仅要能跑,还要跑得优雅。谢谢大家!