上一篇改造里,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,但它仍然有一个问题:读取文章前需要根据当前用户鉴权。
原来的查询逻辑大概是:
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 分开
这次的核心改造是拆路由。
公开阅读页:
/reader/[articleId]
只负责展示已发布、公开可见的文章。
非公开预览页:
/reader/preview/[articleId]
负责作者本人预览非公开文章,保留动态鉴权,不做缓存。
这两个页面看起来都叫 reader,但职责完全不同。
公开 reader 是内容分发页面,适合 ISR。
preview reader 是权限页面,必须动态渲染。
拆开之后,缓存边界就清楚了。
公开 reader:只读取公开文章
公开 reader 页不再读取 cookie,也不再解析 token。
它只查公开文章:
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。
页面里也明确声明为静态缓存,并且不再靠固定时间过期:
export const dynamic = 'force-static';
export const revalidate = false;
force-static 表示公开 reader 按静态页面缓存处理。
revalidate = false 表示这份 HTML 不按时间自动过期,而是长期缓存,直到业务主动让它失效。
完整结构大概是:
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 变成静态缓存路由:
○ /reader/[articleId]
○ 表示这个路由会作为静态内容处理。配合按需 revalidatePath,它就是一个长期缓存、内容变更时主动刷新的 ISR 页面。
preview reader:保留动态鉴权
非公开文章预览不能走公共缓存,所以单独放到:
/reader/preview/[articleId]
这个页面明确声明动态渲染:
export const dynamic = 'force-dynamic';
export const revalidate = 0;
它继续读取 cookie,解析当前用户,再调用原来的权限查询:
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 也设置成不允许索引:
export async function generateMetadata(): Promise<Metadata> {
return {
title: '文章预览',
robots: {
index: false,
follow: false,
},
};
}
这一步也很重要。
预览页本质上不是面向搜索引擎的公开内容页,不应该被抓取和收录。
复用文章正文组件,避免两套 reader UI
拆路由不代表要复制两份页面。
公开 reader 和 preview reader 都需要展示文章标题、作者、发布时间和 Markdown 正文。
所以这部分被抽成了 ArticleReaderContent:
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:
<ReaderClientShell
articleId={article.id}
authorId={article.author_id}
markdown={article.content}
isPreview
>
<ArticleReaderContent article={article} />
</ReaderClientShell>
内部根据 isPreview 跳过阅读记录:
useEffect(() => {
if (isPreview || hasInsertedData.current || isLoading || !articleId || !userId) return;
void insertArticleReadingRecord(articleId, userId);
hasInsertedData.current = true;
}, [articleId, insertArticleReadingRecord, isLoading, isPreview, userId]);
并隐藏操作按钮和评论区:
{!isPreview && <div className={styles.operator}>...</div>}
<div className={styles.centerContent}>
{children}
{!isPreview && <Comments articleId={articleId} />}
</div>
这样 preview 页仍然能看到文章正文和目录,但不会产生公开阅读页才应该有的数据写入。
为什么不再使用固定时间过期
最开始的 ISR 版本使用的是:
export const revalidate = 300;
这个配置的含义是缓存最多有效 300 秒。超过时间后,下一次访问会触发重新生成。
这种方式能工作,但对文章详情页并不是最优。
原因是文章内容不是每 300 秒就会变化一次。固定时间过期会带来一些不必要的重新生成,也会让缓存命中不够稳定。
更适合文章详情页的方式是:
export const revalidate = false;
然后在内容真正变化时主动刷新:
revalidatePath(`/reader/${articleId}`);
这样缓存策略变成:
- 第一次访问
/reader/123。 - 如果 HTML 不存在,Next.js 查询数据库、渲染 Markdown、生成 HTML。
- 生成的 HTML 长期缓存。
- 后续访问直接复用缓存。
- 文章发布、更新或删除时主动
revalidatePath('/reader/123')。 - 下次访问重新生成 HTML。
这比固定时间过期更符合文章详情页的业务特征:不变时一直复用,变化时精准刷新。
哪些地方需要主动刷新
既然公开 reader 不再按时间自动过期,就必须保证内容变化时主动刷新缓存。
目前有两个关键点。
第一,文章审核通过后刷新。
发布新文章或更新已有文章时,审核通过后会更新 articles 表:
await connection.execute(`UPDATE articles SET ... WHERE id = ?`, [..., articleId]);
await updateReviewStatus(reviewId, 'review_success', connection);
revalidatePath(`/reader/${articleId}`);
这样公开文章内容变化后,对应 reader 页缓存会失效。下一次访问时会重新读取数据库并生成新的 HTML。
第二,删除文章后刷新。
删除文章时也要刷新缓存,否则已经生成过的 HTML 可能继续被访问到。
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,预览页继续保持动态安全。
怎么确认改造成功
第一,看构建输出。
改造后构建结果里应该能看到:
○ /reader/[articleId]
ƒ /reader/preview/[articleId]
这里的含义是:
○ /reader/[articleId]是静态缓存页面,可以 ISR。ƒ /reader/preview/[articleId]是按需动态服务端渲染页面。
第二,看公开 reader 代码里是否还存在用户态读取。
公开 /reader/[articleId] 不应该再 import:
cookies
headers
verifyToken
如果这些逻辑还在公开 reader 里,基本就说明它仍然不是一个干净的 ISR 页面。
第三,看缓存刷新点是否齐全。
现在至少要覆盖:
- 发布审核通过:
revalidatePath('/reader/${articleId}') - 更新审核通过:
revalidatePath('/reader/${articleId}') - 删除文章:
revalidatePath('/reader/${articleId}')
因为公开 reader 已经不再按时间兜底过期,所有内容变化都应该主动刷新。
第四,看非公开访问是否走 preview。
作者预览自己的非公开文章,应该访问:
/reader/preview/[articleId]
普通公开访问则继续走:
/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 的刷新方式从固定时间过期改为业务事件驱动,更贴合文章详情页的使用场景。