创见博客
从 SSR 到 ISR:一次 Next.js Reader 页缓存与权限边界改造
七崽爱吃小饼干2026/07/15阅读 5专栏 Next.js

上一篇改造里,reader 页已经从纯客户端渲染迁移到了 SSR。

上一篇文章链接:从客户端渲染到 SSR:一次 Next.js Reader 页首屏与 SEO 优化实践

迁移之后,文章标题、正文和 metadata 都能在服务端直接输出,首屏和 SEO 都比原来只靠 useEffect 拉数据再渲染 Markdown 要好很多。

但 SSR 不是终点。

对公开文章页来说,内容相对稳定,所有用户看到的正文也应该一致。如果每一次访问都重新查数据库、重新渲染 Markdown,其实还有继续优化的空间。

这次的目标就是在 SSR 的基础上继续往前走一步:把公开 reader 页改造成 ISR,同时把非公开文章阅读预览拆到 /reader/preview/[articleId],避免缓存和权限混在一起。

后续又把缓存策略从“按时间过期”调整成了“长期缓存 + 内容变更时按需刷新”。对文章详情页来说,这比固定 300 秒过期更合适。

为什么 SSR 后还要继续做 ISR

SSR 的收益很明显:请求到来时,服务端实时查询文章数据并生成 HTML。

这让初始 HTML 里包含了文章正文。

但它也有一个代价:每次请求都要经过服务端渲染链路。

对于文章详情页,这个代价不一定必要。

一篇公开文章发布后,正文、标题、作者、发布时间、摘要这些内容不会频繁变化。多数访问者看到的内容完全一样。

这种页面更适合缓存 HTML。

也就是说,第一次访问时生成 HTML,后续请求直接复用缓存;文章发布、更新或删除时,再主动让对应路径失效。

这就是 ISR 适合 reader 页的原因。

直接加 revalidate 为什么不够

一开始 reader 页虽然已经是 Server Component,但它仍然有一个问题:读取文章前需要根据当前用户鉴权。

原来的查询逻辑大概是:

sql
SELECT * FROM articles
WHERE id = ?
  AND (
    (is_published = 1 AND view_permission = 'all')
    OR author_id = ?
  )

这个逻辑的含义是:

  • 已发布且公开的文章,所有人都能看。
  • 非公开文章,只有作者本人能看。

为了保留这个行为,SSR 页面里会读取当前请求的 cookie,解析 token,得到 viewerUserId,再传给 getArticle(articleId, viewerUserId)。

这对 SSR 没问题,但对 ISR 有问题。

因为 ISR 缓存的是页面 HTML。如果同一个 URL 的 HTML 会因为当前用户不同而不同,就不能把它作为公共缓存。

举个例子:

  • 作者访问 /reader/123,因为 author_id = 当前用户,能看到非公开文章。
  • 这次请求如果生成了缓存 HTML。
  • 后续未登录用户访问同一个 /reader/123,如果直接命中缓存,就可能看到本不该看到的内容。

这就是权限泄露。

所以问题不在 revalidate 本身,而在页面是否适合被公共缓存。

只要页面读取了 cookies()、headers() 或其他用户态数据,通常就要先问一个问题:这个页面的 HTML 是否对所有用户都一样?

如果答案是否定的,就不能直接做公共 ISR。

正确拆法:公开 reader 和预览 reader 分开

这次的核心改造是拆路由。

公开阅读页:

txt
/reader/[articleId]

只负责展示已发布、公开可见的文章。

非公开预览页:

txt
/reader/preview/[articleId]

负责作者本人预览非公开文章,保留动态鉴权,不做缓存。

这两个页面看起来都叫 reader,但职责完全不同。

公开 reader 是内容分发页面,适合 ISR。

preview reader 是权限页面,必须动态渲染。

拆开之后,缓存边界就清楚了。

公开 reader:只读取公开文章

公开 reader 页不再读取 cookie,也不再解析 token。

它只查公开文章:

