Font 与 Script 优化
第18章:Font 与 Script 优化
字体和第三方脚本是页面性能的两大隐形杀手。next/font 在构建时将字体自托管并计算精确的回退字体尺寸,next/script 按重要程度分层加载第三方脚本。
本章核心问题:next/font 如何消除字体相关的 CLS?next/script 的四种加载策略分别适用于什么场景?
读完本章你将理解:
- next/font/google 零运行时外部请求的实现原理
- CSS 变量方式集成 Tailwind 的字体配置
- beforeInteractive、afterInteractive、lazyOnload、worker 四种策略的选择
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 在构建时做了几件事:
- 从 Google Fonts API 下载字体文件(
.woff2) - 将字体文件复制到
/.next/static/media/目录 - 生成 CSS
@font-face声明,指向本地文件 - 通过
size-adjust和ascent-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-adjust、ascent-override、descent-override 等参数,确保字体切换时布局几乎不变。
CLS 量化:字体优化前后的对比
CLS(布局偏移累积分数)的取值范围是 0 到正无穷,Google 的标准是:
- 0 到 0.1:良好(绿色)
- 0.1 到 0.25:需要改进(橙色)
- 大于 0.25:差(红色)
字体相关的 CLS 典型场景:
优化前(外部 Google Fonts):
- 浏览器用 Arial(系统字体)渲染文本
- 200-800ms 后 Roboto 字体加载完成
- 文字宽度变化,段落换行位置改变,按钮大小改变
- 典型 CLS 分数:0.08-0.15
优化后(next/font + size-adjust):
- 浏览器用经过
size-adjust调整的 Arial 渲染文本 - 字体加载后文字宽度变化极小(差异在 1-2px 以内)
- 典型 CLS 分数:0.01-0.02
对于中文字体,情况更严峻:中文字体文件动辄 2-5MB(包含数千个字形),加载时间可能超过 2 秒。next/font 的子集化(subsets: ['chinese-simplified'])会只下载常用字符的字形,将文件大小减少 80-90%。
next/script:精确控制第三方脚本
第三方脚本(Google Analytics、广告 SDK、客户支持 Widget)是页面变慢的主要原因之一,因为它们通常:
- 在关键渲染路径上执行
- 发起额外的网络请求
- 可能阻塞主线程
next/script 的 strategy 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 要求内联脚本(使用 dangerouslySetInnerHTML 或 children 的脚本)必须提供 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 落地页为例,原本使用:
- Google Fonts(外部
<link>标签) - Google Analytics(直接
<script>标签) - Intercom(直接
<script>标签,影响最大) - Hotjar(直接
<script>标签)
优化后(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 分,而无需改变任何产品功能。