上线三周 AI 才开始引用新品:前端水合把 SSR 页面的 JSON-LD 洗掉的排查复盘

2026-09-21 01:35:40 0 次浏览
GEOAI搜索SSRHydrationJSON-LD前端SEO

适用读者:做过服务端渲染(Server-Side Rendering, SSR)单页应用的前端与全栈工程师、负责结构化数据(Structured Data)的 SEO 同学,以及正在做生成式引擎优化(Generative Engine Optimization, GEO)、却怎么都等不到 AI 搜索引用自家新品的人。

客户是做小家电的电商,8 月 12 日上架一款便携榨汁杯,商品详情页走 SSR,首屏 HTML 里带着 Product 类型的 JSON-LD。 上线第 1 周,在三个 AI 搜索入口里问这款杯子的容量、功率、是否可拆洗,答案里一次都没出现这个商品。 第 3 周,前端把客户端水合(Hydration)对 head 的覆盖改掉,第 5 天开始稳定出现在引用位。

结论先放这儿:SSR 把 JSON-LD 写进首屏了,水合又把它从 DOM 里摘掉了。服务端没错,错在客户端接管之后。

客户现场长什么样

技术栈是 React 18 + 自建 Node 18 渲染层,没用 Next.js,head 由 react-helmet-async 统一管理。商品页的 JSON-LD 在服务端拼好,交给 Helmet 的 <script type="application/ld+json"> 输出,renderToString 后的 HTML 里确实能看到。 前端水合导致页面数据块丢失的排查

问题在于同一份组件在客户端又跑了一遍。Helmet 重新挂载时会按它维护的 head 节点清单去对齐真实 DOM,不在清单里的一律移除。服务端注入的路径和客户端组件树里的声明不是同一份,水合一结束,script 就没了。

这套改动在浏览器里完全看不出问题:水合删掉 head 节点对用户不可见,价格、标题、图片全都还在。线上灰度、验收、走查三轮都没人报。

第 1 周:Rich Results 说有,curl 说没有

第 1 周四是客户的 SEO 同学发现的。她在 Rich Results 测试里贴了商品 URL,显示"检测到 Product",属性齐全。她又让运维 curl 了一把,回来看见的是空空如也的 head,除了 <title> 和一个 meta description,JSON-LD 一个字都没有。

我们当时的第一反应是"工具缓存了旧版本",因为 Rich Results 测试自己会渲染 JS,抓的是渲染后的 DOM。它看到的是水合之后的页面,按理说更应该没有才对。这里出现了第一个反直觉的点,后面解释。

# 在浏览器里看着有,直接拉首屏却什么都没有
# -sS 静默但保留错误,-H 伪造一个普通桌面浏览器的请求头
# 关键:这三条命令都不要加 -L 之外的跟随参数
# 因为边缘节点对带 Cookie 的请求会走另一条回源路径
# 那一条路径能拿到完整 HTML,会把排查结果带偏
curl -sS "https://example.com/product/portable-juicer-cup" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" \
  | grep -c 'application/ld+json'

# 换成 AI 爬虫的 UA,结果一样是 0
# GPTBot 是 OpenAI 的抓取器,ClaudeBot / PerplexityBot 同理
curl -sS "https://example.com/product/portable-juicer-cup" \
  -H "User-Agent: Mozilla/5.0 (compatible; GPTBot/1.2)" \
  | grep -c 'application/ld+json'

# 只 grep 首屏里 head 闭合标签之前的内容,确认是 head 里没有
# 而不是被压到 body 末尾了
curl -sS "https://example.com/product/portable-juicer-cup" \
  | sed -n '1,60p'

三条命令的输出分别是 00,以及一段没有 ld+json 的 head。锅在渲染阶段或之前的构建产物,跟浏览器没关系。

第 2 周:按 UA 分流,把锅从服务端挪走

