React 与 浏览器本地字体加载协议:在 React 组件渲染前自动处理字体子集化与预加载的逻辑封装

欢迎来到“别让用户看到丑陋的文本”研讨会

各位好,我是你们的领路人。

今天我们要聊的点,听起来可能有点枯燥——字体。但别急着划走,因为如果你是 React 开发者,或者你正在维护一个 React 项目,你会发现字体加载是前端性能优化中最容易被忽视,却又最容易搞砸的“暗礁”。

想象一下:你精心设计了 UI,颜色搭配绝了,布局完美,但是当用户打开页面时,正文区域是一片白板,等个几百毫秒,突然“哗啦”一下,文字跳了出来。这就是 FOIT(无样式文本闪烁)。或者更糟,文字先以系统默认字体显示,然后突然变样了,像变脸一样。这就是 FOUT(无样式文本闪烁)

这两种情况,用户体验都很像是在被耍。今天,我们要干一件“硬核”的事:封装一套逻辑,在 React 组件渲染之前,就把字体“喂”饱,让用户看到的就是你想要的样子,而且一点都不卡顿。

我们将深入探讨:子集化预加载,以及如何在 React 的生命周期里,利用 useLayoutEffect 这个“隐秘高手”,在浏览器绘制屏幕之前搞定一切。


第一部分:字体的“减肥”与“预加载”艺术

首先,我们要搞清楚浏览器是怎么加载字体的。

1. 字体文件的“肥胖症”

大多数设计师给你的字体文件,动辄 2MB、5MB,甚至更大。为什么?因为它们包含了全世界所有的字符、所有的变体(细体、粗体、斜体)。

但是,你的网站真的需要这些吗?如果你的网站只有中文,你真的需要加载拉丁字符集吗?如果你只有几个特定的英文单词,你真的需要加载整个英文字体库吗?

答案是:不需要。

这就是 子集化。这就像是把一个巨大的自助餐厅,只给你切一块你爱吃的牛排。你不需要整头牛,你只需要那块肉。

为什么要子集化?

  • 体积小: 2MB 的字体,可能只需要 50KB。这能减少 25 倍的流量。
  • 加载快: 文件越小,浏览器下载越快,解析越快。
  • 渲染快: 字体解析引擎处理的数据量减少了,浏览器渲染页面的时间自然就缩短了。

2. 预加载:给浏览器下“通牒”

字体加载是异步的。浏览器在解析 HTML 时,发现 font-family: 'MyFont',但它手里没有这个字体文件。它得去下载。

下载是需要时间的。在这个等待的时间里,浏览器会怎么做?它不知道该用什么字体显示文字,于是它可能会:

  1. 等待: 一直等到字体下载完(这就是 FOIT)。
  2. 降级: 使用系统默认字体(这就是 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 里,我们可以:

  1. 检查字体是否加载。
  2. 如果没加载,强制使用系统字体(避免 FOIT)。
  3. 如果加载了,应用自定义字体。
  4. 修正 DOM。

注意: 我们不能在 useEffect 里做这件事,因为 useEffect 是在 Paint 之后才运行的,那时候用户已经看到闪烁了。


第三部分:封装逻辑 —— 打造 useFont Hook

好了,理论讲完了,我们开始动手。我们要封装一个名为 useFont 的自定义 Hook。这个 Hook 将成为我们项目的“字体管家”。

它的职责是:

  1. 接收字体 URL 和字符集(用于子集化)。
  2. 自动注入 <link rel="preload">
  3. 监听字体加载状态。
  4. 在组件渲染前切换字体类名。

代码示例 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-loaderurl-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)的。

问题:

  1. 服务端没有 DOM:getServerSidePropsgetInitialProps 里,document 对象是 undefined。你不能在这里写 useLayoutEffect,也不能在这里操作 document.body
  2. Hydration 不匹配: 服务端渲染出来的 HTML,默认使用的是系统字体。客户端渲染时,如果字体还没加载完,React 会看到 DOM 结构一致(都是系统字体),但 classList 可能不一致(如果我们在客户端加了 .font-loaded),从而导致 Hydration 错误。

解决方案:

我们需要在服务端和客户端分别处理。

  1. 服务端: 我们只需要把 <link rel="preload"> 注入到 HTML 的 <head> 里。
  2. 客户端: 使用 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>
  );
};

这种方式的优点是:

  1. 作用域明确: 只在特定的 Provider 下生效。
  2. 解耦: 字体加载逻辑和业务逻辑分离。
  3. 可控性: 可以根据路由、用户设置动态切换字体。

第六部分:处理 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 是基于网络请求的。

最佳实践:
通常,我们结合使用两者。

  1. 在 HTML <head> 中插入 preload 链接,开始下载。
  2. useLayoutEffect 中监听 link.onload,或者等待 document.fonts.ready
  3. 如果两者都用了,要注意避免重复加载。

代码示例 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 中处理字体加载的核心逻辑。但是,工欲善其事,必先利其器。这里有几个“血泪经验”送给大家:

  1. 不要过度子集化: 虽然子集化能减小体积,但如果你把字符集切得太细(比如每个汉字都单独一个文件),会导致 HTTP 请求过多,反而拖慢速度。通常包含 20%-50% 的常用字符就足够了。
  2. Web Font vs. System Font: 尽量避免在关键路径上使用 Web Font。如果你的字体文件太大,直接用系统字体,通过 @font-face 加载备用字体。记住,系统字体加载是瞬时的。
  3. font-display: swap 是双刃剑: 它能防止 FOIT,但可能导致 FOUT(文字先以系统字体出现,再跳变)。如果你的品牌对字体形状极其敏感,请使用 optionalfallback,并做好加载失败的回退方案。
  4. Next.js 的 Head 管理: 在 Next.js 中,useFont Hook 负责客户端的交互,而 next/head_document.js 负责服务端的静态注入。两者缺一不可。
  5. Hydration 错误: 如果你发现控制台报错说样式不匹配,检查一下你的服务端渲染 HTML 里的 class 是否和客户端一致。如果不一致,先在服务端也加上预加载,或者使用 suppressHydrationWarning(但这通常是最后手段)。

好了,今天的讲座就到这里。希望大家回去后,都能给自己的项目加上这个“字体管家”,让用户的阅读体验从此丝般顺滑。记住,代码不仅要能跑,还要跑得优雅。谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注