- 发布日期
第 27 讲:CSS、字体、图片资源处理
CSS Modules / Tailwind、next/font、next/image 的构建时与运行时处理
Next.js 不只编译 JS,还包揽了 CSS、字体、图片三大类静态资源的优化管道。本讲拆开这三套机制:CSS Modules / global / PostCSS / Tailwind 的处理路径、
next/font的子集化与自托管、next/image的运行时 image optimizer。
学习目标
读完本讲,你能:
- 解释 App Router 下 CSS 的 5 种写法(global / module / inline / Tailwind / CSS-in-JS)的编译路径差异。
- 看懂
next/font的 build-time 流程:抓取 Google Fonts、子集化、生成 manifest、注入<style>。 - 看懂
next/image的运行时管道:<Image>组件 →/_next/image?url=...route →image-optimizer.ts→ sharp 处理 → cache 落盘。 - 排查 CSS 顺序错乱、字体闪烁(FOIT/FOUT)、图片优化失败等典型问题。
- 决定 production 用什么 cache header、CDN 策略对待
_next/static与_next/image。
本讲对应代码:
packages/next/src/build/webpack/plugins/css-chunking-plugin.tspackages/next/src/build/webpack/plugins/mini-css-extract-plugin.tspackages/next/src/build/webpack/plugins/next-font-manifest-plugin.tspackages/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 CSS | app/globals.css 在 root layout 导入 | reset.css、CSS variables、全局基础样式 |
| CSS Modules | *.module.css | 局部组件样式,自动作用域 |
| Inline style | style={{}} | 一次性、动态样式 |
| Tailwind / Atomic CSS | className="bg-red-500" | 大量重复 utility class |
| CSS-in-JS | styled-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:
- 解析 CSS 文件,找到所有 selector
- 把每个 class name
xxx重写为<filename>_xxx__<hash> - 生成一个 JS 模块,export
{xxx: 'styles_button__abc123'} - 把改写后的 CSS 注入 chunk 文件
效果:每个 .module.css 文件的 class name 全局唯一,自然 scoped。
PostCSS 默认启用,可在 postcss.config.js 加自定义 plugin(autoprefixer、tailwind 都是 postcss plugin)。
四、Tailwind CSS:JIT + PostCSS
Tailwind 3+ 用 JIT 模式:
- 扫描所有 source 文件(
content: ['./app/**/*.tsx']) - 找到所有 className 中用到的
bg-red-500、flex等 utility - 只生成用到的 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 工作:
- 收集所有 CSS 模块的"import 关系图"
- 计算一个全局确定性顺序(topological sort)
- 保证每个 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 时发生了什么?
- SWC loader 识别
next/font/googleimport:把Inter({...})调用编译成一个特殊的 import。 - build 阶段抓取字体:调
packages/font/src/google/loader.ts,从 Google Fonts 下载 woff2 文件 + 计算 fallback metrics。 - 子集化:根据
subsets: ['latin']只保留 latin 字符的字形,woff2 文件从 200KB+ 缩到 20KB。 - 生成 CSS:
@font-face { font-family: '__Inter_xxx'; src: url(/_next/static/media/inter-xxx.woff2) format('woff2'); font-display: swap; } .__className_xxx { font-family: '__Inter_xxx', sans-serif; } - 写到 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
export function getSharp(concurrency: number | null | undefined) {
export class ImageOptimizerCache {
工作流程:
- server 收到
/_next/image?url=foo&w=1080&q=75请求 - 解析参数,验证
url是 allowed origin - 计算 cache key:
hash(url + w + q + format) - 查
ImageOptimizerCache:- HIT → 直接 stream cached file
- MISS → 进 5
- fetch 原图(本地或远程 CDN)
- 用
sharp(libvips 的 Node binding)resize + 转 webp/avif - 写入 cache:
.next/cache/images/<hash>/ - 返回响应(带
Cache-Control: public, max-age=...)
cache 落盘格式(每个 image 一个目录):
.next/cache/images/<hash>/
├─ 0.<etag>.<expires>.webp
└─ ...
文件名编码了原始 etag 和 expires,便于失效检测。
content-type 检测
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.json | next.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 height | App Router 下 width/height 必填,或用 fill={true} |
_next/image 502 | sharp 异常;看 server log;可能是图太大、format 不支持 |
Hostname "example.com" is not configured | 在 next.config.js 的 images.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加载 Internext/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
十三、本讲小结
- 5 种 CSS 写法:global / module / inline / Tailwind / CSS-in-JS。App Router 推荐 module + Tailwind。
- CssChunkingPlugin 保证 CSS 全局顺序一致,避免不同 page CSS 冲突。
- next/font 是 build-time 字体优化:抓 → 子集化 → 自托管 → preload + fallback metrics。
- next/image 是 runtime image 优化:
<Image>→/_next/image?...→ sharp → cache 落盘 → CDN-friendly。 - Static asset cache:
/_next/static/*用 immutable cache;/_next/image*用短 TTL + revalidation。
下讲预告
第 28 讲《.next/ 目录寻宝:build 产物全景图》。详细解析 .next/ 目录每个子目录、每个 manifest 的含义和用法;以及 standalone build 模式的差异。这是阶段四的收官篇。