ts
export const getPublishedPublicArticle = async (articleId: number) => {
  const connection = await pool.getConnection();

  try {
    const sql = `
      SELECT * FROM articles
      WHERE id = ?
        AND is_published = 1
        AND view_permission = 'all'
    `;

    const [rows] = await connection.execute(sql, [articleId]);
    return Array.isArray(rows) && rows.length > 0 ? rows[0] as ArticleDto : null;
  } finally {
    connection.release();
  }
};

这里的关键点是:查询结果不再依赖当前用户。

同一个 /reader/123,只要文章存在且公开,所有用户拿到的 HTML 都一样。

这样它才适合 ISR。

页面里也明确声明为静态缓存,并且不再靠固定时间过期:

tsx
export const dynamic = 'force-static';
export const revalidate = false;

force-static 表示公开 reader 按静态页面缓存处理。

revalidate = false 表示这份 HTML 不按时间自动过期,而是长期缓存,直到业务主动让它失效。

完整结构大概是:

tsx
import {notFound} from 'next/navigation';
import NavLayout from '@/components/NavLayout';
import {getPublishedPublicArticle} from '@/server/article/article.service';
import ArticleReaderContent from './ArticleReaderContent';
import ReaderClientShell from './ReaderClientShell';

export const dynamic = 'force-static';
export const revalidate = false;

const getPublicArticle = async (articleId: number) => {
  if (!articleId) return null;
  return getPublishedPublicArticle(articleId);
};

const ReaderPage = async ({ params }) => {
  const {articleId: articleIdParam} = await params;
  const article = await getPublicArticle(Number(articleIdParam));

  if (!article) notFound();

  return (
    <NavLayout>
      <ReaderClientShell
        articleId={article.id}
        authorId={article.author_id}
        markdown={article.content}
      >
        <ArticleReaderContent article={article} />
      </ReaderClientShell>
    </NavLayout>
  );
};

这样构建后,路由会从动态 SSR 变成静态缓存路由:

txt
○ /reader/[articleId]

○ 表示这个路由会作为静态内容处理。配合按需 revalidatePath,它就是一个长期缓存、内容变更时主动刷新的 ISR 页面。

preview reader:保留动态鉴权

非公开文章预览不能走公共缓存,所以单独放到:

txt
/reader/preview/[articleId]

这个页面明确声明动态渲染:

tsx
export const dynamic = 'force-dynamic';
export const revalidate = 0;

它继续读取 cookie,解析当前用户,再调用原来的权限查询:

tsx
const getViewerUserId = async () => {
  const token = (await cookies()).get('token')?.value;
  if (!token) return 0;

  try {
    return verifyToken(token).userId;
  } catch {
    return 0;
  }
};

const getPreviewArticle = async (articleId: number) => {
  if (!articleId) return null;
  return getArticle(articleId, await getViewerUserId());
};

这样作者本人仍然能预览自己的非公开文章,但这个页面不会进入公共 ISR 缓存。

同时,preview 页的 metadata 也设置成不允许索引:

tsx
export async function generateMetadata(): Promise<Metadata> {
  return {
    title: '文章预览',
    robots: {
      index: false,
      follow: false,
    },
  };
}

这一步也很重要。

预览页本质上不是面向搜索引擎的公开内容页,不应该被抓取和收录。

复用文章正文组件,避免两套 reader UI

拆路由不代表要复制两份页面。

公开 reader 和 preview reader 都需要展示文章标题、作者、发布时间和 Markdown 正文。

所以这部分被抽成了 ArticleReaderContent:

tsx
const ArticleReaderContent = ({article}: { article: ArticleDto }) => {
  return (
    <article className={styles.readerContent}>
      <ReaderHeader article={article} />
      <MarkdownServer>{article.content}</MarkdownServer>
    </article>
  );
};

公开页和预览页都复用它。

真正不同的是数据来源和缓存策略。

