Next.js 博客构建优化:从按需渲染到静态生成,聊聊文章预渲染改造实践

2026年01月16日16 次阅读0 人喜欢
Next.js前端实践性能优化SSGISR
所属合集

前言

最近对博客系统做了一次构建优化,主要是将文章详情页从动态渲染改为静态生成(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;
  }
);

这种方式的问题在于:

  1. 每次访问都需要查询数据库:即使文章内容没有变化,也要执行数据库查询
  2. 无法充分利用 CDN 缓存:动态页面无法被 CDN 长期缓存
  3. 首屏加载速度较慢:需要等待数据库查询完成后才能渲染

改造方案

基于 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 错误。

排查过程

  1. 检查日志发现 API 返回的数据格式是:
json 复制代码
{
  "status": true,
  "message": "成功",
  "data": { /* 文章数据 */ }
}
  1. 但页面代码中检查的是 result.success
typescript 复制代码
if (!result.success) {  // ❌ 字段名错误
  return null;
}
  1. 进一步发现,URL 中的中文被浏览器编码后(%E8%AE%A4%E8%AF%81),Next.js 传递给页面组件的参数仍然是编码状态。

解决方案

  1. 修正响应字段检查
typescript 复制代码
// 修复后
if (!result.status) {  // ✅ 使用正确的字段名
  return null;
}
  1. 正确处理 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_cachetags 选项在函数定义时就会进行类型检查,而此时 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 和按需重新验证,是一个比较均衡的选择。

改造效果

改造后的效果:

  1. 首屏加载速度提升:静态页面直接从 CDN 获取,无需查询数据库
  2. 数据库压力降低:大部分请求直接返回静态 HTML
  3. SEO 友好:完全静态的页面,爬虫可以直接抓取
  4. 更新及时:文章修改后通过 revalidateTag 立即刷新缓存
  5. 构建成功: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 的 unstable_cache 和重新验证机制,既保证了性能,又不失灵活性。

关键技术决策

  • ✅ 使用 unstable_cache 直接调用 service 层(而非 fetch API)
  • ✅ 解决构建时数据获取问题
  • ✅ 使用缓存标签实现按需清除

核心经验

  • 构建时静态生成需要直接访问数据库,不能依赖 HTTP API
  • fetch 方案适合运行时数据获取,不适合构建时 SSG
  • unstable_cache 是 Next.js 专门为 SSG/ISR 场景设计的 API

对于博客这类内容不经常变化的应用来说,静态生成是一个非常好的选择。不过在实施过程中也遇到了一些细节问题,特别是构建时 API 调用的限制和模板字符串的类型检查,这些都值得在后续开发中注意。

希望这篇文章能给类似场景的优化提供一些参考。

加载评论中...