图片与字体优化

图片和字体是网页中最大的两类资源。一张未优化的 Hero 图片可能 2MB,一套中文字体可能 10MB——直接拖垮 LCP。Next.js 内置了 next/imagenext/font 两个组件,从根源上解决这些问题。本章讲清图片优化的完整策略(格式、尺寸、懒加载、CDN)和字体优化(子集化、预加载、零 CLS)。

1. next/image 深度配置

1.1 为什么不能直接用 <img>

原生 <img> 标签有三个致命问题:

  • 没有自动缩放:上传一张 4000×3000 的照片,即使显示区域只有 400×300,浏览器也会下载完整尺寸
  • 没有格式转换:JPEG 文件即使浏览器支持 WebP/AVIF,也不会自动转换为更小的格式
  • 没有尺寸占位:图片加载前没有预留空间,加载后会把下方内容推开——造成 CLS(布局偏移)

next/image 一次性解决了这三个问题。它不是简单的封装,而是一个图片优化管线:请求到达时,服务端根据浏览器的 Accept 头和请求的尺寸,按需生成最优格式和尺寸的图片,并缓存结果。

// ❌ 原生 img — 下载完整 4000×3000 JPEG
<img src="/hero.jpg" alt="Hero" />
 
// ✅ next/image — 自动缩放到 800×600,转为 WebP,预留空间
import Image from 'next/image'
 
<Image
  src="/hero.jpg"
  alt="Hero"
  width={800}
  height={600}
/>

1.2 本地图片 vs 远程图片

本地图片(从 import 导入):Next.js 在构建时自动读取图片尺寸,无需手动指定 width/height

import heroImage from '@/public/images/hero.jpg'
 
// width 和 height 自动从文件读取,还会生成 blur 占位图
<Image
  src={heroImage}
  alt="Hero banner"
  placeholder="blur"  // 自动生成的模糊占位图,消除 CLS
/>

远程图片(URL 地址):必须手动指定尺寸,或用 fill 模式让图片填满父容器。同时需要在 next.config.ts 中配置允许的域名——这是安全措施,防止你的服务器被当作图片代理:

// next.config.ts
import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
        pathname: '/images/**',
      },
      {
        protocol: 'https',
        hostname: '*.amazonaws.com',  // 支持通配符
      },
    ],
  },
}
 
export default nextConfig
// 远程图片 — 必须指定尺寸
<Image
  src="https://cdn.example.com/images/photo.jpg"
  alt="Remote photo"
  width={800}
  height={600}
/>
 
// 或者用 fill 模式 — 填满父容器,父容器需要 position: relative
<div className="relative w-full h-[400px]">
  <Image
    src="https://cdn.example.com/images/photo.jpg"
    alt="Remote photo"
    fill
    className="object-cover"  // 控制裁剪方式
  />
</div>

1.3 关键属性详解

<Image
  src={heroImage}
  alt="Hero banner"
 
  // 尺寸控制
  width={800}            // 渲染宽度(像素)
  height={600}           // 渲染高度(像素)
  fill                   // 或者用 fill 填满父容器(不能和 width/height 同时用)
  sizes="(max-width: 768px) 100vw, 50vw"  // 响应式尺寸提示(见第 4 节)
 
  // 加载行为
  priority               // 关键图片:禁用懒加载 + preload(用于首屏 LCP 图片)
  loading="lazy"         // 默认行为:滚动到视口附近才加载
  placeholder="blur"     // 占位效果:blur(模糊)或 empty(空白)
  blurDataURL="data:..." // 远程图片的自定义模糊占位
 
  // 质量
  quality={85}           // 压缩质量 1-100,默认 75。85 是视觉无损的甜点
 
  // 样式
  className="rounded-lg" // 直接传 className
  style={{ objectFit: 'cover' }}
/>

priority 是 LCP 优化的关键:默认所有图片都是懒加载。但首屏的 Hero 图片需要立即加载,给它加 priority 会在 &lt;head&gt; 中注入 &lt;link rel="preload"&gt;,让浏览器尽早开始下载。一个页面最多给 1-2 张图片加 priority