这也是拆 Server Component 时比较推荐的方式:

  • 复用稳定的展示组件。
  • 分离不同路由的数据读取和权限策略。
  • 不为了复用,把权限和缓存逻辑重新揉在一起。

preview 模式下禁用写入型交互

reader 页外层还有一个客户端壳 ReaderClientShell。

它负责点赞、收藏、评论、分享、阅读记录、作者卡片和目录滚动。

公开 reader 里这些交互可以保留。

但 preview reader 不应该写阅读记录,也不应该展示点赞、收藏、评论这些面向公开文章的交互。

所以 ReaderClientShell 增加了 isPreview:

tsx
<ReaderClientShell
  articleId={article.id}
  authorId={article.author_id}
  markdown={article.content}
  isPreview
>
  <ArticleReaderContent article={article} />
</ReaderClientShell>

内部根据 isPreview 跳过阅读记录:

tsx
useEffect(() => {
  if (isPreview || hasInsertedData.current || isLoading || !articleId || !userId) return;
  void insertArticleReadingRecord(articleId, userId);
  hasInsertedData.current = true;
}, [articleId, insertArticleReadingRecord, isLoading, isPreview, userId]);

并隐藏操作按钮和评论区:

tsx
{!isPreview && <div className={styles.operator}>...</div>}

<div className={styles.centerContent}>
  {children}
  {!isPreview && <Comments articleId={articleId} />}
</div>

这样 preview 页仍然能看到文章正文和目录,但不会产生公开阅读页才应该有的数据写入。

为什么不再使用固定时间过期

最开始的 ISR 版本使用的是:

tsx
export const revalidate = 300;

这个配置的含义是缓存最多有效 300 秒。超过时间后,下一次访问会触发重新生成。

这种方式能工作,但对文章详情页并不是最优。

原因是文章内容不是每 300 秒就会变化一次。固定时间过期会带来一些不必要的重新生成,也会让缓存命中不够稳定。

更适合文章详情页的方式是:

tsx
export const revalidate = false;

然后在内容真正变化时主动刷新:

ts
revalidatePath(`/reader/${articleId}`);

这样缓存策略变成:

  1. 第一次访问 /reader/123。
  2. 如果 HTML 不存在,Next.js 查询数据库、渲染 Markdown、生成 HTML。
  3. 生成的 HTML 长期缓存。
  4. 后续访问直接复用缓存。
  5. 文章发布、更新或删除时主动 revalidatePath('/reader/123')。
  6. 下次访问重新生成 HTML。

这比固定时间过期更符合文章详情页的业务特征:不变时一直复用,变化时精准刷新。

哪些地方需要主动刷新

既然公开 reader 不再按时间自动过期,就必须保证内容变化时主动刷新缓存。

目前有两个关键点。

第一,文章审核通过后刷新。

发布新文章或更新已有文章时,审核通过后会更新 articles 表:

ts
await connection.execute(`UPDATE articles SET ... WHERE id = ?`, [..., articleId]);
await updateReviewStatus(reviewId, 'review_success', connection);
revalidatePath(`/reader/${articleId}`);

这样公开文章内容变化后,对应 reader 页缓存会失效。下一次访问时会重新读取数据库并生成新的 HTML。

第二,删除文章后刷新。

删除文章时也要刷新缓存,否则已经生成过的 HTML 可能继续被访问到。

ts
export const deleteArticleById = async (articleId: number) => {
  const connection = await pool.getConnection();

  try {
    const draftId = await getDraftId(articleId, connection);
    await connection.execute(`DELETE FROM articles WHERE id = ?;`, [articleId]);
    if (draftId) await connection.execute(`DELETE FROM drafts WHERE id = ?;`, [draftId]);
    revalidatePath(`/reader/${articleId}`);
    return '删除成功';
  } finally {
    connection.release();
  }
};

删除后路径失效,下次访问会重新执行公开查询。因为文章已经不存在,所以会走 notFound()。

如果后续新增“公开改私密”“私密改公开”“只改封面/摘要”等能力,也要在对应写入点补 revalidatePath。

