发布日期

第 27 讲:CSS、字体、图片资源处理

CSS Modules / Tailwind、next/font、next/image 的构建时与运行时处理

Next.js 不只编译 JS,还包揽了 CSS、字体、图片三大类静态资源的优化管道。本讲拆开这三套机制:CSS Modules / global / PostCSS / Tailwind 的处理路径、next/font 的子集化与自托管、next/image 的运行时 image optimizer。

学习目标

读完本讲,你能:

  1. 解释 App Router 下 CSS 的 5 种写法(global / module / inline / Tailwind / CSS-in-JS)的编译路径差异。
  2. 看懂 next/font 的 build-time 流程:抓取 Google Fonts、子集化、生成 manifest、注入 <style>
  3. 看懂 next/image 的运行时管道:<Image> 组件 → /_next/image?url=... route → image-optimizer.ts → sharp 处理 → cache 落盘。
  4. 排查 CSS 顺序错乱、字体闪烁(FOIT/FOUT)、图片优化失败等典型问题。
  5. 决定 production 用什么 cache header、CDN 策略对待 _next/static_next/image

本讲对应代码:

  • packages/next/src/build/webpack/plugins/css-chunking-plugin.ts
  • packages/next/src/build/webpack/plugins/mini-css-extract-plugin.ts
  • packages/next/src/build/webpack/plugins/next-font-manifest-plugin.ts
  • packages/font/src/google/loader.ts(next/font 的 SWC 加载器)
  • packages/next/src/server/image-optimizer.ts(image 运行时)
  • packages/next/src/client/image-component.tsx<Image> client 组件)

一、CSS 写法五选

方式文件适用场景
Global CSSapp/globals.css 在 root layout 导入reset.css、CSS variables、全局基础样式
CSS Modules*.module.css局部组件样式,自动作用域
Inline stylestyle={{}}一次性、动态样式
Tailwind / Atomic CSSclassName="bg-red-500"大量重复 utility class
CSS-in-JSstyled-components / emotion / vanilla-extract复杂动态样式、theming

App Router 强烈推荐 CSS Modules + Tailwind 组合,因为这两种都是 build-time 处理,无 runtime cost。

CSS-in-JS(styled-components)需要 client component 包裹(用 'use client'),失去 RSC 优势;vanilla-extract 是 build-time CSS-in-JS,可在 server component 用。

二、Global CSS 的限制

// app/layout.tsx
import "./globals.css"; // ✅ 只能在 root layout 引

// app/page.tsx
import "./page-style.css"; // ❌ 报错

App Router 规则:global CSS 只能在 root layout 引入。理由:

  • Global CSS 影响整个 App,应当固定在最外层
  • 任意 page 引 global CSS 会让 CSS 顺序难以预测(depend on 渲染顺序)
  • CSS Modules(*.module.css)才是 page/component 级的样式

build 时 Next.js 会 lint 这个规则;违反时 build 失败。

三、CSS Modules 的内部实现

// styles.module.css
.button { padding: 8px; }

// usage
import s from './styles.module.css'
<button className={s.button} />  // 实际值是 'styles_button__abc123'

webpack 的 css-loader 配置 modules: true 时启用 CSS Modules:

  1. 解析 CSS 文件,找到所有 selector
  2. 把每个 class name xxx 重写为 <filename>_xxx__<hash>
  3. 生成一个 JS 模块,export {xxx: 'styles_button__abc123'}
  4. 把改写后的 CSS 注入 chunk 文件

效果:每个 .module.css 文件的 class name 全局唯一,自然 scoped。

PostCSS 默认启用,可在 postcss.config.js 加自定义 plugin(autoprefixer、tailwind 都是 postcss plugin)。

四、Tailwind CSS:JIT + PostCSS