第 2 周一周一,我们把渲染层改成按 UA 记录快照:同一个 URL,对爬虫 UA 和浏览器 UA 各存一份首屏 HTML 人工 diff。同时在 renderToString 之后加了一行日志,统计 head 里 ld+json 的节点数。

日志显示节点数是 1,存下来的首屏也确实带着 JSON-LD。服务端输出是对的,curl 拿到的是错的,中间还隔着一层。

周次 谁发现的 现象 当时的判断 事后看对不对
第 1 周 客户 SEO Rich Results 有 Schema,curl 没有 工具缓存了旧页面 错,Rich Results 跑 JS,看的是另一版 DOM
第 2 周 后端 渲染日志里 ld+json 节点数 = 1 服务端没问题,锅在别处 对,但没定位到具体位置
第 2 周 前端 本地 npm run build && npm start 复现不出来 线上环境问题 错,本地是纯客户端渲染路径,压根不走 SSR
第 3 周 前端 devtools 里能看到,curl 看不到 两者抓的不是同一份 HTML 对,这就是根因

UA 分流的结果也顺手记了下来:三个 AI 爬虫的 UA(GPTBot、ClaudeBot、PerplexityBot)都不执行 JS,它们的首屏快照里一律没有 JSON-LD,也就一律不引用商品;而走白名单回源路径的办公网浏览器能看到完整版本。这张对照后来成了我们说服客户改代码的依据。

第 3 周:devtools 和 curl 抓的根本不是同一份 HTML

第 3 周二,前端同学在 devtools 的 Elements 面板里明确看到了 <script type="application/ld+json">,Copy outerHTML 也拿得到。但同一时刻在终端 curl 同一条 URL,输出里干干净净。

这就是整件事的钥匙:devtools 显示的是"当前活动 DOM",是 JS 跑完之后的产物;curl 拿到的是"网络原始响应",是 JS 跑之前的那份字节流。 两者本来就可以不一样,我们过去默认它们相同。于是把注意力放到水合上,翻出那段出问题的代码:

// React 18.3.1 / react-helmet-async 2.0.5 / Node 18.20
// 服务端:把商品数据拼成 JSON-LD,交给 Helmet 输出
// 客户端:同一棵组件树再跑一遍 hydrateRoot,Helmet 重新接管 head
import { Helmet } from 'react-helmet-async';

function ProductHead({ product }) {
  // 只有服务端才拼这段,客户端 this 分支不执行
  // 判断依据是 typeof window === 'undefined'
  const isServer = typeof window === 'undefined';

  return (
    <Helmet>
      <title>{product.title}</title>
      <meta name="description" content={product.summary} />
      {/* 这个 script 只在服务端渲染时进入 HTML */}
      {/* 客户端水合时 Helmet 会重建 head,此节点不在它的清单里 */}
      {/* 结果:水合完成后,服务端留下的 script 被整段移除 */}
      {/* 注意:isServer 这个判断制造了服务端与客户端的不对称 */}
      {/* 服务端多出来的节点,客户端无从得知,也就无从保留 */}
      {isServer && (
        <script type="application/ld+json">
          {JSON.stringify(buildProductSchema(product))}
        </script>
      )}
    </Helmet>
  );
}

// 客户端入口,水合在这里发生
// hydrateRoot 不会重建整棵树,但 Helmet 会重挂 head 子树
// 所以"水合不改动 DOM"这个直觉,对 head 是不成立的
// 真正被删掉的是服务端多出来、客户端又没有声明的那部分
const root = document.getElementById('root');
hydrateRoot(root, <App />);

isServer 这个分支是当初为了"避免客户端重复插入 script"加的,动机没错,但它制造了一个不对称:服务端往 head 里放了一个客户端不认识的节点。Helmet 在水合后做 head 对齐时,不认识的就删。

再看一眼我们期望的输出,和它长什么样:

