JavaScript 动态设置的标题未被 Google 索引

SPA 渲染后才改 `document.title`,Google 索引到的却是占位符。SERP 每条都显示 "Loading..." 或首页标题。这里是修法。

你的 React / Vue / Angular SPA 在路由挂载后用 document.title = "..."<Helmet> / <Head> 组件设置每页标题。浏览器里标签页标题更新正确,但在 Google 上每个 URL 的 SERP 标题都是同一个通用值:MyAppLoading...,或者服务端初始 HTML 里放的那个。

最快的修法:把标题放进服务端渲染(或预渲染)的 HTML 里,让正确的 <title> 出现在响应的第一个字节里,早于任何 JavaScript 运行。用 curl 拉原始 URL 验证。下面的内容,是这一步还不够时才需要走的弯路。

为什么会这样:Google 用两波(two-wave)来索引 JavaScript 页面。第一波读服务端返回的原始 HTML;第二波在无头 Chromium 里跑你的 JavaScript,再读渲染后的 DOM。截至 2026 年 6 月,两波之间的间隔仍然从几小时到几周不等,取决于抓取预算,而且第二波并不保证每个 URL 都跑。只在 JS 运行后才存在的标题,命运全交给第二波;写在原始 HTML 里的标题,每次都在第一波就被索引。(Google 自己的 JavaScript SEO 文档就描述了这个先抓取、后渲染的两阶段模型。)

还有一个越来越要紧的原因:AI 爬虫根本不渲染 JavaScript。 截至 2026 年 6 月,GPTBot、ClaudeBot、PerplexityBot 这类爬虫发一个 HTTP 请求、读原始 HTML、然后就走了——它们不跑浏览器引擎、不等渲染、也不会再来第二次。一份被广泛引用的 Vercel/MERJ 分析查了超过 5 亿次 GPTBot 抓取,没找到任何执行 JavaScript 的迹象,ClaudeBot、Meta 的爬虫、PerplexityBot 也都是同样的规律。只靠 JS 的标题对它们全是隐形的,于是你的页面在 AI Overviews 和聊天机器人引用里拿到的是通用或空标题。

常见原因

按真实 SPA 上的命中率排序。

1. 服务端给每个路由返回同一个 <title> 壳子

每个 URL 的初始 HTML 都是 <title>MyApp</title>,JS 挂载后才替换。Google 第一波索引的是壳子,第二波可能永远到不了。

怎么判断curl https://example.com/any/route,每个 URL 都返回同一个 <title>

2. 初始标题是 “Loading…” 或空

为了表示 hydration 还没完,壳子用 <title>Loading...</title>。Google 照单全收,把 Loading... 写进 SERP。

怎么判断:Google 上 site:yourdomain.com 搜一下,一堆结果都是同一个 Loading...

3. 标题由仅客户端跑的 state hook 设置

React 里 useEffect(() => { document.title = data.title }, [data]) 只在浏览器跑,服务端渲染从不执行 effect。

怎么判断:hydration 之后浏览器 DevTools 里标题正确;view-source:curl 里看不到。

4. SSR 在数据 fetch 完成前就把 head 流式输出了

streaming SSR 把 <head> 推给链路时标题数据还没解析完,刷出去的 HTML 里就没标题。

怎么判断:HTML 响应的 head 里是 <title></title> 或干脆没 title;浏览器只有在 fetch 完成后才显示正确标题。

5. 标题在客户端重定向之后才设置

/old-pathwindow.location / router push(不是 HTTP 301)跳到 /new-path。Google 爬 /old-path,拿到跳转前的壳子标题,索引下来。

怎么判断:SERP 显示跳转前 URL 配通用标题;跳转路径返回 HTTP 200 而不是 301

标题数据要登录后的 API 调用。Googlebot 是匿名的,API 返 401,标题 state 永远不更新。

怎么判断:爬虫模拟器(或 curl 不带 cookie)的网络面板里,标题数据接口返 401

我属于哪一类?

