在这次优化前,项目里的文章阅读页虽然使用的是 Next.js App Router,但真正的文章内容并不是服务端直接输出的。
页面入口 reader/[articleId]/page.tsx 是一个 Client Component,顶部带着 "use client"。文章 ID 通过 useParams 获取,正文通过 useEffect 调用接口,再写入 Redux,最后由客户端渲染 Markdown。
这种方式能正常展示文章,但从首屏体验、SEO、分享预览和架构边界来看,都有比较明显的问题。
这次改造的目标很明确:先不一步到位做 ISR,而是先把 reader 页改成真正的 SSR,让文章正文在服务端渲染出来,同时保留点赞、收藏、评论、阅读记录这些客户端交互能力。
后续又顺手把项目里的 ReactMarkdown 封装拆成了 SSR/CSR 双入口:服务端页面可以直接复用同一套 Markdown 解析、安全过滤和样式,客户端编辑器仍然保留代码复制、图片预览、diagram 等交互能力。
原来的 reader 页有什么问题
原来的 reader 页结构大概是这样:
"use client";
const ReaderPage = () => {
const articleId = Number(useParams().articleId);
const article = useAppSelector(state => state.rootReducer.articleReducer.value);
const getArticle = useGetArticle();
useEffect(() => {
getArticle(articleId);
}, [articleId, getArticle]);
return <ReactMarkdown>{article.content}</ReactMarkdown>;
};
这里的问题不是“功能不能用”,而是渲染路径不适合文章阅读页。
浏览器拿到的初始 HTML 里没有文章标题和正文。页面需要先下载 JS,再执行 React,再请求文章接口,再更新 Redux,最后才能看到正文。

从用户角度看,这会影响首屏内容出现时间。
从搜索引擎和分享卡片角度看,初始 HTML 里缺少正文和文章元信息,也不利于 SEO 和链接预览。
从架构角度看,文章正文是相对稳定的公开内容,而点赞、收藏、评论、阅读记录才是强交互、强用户态的数据。把所有东西都放在一个 Client Component 里,会让静态内容和动态交互耦合在一起。
所以优化方向不是简单地“把 useEffect 挪一下”,而是重新划分服务端和客户端边界。
为什么先做 SSR,而不是直接做 SSG 或 ISR
文章页很容易想到 SSG 或 ISR,但我没有第一步就做静态化。
原因是当前 reader 页里混合了很多用户态逻辑:
- 作者可以查看自己的文章。
- 页面里有点赞和收藏状态。
- 评论区依赖当前登录用户。
- 阅读记录需要在客户端上报。
- 编辑入口只对作者可见。
如果直接做纯 SSG,需要先拆清楚哪些内容可以缓存,哪些内容必须按用户动态计算。否则很容易把用户态数据错误地静态化。
所以更稳妥的路径是分两步:
- 第一步,先改成 SSR,把正文从客户端请求变成服务端渲染。
- 第二步,在服务端边界清晰之后,再给公开正文加 ISR 和精准 revalidate。
这次做的是第一步。
改造后的整体结构
改造后的 reader 页采用“Server Page + Client Shell”的结构。
import MarkdownServer from '@/components/ReactMarkdown/server';
export default async function ReaderPage({ params }) {
const articleId = parseArticleId(params.articleId);
const article = await getPublicArticle(articleId);
if (!article) notFound();
return (
<NavLayout>
<ReaderClientShell
articleId={article.id}
authorId={article.author_id}
markdown={article.content}
>
<article>
<ReaderHeader article={article} />
<MarkdownServer>{article.content}</MarkdownServer>
</article>
</ReaderClientShell>
</NavLayout>
);
}
这里有三个关键点。
第一,page.tsx 不再是 Client Component,而是 Server Component。
第二,文章正文由服务端直接查询并渲染。
第三,客户端交互没有被删掉,而是收敛到 ReaderClientShell 里。
这样一来,文章正文可以进入初始 HTML,而点赞、收藏、评论、目录滚动这些交互仍然可以在客户端运行。

