单页应用SEO代价:预渲染与SSR四种方案取舍

首页只有一行 HTML,正文却要靠 JS 渲染出来

去年我把一个工具站的前端重写成了 Vue,首屏从 2.1 秒降到 0.8 秒,用户体验很好。三个月后看 Search Console:抓取到的页面数量没变,但「已发现,尚未编入索引」从 60 涨到 900,「已抓取,尚未编入索引」里出现大量本该有价值的工具详情页,展示量掉了七成。

用 curl 抓一下自己的页面就明白了:

curl -s https://example.com/tool/123 | wc -c
# 1832

一个「完整」的详情页,服务端返回的 HTML 只有 1.8KB,里面除了 <div id="app"></div> 就是几个 script 标签。所有标题、正文、内链都在 JS 里拼出来。Google 能渲染 JS,但渲染需要额外的抓取预算、有队列延迟、而且渲染后的内容在判断上有更多不确定性。对一个每天都想被收录的新站来说,这等于把自己的收录速度砍掉一半。

这篇写清楚单页应用(SPA)在 SEO 上的具体损失在哪、几种补救方案各要多少成本、以及怎么验证「爬虫到底看到了什么」。不谈框架选型,只谈能被搜索引擎和用户都正确读到这件事。

先把「爬虫看到了什么」量出来

所有判断都建立在同一个事实上:服务端返回的原始 HTML 里有什么。这一步不需要任何工具,curl 加上不太跟随跳转就够了:

curl -sL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  https://example.com/tool/123 -o /tmp/bot.html
wc -c /tmp/bot.html
grep -o '<h1[^>]*>[^<]*' /tmp/bot.html
grep -c '<a href' /tmp/bot.html
grep -o '<title>[^<]*' /tmp/bot.html

四个数字各自说明一件事:

wc -c 只有一两 KB,说明服务端基本没给内容。正常的内容页(不含图片、CSS)通常有 15KB 以上。

<h1> 抓不到或者抓到的是空的,说明主标题是客户端渲染的。

<a href 数量少(比如 0~3 个),说明站内链接是 JS 事件绑定的,不是真正的链接。这一点比正文缺失更致命,因为它切断了爬虫发现新页面的路径。第 1828 篇讲过内链深度的影响,SPA 的问题更极端:深度直接变成无限。

<title> 是模板里的通用值而不是这篇文章自己的标题,说明标题也没在服务端生成。搜索结果里几百个页面共用一个标题,是典型的重复内容信号。

再补一刀:看 view-source 与「检查元素」的差异。这个差异在老手眼里是常识,但新手最容易混淆——「检查元素」显示的是渲染后的 DOM,里面有你想要的正文;curl 或 view-source 显示的才是服务端发出的 HTML。所有关于 SEO 的判断都必须基于后者。

Google 到底渲不渲染:真实边界

关于「Google 能执行 JS」这句话,需要拆成三层来理解,否则很容易做出错误决策。

第一层,能渲染。Googlebot 用的是新版 Chromium,确实会执行 JS。这一点不需要怀疑,很多纯 SPA 的站也收录了。

第二层,渲染有代价。渲染不是抓取时同步做的,而是排进一个渲染队列,抓取与渲染之间可能有从几小时到几周的延迟。对新站、对内链少的页面,这个延迟会更长。渲染本身消耗资源,所以 Google 对每个站有渲染预算,页面越多、越慢,能分到的渲染次数越少。

第三层,渲染结果不完全等价。渲染超时(Google 有一个等待上限)、JS 报错、依赖的 API 被爬虫 IP 挡住、依赖 localStorage 里的状态——这些都会导致渲染出来的内容不完整。我遇到过最典型的一次:详情页的正文靠一个 /api/detail 接口拉,而这个接口配了「只允许 Referer 是本站」的防盗链,Googlebot 渲染时没有 Referer,接口返回 403,于是 Google 看到一个空页面。而我在浏览器里一切正常。

