跳到正文
AS

为什么 AI crawler 看不见你的动效标题区

  • seo
  • geo
  • nextjs
  • react

如果页面内容要等 JavaScript 跑完才出现,AI 搜索 crawler 就看不到它。Googlebot 会额外做一次渲染,而面向 LLM 的 crawler 大多只抓取原始 HTML,读到什么就是什么。空的地方,永远是空的。

这种失败是无声的

危险之处正在这里。一个纯客户端渲染的标题区,在浏览器里看起来完美无缺,在 Google 里排名也说得过去(因为 Googlebot 最终会渲染它),却对每一个来访的 AI crawler 返回一个空的 <h1>。没有报错,没有告警,页面只是悄悄地不再具备被引用的资格。

常见的诱因是动画包装组件。你顺手选了一个漂亮的文字揭示组件,把标题作为 prop 传进去,于是这段标题从此只存在于 "use client" 边界之内。

十秒钟内完成自查

用 curl 抓一下页面,看看标题在不在:

curl -s https://example.com | grep -o '<h1[^>]*>.*</h1>'

如果什么都没返回,说明内容是纯客户端的。在浏览器里用 view-source: 也会得到同样的结果——那是任何脚本执行之前的原始文档。

大多数人都搞错的一点

直觉上的诊断是「Client Component 里的文本不会出现在 HTML 里」。这个说法并不成立,而且一旦相信它,你就会一路修错方向。

在 Next.js App Router 中,Client Component 同样会在服务端渲染。标记了 "use client" 的组件会在服务端被渲染成 HTML,只是在浏览器里再做一次水合(hydration)。仅仅把标题当作 prop 传进去,本身并不会让它从任何地方消失。

真正会丢失内容的是这三种写法:

// 1. Effect-gated render — the server emits nothing at all.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
 
// 2. Explicitly opting out of server rendering.
const Hero = dynamic(() => import("./hero"), { ssr: false });
 
// 3. Text present in the HTML, but painted invisible until JS animates it.
<motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
  {title}
</motion.h1>;

真正让你损失 AI 引用的是前两种。第三种仍然会把文本交付给 crawler,但一旦 JavaScript 加载失败,真人看到的就是一片空白——它值得修,只是理由不同。

纯 CSS 动画可以避开这三种问题。@keyframes 的淡入不依赖 JavaScript,因此内容既存在于 HTML 中,也确实可见。

把它写成测试,而不是靠自觉

自觉会退化,测试不会。Playwright 可以在禁用 JavaScript 的情况下加载页面,并断言内容既存在又可见:

test.use({ javaScriptEnabled: false });
 
test("headline survives without JavaScript", async ({ page }) => {
  await page.goto("/");
  const heading = page.locator("h1");
 
  await expect(heading).toHaveCount(1);
 
  // Playwright counts opacity-0 elements as visible, so check it directly.
  const opacity = await heading.evaluate((el) => getComputedStyle(el).opacity);
  expect(Number(opacity)).toBeGreaterThan(0);
});

这样一来,下一个把标题藏在 mount 状态之后的人,收到的是一次红色的构建失败,而不是六周之后一场悄无声息的排名下滑。