KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · JavaScript SEO 深水区 — keel 龙骨

本章目标:把"渲染"从基础课的取舍题,推进到可验证、可自动化的工程问题。 验收标准:能对任一页面自动验证"渲染前后内容一致",并给出混合渲染的落地方案。

本章目标:把"渲染"从基础课的取舍题,推进到可验证、可自动化的工程问题。
验收标准:能对任一页面自动验证"渲染前后内容一致",并给出混合渲染的落地方案。

为什么基础课的结论不够用:基础课告诉你"优先直出、别依赖 JS"。但真实项目里,你往往是半 JS——正文直出,交互靠 JS;或者 SSR 出首屏,客户端再水合。这时问题不再是"要不要 JS",而是渲染前后内容是否一致、链接是否可达、二次渲染是否会改写关键元数据。本章就是这个深水区。


一、渲染管线:内容从哪来、到哪去

请求 URL
  │
  ├─ 服务端:产出 HTML(可能含正文,也可能只有骨架)
  │     └─ SSR / SSG / ISR 在此处完成
  │
  ├─ 浏览器/无头浏览器:下载 HTML
  │     ├─ 解析 DOM
  │     ├─ 执行 JS(fetch 数据 → 渲染 → 水合)
  │     └─ 得到最终 DOM
  │
  ├─ 引擎第一波索引:使用"服务端返回的 HTML"
  └─ 引擎第二波索引:使用"渲染后的最终 DOM"

深水区的三个断点:

  1. 服务端 HTML 与渲染后 DOM 不一致 → 两波索引内容打架。
  2. JS 改写了 title/canonical → 元数据不可信。
  3. 链接是渲染后才有的 → 链接图在第一波是断的。

二、四种渲染形态的工程代价

形态 服务端 HTML 首屏 SEO 风险 工程代价
纯静态 / SSG 完整正文 极快 最低 构建变慢、路由需枚举
SSR 完整正文 快 低 需常驻服务、有缓存问题
ISR 完整正文(可滞后) 快 低 新鲜度延迟、一致性窗口
混合(直出+水合) 正文直出 快 低(若一致性守得住) 需维护水合逻辑
CSR 空壳 慢 高 最省服务端,SEO 最差

选择原则:能静态就静态;要动态就 SSR/ISR;实在要 CSR 的部分,只放在登录后、无搜索价值的界面里。


三、动态渲染(Dynamic Rendering)的适用与代价

动态渲染 = 检测到爬虫 UA 就返回预渲染版本,普通用户走 SPA。

维度 说明
适用场景 存量 SPA、短期无法重构、内容确有价值
代价 维护两套渲染路径;UA 判断可被滥用;一致性难保证
官方态度 曾作为过渡方案推荐,现在定位为"不得已才用"
风险 给爬虫和用户返回不同内容,可能被判定为 cloaking

判据:如果你能做到 SSG/SSR,就不要用动态渲染。动态渲染是过渡方案,必须配上"何时下线"的计划。

# 动态渲染示意:按 UA 分流(仅在过渡期使用,需谨慎)
map $http_user_agent $is_bot {
  default 0;
  ~*(googlebot|bingbot|baiduspider) 1;
}
# 由预渲染服务返回快照,普通用户走 SPA

四、渲染前后一致性验证(核心方法)

这是本章最该带走的一项能力:把"一致性"变成一条可自动化的断言。

4.1 三个必须一致的字段

字段 第一波来源 第二波来源 判据
正文文本 服务端 HTML 渲染后 DOM 正文长度差异 < 阈值
<title> 服务端 HTML 渲染后 DOM 完全一致
canonical 服务端 HTML 渲染后 DOM 完全一致

4.2 用无头浏览器做对照

# 无 JS 版本(爬虫第一波视角)
curl -s https://www.xxxxxx.cn/learning/courses/tools/lessons/1/ > /tmp/no-js.html

# 渲染后版本(第二波视角)——需要 headless 环境
npx playwright screenshot --wait-for-timeout=3000 \
  https://www.xxxxxx.cn/learning/courses/tools/lessons/1/ /tmp/rendered.png

# 若用 pupeteer/playwright 脚本抓最终 DOM:
node -e "
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch();
  const p = await b.newPage();
  await p.goto('https://www.xxxxxx.cn/learning/courses/tools/lessons/1/', {waitUntil:'networkidle'});
  require('fs').writeFileSync('/tmp/rendered.html', await p.content());
  await b.close();
})();
"

4.3 对照脚本

# 提取两版的 title 与正文长度做对比
extract_title() { grep -oiE '<title>[^<]*</title>' "$1" | head -1; }
extract_text()  { sed 's/<[^>]*>//g' "$1" | tr -s ' \n' ' ' | wc -c; }

echo "no-js title:   $(extract_title /tmp/no-js.html)"
echo "rendered title:$(extract_title /tmp/rendered.html)"
echo "no-js text len:   $(extract_text /tmp/no-js.html)"
echo "rendered text len:$(extract_text /tmp/rendered.html)"

判据:

4.4 关键内容覆盖率

# 第一波 HTML 是否包含每个 h1/h2 的文本
for kw in "本章目标" "抓取预算" "状态码"; do
  printf "%s: no-js=%s\n" "$kw" "$(grep -c "$kw" /tmp/no-js.html)"
done

五、混合渲染的落地清单

项 做法
正文直出 首屏 HTML 含完整正文
链接真实 导航/分页/相关推荐用 <a href>,非 onclick
元数据稳定 JS 不改写 title/canonical/robots
水合不闪 服务端与客户端首帧一致,避免 FOUC
关键数据内联 首屏所需数据内联进 HTML,减少 fetch
增量内容可降级 评论/推荐等失败时不影响正文

六、故障速查

现象 渲染深水区原因 处理
Google 收录内容与页面不符 两波不一致 修水合,稳定 title/canonical
内页不被发现 链接渲染后才出现 改直出 <a href>
结构化数据不生效 JSON-LD 由 JS 注入且未进水合 服务端直出 JSON-LD
首屏闪一下 水合前后 DOM 差异大 对齐首帧
动态渲染被判 cloaking 爬虫与用户内容差异大 下线动态渲染,改 SSR
渲染预算超时 JS 过重 拆包、关键内容直出

七、本章验收

# 一致性验证的落地形式:接进 CI
# 步骤:curl 无 JS 版 → headless 渲染版 → 对比 title/canonical/正文长度
# 任一不一致即失败(见第 09、12 章的门禁)

渲染之后进入索引与实体层。下一章讲结构化数据如何从"标记"升级为"实体图谱"。

进入 keel 阅读