所以正确的结论不是「SPA 不能被收录」,而是「SPA 把收录从确定性事件变成了概率事件」。对每天发十几篇、靠量取胜的站,这个概率折损是不可接受的。

四种方案的取舍:从最省事到最彻底

方案一:预渲染(prerender),成本最低。不改架构,用无头浏览器在构建或请求时生成静态 HTML。适合页面数量可控(几千以内)、更新频率不高的站。实现上用 Puppeteer 或 Playwright 遍历路由表,把渲染后的 DOM 序列化落盘:

// prerender.js 的核心逻辑(示意)
const routes = require('./routes.json');   // 从 sitemap 或路由表导出
for (const r of routes) {
  await page.goto(base + r, { waitUntil: 'networkidle0' });
  const html = await page.content();
  fs.writeFileSync(outDir + r + '/index.html', html);
}

坑在于:waitUntil: 'networkidle0' 在长轮询或埋点请求多的站上会一直等不到,要改成 domcontentloaded 再加一个明确的选择器等待;渲染出来的 HTML 里包含框架注入的属性,体积会膨胀 2~3 倍;最重要的是预渲染产物必须能直接返回给爬虫,也就是 Nginx 要在 $http_user_agent 命中爬虫时走静态文件、否则走 SPA 路由。用 UA 判断在技术上是「cloaking」的边缘,Google 官方态度是接受动态渲染但要保证内容一致,实际操作里更稳的做法是不分 UA,全部返回预渲染 HTML,让 JS 在客户端接管为可交互状态(hydration)。

方案二:SSR(服务端渲染),成本中等,收益确定。Nuxt、Next、SvelteKit 这类框架都支持。改造成本取决于现有代码对 window、document 的依赖程度,如果组件里到处是 window.innerWidth(响应式布局判断),迁移会非常痛。

SSR 部署上有两个具体的坑值得提前知道:一是 Node 进程的内存和并发能力远低于静态文件,要配 proxy_cache 或 CDN 兜住大部分请求;二是 SSR 进程崩了整站白屏,必须有进程守护(systemd Restart=always)和一个降级到静态壳的兜底页。

方案三:同构 + 静态生成(SSG),最适合内容站。内容页在构建时生成静态 HTML,交互部分在客户端激活。Hugo、Astro、Eleventy 都属于这一类。对「每天发 18 篇」的内容站,构建时间是个真实约束:几百篇没问题,几万篇要考虑增量构建。Astro 的 islands 架构在这里很合适,因为内容页几乎不需要 JS,只有评论、搜索这类小组件需要。

方案四:动态渲染(Dynamic Rendering),过渡方案,不建议长期使用。用 Rendertron 或类似服务,在 Nginx 层判断 UA 是爬虫就转发给渲染服务,普通用户拿到 SPA。它能在不改代码的前提下快速止血,但引入了「两套内容」的风险:渲染服务的版本和线上版本可能不一致,缓存可能过期。Google 在 2024 年之后对动态渲染的态度已经明确为「过渡性方案」。

我的选择是:新项目直接上 SSG;老 SPA 项目先用预渲染止血,再花两个月重写详情页为 SSG。不值得为了「保留 SPA 的开发体验」跟搜索引擎较劲。

不管选哪个方案,这五件事必须做到

第一,每个页面必须有服务端生成的唯一 <title> 和 <meta name="description">。这在 SPA 里通常用 vue-meta 或 react-helmet 在客户端设置,但客户端设置对爬虫无效(至少在原始 HTML 里无效)。所以必须由服务端注入。

第二,站内链接必须是 <a href>,不能是 @click。这条最容易被忽略,因为用户在浏览器里点起来感觉完全一样。<router-link to="/x"> 会渲染成真正的 <a href>,而 <div @click="$router.push('/x')"> 不会。写代码时统一用前者。

第三,路由要用 history 模式,不要用 hash 模式。/#/tool/123 里的 # 后面的内容根本不会发给服务器,搜索引擎对 hash 路由的处理一直很差。history 模式需要在 Nginx 里配 try_files $uri $uri/ /index.html——但注意这条规则会在 URL 不存在时返回 200 的首页,形成软 404,正确写法见第 1842 篇。