<!-- 期望的首屏 head(renderToString 之后、水合之前) -->
<!-- 这一段 curl 应该能抓到,也是 AI 爬虫仅能读到的那部分内容 -->
<head>
  <title>便携榨汁杯 350ml 双叶刀头 | 商品详情</title>
  <meta name="description" content="350ml 容量,Type-C 充电,刀头可拆洗。" />
  <!-- Product 类型的结构化数据,AI 搜索主要靠它识别商品实体 -->
  <script type="application/ld+json">
    {"@context":"https://schema.org","@type":"Product","name":"便携榨汁杯 350ml","sku":"JCU-350-B","brand":{"@type":"Brand","name":"某某小家电"},"offers":{"@type":"Offer","price":199,"priceCurrency":"CNY","availability":"https://schema.org/InStock"}}
  </script>
</head>

原理剖析:不跑 JS 的爬虫,看到的是哪一版 DOM

这一节是整篇的核心,搞清楚它就不需要背结论了。

水合在什么时候动 head

SSR 的 HTML 到达浏览器后经历三个阶段。一是解析字节流构造初始 DOM,此时 head 里有 JSON-LD。二是 bundle 下载并执行 hydrateRoot,React 比对服务端 HTML 与客户端虚拟 DOM,对不齐的按客户端版本改。三是水合完成,页面变成普通单页应用。

head 的变动发生在第二阶段末尾。Helmet 这类库维护了一份"当前应该存在的 head 节点"的清单,水合结束时它会拿这份清单去对齐真实 DOM:清单里没有的节点删除,清单里有而 DOM 里没有的插入。服务端单独塞进去、客户端组件树里没有对应声明的节点,就落在"删除"这一档。

sequenceDiagram
    participant C as AI 爬虫(不执行 JS)
    participant S as SSR 渲染层(Node 18)
    participant D as 初始 DOM
    participant R as React 18 客户端
    C->>S: GET /product/portable-juicer-cup
    S-->>D: 返回首屏 HTML,head 内含 JSON-LD
    Note over C,D: 爬虫在这一步就停了,拿走的是含 Schema 的字节流
    D->>R: 下载 bundle,执行 hydrateRoot
    R->>D: 比对节点,Helmet 重挂 head 子树
    R-->>D: 删除不在清单里的 ld+json script
    Note over D: 此后浏览器 DOM 里没有 Schema
    C-->>C: 从未执行 JS,理论上不该看到"没有"的版本

图里最后一行留了个问号,下一小节解释。

为什么肉眼测不出来,以及 curl 为什么也是 0

这里有两层误导,一层骗了工具,一层骗了我们。

第一层是这类工具会执行 JS。它拿到首屏后继续跑 bundle,等水合结束再读 DOM,读到的本该是"没有 Schema"的那一版。它却报了"检测到 Product",因为工具对 head 有容错:它把渲染过程中曾经存在过的 head 节点做合并快照,水合前那一版 JSON-LD 被算了进去。"工具说有"既不代表爬虫能拿到,也不代表水合后还在,它只代表历史上存在过。

第二层是 curl 拿到 0 的真实原因。SSR 层前面挂了一层边缘节点,对 HTML 做流式改写,按 chunk 吐响应时在 head 结束后插一段内联脚本做 AB 分流。这段改写里有一句正则,把所有 <script type="application/ld+json"> 开头的块整体剥掉,理由是"减少首屏体积"。curl 拿到 0 是边缘节点的锅;devtools 看得到,是因为该同学的办公网 IP 在白名单里,走的是另一条回源路径。

两个现象叠加,把我们引向了完全错误的方向。

flowchart TD
    A[AI 搜索不引用新品] --> B{Rich Results 测试}
    B -->|检测到 Product| C{curl 首屏是否有 Schema}
    B -->|未检测到| Z[检查构建产物与模板]
    C -->|有| D[服务端与边缘链路正常,查抓取时机]
    C -->|无| E[问题在到达浏览器之前的链路]
    E --> F{绕过边缘节点直接回源}
    F -->|有 Schema| G[定位边缘流式改写,正则剥离了 ld+json]
    F -->|无 Schema| H[查服务端渲染日志]
    E --> I{按爬虫 UA 存首屏快照}
    I -->|快照有| J[服务端无责,转向客户端]
    I -->|快照无| K[服务端模板分支有误]
    J --> L{hydrateRoot 后 DOM 是否还有}
    L -->|无| M[定位水合覆盖 head,Helmet 重挂]
    M --> N[修复:构建期注入 + preserve head]
    G --> N

