服务端渲染与 SEO:为什么源码里必须有正文
从爬虫的两阶段索引机制讲清 CSR 为什么拖慢收录甚至进不了索引,SSR 与预渲染为什么对爬虫友好;附四种自查方法、SSR 也会翻车的五个坑与按页面职责分的渲染选型表。
- SEO
- 概念
- 入门
- 更新于 2026-10-31
做技术内容站最容易踩的坑,不是关键词没选好,而是发布三个月了,搜索结果里一条都没有。原因往往不在内容本身,而在渲染方式:你写给搜索引擎看的正文,压根没出现在它第一次抓取时拿到的 HTML 里。
这篇把这件事从头讲透:搜索引擎到底分几步读你的页面、客户端渲染具体会造成哪些可观察的后果、怎么用五分钟自证自己的页面是不是安全的,最后给一张按页面职责分的渲染选型表。你读完应该能自己判断一个站点该用 SSR、SSG 还是 ISR,以及怎么在不发版的情况下验证爬虫到底看到了什么。
一、搜索引擎读一个页面,其实分两步
这是所有误解的源头。搜索引擎处理一个 URL 时,并不是「拿到 HTML → 立刻索引」这么简单,而是两个阶段:
第一阶段:抓取(Crawl)
爬虫下载原始 HTML。这一步拿到什么,它就登记什么:发现了哪些链接、页面里有哪些文字、<head> 里有没有 title 和 description。如果 HTML 是个空壳,这一阶段登记到的就是「一个空壳」。
第二阶段:渲染(Render) 对于需要执行 JavaScript 的页面,搜索引擎会把它放进一个渲染队列,由渲染服务执行 JS、等待接口请求完成,再把渲染后的 DOM 拿去做真正的索引。这一步叫「第二波索引」,因为它是发生在第一波抓取之后的第二次处理。
关键在于:第二波不是免费的。渲染队列有优先级,取决于站点权重、页面重要性、外链、更新频率和抓取预算分配。实践中,从第一波抓取到第二波渲染完成,间隔可以是几小时,也可以是几天;权重低的新站或站点深处的页面,甚至可能长期排不上队。这正是「发布了但一直没被收录」最常见的技术成因——不是内容被判低质,而是根本没进第二轮。
不执行 JavaScript 的爬虫,问题更大
Googlebot 是目前最「能扛事」的爬虫,它会执行 JS。但生态里还有一批爬虫不会:
| 爬虫类型 | 典型代表 | 是否执行 JS | 拿不到就意味着 |
|---|---|---|---|
| 搜索引擎主爬虫 | Googlebot | 会(需排队) | 收录延迟,可能永不收录 |
| 社交卡片抓取器 | Facebook、LinkedIn、Slack | 不会 | 分享出去没有标题和封面图 |
| AI 检索爬虫 | 各类答案引擎的抓取器 | 多数不会 | 内容无法被引用 |
| 部分 Bing 配置 | 某些部署形态 | 不保证 | 索引质量不稳定 |
社交分享这一条最容易被忽略:链接发到群里,标题和封面是抓取器读 <head> 里的 og: 标签生成的。如果这些标签是前端 JS 注入的,抓取器看到的就是一片空白——卡片没有标题、没有图,点进去才知道是什么内容。
二、客户端渲染的四个可观察症状
把上面的机制对着自己的站点看,CSR(纯客户端渲染)的问题会表现为四种具体现象,而不是一句「SEO 不好」:
1. 新页面收录延迟甚至不收录 内容发布了,站点地图也提交了,但搜索结果里迟迟不出现。查看「已呈现的 HTML」会看到内容在——说明第一波没拿到,是第二波渲染没赶上或没轮到。
2. 分享卡片是空的
链接发出去只有域名,没有标题、描述和封面图。这是社交抓取器读不到 og: 标签的直接后果,与页面在浏览器里显示得漂不漂亮无关。
3. 元数据被前端注入,爬虫读不到
title、meta description、canonical、结构化数据如果写在客户端组件里,爬虫在第一阶段拿到的是没有这些标签的 HTML,等它渲染完再补上——而很多爬虫根本不补。
4. 首屏指标变差,拖累排名与转化 HTML 里没有内容时,浏览器要等 JS 下载、解析、执行、请求接口,才把内容画出来。首次内容绘制(FCP)和最大内容绘制(LCP)都被推后,布局还容易在注水时跳动(CLS)。这不只是体验问题——Core Web Vitals 本身是排名信号。
一个具体的对照
同样是「文章详情页」,两种实现给爬虫的东西完全不同。
客户端渲染返回的 HTML:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<!-- 正文要等 JS 执行后才会出现 -->
<div id="root"></div>
<script src="/static/js/main.8f3a21.js" defer></script>
</body>
</html>
服务端渲染返回的 HTML:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Koa 中间件实战:从洋葱模型到完整 API 骨架</title>
<meta name="description" content="Koa 中间件是接收 ctx 与 next 的 async 函数…" />
<link rel="canonical" href="https://example.com/blog/koa-onion" />
</head>
<body>
<article>
<h1>Koa 中间件实战:从洋葱模型到完整 API 骨架</h1>
<p>Koa 的核心是中间件……</p>
</article>
</body>
</html>
第二份 HTML 里,标题、摘要、正文、链接一应俱全。任何爬虫——包括不执行 JS 的——拿到它都能直接建立索引。
三、SSR、SSG、ISR 对 SEO 意味着什么
关键结论先说:SSR 和预渲染(SSG / ISR)在 SEO 上是同一阵营,它们都把完整 HTML 交给爬虫;客户端渲染是另一阵营。差别不在「能不能被收录」,而在「内容有多新」和「服务器扛不扛得住」。
| 维度 | 客户端渲染 CSR | 服务端渲染 SSR | 静态生成 SSG | 增量静态再生 ISR |
|---|---|---|---|---|
| HTML 什么时候生成 | 浏览器里 | 每次请求 | 构建时 | 构建时 + 按周期重建 |
| 爬虫第一次拿到 | 空壳 | 完整正文 | 完整正文 | 完整正文 |
| 收录表现 | 延迟,甚至不收录 | 正常 | 最好 | 正常 |
| 内容新鲜度 | 实时 | 实时 | 直到下次构建 | 有过期时间 |
| 服务器压力 | 低 | 每次请求都算 | 几乎为零 | 只有重建时算 |
| 首屏速度 | 慢(等 JS) | 中 | 最快 | 快(命中缓存时) |
| 典型场景 | 登录后的面板 | 搜索结果页、个性化页 | 文档、落地页 | 文章、商品详情 |
ISR 的工作方式值得单独说,因为它是内容站的甜点:
- 构建阶段,页面被预渲染成静态 HTML 并缓存;
- 用户请求到达,如果缓存没过期,直接返回这份静态 HTML(快,且能被 CDN 缓存);
- 缓存过期时,先把旧内容返回给用户,同时在后台异步触发这个页面的重新生成,不阻塞当前请求;
- 下一次请求拿到的就是更新后的 HTML。
也就是常说的 stale-while-revalidate:用户永远不等待重建,只有「被访问到的页面」会重建,而不是全站重新构建一遍。
四、渲染方式之外的四个变量
渲染方式决定了「爬虫能不能读到正文」,但它不是唯一变量。下面四项同样会直接影响收录与排名,实践里经常和渲染问题混在一起被误判。
站点地图:决定爬虫能发现哪些页面
爬虫靠链接发现页面,但靠链接发现有天然盲区:孤岛页面(没有任何页面链向它)可能永远进不了队列。sitemap.xml 就是显式告诉爬虫「这些 URL 存在」的清单。
// app/sitemap.ts —— Next.js 自动生成 /sitemap.xml
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await listPublishedArticles();
return [
{ url: 'https://example.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
...articles.map((a) => ({
url: `https://example.com/articles/${a.id}`,
lastModified: a.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
];
}
单个站点地图上限 5 万条 URL;更大的站点要拆分成多个文件。站点地图里的 URL 必须与站点实际返回的 URL 一致——指向 404 的地址会浪费抓取预算。
内部链接:决定内容深度与权重如何流动
爬虫顺着链接爬,链接结构因此决定了页面被发现的速度与被赋予的权重:
- 孤岛页面没有任何入链,权重低、收录慢;
- 导航栏与页脚是全局的链接出口,能让所有页面被顺利发现;
- 面包屑既给用户看,也给爬虫一条语义路径;
- 锚文本(链接文字)告诉爬虫目标页面是关于什么的,用「Next.js 渲染方式」比用「点击这里」有效得多。
canonical 与重复内容
同一个内容如果能通过多个 URL 访问(带参数的列表页、分页、带追踪参数的链接),搜索引擎需要知道哪个是权威版本:
<link rel="canonical" href="https://example.com/articles/koa-onion" />
缺了 canonical,权重会分散到多个 URL 上。站点地图里的 URL、页面里的 canonical、内部链接指向的地址,三者要一致。
robots.txt 与 noindex 是两件事
这是最常被搞混的一处:
| 指令 | 位置 | 含义 |
|---|---|---|
robots.txt 的 Disallow |
站点根目录 | 不让抓取这个 URL |
<meta name="robots" content="noindex"> |
页面 <head> |
抓到了,但别索引 |
危险组合:在 robots.txt 里屏蔽了某个页面,同时那个页面又有 noindex。爬虫按规则不会去抓它,于是永远看不到这条 noindex,反而可能把它的 URL 当作正常链接收录。要禁止收录又不给抓取权,就把 noindex 放在页面里,并且不要在 robots.txt 里屏蔽它。
Core Web Vitals 的阈值
Google 当前的三个核心网页指标阈值:
| 指标 | 含义 | 良好阈值 |
|---|---|---|
| LCP | 最大内容绘制,页面主体内容出现的时间 | ≤ 2.5 秒 |
| INP | 交互到下次绘制,点击后的响应速度 | ≤ 200 毫秒 |
| CLS | 累积布局偏移,页面元素有没有乱跳 | ≤ 0.1 |
服务端渲染与预渲染主要改善的是 LCP 与 FCP(HTML 里就有内容,不用等 JS 画);INP 取决于交互逻辑与主线程是否被长任务阻塞,通常要靠代码分包、减少注水负担来解决。CLS 最常见的来源是图片没有预留尺寸、字体替换造成的重排。
五、四种方法自证爬虫到底看到了什么
别猜,去看。下面四种方法按「成本从低到高」排列,建议固定在发布流程里跑一遍。
方法 1:右键「查看网页源代码」
最基础也最有效。右键页面 → 查看网页源代码,在源码里搜正文里出现过的那句话。
- 搜得到 → 正文是服务端或预渲染输出的,SEO 基础没问题;
- 搜不到 → 无论浏览器里显示得多正常,这就是客户端渲染。
注意区分两个面板:开发者工具的元素面板显示的是 JavaScript 执行后的 DOM,它永远「看起来正常」,不能作为任何 SSR 证据;要看的只有「源代码」。
方法 2:用命令行直接抓原始 HTML
比肉眼点右键更可靠,也更适合放进自动化检查:
# 抓取服务端返回的原始 HTML,正文关键词应能命中
Invoke-WebRequest -Uri 'https://example.com/blog/koa-onion' -UseBasicParsing |
Select-String -Pattern 'Koa 中间件实战' -Quiet
返回 True 说明正文在服务端 HTML 里;返回 False 就是空壳。
方法 3:禁用 JavaScript 后重新加载
在浏览器设置里禁用 JavaScript(或用开发者工具的 Network 面板把脚本请求全部 block),然后刷新页面。
- 页面依然完整 → 爬虫看到的和你看到的一致;
- 页面变空白 → 爬虫看到的也是空白。
方法 4:Google Search Console 的 URL 检查工具
这是以 Google 视角为准的最终依据:
- 在 Search Console 里用「URL 检查」输入页面地址;
- 点「测试实时网址」(Test Live URL);
- 看「已呈现的 HTML」(Rendered HTML)这一栏的截图和源码;
- 如果这里缺内容,说明 Google 拿到的版本和用户看到的不一致。
同一页面还可以对比「抓取的 HTML」与「已呈现的 HTML」两栏,差异就是客户端渲染造成的信息损失。
六、SSR 也会翻车:五个真实踩坑点
服务端渲染不是免死金牌,下面五种情况照样让爬虫读不到该读的。
1. 注水不匹配(hydration mismatch)导致内容被清空 服务端渲染的 HTML 与客户端首次渲染的结果不一致时,React 会丢弃服务端那份、用客户端结果覆盖。极端情况下页面直接白屏——对用户是 bug,对爬虫是「这页没内容」。
2. 元数据写在客户端组件里
把 title、description、OG 标签放进带 'use client' 的组件里,它们只会出现在渲染后的 DOM 里,第一波抓取拿不到。正确做法是在服务端段(page.tsx / layout.tsx)导出 metadata。
3. robots.txt 把 CSS 和 JS 挡掉了 有些站为了「防爬」屏蔽了静态资源目录。问题是渲染引擎需要 CSS 判断布局、需要 JS 执行代码,资源被挡后渲染出来的页面可能大不一样,甚至直接放弃。
4. 软 404 与客户端重定向 「页面不存在」时返回 200 加一段前端提示文案,爬虫会判为软 404——它以为这是个正常页面,只是内容为「没有这篇」,但并不据此丢掉整站。同理,页面加载后用 JS 跳到别处,爬虫看到的仍是原地址,容易把两个 URL 当成重复内容。
5. JS 包太大,SSR 只是换了个地方卡 HTML 是送到了,但注水要执行的 JS 有 2MB,用户和渲染引擎都得等。「服务器渲染 + 巨大客户端包」的实际首屏可能比纯 CSR 还差。
七、渲染方式怎么选:按页面职责分
不要用一个渲染方式套全站。绝大多数内容站的最优解是混合,按页面职责分开定:
| 页面类型 | 推荐渲染 | 理由 |
|---|---|---|
| 文章详情、文档、落地页 | SSG / ISR | 内容稳定,静态 HTML 对爬虫最友好,CDN 还能兜住流量 |
| 列表页、标签页 | ISR | 内容偶尔更新,定期重建即可,不必每次请求都算 |
| 商品详情、库存、报价 | ISR 或 SSR | 数据要相对新,但不需要毫秒级实时 |
| 搜索结果、筛选页 | SSR | 每次查询组合都不同,缓存没有意义 |
| 个人中心、后台面板 | CSR | 登录后才可见,本来就不需要被收录 |
| 需要个性化推荐位 | SSR 优先 | 爬虫至少能读到主体内容 |
判断标准可以压缩成一句话:这个页面的内容需要被搜索到吗? 需要,就服务端渲染或构建期生成;不需要,CSR 完全够用,而且更简单。
八、实战:给一个内容站定一套渲染方案
前面讲的是原理,这一段把它落成一个具体站点。以「技术博客 + 文档站 + 后台」这类典型内容站为例:
| 页面 | 路由示例 | 渲染方式 | 为什么 |
|---|---|---|---|
| 首页 | / |
预渲染(SSG) | 内容每天只变几次,构建时生成即可 |
| 文章详情 | /articles/[id] |
ISR,周期 10 分钟 | 内容偶尔更新,要能被及时收录 |
| 标签 / 分类列表 | /tags/[tag] |
ISR,周期 1 小时 | 组合多、变化慢 |
| 站内搜索结果 | /search?q= |
客户端取数或 SSR | 不需要被收录,参数组合无限 |
| 文档页 | /docs/** |
预渲染(SSG) | 版本化内容,随发布一起更新 |
| 登录后控制台 | /dashboard/** |
纯客户端(CSR) | 登录后才可见,没有收录价值 |
| 订阅接口 | /api/** |
与前端无关 | JSON 接口 |
对应的 Next.js 配置:
// app/articles/[id]/page.tsx —— 文章详情:ISR
export const revalidate = 600; // 10 分钟重建一次
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const article = await getArticle(id);
return <Article article={article} />;
}
// app/dashboard/page.tsx —— 控制台:只在浏览器渲染
'use client';
export default function DashboardPage() {
return <Dashboard />; // 数据在浏览器里取,服务端不参与
}
// app/search/page.tsx —— 搜索结果:客户端取数
'use client';
import { useState } from 'react';
export default function SearchPage() {
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<ArticleSummary[]>([]);
async function search() {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
setResults(await res.json());
}
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button onClick={search}>搜索</button>
<ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
</div>
);
}
上线后的检查节奏
- 发版当天:用第四节四种方法过一遍关键页面(首页、一篇新文章、一个标签页);
- 每周:在 Search Console 看「已编入索引」与「已发现但未编入索引」两个数字的走势;
- 每月:抽查站点地图里的 URL 是否都能正常返回 200,canonical 是否与实际 URL 一致。
这套节奏的意义在于:渲染相关的故障是静默的。页面照常显示、接口照常返回,只有搜索结果悄悄不来。等流量少了才发现,通常已经过去几周。
九、发布前的检查清单
把这张表贴到发布流程里,比事后补救便宜得多:
- 文章正文能在「查看网页源代码」里搜到(不是元素面板);
-
title、meta description、canonical出现在服务端返回的 HTML 里; - OG 标签在服务端输出,社交分享卡片能正常显示;
- 禁用 JS 后页面仍可读;
- robots.txt 没有屏蔽 CSS / JS;
- 不存在的文章返回真正的 404,而不是 200 + 空文案;
- URL 变更时同步更新
canonical与站点地图; - sitemap.xml 里包含这个页面(Next.js 里可用
app/sitemap.ts自动生成); - 关键页面在 Search Console 的「已呈现的 HTML」里内容完整。
参考资料
- Client-side vs Server-side Rendering in SEO – Differences, Pros & SEO Impact(Entri) —— 搜索引擎两阶段处理、渲染队列与收录延迟
- Server-side rendering and SEO(pykero) —— SSR/SSG/CSR 的 SEO 对比、注水不匹配与软 404 等常见坑
- 三种建站前端渲染模式对 SEO 点击率影响的测评参考(南方传媒) —— 三种渲染模式在收录与摘要展示上的差异
- Server-Side vs Client-Side Rendering: Technical SEO Guide for SaaS Apps(Domain Authority) —— 混合渲染的落地策略与审计方法
- 服务器端渲染为什么对 SEO 更友好(酷盾) —— 首屏指标与渲染模式的关系
- 增量静态再生(ISR)详解:Next.js 中的实现与应用(CSDN) —— ISR 的后台重建流程与按需再生
- Next.js 缓存与再验证文档 —— 各渲染方式对应的缓存配置
- Next.js generateMetadata 文档 —— 服务端输出元数据的官方写法