Next.js 博客构建优化:从按需渲染到静态生成,聊聊文章预渲染改造实践
前言
最近对博客系统做了一次构建优化,主要是将文章详情页从动态渲染改为静态生成(SSG),同时引入了 Next.js 的按需重新验证(On-Demand Revalidation)机制。这篇文章记录一下改造过程、技术方案选择、遇到的问题以及后续的优化方向。
改造背景
在之前的实现中,博客文章详情页采用动态渲染方式:
typescript
// 旧方案:直接调用服务层获取数据
const getPost = cache(
async (params: PageProps["params"]): Promise<Post | null> => {
const resolvedParams = await resolveParams(params);
const path = `/${year}/${month}/${date}/${slug}`;
const post = await getPostByPath(path);
return post;
}
);
这种方式的问题在于:
- 每次访问都需要查询数据库:即使文章内容没有变化,也要执行数据库查询
- 无法充分利用 CDN 缓存:动态页面无法被 CDN 长期缓存
- 首屏加载速度较慢:需要等待数据库查询完成后才能渲染
改造方案
基于 Next.js 16 的缓存机制,我采用了 unstable_cache 直接调用 service 层的方案。
方案对比
在实施过程中,我尝试了两种方案:
方案 1:使用 fetch 调用本地 API
创建 /api/post/by-path/[...path]/route.ts,专门用于文章详情页的数据获取:
typescript
export async function GET(
_request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const { path } = await context.params;
const fullPath = '/' + path.join('/');
const post = await getPostByPath(fullPath);
const response = NextResponse.json(successResponse(post));
// 声明缓存策略
response.headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
response.headers.set('Next-Cache-Tags', `post:${post.id},post`);
return response;
}
export const fetchCache = 'force-cache';
export const dynamic = 'force-static';
在页面层使用 fetch API 调用:
typescript
async function getPost(params: PageProps["params"]): Promise<Post | null> {
const resolvedParams = await resolveParams(params);
const { year, month, date, title } = resolvedParams;
const decodedTitle = decodeURIComponent(title);
const apiPath = `${baseUrl}/api/post/by-path/${year}/${month}/${date}/${decodedTitle}`;
const response = await fetch(apiPath, {
next: {
tags: [`post`],
},
});
const result = await response.json();
if (!result.status) {
return null;
}
return result.data;
}
方案 1 的问题:
- 构建时使用
fetch调用localhost:3000/api/...会出现超时 - 因为构建时服务器尚未运行,
localhost:3000无法访问 - 即使设置了环境变量,构建阶段也没有 HTTP 服务器在监听
方案 2:使用 unstable_cache 直接调用 service 层(最终采用)
这是 Next.js 推荐的做法,绕过 HTTP 层直接在服务层做缓存:
typescript
import { unstable_cache } from 'next/cache';
import { getPostByPath, getPostList } from '@/services/post';
const getCachedPost = unstable_cache(
async (path: string) => {
return await getPostByPath(path);
},
['post'],
{
revalidate: 3600, // 1小时后重新验证(兜底机制)
tags: ['post'],
}
);
async function getPost(params: PageProps["params"]): Promise<Post | null> {
const resolvedParams = await resolveParams(params);
const { year, month, date, title } = resolvedParams;
const path = `/${year}/${month}/${date}/${title}`;
return await getCachedPost(path);
}
方案 2 的优势:
- ✅ 构建时直接访问数据库,不依赖 HTTP 服务器
- ✅ 缓存粒度更细,标签控制更灵活
- ✅ 性能更好,省去了 HTTP 请求的开销
- ✅ 代码更简洁,直接调用 service 函数
1. 文章详情页改造
完整的文章详情页实现:
typescript
// src/app/[year]/[month]/[date]/[title]/page.tsx
import { unstable_cache } from 'next/cache';
import { getPostByPath, getPostList } from '@/services/post';
const getCachedPost = unstable_cache(
async (path: string) => {
return await getPostByPath(path);
},
['post'],
{
revalidate: 3600,
tags: ['post'],
}
);
async function getPost(params: PageProps["params"]): Promise<Post | null> {
const resolvedParams = await resolveParams(params);
const { year, month, date, title } = resolvedParams;
const path = `/${year}/${month}/${date}/${title}`;
return await getCachedPost(path);
}
export default async function PostDetail({ params }: PageProps) {
const post = await getPost(params);
if (!post) {
notFound();
}
// 渲染文章...
}
export async function generateStaticParams() {
const { record } = await getPostList({ pageNum: 1, pageSize: 10000 });
return record.map((post) => {
const [, year, month, date, title] = post.path!.split("/");
return { year, month, date, title };
});
}
export const revalidate = 3600;
2. 其他列表页改造
同样的模式应用到其他页面:
首页:
typescript
const getCachedPosts = unstable_cache(
async (pageSize: number) => {
return await getPostList({
pageNum: 1,
pageSize,
hide: "0",
});
},
['home', 'post-list'],
{
revalidate: 3600,
tags: ['home', 'post-list'],
}
);
合集列表页:
typescript
const getCachedCollections = unstable_cache(
async () => {
return await getCollectionList({
pageSize: 100,
pageNum: 1,
status: 1,
});
},
['collection', 'collection-list'],
{
revalidate: 3600,
tags: ['collection', 'collection-list'],
}
);
标签页:
typescript
const getCachedAllTags = unstable_cache(
async () => {
return await getAllTags();
},
['tags', 'tag-list'],
{
revalidate: 3600,
tags: ['tags', 'tag-list'],
}
);
归档页:
typescript
const getCachedArchives = unstable_cache(
async () => {
return await getArchives();
},
['archives'],
{
revalidate: 3600,
tags: ['archives'],
}
);
3. 按需重新验证缓存
当文章被更新、创建或删除时,清除对应的缓存:
typescript
import { revalidateTag, revalidatePath } from 'next/cache';
// 创建文章
revalidateTag('home');
revalidateTag('post-list');
revalidateTag('archives');
revalidateTag('tags');
revalidateTag('tag-list');
// 更新/删除文章
revalidateTag('post');
revalidatePath(post.path);
// 合集操作
revalidateTag('collection');
revalidateTag('collection-list');
这样就实现了:
- 构建时预渲染所有文章页面
- 文章更新后按需刷新缓存
- 其他时候直接使用静态 HTML,性能最优
遇到的问题及解决方案
在实施 SSG 改造的过程中,遇到了几个棘手的问题,这里记录一下解决过程。
问题 1:文章标题包含中文时返回 404
现象:
访问包含中文标题的文章(如 /2026/01/15/MCP-认证升级:从-Headers-到-OAuth-2.0...)时,页面返回 404 错误。
排查过程:
- 检查日志发现 API 返回的数据格式是:
json
{
"status": true,
"message": "成功",
"data": { /* 文章数据 */ }
}
- 但页面代码中检查的是
result.success:
typescript
if (!result.success) { // ❌ 字段名错误
return null;
}
- 进一步发现,URL 中的中文被浏览器编码后(
%E8%AE%A4%E8%AF%81),Next.js 传递给页面组件的参数仍然是编码状态。
解决方案:
- 修正响应字段检查:
typescript
// 修复后
if (!result.status) { // ✅ 使用正确的字段名
return null;
}
- 正确处理 URL 编码:
typescript
// Next.js 传入的 title 是编码的,需要先解码
const decodedTitle = decodeURIComponent(title);
const apiPath = `${baseUrl}/api/post/by-path/${year}/${month}/${date}/${decodedTitle}`;
问题 2:构建时 fetch 本地 API 超时(关键问题)
现象:
在使用 fetch 调用本地 API 的方案中,执行 pnpm build 时出现超时错误:
Timeout fetching pathname: /2026/01/15/...
根本原因:
构建时使用 fetch 调用 localhost:3000/api/... 需要 HTTP 服务器在运行,但:
- Next.js 构建阶段
localhost:3000服务器尚未启动 - 即使设置环境变量指向本地服务器,构建时也没有 HTTP 服务监听
- 这是构建时静态生成(SSG)和运行时 API 调用的时序矛盾
尝试过的方案:
❌ 方案 A:设置环境变量
typescript
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
- 问题:构建时无论设置什么 URL,服务器都还没运行
❌ 方案 B:使用动态导入
typescript
const getPost = dynamic(() => import('./getPost'), { ssr: true });
- 问题:无法解决构建时数据获取的问题
✅ 方案 C:使用 unstable_cache 直接调用 service(最终方案)
typescript
import { unstable_cache } from 'next/cache';
import { getPostByPath } from '@/services/post';
const getCachedPost = unstable_cache(
async (path: string) => {
return await getPostByPath(path); // 直接调用 service 层
},
['post'],
{
revalidate: 3600,
tags: ['post'],
}
);
关键要点:
- 构建时 SSG 需要直接访问数据库,不能通过 HTTP API
unstable_cache是 Next.js 专门为此场景设计的 API- fetch 方案适合运行时数据获取,不适合构建时静态生成
问题 3:模板字符串在缓存标签中报错
现象:
在合集详情页和标签详情页中,使用模板字符串作为缓存标签时出现 TypeScript 错误:
typescript
// ❌ 错误写法
const getCachedCollectionBySlug = unstable_cache(
async (slug: string) => {
return await getCollectionBySlug(slug);
},
['collection'],
{
revalidate: 3600,
tags: ['collection', `collection:${slug}`], // TypeScript 错误:找不到 'slug'
}
);
错误原因:
unstable_cache 的 tags 选项在函数定义时就会进行类型检查,而此时 slug 参数还不存在。
解决方案:
移除动态标签,只保留静态标签。后续可以通过 revalidateTag('collection') 清除所有合集缓存,或者使用 revalidatePath('/collections/[slug]') 清除特定路径。
typescript
// ✅ 正确写法
const getCachedCollectionBySlug = unstable_cache(
async (slug: string) => {
return await getCollectionBySlug(slug);
},
['collection'],
{
revalidate: 3600,
tags: ['collection'], // 只使用静态标签
}
);
问题 4:归档页 reduce 函数类型错误
现象:
归档页面在计算总文章数时出现类型错误:
typescript
// ❌ 错误写法
const totalPosts = archives.reduce(
(sum: number, item: { posts: string[] }) => sum + item.posts.length,
0
);
错误原因:
posts 数组元素类型是 SerializedPost,不是 string。
解决方案:
使用更宽松的类型注解:
typescript
// ✅ 正确写法
const totalPosts = archives.reduce(
(sum: number, item: { posts: unknown[] }) => sum + item.posts.length,
0
);
技术方案对比
下面对比一下不同渲染方式的特点:
| 方式 | 首屏速度 | 数据新鲜度 | 构建时间 | 适用场景 |
|---|---|---|---|---|
| SSR(服务端渲染) | 中等 | 实时 | 快 | 需要实时数据的页面 |
| SSG(静态生成) | 最快 | 构建时 | 慢(文章多时) | 内容不经常变化 |
| ISR(增量静态再生成) | 快 | 按需更新 | 可控 | 内容偶尔更新 |
下面对比一下两种缓存方案:
| 方案 | 构建时支持 | 性能 | 复杂度 | 推荐场景 |
|---|---|---|---|---|
| fetch + API | ❌ 超时 | 中等 | 高 | 运行时数据获取 |
| unstable_cache | ✅ 正常 | 高 | 低 | 构建时 SSG/ISR |
我们的方案结合了 SSG 和按需重新验证,是一个比较均衡的选择。
改造效果
改造后的效果:
- 首屏加载速度提升:静态页面直接从 CDN 获取,无需查询数据库
- 数据库压力降低:大部分请求直接返回静态 HTML
- SEO 友好:完全静态的页面,爬虫可以直接抓取
- 更新及时:文章修改后通过
revalidateTag立即刷新缓存 - 构建成功:GitHub Actions 可以正常构建和部署
下一步优化方向
基于这次改造经验,还有以下几个方面可以优化:
1. 增量静态生成(ISR)
当前方案在构建时会生成所有文章页面,当文章数量很多时会导致构建时间过长。可以考虑:
typescript
// 只构建最新的 N 篇文章,其他按需生成
export async function generateStaticParams() {
const { record } = await getPostList({
pageNum: 1,
pageSize: 100 // 只构建前 100 篇
});
return record.map(/* ... */);
}
访问未构建的文章时,Next.js 会按需生成并缓存。
2. 部分预渲染(PPR)
Next.js 15 引入了部分预渲染(Partial Prerendering),可以结合静态 Shell 和动态内容:
typescript
export const experimental_ppr = true;
这样可以让页面的导航栏、侧边栏等静态部分立即显示,文章内容动态加载。
3. CDN 边缘缓存
结合 Vercel 或 Cloudflare 的边缘缓存,进一步提升性能:
typescript
response.headers.set(
'Cache-Control',
'public, s-maxage=3600, stale-while-revalidate=86400'
);
4. 图片优化
使用 Next.js Image 组件优化文章图片加载:
typescript
import Image from 'next/image';
<Image
src={cover}
alt={title}
width={1200}
height={630}
priority // 首屏图片优先加载
/>
5. 代码分割和懒加载
对评论组件等非首屏内容使用 Suspense:
typescript
<Suspense fallback={<div>加载评论中...</div>}>
<CommentSection postId={post.id} />
</Suspense>
参考资料
- Next.js Caching Documentation
- Next.js Incremental Static Regeneration
- Next.js generateStaticParams
- Next.js unstable_cache
总结
这次改造的核心思想是:将博客从动态渲染改为静态生成,同时保留按需更新的能力。通过合理使用 Next.js 的 unstable_cache 和重新验证机制,既保证了性能,又不失灵活性。
关键技术决策:
- ✅ 使用
unstable_cache直接调用 service 层(而非 fetch API) - ✅ 解决构建时数据获取问题
- ✅ 使用缓存标签实现按需清除
核心经验:
- 构建时静态生成需要直接访问数据库,不能依赖 HTTP API
- fetch 方案适合运行时数据获取,不适合构建时 SSG
unstable_cache是 Next.js 专门为 SSG/ISR 场景设计的 API
对于博客这类内容不经常变化的应用来说,静态生成是一个非常好的选择。不过在实施过程中也遇到了一些细节问题,特别是构建时 API 调用的限制和模板字符串的类型检查,这些都值得在后续开发中注意。
希望这篇文章能给类似场景的优化提供一些参考。