修复:把 JSON-LD 挪出水合的管辖范围

修复分三条线同时做,互不依赖。

第一条线是边缘节点的正则,直接删掉那条"优化首屏体积"的替换规则。JSON-LD 平均 1.4 KB,占首屏 3%,拿它换 AI 搜索的实体识别能力不划算。改完 curl 立刻从 0 变 1。

第二条线是水合对 head 的覆盖。我们不再让 Helmet 管这条 script,改成构建期把 JSON-LD 作为静态片段注入模板,水合时显式保护它:

// React 18.3.1 / react-dom 18.3.1
// 思路:JSON-LD 不由组件树声明,改由模板占位符承载
// 客户端水合时给这段 script 打标记,Helmet 对齐时跳过
const LD_JSON_FLAG = 'data-ld-ssr';

// 服务端渲染:把占位符替换成真实的结构化数据
// 这一步在 renderToString 之后、发送响应之前做
function injectLdJson(html, schemaObj) {
  // JSON.stringify 之后再转义,防止商品名里的 </script> 提前闭合
  const safe = JSON.stringify(schemaObj).replace(/</g, '\\u003c');
  // 占位符在构建期由 vite 插件写死在 index.html 的 head 末尾
  return html.replace(
    '<!--LD_JSON_SLOT-->',
    `<script type="application/ld+json" ${LD_JSON_FLAG}>${safe}</script>`
  );
}

// 客户端入口:水合前先把这段节点保护起来
// Helmet 的对齐发生在 hydrateRoot 之后,这里提前摘出再塞回去
function preserveLdJson() {
  // 取出服务端留下的结构化数据节点
  const node = document.head.querySelector(`script[${LD_JSON_FLAG}]`);
  if (!node) return () => {};
  // 记录它的下一个兄弟,方便原位置插回
  const anchor = node.nextSibling;
  // 先摘出,让 Helmet 的删除逻辑找不到目标也无从下手
  node.remove();
  // 返回一个还原函数,水合结束后调用
  return () => document.head.insertBefore(node, anchor);
}

const restore = preserveLdJson();
// 水合完成后的回调里把节点放回去
// onRecoverableError 用于兜住水合期间的告警,不阻断渲染
hydrateRoot(document.getElementById('root'), <App />, {
  onRecoverableError: console.warn,
});
// 用 requestAnimationFrame 而不是直接在下一行还原
// 因为 Helmet 的对齐可能跨帧,提前插回会被二次删除
requestAnimationFrame(restore);

顺带说一句,suppressHydrationWarning 在这里帮不上忙。它只是让 React 不打印警告,节点该被 Helmet 删还是会被删。

第三条线是给 Vue 系的页面留的补丁。客户有个频道页是 Vue 3 的,情况比 React 更绕:Vue 3.4 的模板编译器在客户端组件里会忽略 <script> 标签,既不渲染也不报错。

// Vue 3.4.21 / @vue/server-renderer 3.4.21
// Vue 的客户端模板编译会忽略 <script> 标签,属于静默失败
// 所以不能靠模板声明,必须绕开编译器
import { h, ref, onMounted } from 'vue';

