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"
深水区的三个断点:
- 服务端 HTML 与渲染后 DOM 不一致 → 两波索引内容打架。
- JS 改写了 title/canonical → 元数据不可信。
- 链接是渲染后才有的 → 链接图在第一波是断的。
二、四种渲染形态的工程代价
| 形态 | 服务端 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)"
判据:
title不一致 → 严重,元数据不可信,必须修。- 正文长度:
no-js明显偏小(如小于渲染后的 50%)→ 存在 CSR 依赖。 canonical不一致 → 会引发规范页争议,必须修。
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 过重 | 拆包、关键内容直出 |
七、本章验收
- 有脚本能对照"无 JS"与"渲染后"两版的 title/正文/canonical
- 关键页面三字段完全一致
- 所有导航与分页链接在无 JS 情况下可提取
- JSON-LD 在服务端直出(非 JS 注入)
- 若使用动态渲染,已写明下线计划
# 一致性验证的落地形式:接进 CI
# 步骤:curl 无 JS 版 → headless 渲染版 → 对比 title/canonical/正文长度
# 任一不一致即失败(见第 09、12 章的门禁)
渲染之后进入索引与实体层。下一章讲结构化数据如何从"标记"升级为"实体图谱"。