1.4 图片加载器(Loader)

默认情况下,Next.js 用内置的图片优化服务器(/_next/image)处理图片。在 Vercel 部署时这很好用。但如果你用 Cloudflare、AWS 或自托管,可能需要自定义 Loader:

// lib/image-loader.ts
import type { ImageLoaderProps } from 'next/image'
 
// Cloudflare Images 加载器
export function cloudflareLoader({ src, width, quality }: ImageLoaderProps): string {
  return `https://imagedelivery.net/your-account-hash/${src}/w=${width},q=${quality || 85}`
}
 
// 阿里云 OSS 图片处理
export function ossLoader({ src, width, quality }: ImageLoaderProps): string {
  return `${src}?x-oss-process=image/resize,w_${width}/quality,q_${quality || 85}/format,webp`
}
import Image from 'next/image'
import { cloudflareLoader } from '@/lib/image-loader'
 
<Image
  loader={cloudflareLoader}
  src="photo-id-123"
  alt="Photo"
  width={800}
  height={600}
/>

全局配置(所有图片都用同一个 Loader):

// next.config.ts
const nextConfig: NextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
  },
}

2. next/font 字体优化

2.1 Web 字体的性能问题

Web 字体不优化有三个严重问题:

  • FOIT(Flash of Invisible Text):字体下载期间文字不可见——用户看到空白区域
  • FOUT(Flash of Unstyled Text):先用系统字体显示,字体下载完后切换——造成明显的闪烁和 CLS
  • 体积巨大:一套完整的 Google Fonts 中文字体可能超过 10MB

next/font 的解决方案:

  1. 构建时下载字体文件,打包到你的域名下——不再需要运行时连接 Google Fonts CDN
  2. 使用 CSS size-adjust 技术让回退字体和目标字体的度量一致——消除 CLS
  3. 自动启用 font-display: swap——文字立即可见(用系统字体),字体下载完无缝切换

2.2 Google Fonts

// app/layout.tsx
import { Inter, Noto_Sans_SC } from 'next/font/google'
 
// 英文字体
const inter = Inter({
  subsets: ['latin'],          // 只加载拉丁字符子集(~15KB,完整字体 ~300KB)
  display: 'swap',             // 字体加载前先用系统字体
  variable: '--font-inter',    // 注入 CSS 变量(推荐方式)
})
 
// 中文字体 — 会自动进行子集化和分片加载
const notoSansSC = Noto_Sans_SC({
  subsets: ['latin'],          // 中文字体无需指定 chinese 子集,next/font 会自动处理
  weight: ['400', '500', '700'],
  display: 'swap',
  variable: '--font-noto',
})
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN" className={`${inter.variable} ${notoSansSC.variable}`}>
      <body>{children}</body>
    </html>
  )
}

在 Tailwind CSS 中使用:

// tailwind.config.ts
import type { Config } from 'tailwindcss'
 
const config: Config = {
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'var(--font-noto)', 'system-ui', 'sans-serif'],
        mono: ['var(--font-jetbrains)', 'Menlo', 'monospace'],
      },
    },
  },
}
 
export default config

2.3 本地字体

对于自定义字体或需要精确控制的场景:

import localFont from 'next/font/local'
 
// 单个文件
const customFont = localFont({
  src: './fonts/CustomFont.woff2',  // 相对于当前文件的路径
  display: 'swap',
  variable: '--font-custom',
})
 
// 多粗细(Variable Font 最优——一个文件包含所有粗细)
const variableFont = localFont({
  src: './fonts/InterVariable.woff2',
  display: 'swap',
  variable: '--font-inter',
  weight: '100 900',  // 范围声明
})
 
// 多文件(非 Variable Font 时需要分别指定)
const multiWeightFont = localFont({
  src: [
    { path: './fonts/Font-Regular.woff2', weight: '400', style: 'normal' },
    { path: './fonts/Font-Medium.woff2', weight: '500', style: 'normal' },
    { path: './fonts/Font-Bold.woff2', weight: '700', style: 'normal' },
    { path: './fonts/Font-Italic.woff2', weight: '400', style: 'italic' },
  ],
  display: 'swap',
  variable: '--font-multi',
})

