## 什么是 "JavaScript 渲染"?

一个页面是 JavaScript 渲染的,意味着服务器发送的 HTML 实质上是空的 — 实际内容(标题、正文、产品信息)只有在浏览器下载并运行了一个 JavaScript 包,把 React、Vue、Svelte 或 Angular 这样的框架挂载到一个空的根元素之后才会出现。

典型的 JS 渲染主页在网络上看起来是这样:

```html
<body>
  <div id="root"></div>
  <script type="module" src="/assets/index-D9LVtTP6.js"></script>
</body>
```

可见的 `<body>` 内容是零字节。你在浏览器中看到的一切都是 JS 运行之后在客户端构造出来的。

## 为什么对智能体很重要

大多数 AI 爬虫不执行 JavaScript。当 ChatGPT、Claude、Perplexity、Gemini 的抓取或典型的智能体框架访问你的 URL 时,他们拿到的是原始 HTML — 而你的主页是空白的。你的产品描述、标题、链接和正文对他们来说都是不可见的。

这对检索和摘要是真正的伤害。当一个 LLM 在引用你的 URL 时被问到 "这家公司是做什么的?",它会产出糟糕的回答或产生幻觉,因为页面上没有任何东西可读。

## 这 *不会* 破坏什么

输出到 HTML 静态 `<head>` 中的标签不受客户端渲染的影响。即使是完全 JS 渲染的 SPA,只要构建工具正确填充了 head,这些检查仍然可以通过:

- JSON-LD 结构化数据 (`<script type="application/ld+json">`)
- OpenGraph (`og:image`、`og:title` 等)
- Twitter Card 元数据
- 规范 URL
- favicon 和 `<link rel="alternate">` 标签

如果你的站点是 JS 渲染的,*而且* 这些检查也都失败了,那是独立的缺口 — 通过给构建的 HTML 模板加上正确的 `<head>` 标签来修复,不论是否修复渲染本身。

## 共同的根本原因: 静态托管的 SPA 包

JS 渲染的站点通常以静态包(一个 `index.html` 和一个 JS/CSS 文件夹)交付,部署到对任何 GET 请求都返回 `index.html` 的主机上: Vercel 静态、Netlify、Cloudflare Pages、GitHub Pages、S3 + CloudFront、Fastly Frontend、Firebase Hosting。

这种部署模式根本没有任何按请求的服务器端逻辑。产生 JS 渲染 body 的同一份配置,**也** 会产生:

- 对每个 `Accept` 头都返回相同的 HTML(没有内容协商)
- 对未知路径返回 HTML 200,而不是结构化的 JSON 404
- 没有为智能体调优的 `Vary`、`Cache-Control` 或速率限制头
- 没有按 User-Agent 改变响应的方式

不触及部署模型而单独修复一个症状(例如内容协商)通常是不可能的 — 你需要服务器端逻辑,或者在静态包前面放一个边缘函数。

## 修复阶梯

挑选能解决你情况的最低成本选项。

### 1. 构建时预渲染

如果主页内容主要是静态的,在构建期间预渲染。浏览器仍然会用 React/Vue 等进行水合,但服务器发送的初始 HTML 已经包含渲染后的 DOM。爬虫看到真实内容。

- **Vite + react-snap** 或 **vite-plugin-prerender**
- **Astro**(`output: 'static'`)— JS 渲染的组件在构建时被服务端渲染为 HTML
- **Next.js** 配 `output: 'export'` 或 `getStaticProps`
- **Nuxt** 配 `nuxt generate`

对营销页面、博客和文档来说,这是最低成本的修复。

### 2. 完整的服务器端渲染(SSR)

如果主页需要动态数据(登录态、A/B 测试、新鲜内容),使用每次请求都渲染的框架:

- **Next.js**(默认的 App Router + React Server Components)
- **Remix**
- **SvelteKit**
- **Nuxt** SSR 模式

SSR 需要一个运行时(Node、Bun、Deno 或边缘运行时),意味着离开纯静态托管。

### 3. 用于内容协商的边缘函数

如果迁移到 SSR 太破坏现状,在静态包前面放一个边缘函数,拦截请求并返回备用表示:

```javascript
// Cloudflare Worker / Vercel Edge / Netlify Edge
export default async function (request) {
  const accept = request.headers.get('accept') || '';
  const ua = (request.headers.get('user-agent') || '').toLowerCase();
  const isAgent = /claudebot|gptbot|chatgpt-user|perplexitybot|google-extended/.test(ua);

  if (isAgent || accept.includes('text/markdown')) {
    return new Response(await fetchMarkdownSummary(), {
      headers: { 'Content-Type': 'text/markdown', 'Vary': 'Accept, User-Agent' },
    });
  }

  return fetch(request); // 透传到静态包
}
```

当底层应用必须保持客户端渲染(例如重交互应用)时,这是合适的修复 — 但你仍然希望智能体读到有意义的内容。

### 4. `<noscript>` 兜底(最小可行)

至少在静态 HTML 的 `<noscript>` 块里放上你的关键内容:

```html
<noscript>
  <h1>你的公司</h1>
  <p>你做什么,两句话说清。链到文档、价格、API。</p>
  <a href="/about">关于</a> · <a href="/docs">文档</a> · <a href="/pricing">价格</a>
</noscript>
```

这不能代替预渲染,但能避免爬虫看到完全空白的页面。大多数 JS 渲染的站点并没有这一项。

## 如何验证

不执行 JavaScript 地抓取你的站点,看看 body 里是否有真实内容:

```bash
curl -s -A "ClaudeBot/1.0" https://yourdomain.com/ | \
  python3 -c "import sys, re; t = sys.stdin.read(); body = re.search(r'<body[^>]*>(.*?)</body>', t, re.S); print(len(re.sub(r'<[^>]+>', '', body.group(1) if body else '').strip()))"
```

如果打印出来的数字小于几百个字符,智能体看不到你的内容。

## 了解更多

- [Google: Rendering on the Web](https://web.dev/articles/rendering-on-the-web)
- [Vercel: Static vs. SSR vs. ISR](https://vercel.com/docs/frameworks/nextjs#rendering-strategies)
- [Astro Islands](https://docs.astro.build/en/concepts/islands/)

## 相关

- [WebMCP](/kb/zh/webmcp)
- [llms.txt](/kb/zh/llms-txt)