Server Component 可以包含 Client Component 吗
可以。
这是 Next.js App Router 里非常常见的模式。Server Component 可以 import 并渲染 Client Component,但 Client Component 不能反向 import Server Component。
这次 reader 页就是这种组合:
<ReaderClientShell articleId={article.id} markdown={article.content}>
<article>
<MarkdownServer>{article.content}</MarkdownServer>
</article>
</ReaderClientShell>
ReaderClientShell 是客户端组件,但传进去的 children 是服务端渲染出来的文章内容。React Server Components 支持这种模式。
需要注意的是,传给 Client Component 的普通 props 必须是可序列化的。比如 articleId、authorId、markdown 都是普通数据,可以安全传递。
服务端读取文章数据
原来的客户端页面会请求 /api/articles/[articleId]。改成 SSR 后,页面可以直接调用服务端 service:
const getPublicArticle = async (articleId: number) => {
if (!articleId) return null;
return getArticle(articleId, await getViewerUserId());
};
这里没有只传 0,而是读取了当前请求里的 token cookie,并解析出 viewerUserId。
原因是原来的接口有这样的权限逻辑:
SELECT * FROM articles
WHERE id = ?
AND ((is_published = 1 AND view_permission = 'all') OR author_id = ?)
也就是说,公开文章所有人能看,作者本人还能看自己的文章。SSR 后需要保留这个行为,所以服务端页面也要知道当前 viewer 是谁。
实现上可以通过 next/headers 的 cookies() 读取 token,再用已有的 verifyToken 解析:
const getViewerUserId = async () => {
const token = (await cookies()).get('token')?.value;
if (!token) return 0;
try {
return verifyToken(token).userId;
} catch {
return 0;
}
};
这一步让 SSR 页面保持了和原 API 一致的访问语义。
为什么要把 ReactMarkdown 拆成 SSR/CSR 双入口
改造时遇到一个很典型的问题:原来的 ReactMarkdown 封装不能直接放进 Server Component。
原因不是 react-markdown 这个库不能 SSR,而是项目里的封装组件把服务端安全的 Markdown 解析逻辑和客户端交互组件混在了一起。
比如原组件里有这些能力:
- 代码块复制,需要
document和navigator.clipboard。 - 代码块折叠,需要
useState。 - diagram 预览,需要
useEffect请求数据。 - 图片预览使用
antd Image,更适合作为客户端增强。
当服务端页面直接 import 这个封装时,Next.js build 会报错:
You're importing a component that needs useState.
This React hook only works in a client component.
所以正确的方向不是在服务端组件里绕过报错,而是把 Markdown 渲染拆成两层。
第一层是 SSR/CSR 共享的核心渲染器:
// src/components/ReactMarkdown/core.tsx
export const MarkdownRenderer = ({ components, ...props }) => {
return (
<span className={styles.markdown}>
<ReactMarkdown
{...props}
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitizeMarkdownHtml, rehypeHighlight, rehypeKatex]}
urlTransform={safeUrlTransform}
components={{
...serverMarkdownComponents,
...components,
}}
/>
</span>
);
};
这一层只保留 Markdown 解析、GFM、数学公式、高亮、HTML sanitize、链接和图片地址过滤、标题锚点等服务端安全能力。
第二层是服务端入口:
// src/components/ReactMarkdown/server.tsx
import {MarkdownRenderer, serverMarkdownComponents} from '@/components/ReactMarkdown/core';
const MarkdownServer = (props) => {
return (
<MarkdownRenderer
{...props}
components={{
...serverMarkdownComponents,
...props.components,
}}
/>
);
};
export default MarkdownServer;
这个入口不带 "use client",也不 import 任何依赖浏览器环境的组件,因此可以被 reader/[articleId]/page.tsx 直接使用。
第三层是客户端入口:
// src/components/ReactMarkdown/index.tsx
"use client";
const MyReactMarkdown = (props) => {
return (
<MarkdownRenderer
{...props}
components={{
...serverMarkdownComponents,
pre: PreParseComponents,
img: ImageComponents,
...props.components,
}}
/>
);
};
客户端入口继续保留代码复制、折叠、图片预览和 diagram 等交互能力。编辑器、助手对话等本来就在客户端运行的地方,仍然可以从 @/components/ReactMarkdown 导入默认组件。
reader 页则改成直接导入服务端入口:
import MarkdownServer from '@/components/ReactMarkdown/server';
这样做之后,项目里不需要为 reader 页单独维护一份本地 MarkdownServer.tsx。服务端和客户端共享同一套 Markdown 解析、安全过滤和基础样式,只是在具体组件增强上走不同入口。
客户端交互收敛到 ReaderClientShell
文章正文服务端化之后,原来页面里的交互逻辑被挪到了 ReaderClientShell。
它负责:
- 点赞。
- 收藏。
- 分享。
- 评论列表和评论发送。
- 阅读记录上报。
- 作者信息卡片。
- 右侧目录和锚点滚动。
- 作者本人编辑入口。
这些功能本来就依赖浏览器环境、登录态、用户行为或客户端状态,不适合服务端静态输出。
拆分之后,页面边界变得更清楚:
page.tsx负责服务端数据获取和首屏内容。@/components/ReactMarkdown/server负责服务端 Markdown 渲染。ReaderClientShell负责用户交互。
这比原来一个超大的 Client Component 更容易维护,也为后续 ISR 做好了准备。
Metadata 也顺手服务端化
文章页改成 Server Component 后,可以直接加 generateMetadata。
export async function generateMetadata({ params }) {
const article = await getPublicArticle(parseArticleId(params.articleId));
if (!article) return {};
return {
title: article.title,
description: article.summary || article.content.slice(0, 120),
openGraph: {
title: article.title,
description: article.summary || article.content.slice(0, 120),
images: article.cover ? [article.cover] : undefined,
type: 'article',
},
};
}
这样浏览器拿到的 HTML 里会直接包含文章标题、摘要和 Open Graph 信息。分享链接时,平台可以更准确地抓取文章信息。
这也是 SSR 对内容页最直接的收益之一。
怎么确认已经变成 SSR
改造后可以从两个维度确认。
第一,看构建输出。
ƒ /reader/[articleId]
ƒ 表示这个路由是 server-rendered on demand。
第二,看初始 HTML。
改造前,HTML 里类似这样:
<span class="ReaderHeader_title"></span>
<span class="ReactMarkdown_markdown"></span>
标题和正文都是空的,说明内容要等客户端 JS 请求后再填充。
改造后,HTML 里已经能看到完整标题、正文段落、代码块和标题锚点:
<span class="_articleId__title">从 img 埋点到 react-track-hooks...</span>
<span class="ReactMarkdown_markdown">
<p>在上一家公司做埋点业务开发时...</p>
<pre><code class="hljs language-ts">...</code></pre>
<h2 id="原来的-img-埋点方案有什么问题">原来的 img 埋点方案有什么问题</h2>
</span>
这就说明正文已经在服务端完成渲染,不再依赖客户端请求后补内容。
第三,看 Markdown 组件边界。
服务端 reader 页只 import:
import MarkdownServer from '@/components/ReactMarkdown/server';
客户端编辑器、AI 助手等地方继续 import:
import ReactMarkdown from '@/components/ReactMarkdown';
这说明 Markdown 组件本身已经具备 SSR 和 CSR 两套入口,而不是靠 reader 页临时复制一份渲染逻辑。
这次改造带来的收益
第一,首屏内容更快可见。
文章标题和正文随 HTML 一起返回,浏览器不需要等 JS 执行和接口请求完成后才看到内容。
第二,SEO 和分享预览更友好。
初始 HTML 中已经有正文和 metadata,搜索引擎和分享平台更容易抓取有效信息。
第三,服务端和客户端职责更清楚。
稳定的文章正文走服务端渲染,用户交互走客户端组件,后续维护成本更低。
第四,Markdown 渲染逻辑不再重复。
服务端和客户端共享 core.tsx 里的解析、安全过滤和基础组件,reader 页不需要维护一份孤立的本地 Markdown 渲染器。
第五,为 ISR 铺路。
现在正文已经服务端化,下一步只需要围绕公开文章内容加 revalidate 和发布后的 revalidatePath,就可以进一步从 SSR 演进到 ISR。
后续可以继续优化什么
这次只是先完成 SSR,不是最终形态。
后续可以继续做几件事。
第一,加入 ISR。
公开文章可以设置 revalidate = 300,让正文在缓存中复用,减少数据库压力。
第二,发布和更新文章时精准刷新。
文章发布、更新、删除后调用 revalidatePath('/reader/[articleId]') 或使用 tag revalidate,避免缓存长期不一致。
第三,给 Markdown 交互做更细的客户端增强。
目前 CSR 入口已经保留了代码复制、图片预览和 diagram。后续如果 reader 页也想要这些增强,可以继续拆成更小的 client island,而不是让整篇 Markdown 正文退回客户端渲染。
第四,继续优化评论区。
评论目前仍然是客户端请求,这本身合理。但如果希望首屏展示最新评论,也可以把首屏评论做服务端渲染,发送评论后再客户端刷新。
总结
这次 reader 页 SSR 改造,本质上不是简单地把 useEffect 换成服务端请求,而是重新划分内容页的渲染边界。
文章正文、标题、作者、发布时间和 metadata 属于稳定内容,适合服务端输出。
点赞、收藏、评论、阅读记录、目录滚动和编辑入口属于用户交互,适合客户端组件承载。
通过 Server Component 渲染正文,再用 Client Component 包住交互层,reader 页既保留了原有交互能力,又让初始 HTML 真正包含了文章内容。
同时,ReactMarkdown 封装也从“只能客户端使用的组件”升级成了“SSR/CSR 双入口组件”:core.tsx 负责共享解析与安全处理,server.tsx 负责服务端输出,index.tsx 负责客户端增强。
这一步完成后,页面已经从客户端 runtime 渲染升级为 SSR。接下来再加 ISR,就可以在首屏、SEO 和服务端性能之间取得更好的平衡。