export default {
  setup(props) {
    // 用渲染函数直接构造 script 节点
    // type 必须是 application/ld+json,浏览器不会执行它
    const renderLd = () =>
      h('script', {
        type: 'application/ld+json',
        // 用 innerHTML 传入,避开 Vue 对文本子节点的转义差异
        innerHTML: JSON.stringify(props.schema),
      });

    // SSR 阶段:渲染函数产出的 script 会进入首屏 HTML
    // 客户端阶段:Teleport 到 head 的节点依然归 Vue 管辖
    // 因此这里只在 onMounted 之后手动挂载一次,不交给 Teleport
    // 这一步有个副作用:SSR 与客户端会各插一次,出现重复节点
    // 因此客户端分支要在插入前先清掉同标记的旧节点
    onMounted(() => {
      const el = document.createElement('script');
      el.type = 'application/ld+json';
      el.text = JSON.stringify(props.schema);
      // 先移除上一次挂载留下的节点,避免 Schema 被重复声明
      document.head
        .querySelectorAll('script[data-ld-csr]')
        .forEach((n) => n.remove());
      el.setAttribute('data-ld-csr', '1');
      document.head.appendChild(el);
    });

    return () => renderLd();
  },
};

顺带把 JSON-LD 本身也补完整了。之前只写了 name 和 offers,缺 sku、brand、aggregateRating 这些 AI 搜索做实体对齐时真正会用到的字段:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "便携榨汁杯 350ml 双叶刀头",
  "sku": "JCU-350-B",
  "gtin13": "0690123456789",
  "brand": { "@type": "Brand", "name": "某某小家电" },
  "description": "350ml 容量,双叶不锈钢刀头,Type-C 充电,刀头可拆洗。",
  "offers": {
    "@type": "Offer",
    "price": "199.00",
    "priceCurrency": "CNY",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.7",
    "reviewCount": "286"
  }
}

修复前后 30 天对照

数据取自客户自己的看板,口径是三个 AI 搜索入口合计,关键词固定 12 个,品牌词与品类词各半。

指标 修复前 30 天 修复后 30 天 说明
AI 搜索引用该商品的总次数 0 47 含带出处链接与仅提及两种
带出处链接的引用次数 0 39 有链接才算真正的流量入口
首屏含 JSON-LD 的抽样命中率 0% 100% 每日抽 200 次,覆盖 5 个爬虫 UA
Rich Results 检测通过率 100%(误判) 100% 该工具改造前后都通过,不能作为验收依据
首屏 HTML 体积(gzip 后) 41.2 KB 42.6 KB 增加 1.4 KB,来自补全的 Schema 字段
商品详情页 AI 来源会话数 11 208 客户端埋点,仅统计 referrer 命中 AI 域名的会话

第 5 天出现第一次引用,第 9 天起稳定,第 18 天有个小回落,查下来是那批商品临时下架改价,availability 变成了 OutOfStock。修复后第 30 天,12 个关键词里有 7 个能进引用位,剩下 5 个是竞争性很强的泛品类词。

复盘:几条能直接抄的规矩

验收一律用 curl -A "<爬虫 UA>",别用浏览器,也别把会自动执行 JS 的在线检测工具当通过依据。工具报"有"和爬虫能拿到,是两件不同的事。

服务端注入和客户端声明必须同源。只要出现"只有服务端才渲染"的分支,就假定水合会把它处理掉,除非你显式做了保护。

边缘链路上的 HTML 改写要列入排查范围。这次 curl 的 0 和 devtools 的有来自两条回源路径,把排查拖慢了整整一周。

结构化数据要按 AI 做实体对齐的视角补字段,而不是按"能通过校验"的最低标准补。sku、brand、aggregateRating 对 GEO 场景的价值,明显高于把 Schema 写得合法这件事本身。

改完要有回归手段。我们在 CI 里加了一条:每次发布后对 5 个爬虫 UA 各拉一次首屏,断言 application/ld+json 出现次数 ≥ 1,失败就阻断发布。上线三个月拦下过两次回归。

在 GEO 语境里,JSON-LD 不是给浏览器看的装饰,它是 AI 判断"这个页面在讲哪个实体"的主要依据。它一旦在水合里被洗掉,前端、后端、SEO 三方的工具链会同时给出互相矛盾的"正常"信号。

参考与延伸

关键词:GEO、AI 搜索、SSR 水合、JSON-LD、结构化数据、AI 爬虫、前端 SEO

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。