ISR 和权限的边界

这次改造最重要的点不是加了两行配置,而是把缓存边界和权限边界拆清楚了。

公开 reader 满足这些条件:

  • 不读取 cookie。
  • 不依赖当前用户。
  • 只查询公开已发布文章。
  • 同一个 URL 对所有用户返回同一份正文 HTML。
  • 可以 force-static 和长期缓存。
  • 内容变化时由业务主动 revalidatePath。

preview reader 则满足另一组条件:

  • 读取 cookie。
  • 依赖当前用户。
  • 可以访问作者自己的非公开文章。
  • 不缓存。
  • 不允许搜索引擎索引。

这两个页面如果混在一起,就会导致缓存和权限相互污染。

拆开之后,公开页可以大胆做 ISR,预览页继续保持动态安全。

怎么确认改造成功

第一,看构建输出。

改造后构建结果里应该能看到:

txt
○ /reader/[articleId]
ƒ /reader/preview/[articleId]

这里的含义是:

  • ○ /reader/[articleId] 是静态缓存页面,可以 ISR。
  • ƒ /reader/preview/[articleId] 是按需动态服务端渲染页面。

第二,看公开 reader 代码里是否还存在用户态读取。

公开 /reader/[articleId] 不应该再 import:

tsx
cookies
headers
verifyToken

如果这些逻辑还在公开 reader 里,基本就说明它仍然不是一个干净的 ISR 页面。

第三,看缓存刷新点是否齐全。

现在至少要覆盖:

  • 发布审核通过:revalidatePath('/reader/${articleId}')
  • 更新审核通过:revalidatePath('/reader/${articleId}')
  • 删除文章:revalidatePath('/reader/${articleId}')

因为公开 reader 已经不再按时间兜底过期,所有内容变化都应该主动刷新。

第四,看非公开访问是否走 preview。

作者预览自己的非公开文章,应该访问:

txt
/reader/preview/[articleId]

普通公开访问则继续走:

txt
/reader/[articleId]

这次改造带来的收益

第一,公开文章访问更快。

公开 reader 的 HTML 可以被缓存,后续请求不需要每次都重新走完整 SSR 链路。

第二,数据库压力更低。

热门文章可以长期复用同一份 HTML,减少重复查询。

第三,缓存更新更精准。

不再每 300 秒无意义过期,而是在发布、更新、删除这些真实内容变化时刷新。

第四,权限边界更安全。

非公开文章不再和公开 reader 共用同一个缓存路径,避免作者预览内容被公共缓存复用。

第五,代码职责更清楚。

/reader/[articleId] 是公开内容分发页,/reader/preview/[articleId] 是动态鉴权预览页。

第六,后续扩展更简单。

如果以后要给公开文章列表、用户主页文章列表、专栏页继续加 ISR,也可以沿用这套思路:先确认页面输出是否和用户身份无关,再决定是否缓存。

总结

从 SSR 到 ISR,不是简单地给页面加一个 revalidate。

真正要解决的是:这个页面的 HTML 能不能被所有用户安全复用,以及内容变化时能不能精准刷新缓存。

reader 页原来同时承担公开阅读和作者预览两个职责,所以不能直接公共缓存。

这次改造把它拆成了两个路由:

  • /reader/[articleId]:只展示公开已发布文章,长期静态缓存。
  • /reader/preview/[articleId]:保留作者鉴权预览,动态渲染,不缓存。

然后再配合 revalidate = false 和发布、更新、删除时的 revalidatePath,让公开文章页既能长期复用缓存,又能在内容变化后精准刷新。

这一步完成后,reader 页的渲染链路就更完整了:先从 CSR 迁到 SSR,让 HTML 里真正有内容;再从 SSR 演进到 ISR,让公开内容可以安全缓存;最后把 ISR 的刷新方式从固定时间过期改为业务事件驱动,更贴合文章详情页的使用场景。

评论
0/100