2.4 中文字体优化策略

中文字体的挑战:常用汉字就有 6000+ 个,完整字体文件 5-20MB。三种策略:

方案一:系统字体栈(零成本,推荐大多数项目)

font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
  "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;

方案二:Google Fonts 自动分片next/font 默认行为)

next/font 对中文 Google Fonts 会自动按 Unicode Range 分片加载——只下载页面实际用到的字符对应的分片。一个分片通常只有几十 KB。

方案三:自定义子集化(极致优化)

如果你确切知道页面用到哪些字符(如 Landing Page 的标题),可以用 fonttools 工具提取子集:

# 安装 fonttools
pip install fonttools brotli
 
# 只保留指定字符
pyftsubset NotoSansSC-Regular.ttf \
  --text="让创意自由生长" \
  --output-file=NotoSansSC-Subset.woff2 \
  --flavor=woff2

3. 图片格式选择

3.1 现代格式对比

格式压缩比浏览器支持适用场景
JPEG基准100%照片、渐变
PNG无损,较大100%透明图、截图
WebP比 JPEG 小 25-35%97%+通用推荐
AVIF比 JPEG 小 50%+92%+追求极致压缩
SVG极小(矢量)100%图标、Logo、插图

next/image 默认行为:检查浏览器的 Accept 请求头,支持 AVIF 就返回 AVIF,支持 WebP 就返回 WebP,否则返回原格式。你不需要手动处理格式转换。

3.2 配置格式优先级

// next.config.ts
const nextConfig: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],  // 优先级:AVIF > WebP > 原格式
    // 默认就是这个配置。如果你的用户浏览器较旧,可以只保留 WebP:
    // formats: ['image/webp'],
  },
}

3.3 静态资源的手动优化

对于不经过 next/image 的静态资源(如 OG Image、邮件模板图片),在构建流程中手动优化:

// scripts/optimize-images.mts
import sharp from 'sharp'
import { readdir, stat } from 'fs/promises'
import { join, parse } from 'path'
 
async function optimizeImages(dir: string) {
  const files = await readdir(dir)
 
  for (const file of files) {
    const filePath = join(dir, file)
    const fileStat = await stat(filePath)
 
    if (fileStat.isDirectory()) {
      await optimizeImages(filePath)
      continue
    }
 
    const { ext, name } = parse(file)
    if (!['.jpg', '.jpeg', '.png'].includes(ext.toLowerCase())) continue
 
    const outputWebP = join(dir, `${name}.webp`)
    const outputAVIF = join(dir, `${name}.avif`)
 
    // WebP — 兼容性好
    await sharp(filePath)
      .webp({ quality: 85 })
      .toFile(outputWebP)
 
    // AVIF — 更小但编码更慢
    await sharp(filePath)
      .avif({ quality: 70 })  // AVIF 的 70 质量 ≈ WebP 的 85 质量
      .toFile(outputAVIF)
 
    const original = fileStat.size
    const webpStat = await stat(outputWebP)
    const avifStat = await stat(outputAVIF)
 
    console.log(`${file}: ${(original / 1024).toFixed(0)}KB → WebP ${(webpStat.size / 1024).toFixed(0)}KB (${((1 - webpStat.size / original) * 100).toFixed(0)}% smaller) | AVIF ${(avifStat.size / 1024).toFixed(0)}KB (${((1 - avifStat.size / original) * 100).toFixed(0)}% smaller)`)
  }
}
 
optimizeImages('./public/images')

4. 响应式图片

4.1 sizes 属性

sizes 告诉浏览器在不同视口宽度下图片的实际显示宽度。浏览器根据这个信息选择最合适的尺寸下载——不用在手机上下载桌面尺寸的图片。

如果不设 sizesnext/image 会生成一个保守的默认 srcset,可能导致手机下载过大的图片。

// 示例:桌面端图片占页面 33%,平板占 50%,手机占 100%
<Image
  src={photo}
  alt="Responsive photo"
  fill
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