curl<title> 显示什么浏览器标签页显示什么最可能的原因去看
每个路由都一样每个路由都对壳子标题,JS 替换(#1)第 2 步
Loading... 或空稍等就对占位符壳子(#2)第 2 步
只有动态路由是空的fetch 后才对仅客户端 hook 或流式 head(#3、#4)第 2 / 3 步
通用值,且 URL 是过期路径真实路径上才对客户端重定向(#5)第 5 步
通用值,数据调用返 401只有登录才对鉴权后的标题数据(#6)第 6 步

开始前

  • 确认症状:curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://example.com/some-route,读 <title>
  • 决定渲染策略:全 SSR、静态预渲染(SSG),还是混合。不同方案打开不同修复路径。
  • 看看框架是否已原生支持服务端 metadata:Next.js generateMetadata、Nuxt useHead / useSeoMeta、SvelteKit <svelte:head>、Angular 配 @angular/ssrTitle 服务。
  • 数一下要修的不同路由模板数量。经常一个共享 layout 就驱动全部。

需要收集的信息

  • 5-10 个代表性 URL 的服务端渲染 HTML(是 curl 的输出,不是 DevTools)。
  • 同一批 URL hydration 之后的浏览器 DOM,确认客户端标题是对的。
  • 框架是否支持 SSR / SSG,是否真的启用了。
  • 标题的数据来源:frontmatter、API 调用、路由参数,还是 CMS。
  • SERP 索引 URL 和正典 URL 之间是否有跳转。

一步步修

按影响和代价排序。

第 1 步:确认爬虫究竟看到什么

curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  https://example.com/products/widget \
  | grep -oE '<title>[^<]*</title>'

如果这里返回的标题就是错的,问题在原始 HTML 里,第二波无关紧要。想看 Google 实际渲染的快照,打开 Search Console,对该 URL 跑 URL Inspection,点 Test Live URL,再点 View Tested Page。右侧面板有三个标签:HTMLScreenshotMore InfoHTML 标签是 Google 索引的渲染后 DOM;More Info 标签列出被屏蔽的资源和 console 报错。(对已索引的 URL,改用 View Crawled Page,看上次存下的渲染结果。)

第 2 步:标题生成挪到服务端

这是 #1-#4 的真正修法。在 SSR 阶段就把标题输出,让它进原始 HTML。

Next.js App Router(Next.js 15+,params 现在是异步的):

// app/products/[slug]/page.tsx
import type { Metadata } from "next";

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params;
  const product = await getProduct(slug);
  return {
    title: `${product.name} | Acme Store`,
    description: product.summary,
  };
}

Nuxt 3:

useSeoMeta({
  title: () => `${product.value.name} | Acme Store`,
  description: () => product.value.summary,
});

SvelteKit(数据在 +page.ts / +page.server.ts 里加载):

<svelte:head>
  <title>{data.product.name} | Acme Store</title>
</svelte:head>

Angular(启用了 @angular/ssr / 服务端渲染):

import { Title } from "@angular/platform-browser";
constructor(private title: Title) {}
ngOnInit() { this.title.setTitle(`${this.product.name} | Acme Store`); }

这些都在 SSR 阶段跑,把正确标题直接放进响应 HTML。务必让 await 链在 head 被刷出去之前解析完标题数据(这同时也堵上了 #4)。

Next.js 的坑(15.2+):对动态渲染的路由,Next.js 现在会把 metadata 流式输出,把解析出的 <title> / <meta> 标签追加到 <body> 末尾,而不是放进初始 <head>——为的是压低 TTFB。Googlebot(会跑 JS、读完整 DOM)能正常处理;Next.js 也会按 User-Agent 识别 “HTML-limited bots”,对它们退回阻塞模式,把标签放进 <head>。但最干净的修法是让路由保持可静态预渲染:当 generateMetadata 不引入运行时数据(cookies()headers()searchParams)时,标题会对所有人都烤进初始 HTML。如果你必须动态渲染、又想让每个爬虫都在原始 <head> 里看到标题,就在 next.config.ts 里设 htmlLimitedBots: /.*/ 关掉流式输出(代价是 TTFB 变高)。见 Next.js streaming-metadata 文档

第 3 步:SSR 大材小用就静态预渲染

不按请求变化的内容,在 build time 直接预生成静态 HTML。没有运行时服务器,对爬虫完全安全。

// next.config.* / nuxt.config.* / astro.config.*
export default { output: "static" };

Next.js App Router 里,动态路由在你提供 generateStaticParams 时会被预渲染。静态预渲染在 build time 就把标题写进 HTML。

第 4 步:纯 SPA 框架加 build-time 预渲染步骤

如果你没法上 SSR/SSG,那就爬自己的构建产物,给每个路由写出静态 HTML。截至 2026 年 6 月,选一个还在维护的工具:

  • Vike(前身是 vite-plugin-ssr,npm 包现在叫 vike)—— Vite 应用的预渲染模式,活跃维护中。
  • prerender-spa-plugin v3 —— 基于 Puppeteer(旧的 v2 用的是已弃用的 PhantomJS),适合 Webpack 构建。
  • presiteprerenderer —— 极简、与框架无关。

注意:曾经流行的 react-snap 截至 2026 年 6 月基本已无人维护——能用,但不推荐用于新项目。把你选的工具接进 postbuild 步骤:

"scripts": {
  "build": "vite build",
  "postbuild": "vike prerender"
}

第 5 步:客户端重定向替换为 HTTP 301

/old-path 该跳转就在服务端 / 边缘跳,而不是用 JS:

# Netlify _redirects(或 Cloudflare/Vercel 规则,或 Express res.redirect(301, ...))
/old-path  /new-path  301

爬虫直接顺着 301 走,索引 /new-path 和它的正确标题。客户端重定向会把 Google 留在 /old-path 上,配着壳子标题。

第 6 步:标题数据不要依赖鉴权

标题依赖鉴权后的数据,就给匿名爬虫准备一个公共兜底:

const title = isAuthed
  ? `${user.name}'s Dashboard | Acme`
  : "Sign In to Acme";

或者把要鉴权的部分挪到正文,<title> 保持公开。不要按 user-agent 分支给爬虫投不同标题——那是 cloaking(见 FAQ)。

第 7 步:在修好的 URL 上请求重新索引

在 Search Console 里对高流量受影响 URL 跑 URL Inspection,点 Request Indexing。成功重抓后,SERP 标题通常 1-2 周内刷新;长尾 URL 可能要一个月甚至更久。

怎么确认已修好

  • curl 任一路由,原始 HTML 里就有路由特定的标题(不只是在 DevTools 里)。
  • Search Console URL Inspection -> View Tested Page -> HTML 标签里 <title> 正确。
  • site:yourdomain.com 搜出来每个结果都有自己的、不同的标题。
  • 点击率随相关标题在 SERP 出现而回升。
  • 可选:用 AI 爬虫 UA 抓一下,比如 curl -A "GPTBot" https://example.com/some-route | grep -i '<title>',确认标题就在原始 HTML 里(这些 bot 不跑 JS)。

长期预防

  • 从第一天起,每条路由默认走服务端 metadata API(generateMetadatauseSeoMeta<svelte:head>、Angular Title)。
  • 代码评审里禁止把 document.title = "..." 当作生产路由唯一的标题来源。
  • 加 CI 断言:curl 每条路由返回独特、非占位符的 <title>
  • 在 CI 里跑 Lighthouse SEO 或一个无头 curl 检查,新路由上线前第一时间发现缺失标题。
  • 在组件库文档里写清楚标题来源模式,让新贡献者自动按规矩来。

常见坑

  • “Googlebot 能跑 JS 应该没事吧。“——第二波确实能跑,但第二波是延迟的,也不保证每个 URL 都跑;而 GPTBot / ClaudeBot / PerplexityBot 则根本不跑 JS。服务端渲染的 metadata 是唯一能让每个爬虫在第一次抓取就拿到标题的路径。
  • 在框架级别设了默认标题(MyApp),忘了在某些路由场景下它会盖掉分页标题。
  • React Router SPA 没 SSR 还用 <Helmet>——<Helmet> 只在浏览器改 document.title
  • router.events.on("routeChangeComplete", updateTitle)——一样只在浏览器跑。
  • 想用 Google 旧的 “dynamic rendering” 变通方案(用 Rendertron/Prerender.io 给爬虫单独投一份预渲染副本)。Google 自己的文档现在把它定性为 “a workaround and not a long-term solution”,并引导你改用 SSR、SSG 或 hydration。见 Google dynamic rendering 文档

FAQ

Q:Googlebot 到底跑不跑 JavaScript?

跑,在第二波,大多数 URL 最终会跑。但首次索引那一遍用的是原始 HTML,只靠 JS 的标题要到第二波才浮现,可能要几天甚至几周(截至 2026 年 6 月)。服务端渲染的标题在第一波就被索引。

Q:我们是纯 SPA 框架。一定要迁到 Next.js / Nuxt 吗?

不用。加一个 build-time 预渲染步骤(Vike、prerender-spa-plugin v3、presite)给每条路由写静态 HTML,不迁框架也能解决标题索引问题。

Q:标题修了之后 SERP 多久更新?

通常 1-2 周。高流量 URL 重抓最快;长尾要一个月甚至更久。关键页面走一下 Request Indexing 加速。

Q:我能给用户保留 “Loading…” 标题,给 Googlebot 投真标题吗?

不行。给爬虫和用户投不同内容属于 cloaking,违反 Google 的垃圾内容政策。给所有人都投真标题。如果加载态是 UX 需要,在 SSR HTML 里就渲染真标题,只在正文里放转圈动画。

Q:ChatGPT 和 Claude 看得到我用 JS 设的标题吗?

看不到。截至 2026 年 6 月,主流 AI 爬虫(GPTBotClaudeBotPerplexityBot)只抓 HTML、不执行客户端 JavaScript,所以只靠 JS 的标题对它们隐形。用 curl -A "GPTBot" <url> 验证——你在那份原始响应里看到的,就是它们索引到的。这是又一个把标题放进原始 HTML 的理由。

Q:我用 Next.js App Router,静态页面 curl 出来标题是对的,动态页面却没标题,为什么?

从 Next.js 15.2 起,动态渲染的路由会在初始响应之后把 metadata 流式输出到 <body>,而不是放进 <head>。会跑 JS 的爬虫(Googlebot)读得到;不跑 JS 的爬虫可能读不到。要么让路由保持可静态预渲染(generateMetadata 里别用 cookies() / headers() / searchParams),把标题烤进 HTML;要么在 next.config.ts 里设 htmlLimitedBots: /.*/,强制对所有爬虫把标题放进 <head>

标签: #SEO #排查 #spa #title-tag #rendering