第四,canonical 必须服务端输出且自我指向。SPA 常见的错误是所有页面共用模板里的一个 canonical,指向首页。这等于告诉搜索引擎「这些页面都是首页的副本」,直接放弃收录。

第五,接口不能挡住爬虫。前面那个 Referer 防盗链的例子,还有「接口要求登录」「接口有 CSRF token 校验」「接口限流按 IP」都会让渲染失败。验证方法很简单:用不带 Cookie、不带 Referer 的 curl 请求你的 API,看是否正常返回 JSON。

验证渲染结果:三个可执行的检查

检查一,Google 的「网址检查」工具。在 Search Console 里输入一个 URL,看「已抓取的页面」的 HTML 源码,和「查看抓取的页面」的截图。源码里有没有正文,是最终判据。这个工具直接告诉你 Googlebot 实际拿到了什么,比任何第三方模拟都准。

检查二,日志里看 Googlebot 的请求分布。如果你的页面是 SSR 或预渲染,Googlebot 应该直接请求 /tool/123 并拿到完整 HTML,日志里不该看到大量 XHR 接口请求。如果日志里 Googlebot 请求了 /api/*,说明它在渲染,这是有成本的信号。

grep -i googlebot /var/log/nginx/access.log \
  | awk '{print $7}' | sed 's/?.*//' \
  | sort | uniq -c | sort -rn | head -20

检查三,用无头浏览器对比「原 HTML」和「渲染后 DOM」的文本差异。差异越大,说明依赖 JS 的程度越高:

python3 - <<'PY'
import re, urllib.request
raw = urllib.request.urlopen('https://example.com/tool/123').read().decode('utf-8','replace')
text = re.sub(r'<[^>]+>', '', raw)
cn = len(re.findall(r'[\u4e00-\u9fff]', text))
print('服务端 HTML 里的中文字数:', cn)
PY

一个内容页的正文如果超过 1500 字,服务端 HTML 里应该至少能看到 1200 个中文字符。低于这个数,说明内容是客户端拉的。

我的实际改造路径和结果

回到开头那个工具站,我做的是三阶段:

第一阶段(一周),用 Puppeteer 预渲染全部 800 个详情页,Nginx 判断路径命中预渲染产物就优先返回,未命中的走原 SPA。这一步是最低成本的止血,「已发现未编入索引」在两周内从 900 降到 300。

第二阶段(一个月),把详情页改用 Astro 重写为静态页,列表页仍保留 SPA 的交互。构建时间从 0 到 3 分钟,可接受。

第三阶段,加了一批真正的内链:每篇文章底部有「相关工具」三个真链接,由构建时的标签匹配生成。这一步对收录的推动比前两步加起来都明显——因为爬虫终于有了稳定的发现路径。

三个月后的数字:展示量恢复到原来的 1.4 倍,收录率(已编入索引 / 已提交)从 61% 到 92%。

一个容易被忽略的边界:不是所有页面都值得渲染

最后说个反方向的问题。有些页面不该被收录,比如搜索结果的 ?q= 页、用户个人中心、带排序参数的列表页。/list?sort=price&page=2 和 /list?sort=price&page=3 在形式上是一万个不同的 URL,每个都渲染一遍,纯粹消耗抓取预算,还会造成重复内容。

处理方式是三条:robots.txt 里 Disallow: /*?sort= 这类参数路径;页面上加 <meta name="robots" content="noindex,follow">;canonical 指向不带参数的标准版本。注意 Disallow 和 noindex 不要混用——被 Disallow 的页面爬虫抓不到,就看不到你的 noindex,索引状态会一直悬着。正确做法是「能抓但 noindex」。

把抓取预算看成一种有限资源(这个比喻不完全准确,但对做决策够用):你希望它花在有价值的页面上,而不是渲染一万个排序组合。SPA 的技术选型最终服务于这个判断,而不是反过来。

Last modification:October 25th, 2026 at 08:26 pm

Leave a Comment