sizes 的工作原理:浏览器在下载图片之前就知道了图片的显示宽度(通过 sizes),然后从 srcset 中选择最接近的尺寸。这发生在 CSS 解析之前,所以不能依赖 CSS 来决定图片尺寸。

4.2 常见响应式布局

// 全宽 Hero 图片
<Image
  src={heroBanner}
  alt="Hero"
  fill
  priority
  sizes="100vw"
  className="object-cover"
/>
 
// 网格布局(桌面 3 列,平板 2 列,手机 1 列)
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  {photos.map((photo) => (
    <div key={photo.id} className="relative aspect-[4/3]">
      <Image
        src={photo.url}
        alt={photo.alt}
        fill
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        className="object-cover rounded-lg"
      />
    </div>
  ))}
</div>
 
// 侧边栏 + 内容区(侧边栏 300px,图片在内容区)
<Image
  src={contentImage}
  alt="Content"
  fill
  sizes="(max-width: 768px) 100vw, calc(100vw - 300px)"
/>

4.3 Art Direction(不同设备不同图片)

有时不只是尺寸不同,而是需要在手机和桌面展示完全不同的图片(手机用竖版,桌面用横版)。这时需要 &lt;picture&gt; 元素——但 next/image 不直接支持。用 getImageProps 解决:

import { getImageProps } from 'next/image'
 
export function HeroBanner() {
  // 桌面版横幅
  const { props: desktopProps } = getImageProps({
    src: '/images/hero-desktop.jpg',
    alt: 'Hero banner',
    width: 1920,
    height: 600,
    quality: 85,
  })
 
  // 移动版竖版图
  const { props: mobileProps } = getImageProps({
    src: '/images/hero-mobile.jpg',
    alt: 'Hero banner',
    width: 750,
    height: 1000,
    quality: 85,
  })
 
  return (
    <picture>
      <source media="(min-width: 768px)" srcSet={desktopProps.srcSet} />
      <img {...mobileProps} className="w-full h-auto" />
    </picture>
  )
}

5. 实战最佳实践

5.1 图片优化 Checklist

// ✅ 首屏 LCP 图片 — 加 priority
<Image src={hero} alt="Hero" fill priority sizes="100vw" />
 
// ✅ 本地图片 — 用 import + blur 占位
import photo from '@/public/images/photo.jpg'
<Image src={photo} alt="Photo" placeholder="blur" />
 
// ✅ 用户上传的图片 — 限制尺寸 + 配置远程域名
<Image
  src={user.avatarUrl}
  alt={user.name}
  width={48}
  height={48}
  className="rounded-full"
/>
 
// ✅ 图标和装饰性图片 — 用 SVG,不用 next/image
import { SearchIcon } from 'lucide-react'
<SearchIcon className="w-5 h-5" />
 
// ❌ 不要给所有图片都加 priority
// ❌ 不要忽略 sizes 属性(尤其是 fill 模式)
// ❌ 不要用 next/image 处理 SVG(SVG 本身就是最优的)

5.2 图片与字体联合优化效果

一个典型的优化前后对比:

指标优化前优化后改善
Hero 图片大小2.4 MB (JPEG 4000×3000)85 KB (AVIF 800×600)-96%
字体文件大小12 MB (完整中文字体)120 KB (分片加载)-99%
LCP4.2s1.1s-74%
CLS0.350.01-97%
首屏总传输15 MB800 KB-95%

本章小结

  • next/image:自动格式转换(AVIF/WebP)、按需缩放、懒加载、blur 占位,用 priority 标记 LCP 图片
  • next/font:构建时下载字体、size-adjust 消除 CLS、自动子集化,推荐用 CSS 变量方式注入
  • 格式选择:AVIF > WebP > JPEG,next/image 自动处理格式协商
  • 响应式sizes 属性告诉浏览器图片实际显示宽度,避免手机下载桌面尺寸图片
  • 中文字体:优先用系统字体栈,其次用 next/font 的自动分片,极致场景用 fonttools 子集化