第 18 章

Font 与 Script 优化

第18章:Font 与 Script 优化

字体和第三方脚本是页面性能的两大隐形杀手。next/font 在构建时将字体自托管并计算精确的回退字体尺寸,next/script 按重要程度分层加载第三方脚本。

本章核心问题:next/font 如何消除字体相关的 CLS?next/script 的四种加载策略分别适用于什么场景?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

字体加载为什么是性能难题

字体是 Web 性能中最容易被忽视却影响最大的因素之一。一个典型的问题链条是这样的:浏览器解析 HTML,发现 <link> 标签引用 Google Fonts,发起外部 DNS 解析(google.com → fonts.googleapis.com → fonts.gstatic.com),等待字体文件下载,应用字体,这整个过程中浏览器不知道字体的宽高信息,只好先用系统字体渲染文字,字体加载完成后重新排版——这就是 FOUT(Flash of Unstyled Text)CLS(Cumulative Layout Shift) 的来源。

Google Fonts 的传统使用方式(在 HTML 中添加 <link> 标签)还存在一个问题:每个用户都需要向 Google 服务器发起请求,这在中国大陆等地区可能导致字体加载失败或极慢。

next/font 在构建时将字体文件下载到本地,作为静态资源自托管。用户浏览器请求字体时,直接从你的 CDN/服务器获取,消除外部依赖,同时通过 font-display: swap 和提前注入尺寸信息来最小化 CLS。

next/font/google:零运行时外部请求

// app/layout.tsx
import { Inter, Noto_Sans_SC } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})

const notoSansSC = Noto_Sans_SC({
  subsets: ['chinese-simplified'],
  weight: ['400', '500', '700'],
  display: 'swap',
  variable: '--font-noto-sans-sc',
})

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

next/font 在构建时做了几件事:

  1. 从 Google Fonts API 下载字体文件(.woff2
  2. 将字体文件复制到 /.next/static/media/ 目录
  3. 生成 CSS @font-face 声明,指向本地文件
  4. 通过 size-adjustascent-override 等 CSS 属性,让回退字体(系统字体)的尺寸尽量接近目标字体,从而最小化字体切换时的布局偏移

为什么用 CSS 变量而不是直接用 className

上面的代码使用了 variable 选项并在 HTML 根元素上设置了 CSS 变量,而不是直接使用 inter.className。原因是灵活性:CSS 变量可以在整个 CSS 体系(包括 Tailwind)中自由引用,而 className 只能直接应用到一个元素上。

// 方式一:直接使用(只能应用到 body 自身)
<body className={inter.className}>

// 方式二:CSS 变量(可在整个 CSS/Tailwind 中引用)
<html className={inter.variable}>
// 然后在 CSS 中:font-family: var(--font-inter)

在 Tailwind CSS 中使用自定义字体

将 CSS 变量与 Tailwind 集成,只需在 tailwind.config.ts 中扩展字体配置:

// tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
        'noto-sc': ['var(--font-noto-sans-sc)', 'sans-serif'],
      },
    },
  },
}

export default config

之后在组件中直接使用 Tailwind 类名:

// 使用 sans 字体(Inter)
<p className="font-sans text-base">正文文字</p>

// 使用中文字体
<h1 className="font-noto-sc font-bold text-2xl">标题文字</h1>

Level 2 · 它是怎么运行的(3-5年经验)

next/font/local:使用自定义字体文件

当你拥有品牌字体或不在 Google Fonts 上的字体时,使用 next/font/local

// app/layout.tsx
import localFont from 'next/font/local'