Tailwind 3+ 用 JIT 模式:

  1. 扫描所有 source 文件(content: ['./app/**/*.tsx']
  2. 找到所有 className 中用到的 bg-red-500flex 等 utility
  3. 只生成用到的 CSS,输出到最终 bundle

build 时通过 postcss-loader 触发 tailwind plugin,编译过程:

.tsx → swc parse → 扫 className → 收集 utilities
                          tailwindcss postcss plugin
                          生成 CSS rule
                          mini-css-extract 抽离到 .css

dev 模式下 Tailwind 监听文件变化,重新 JIT;prod 一次性扫描全部。

业务陷阱:动态生成 className(className={bg-${color}-500})Tailwind 扫不到,会被 purge 掉。修复:写 safelist 或避免动态拼接。

五、CSS Chunking 与顺序

App Router 引入 CssChunkingPlugin,解决一个微妙问题:

// /foo/page.tsx
import "./a.module.css";
import "./b.module.css";

// /bar/page.tsx
import "./b.module.css";
import "./a.module.css";

webpack 默认按 import 顺序合并 CSS,会导致 /foo/bar 的 CSS 顺序不一致,浏览器渲染结果不同。

CssChunkingPlugin 工作:

  1. 收集所有 CSS 模块的"import 关系图"
  2. 计算一个全局确定性顺序(topological sort)
  3. 保证每个 page 的 CSS 都按这个顺序输出

效果:不管你在哪个 page 引 CSS,相同模块在最终 bundle 里顺序一致。

dev 模式下还会注入 <link rel="stylesheet"><head>,hydration 前同步加载,避免闪烁。

六、next/font:build-time 字体优化

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

const inter = Inter({ subsets: ['latin'] })

export default function RootLayout({ children }) {
  return (
    <html className={inter.className}>
      <body>{children}</body>
    </html>
  )
}

build 时发生了什么?

  1. SWC loader 识别 next/font/google import:把 Inter({...}) 调用编译成一个特殊的 import。
  2. build 阶段抓取字体:调 packages/font/src/google/loader.ts,从 Google Fonts 下载 woff2 文件 + 计算 fallback metrics。
  3. 子集化:根据 subsets: ['latin'] 只保留 latin 字符的字形,woff2 文件从 200KB+ 缩到 20KB。
  4. 生成 CSS
    @font-face &#123;
      font-family: '__Inter_xxx';
      src: url(/_next/static/media/inter-xxx.woff2) format('woff2');
      font-display: swap;
    &#125;
    .__className_xxx &#123; font-family: '__Inter_xxx', sans-serif; &#125;
    
  5. 写到 build manifest:让 SSR render 时把 <link rel="preload" as="font"> 注入 <head>
const inter = Inter({ subsets: ["latin"] });
inter.className; // '__className_inter_xxx'
inter.style; // { fontFamily: '__Inter_xxx', ... }
inter.variable; // CSS variable name(若启用)

字体的核心收益

不用 next/font用 next/font
浏览器 → Google Fonts CSS → 解析 → fetch woff2,3 round trip字体跟 page bundle 一起 self-host,1 round trip
字体加载前 FOIT(不显示文字)/FOUT(系统字体闪一下)通过 fallback metrics override 减小 CLS
跨域 cache 命中差同源 + immutable cache,命中率 99%
隐私问题(暴露 IP 给 Google)完全自托管

Local Font

import localFont from "next/font/local";

const myFont = localFont({
  src: "./my-font.woff2",
  variable: "--font-my",
});

local font 不需要网络抓取,build 时直接 hash + 复制到 static/media/

七、next/image:运行时图像优化

<Image> 不是简单的 <img> 包装,背后是一套完整的 image optimizer。

组件层

import Image from "next/image";

<Image src="/photo.jpg" alt="..." width={800} height={600} />;

编译后生成的 HTML 是:

<img
  alt="..."
  loading="lazy"
  width="800"
  height="600"
  decoding="async"
  data-nimg="1"
  style="color:transparent"
  srcset="
    /_next/image?url=%2Fphoto.jpg&w=640&q=75  1x,
    /_next/image?url=%2Fphoto.jpg&w=1080&q=75 2x
  "
  src="/_next/image?url=%2Fphoto.jpg&w=1080&q=75"
/>

注意:

  • 不是直接 src="/photo.jpg"
  • 而是经过 /_next/image?url=...&w=...&q=... 这个 route
  • 这是一个运行时 endpoint,每次访问都通过 image-optimizer 处理

运行时:image-optimizer.ts

packages/next/src/server/image-optimizer.ts
export function getSharp(concurrency: number | null | undefined) {
packages/next/src/server/image-optimizer.ts
export class ImageOptimizerCache {

工作流程:

  1. server 收到 /_next/image?url=foo&w=1080&q=75 请求
  2. 解析参数,验证 url 是 allowed origin
  3. 计算 cache key:hash(url + w + q + format)
  4. ImageOptimizerCache
    • HIT → 直接 stream cached file
    • MISS → 进 5
  5. fetch 原图(本地或远程 CDN)
  6. sharp(libvips 的 Node binding)resize + 转 webp/avif
  7. 写入 cache:.next/cache/images/<hash>/
  8. 返回响应(带 Cache-Control: public, max-age=...

cache 落盘格式(每个 image 一个目录):

.next/cache/images/<hash>/
  ├─ 0.<etag>.<expires>.webp
  └─ ...

文件名编码了原始 etag 和 expires,便于失效检测。

content-type 检测

packages/next/src/server/image-optimizer.ts
export async function detectContentType(

通过读 buffer 前几字节的 magic number 判断真实文件类型(不信任 Content-Type header),防止 SVG XSS 等攻击。

Image 的安全设计

next.config.js 必须显式声明允许的远程 origin:

module.exports = {
  images: {
    remotePatterns: [{ protocol: "https", hostname: "cdn.example.com" }],
  },
};

不允许的 origin 会被拒绝,防止把 image optimizer 当成 SSRF 代理。

八、Static Asset:_next/static/

public/ 目录下的文件不被处理,直接复制到 .next/static/...,URL 形如 /<file>

Next.js 自己生成的产物(JS chunk、CSS、字体)放在 static/

.next/static/
├─ chunks/
├─ css/
├─ media/        # next/font 字体 + 静态 image 优化产物
└─ <buildId>/
   └─ _ssgManifest.js

这些资源都带 hash filename(如 framework-abc123.js),可以用 immutable cache

Cache-Control: public, max-age=31536000, immutable

部署到 Vercel/Cloudflare/Nginx 时,要给 /_next/static/* 配 immutable,给 /_next/image* 配较短 cache + revalidation。

九、关键 manifest

Manifest内容谁读
next-font-manifest.json字体路径与 preload 关系SSR render 时注入 <link rel="preload" as="font">
images-manifest.jsonnext.config.js 的 image 配置image-optimizer 读取
static-css/*.css抽离的 CSS 内容浏览器加载

十、CSP 与 nonce

Next.js 支持 CSP(Content Security Policy)配合 nonce:

// middleware.ts
import { NextResponse } from "next/server";

export function middleware(req) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const cspHeader = `script-src 'nonce-${nonce}' 'strict-dynamic'; ...`;

  const res = NextResponse.next({
    headers: { "x-nonce": nonce },
  });
  res.headers.set("Content-Security-Policy", cspHeader);
  return res;
}

server component 通过 headers() 拿到 nonce,注入到 inline <script>

import { headers } from "next/headers";

export default async function Layout({ children }) {
  const nonce = (await headers()).get("x-nonce") ?? "";
  return (
    <html>
      <body>{children}</body>
      <Script nonce={nonce} src="..." />
    </html>
  );
}

Next.js 自己注入的 inline script(hydration data 等)也会自动加 nonce,前提是用户在 middleware 里把 nonce 设到 header 上。

十一、生产排障实战清单

现象排查
Global CSS cannot be imported from files other than your custom <App>global CSS 只能在 root layout 引
CSS 在 prod 顺序错乱老版本 Next.js 没有 CssChunkingPlugin;升级到 14+
字体闪烁(CLS 跳动)没用 next/font 或 fallback metrics 配置错;查 adjustFontFallback
字体 404/_next/static/media/* 没被 CDN 缓存;检查 deployment 是否上传
Image with src "/foo.jpg" must use width and heightApp Router 下 width/height 必填,或用 fill={true}
_next/image 502sharp 异常;看 server log;可能是图太大、format 不支持
Hostname "example.com" is not configurednext.config.jsimages.remotePatterns 加上
图片 cache hit 率低检查 minimumCacheTTL 配置、CDN cache header
dev 改 CSS 不立刻反映Fast Refresh 与 CSS HMR 异常;重启 dev server
Tailwind class 不生效dynamic class string、content 路径未覆盖;查 tailwind.config.js
styled-components SSR mismatch没加 SwcPlugin;用 experimental.swcPlugins 启用

十二、配套 fixture:CSS / 字体 / 图片三件套

fixtures/lecture-27/ 提供:

  • globals.css + CSS Module 两种 CSS 演示
  • next/font/google 加载 Inter
  • next/image 加载本地 + 远程 image

启动:

cd learning/nextjs-40-lectures/fixtures/lecture-27
pnpm install
pnpm build
pnpm start
# http://localhost:3027

# 观察
ls -lh .next/static/css/
ls -lh .next/static/media/   # 字体 + 优化后的 image
cat .next/next-font-manifest.json | jq
cat .next/images-manifest.json | jq

dev 模式下打开 DevTools Network 面板,访问 page 时看:

  • CSS link 在 <head>
  • 字体 <link rel="preload" as="font">
  • image 走 _next/image?url=...,response 是 webp

十三、本讲小结

  1. 5 种 CSS 写法:global / module / inline / Tailwind / CSS-in-JS。App Router 推荐 module + Tailwind。
  2. CssChunkingPlugin 保证 CSS 全局顺序一致,避免不同 page CSS 冲突。
  3. next/font 是 build-time 字体优化:抓 → 子集化 → 自托管 → preload + fallback metrics。
  4. next/image 是 runtime image 优化:<Image>/_next/image?... → sharp → cache 落盘 → CDN-friendly。
  5. Static asset cache/_next/static/* 用 immutable cache;/_next/image* 用短 TTL + revalidation。

下讲预告

第 28 讲《.next/ 目录寻宝:build 产物全景图》。详细解析 .next/ 目录每个子目录、每个 manifest 的含义和用法;以及 standalone build 模式的差异。这是阶段四的收官篇。