上线三周 AI 才开始引用新品:前端水合把 SSR 页面的 JSON-LD 洗掉的排查复盘
适用读者:做过服务端渲染(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'
三条命令的输出分别是 0、0,以及一段没有 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 三方的工具链会同时给出互相矛盾的"正常"信号。
参考与延伸
- schema.org — Product 类型官方文档
- Google Search Central — 结构化数据工作原理
- react.dev — hydrateRoot API 参考
- vuejs.org — 服务端渲染(SSR)指南
关键词:GEO、AI 搜索、SSR 水合、JSON-LD、结构化数据、AI 爬虫、前端 SEO