const brandFont = localFont({
  src: [
    {
      path: '../public/fonts/BrandFont-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: '../public/fonts/BrandFont-Medium.woff2',
      weight: '500',
      style: 'normal',
    },
    {
      path: '../public/fonts/BrandFont-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
  variable: '--font-brand',
  display: 'swap',
  // 指定回退字体的尺寸调整参数(通过 fontpie 工具计算)
  adjustFontFallback: 'Arial',
})

adjustFontFallback 让 Next.js 自动计算回退字体需要的 size-adjustascent-overridedescent-override 等参数,确保字体切换时布局几乎不变。

CLS 量化:字体优化前后的对比

CLS(布局偏移累积分数)的取值范围是 0 到正无穷,Google 的标准是:

字体相关的 CLS 典型场景:

优化前(外部 Google Fonts)

优化后(next/font + size-adjust)

对于中文字体,情况更严峻:中文字体文件动辄 2-5MB(包含数千个字形),加载时间可能超过 2 秒。next/font 的子集化(subsets: ['chinese-simplified'])会只下载常用字符的字形,将文件大小减少 80-90%。

next/script:精确控制第三方脚本

第三方脚本(Google Analytics、广告 SDK、客户支持 Widget)是页面变慢的主要原因之一,因为它们通常:

next/scriptstrategy prop 提供四个选项,让你精确控制脚本何时加载:

beforeInteractive

脚本在页面变为可交互之前加载。这是最激进的策略,会阻塞页面交互,只应用于页面运行绝对必需的脚本(如 polyfills、同意管理器):

import Script from 'next/script'

// 在 app/layout.tsx 中
<Script
  src="https://cdn.cookieconsent.com/cookieconsent.js"
  strategy="beforeInteractive"
/>

afterInteractive(默认)

脚本在页面变为可交互之后立即加载。适合 Google Analytics、Facebook Pixel 等分析脚本:

<Script
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
  strategy="afterInteractive"
/>

lazyOnload

脚本在浏览器空闲时才加载(利用 requestIdleCallback)。适合非关键的第三方功能,如聊天 Widget、调查工具:

<Script
  src="https://widget.intercom.io/widget/your-app-id"
  strategy="lazyOnload"
  onLoad={() => {
    console.log('Intercom 加载完成')
  }}
/>

worker(实验性)

脚本在 Web Worker 中执行,完全不占用主线程。使用 Partytown 库实现,适合计算密集型的分析脚本:

<Script
  src="https://www.googletagmanager.com/gtag/js"
  strategy="worker"
/>

注意:worker 策略需要额外配置 Partytown,且某些脚本(需要访问 DOM)不兼容 Web Worker。

Level 3 · 规范怎么定义的(资深)

Google Analytics 的完整实现

生产环境中的 Google Analytics 需要处理同意管理(GDPR)和条件加载:

// components/GoogleAnalytics.tsx
'use client'

import Script from 'next/script'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'

const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_ID

declare global {
  interface Window {
    gtag: (...args: unknown[]) => void
    dataLayer: unknown[]
  }
}

function sendPageView(url: string) {
  if (typeof window.gtag === 'function') {
    window.gtag('config', GA_MEASUREMENT_ID!, {
      page_path: url,
    })
  }
}

export function GoogleAnalytics() {
  const pathname = usePathname()

  // 路由变化时手动触发 pageview(SPA 导航)
  useEffect(() => {
    sendPageView(pathname)
  }, [pathname])

  if (!GA_MEASUREMENT_ID) return null

  return (
    <>
      <Script
        src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
        strategy="afterInteractive"
      />
      <Script
        id="google-analytics"
        strategy="afterInteractive"
        dangerouslySetInnerHTML={{
          __html: `
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', '${GA_MEASUREMENT_ID}', {
              page_path: window.location.pathname,
            });
          `,
        }}
      />
    </>
  )
}
// app/layout.tsx
import { GoogleAnalytics } from '@/components/GoogleAnalytics'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh">
      <body>
        {children}
        <GoogleAnalytics />
      </body>
    </html>
  )
}

为什么 App Router 中的 GA 需要手动追踪路由变化

Pages Router 中每次路由变化都是完整的页面导航,GA 脚本会自动触发 pageview。但 App Router 的客户端导航是 SPA 模式,页面不会真正重新加载,GA 脚本不会重新执行。需要监听路由变化(使用 usePathname)并手动调用 gtag('config', ...) 来发送 pageview 事件。

内联脚本的 id 要求

next/script 要求内联脚本(使用 dangerouslySetInnerHTMLchildren 的脚本)必须提供 id prop。Next.js 用它来跟踪脚本是否已经插入,防止重复插入:

// 正确:提供 id
<Script id="my-inline-script">
  {`console.log('hello')`}
</Script>

// 错误:缺少 id 会报警告
<Script dangerouslySetInnerHTML={{ __html: '...' }} />

Script 的 onLoad 和 onReady 回调

<Script
  src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY"
  strategy="lazyOnload"
  onLoad={() => {
    // 脚本首次加载完成后执行
    initializeMap()
  }}
  onReady={() => {
    // 每次组件挂载时执行(包括从缓存恢复的情况)
    // 适合需要在每次页面显示时重新初始化的场景
  }}
  onError={(error) => {
    console.error('脚本加载失败:', error)
  }}
/>

综合性能影响:量化收益

以一个典型的 B2B SaaS 落地页为例,原本使用:

优化后(next/font + next/script 按策略分配)的 Lighthouse 变化:

指标 优化前 优化后 改善
Performance 58 84 +26
LCP 3.8s 2.1s -45%
TBT (总阻塞时间) 680ms 220ms -68%
CLS 0.12 0.02 -83%

TBT 的大幅改善主要来自 Intercom 脚本改用 lazyOnload 策略,主线程不再在关键渲染路径上被阻塞。

Level 4 · 边界与陷阱(所有人)

陷阱1:App Router 的客户端导航是 SPA 模式,GA 脚本不会重新执行——需要用 usePathname 监听路由变化并手动发送 pageview。

陷阱2:next/script 要求内联脚本必须提供 id prop——Next.js 用它防止脚本重复插入。

陷阱3:中文字体文件动辄 2-5MB——next/font 的子集化(subsets: ['chinese-simplified'])可将文件大小减少 80-90%。

小结

字体和第三方脚本是页面性能的两大隐形杀手。next/font 通过在构建时将字体自托管、计算精确的回退字体尺寸,从根源上消除了字体相关的 CLS 和外部网络依赖。next/script 则提供了按重要程度分层加载第三方脚本的能力,让非关键脚本不再阻塞首次可交互时间。这两个组件的正确配置,通常能让 Lighthouse Performance 分数提升 20-30 分,而无需改变任何产品功能。

本章评分
4.7  / 5  (12 评分)

💬 留言讨论