没有不值得去解决的问题,也没有不值得去学习的技术!

SPA 的 SEO 老问题:为什么最后还是给 103 个 A Tour of Go 课程页生成了 Prerender HTML

图 1:Google Search Console 中,多个不同的 A Tour of Go 课程 URL 被判断为“重复网页,用户未选定规范网页”。

作者:

A Tour of Go 多语言翻译项目

图3:访问过去的 Go Tour 简体中文站 tour.go-zh.org 时,当前已经无法正常建立连接。

(1) 从「A Tour of Go 中文版」这个搜索词开始:我决定做一个持续维护的 Go Tour 中文版

本地运行的 A Tour of Go「Methods continued」课程页面,左侧为课程说明,右侧为 Go 代码编辑器及运行结果。

(2) A Tour of Go 中文版项目设计冻结:从 101 页到 103 页,从 Gin 转向 Cobra CLI

A Tour of Go 中文版开发实战:英文基线、在线运行与 101 页上游同步体系

(3) A Tour of Go 中文版开发实战:英文基线、在线运行与 101 页上游同步体系

go-tour-i18n 项目完成首个简体中文课程页面 welcome/1 的整页翻译、结构校验和本地预览。

(4) A Tour of Go 多语言翻译项目实录:完成首个 zh-CN 页面翻译闭环

图 1:DeepSeek 官方更新日志显示,本次只更新 DeepSeek-V4-Flash,DeepSeek-V4-Pro API 与 APP、Web 模型均未更新

(5) A Tour of Go 中文翻译项目进展:完成前 8 页、修复 present 语法,并暂缓 DeepSeek 对比

图 1:generics/1 简体中文页面本地预览

(6) 一个页面重试五次:使用 GLM-5.2 翻译 A Tour of Go 时遇到的 Token 与 Present 结构问题

图 1:methods/16 简体中文页面浏览器预览

(7) A Tour of Go 中文翻译实录:如何只翻译教学代码注释,而不破坏 Go 代码

图 2:A Tour of Go methods/20「练习:错误」中文候选页面最终渲染效果

(8) A Tour of Go 多语言翻译:当正确的中文语序被受保护标记顺序校验误判

图 3:methods/24 中文页面,静态代码、Bounds 行内代码和链接内的 image.Rectangle 均正确渲染

(9) A Tour of Go 中文翻译完成 7 个代表页校准:最后 3 页又发现了哪些真实问题

图 2:首批 10 个普通页面经过试跑与流程校准后全部进入 ready

(10) A Tour of Go 多语言翻译项目:首批 10 个普通页面试跑,从 2 个 blocked 到全部 ready

图 2:flowcontrol/6 最终中文页面,return 与 v 为适应自然中文语序发生整体换位

(11) A Tour of Go 中文翻译第二批实跑:从 Inline Code 顺序误判到历史响应重新验证

图 1:flowcontrol/6 首次原始输入实验直接通过统一自动校验

(12) A Tour of Go 多语言翻译项目:从 swap 问题重新审视 Token 保护,原始输入与最小保护的一次真实实验

图 7:moretypes/1 最终中文页面预览。左侧课程正文、静态代码块、行内代码和教学注释均正常渲染,右侧官方 Go 示例继续保持原样。

(13) A Tour of Go 多语言翻译实战:第三批 10 页全部 Ready,继续校准 Protected Token 的结构角色

图 4:上游源码确认共有 103 个课程页面,zh-CN 最终达到 ready=103、pending=0、blocked=0

(14) A Tour of Go 多语言翻译项目:zh-CN 103 个课程页面全部 ready,课程正文翻译阶段完成

图 4:GitHub 与 ChatGPT 已成功连接

(15) ChatGPT 连接 GitHub 实测:让 AI 直接读取 go-tour-i18n 仓库参与译文审核

图 3:中文 Go 术语校准完成后,将确定的译法写入 zh-CN glossary

(16) 为什么 channel 最终选择“通道”:A Tour of Go 中文术语统一的一次实践

图 1:技术含义基本正确,但“返回一个返回……”已经明显影响阅读,因此仍被列入 C 类修订

(17) 103 页逐页审核后,只返修 16 页:A Tour of Go 中文正文发布前质量审计

图 2:中文课程页面以及已经完成本地化的编辑器控制区

(18) A Tour of Go 多语言翻译项目:公共 UI 本地化完成,从课程译文走向完整中文界面

图 2:完整 zh-CN 正式投影成功生成,103 个课程页面被重新组装为 7 个 .article

(19) 从 103/103 ready 到完整站点验收:A Tour of Go 中文版补齐正式投影与课程元数据本地化

图 3:从 production bundle 独立启动的 A Tour of Go 简体中文页面,课程正文、公共 UI 和官方 Go 示例均已进入最终发布形态。

(20) A Tour of Go 多语言翻译项目:103 页全部完成后,我终于生成了可独立部署的 zh-CN Production Bundle

图 1:A Tour of Go 简体中文版正式生产页面,远程运行已经成功输出“Hello, 世界”。

(21) A Tour of Go 多语言翻译项目:zh-CN 正式上线,从生产发布到 go.dev Playground 的完整部署记录

图 1:新生产域名 go-dev.shuijingwanwq.com 已正常访问,Go 示例远程运行成功

(22) A Tour of Go 多语言翻译项目:从 go-tour 到 go-dev,完成 EdgeOne、Nginx 与 HTTPS 生产域名迁移

图 1:A Tour of Go 多语言翻译项目正式生产首页

(23) A Tour of Go 多语言翻译项目正式上线:从项目首页、公共 Footer 到生产发布元数据的完整收尾

图 3:GA4 g/collect 请求返回 HTTP 204,并携带当前课程页面地址,确认 Google Analytics 已产生真实事件上报

(24) A Tour of Go 多语言翻译项目:接入 Google Analytics 与百度统计,从 systemd 注入到 EdgeOne 与真实上报验证

图 1:生产环境 robots.txt 与 sitemap.xml 已正确对外提供,Sitemap 共包含 104 个规范 URL。

(25) A Tour of Go 中文站上线后:补齐 robots.txt、Sitemap,并完成五大搜索引擎提交

图 4:发布生产并刷新 EdgeOne 缓存后,再次通过真实手机访问,顶栏项目名称已经完整保持在一行,最终移动端验收通过。

(26) A Tour of Go 多语言翻译项目:修复手机端首页顶栏标题换行,并完成真机验证

图 3:生产自动部署脚本第一次真实运行时,systemd 已经显示 active,但首次 localhost 探测仍然得到 HTTP 000;随后连续 3 次同时满足 active + HTTP 200 后,脚本才判定部署成功,并继续完成公网 HTTP 200 验收。这次真实运行验证了连续应用层健康检查的必要性。

(27) A Tour of Go 多语言翻译项目:从一次 203/EXEC 故障到生产自动部署脚本落地

图 1:阿里云生产环境访问 go.dev Playground 时出现 TLS 握手超时

(28) A Tour of Go 多语言翻译项目:为 Playground 搭建独立的 ZgoCloud 执行代理

图 3:清除 /tour/script.js 缓存后,Run 已直接请求 ZgoCloud /compile 并返回 HTTP 200

(29) A Tour of Go 多语言翻译项目:将生产 Run / Format 切换到 ZgoCloud 执行节点

【图 1:微信中运行 Go 示例时出现 Error communicating with remote server.】

(30) A Tour of Go 中文版手机端运行与格式化偶发失败排查:从间歇性报错到补强 Nginx 可观测性

【图 4:向 golang-dev 公开邮件列表发送 Playground 使用告知邮件并投递成功】

(31) A Tour of Go 多语言项目:为 Playground 代理增加唯一 User-Agent,并主动告知 Go 官方

【图 3:百度剩余 9 个核心 URL 提交成功,remain 归零】

(32) A Tour of Go 搜索引擎收录实测:从 Google 自然索引到百度主动提交与 Bing IndexNow

【图 1:当前正式中文 methods/24 页面,右侧示例代码运行成功】

(33) A Tour of Go 中文翻译完成后,我为什么重新评估 minimal-protect 翻译模式

【图 3:5 个页面 Request SHA256 完全一致,但 Response SHA256 全部不同】

(34) A Tour of Go 翻译实验:更多原始上下文为什么没有更好?从 157 个保护标记到 30 次重复盲评

图 3:3 个代表页 × 4 个评审模型,共 12 次匿名评审的完整排名与第一名票数。

(35) A Tour of Go 翻译质量再评估:minimal-protect 未达预期后,我开始比较 ChatGPT、Codex 与 GLM-5.2

图 2:GitHub 中的 chatgpt-zh-CN-008 重译批次。一个 Batch 同时保存 inputs、raw-responses、candidates、validation 和 retry 记录,GitHub 成为 ChatGPT 与本地翻译流水线之间的数据交接层。

(36) 没有 OpenAI API,我如何用 ChatGPT + GitHub 完成 A Tour of Go 103 页全量重译

图 1:103 个正式课程页面完成最终语义质量审核,结果为 A=103、B/C/D=0,同时不存在缺失、重复或额外页面。A/B/C/D 是本轮项目内部语义质量分级,并不是自动结构 validator 的结果。

(37) 103 页翻完还不能发布:A Tour of Go ChatGPT 译文的语义审核与 Canonical Promotion

图 3:7 个 article endpoint 的 production origin 与 EdgeOne 公网响应全部 exact match,同时新的 ChatGPT 译文标记存在,旧版译文标记已经消失。

(38) 103/103 与 7/7 Exact Match:A Tour of Go ChatGPT 新译文的最终线上验收

【图 2:upstream source preview】

(39) A Tour of Go 多语言翻译项目首次实现 upstream 同步机制:从一次性翻译到长期维护

图 2:A/B/C/D 质量评级规则,展示正式质量 rubric 与 Promotion gate。

(40) A Tour of Go 多语言翻译项目引入 Translation Quality Review:从自动验证到 AI 翻译质量控制

图 4:翻译后 Playground Example,展示中文注释与保持不变的 Go 代码结构。

(41) A Tour of Go 多语言翻译项目实现 Playground Example 本地化:从代码注释翻译到完整学习体验

图 5:公网页面验证广告脚本输出

(42) A Tour of Go 多语言翻译项目接入广告系统:从 AdSense 专用配置到通用 HTML 注入架构

图 3:百度统计单页应用设置

(43) go-dev 单页网站接入统计与 Google AdSense Auto Ads 验证记录

图 1:GitHub 仓库中的多语言翻译项目结构

(44) A Tour of Go 多语言翻译项目:选择日语作为第二语言并启动翻译验证

图 5:模型身份描述不一致

(45) A Tour of Go 多语言翻译项目中的 AI 协作异常排查:一次长期工程会话失效分析

【图 1:ChatGPT GitHub 写入流程调整说明截图】

(46) go-tour-i18n 翻译流程调整:从 ChatGPT GitHub 写入到本地 artifact 导入

图 4 展示了本次异常状态。

(47) go-tour-i18n 翻译自动化流程复盘:已验证的 ChatGPT 翻译能力在新任务中的稳定性问题

【图 6:全部评审结束以后才读取 secret key,完成最终揭盲】

(48) A Tour of Go 翻译质量再评估:Codex High 已接近 ChatGPT High,我决定调整默认翻译引擎

图 4:ja-JP 生产课程页实际运行 Go 示例

(49) A Tour of Go 日语版正式上线:第二门语言 ja-JP 完成生产发布

图 1:ja-JP 上线以后,先提交“新增语言标准化流程”

(50) A Tour of Go 多语言扩展复盘:为第三门语言建立标准化流程

图 1:课程正文结束以后,页面左侧仍然存在大块视觉空白,但 AdSense 并没有在这里插入展示广告。

(51) A Tour of Go 明明有大片空白,为什么 AdSense 就是不显示展示广告?

图 2:golang/website 当前的 _content/tour/。课程内容、静态资源和模板都在这个上游目录中持续维护。

(52) 为了 AdSense,要不要放弃 SPA?A Tour of Go 页面架构的一次取舍

图 4:最终的手动课程广告真实展示在课程内容区域内,而不是 footer 之后。

(53) 保留 SPA 以后,广告应该放在哪里?从 AdSense 预览到方案 B

图 1:Google Search Console 中,多个不同的 A Tour of Go 课程 URL 被判断为“重复网页,用户未选定规范网页”。

(54) SPA 的 SEO 老问题:为什么最后还是给 103 个 A Tour of Go 课程页生成了 Prerender HTML

图 2:方案 B 生产环境中,源码已经正常,但课程主体高度异常缩短,footer 提前进入第一屏。

(55) 从源码空白到 AdSense 高度污染:A Tour of Go 方案 B 的生产收尾

在前几篇文章中,我一直围绕 A Tour of Go 的 Google AdSense 展示广告问题进行调整。

最开始的问题是:

课程页面明明存在大片视觉空白,但 Auto Ads 始终没有插入普通展示广告。

继续分析以后,又先后做了几项决定:

  • 不为了 AdSense 放弃 A Tour of Go 原来的 SPA 架构;
  • 不依赖 Google 自动选择课程广告位置,而是增加明确的手动课程广告位;
  • 第一版方案 A 使用 MutationObserver 跟踪 SPA 页面变化;
  • 后来改成方案 B,让广告直接跟随 Angular course view 的 mount() / $destroy / unmount() 生命周期。

但是,在广告问题之外,其实一直还有另外一个早就知道的问题:

SPA 对搜索引擎并不友好。

这个问题并不是这一次做 AdSense 优化以后才发现的。

只是过去一直没有一个足够强的理由,让我决定对整个课程页面体系做一次更完整的 Prerender。

而这一次,SEO 和广告两个方向的需求恰好汇合到了一起。

103 个 URL,在用户眼里明明是 103 个不同页面

A Tour of Go 的课程页面有很多独立 URL。

例如:

Plaintext
/tour/welcome/1
/tour/basics/1
/tour/basics/2
/tour/moretypes/26
/tour/methods/24
/tour/concurrency/7

从用户角度来看,这些当然是不同页面。

每个 URL:

  • 有不同课程标题;
  • 有不同正文;
  • 有不同 Go 示例代码;
  • 有不同上下页关系;
  • 讲解完全不同的 Go 知识点。

整个正式课程一共有:

Plaintext
103

个页面。

所以从内容模型上看,应该是:

Plaintext
103 个 URL
=
103 个独立课程页面

问题在于:

浏览器最终看到 103 个不同页面,不代表搜索引擎第一次访问这些 URL 时,也能立刻看到 103 份明显不同的 HTML。

这正是传统客户端 SPA 比较麻烦的地方。

Search Console 曾经把多个课程 URL 判断成重复网页

Google Search Console 之前已经出现过比较明显的信号。

多个不同的 A Tour of Go 课程 URL 被归到了:

“重复网页,用户未选定规范网页”

这一类状态中。

图 1:Google Search Console 中,多个不同的 A Tour of Go 课程 URL 被判断为“重复网页,用户未选定规范网页”。
图 1:Google Search Console 中,多个不同的 A Tour of Go 课程 URL 被判断为“重复网页,用户未选定规范网页”。

截图里的示例并不是同一个页面的不同参数。

而是实际不同课程,例如:

Plaintext
/tour/moretypes/2
/tour/moretypes/21
/tour/moretypes/26
/tour/moretypes/8
/tour/moretypes/15
/tour/basics/12

对学习者来说,这些页面之间显然差别很大。

但是搜索引擎曾经没有充分建立这种区别。

这其实正是 SPA SEO 中很典型的一类问题。

URL 变了,不代表第一次返回的 HTML 已经变了

传统服务器渲染页面通常是这样的:

Plaintext
GET /tour/basics/1

服务器生成 basics/1 HTML

浏览器获得完整课程内容

再请求:

Plaintext
GET /tour/basics/2

服务器生成 basics/2 HTML

浏览器获得另一份完整课程内容

也就是说,不同 URL 从第一次 HTTP 响应开始就已经明显不同。

而传统 SPA 更接近:

Plaintext
GET /tour/basics/1

获得 SPA shell

加载 JavaScript

Angular 启动

根据 route 渲染 basics/1

另外一个 URL:

Plaintext
GET /tour/basics/2

获得高度相似的 SPA shell

加载 JavaScript

Angular 启动

根据 route 渲染 basics/2

对于现代浏览器来说,这没有什么问题。

JavaScript 执行以后,用户最终还是能看到正确的课程页面。

Google 也具备执行 JavaScript 的能力。

但这里始终存在一个问题:

为什么要让搜索引擎先取得一份高度相似的 shell,再依赖第二阶段 JavaScript 执行,才能理解这些 URL 到底有什么不同?

如果服务器本来就知道当前 URL 对应哪一节课程,那么完全可以在第一次响应中就把这些信息直接给出去。

SEO 是老问题,但这一次又增加了一个广告方面的理由

单纯从 SEO 角度来看,其实早就有理由做 Prerender。

但是当时一直需要权衡:

  • 会不会明显增加实现复杂度;
  • 会不会破坏原来的 SPA;
  • 会不会增加与 upstream 同步的成本;
  • 是否值得为 103 个课程页增加额外生成流程。

这一次继续排查 AdSense 时,又出现了另一个考虑。

前面已经发现,Google Auto Ads 对 A Tour of Go 课程主体的理解并不理想。

页面明明有大片视觉空白,Google 却一直没有在那里插入普通展示广告。

后来即使增加了手动广告位,Google 最终要返回什么广告,仍然需要理解当前页面的内容。

例如:

Plaintext
/tour/methods/24

实际上讲的是 Go 的 image.Image 接口。

而:

Plaintext
/tour/concurrency/7

讲的是等价二叉树与 Go 并发。

从内容主题来看,两者完全不同。

如果 Google 第一次请求这两个 URL 时就能直接得到:

  • 本页标题;
  • 本页正文;
  • 本页示例代码;
  • 本页 description;

显然会比先拿到一份高度相似的 SPA shell,在页面语义上更加清晰。

这里需要特别说明:

我并没有数据证明 Prerender 一定能够提高 AdSense 填充率,也没有证据证明它一定会让广告更加相关。

这不是一个可以直接下结论的因果关系。

更准确的考虑是:

如果搜索引擎和 Google 的其他系统第一次取得 URL 时,就能直接理解当前页面的真实内容,那么至少能够提供更加完整、明确的页面上下文。

从工程角度来看,这显然比 103 个 URL 首先返回高度类似的客户端 shell 更合理。

于是:

SEO 的长期问题 + 页面上下文对广告系统也可能有帮助

最终共同推动了这次 Prerender 实现。

目标不是放弃 SPA

这里很容易产生一个误解。

增加 Prerender,并不等于把上一篇文章刚刚决定保留的 SPA 又推翻了。

我真正希望实现的是:

Plaintext
搜索引擎 / 首次 HTTP 请求

直接获得完整课程 HTML

正常浏览器

先看到完整课程 HTML

Angular 启动

接管页面

之后继续 SPA 导航

也就是说:

首次访问像一个完整的独立页面,后续交互仍然保持 SPA。

这两个目标并不冲突。

甚至可以说,这才是当时比较理想的折中:

Plaintext
服务器端
提供完整页面身份

客户端
继续保持上游 SPA 体验

不是只 Prerender 几个代表页面

既然决定解决这个问题,我不希望只给几个搜索流量比较高的课程做特殊处理。

正式课程本身就是 103 页。

所以最终采用的是完整覆盖:

Plaintext
103 个正式课程 URL

103 个独立 Prerender HTML

在正式 production bundle 中可以直接看到:

Plaintext
========== PRERENDER COUNT ==========
103

并且目录中实际存在:

Plaintext
prerender/basics/10.html
prerender/basics/11.html
prerender/basics/12.html
...

prerender/generics/3.html

prerender/methods/10.html
prerender/methods/11.html
...

prerender/moretypes/6.html
prerender/moretypes/7.html
...

prerender/welcome/1.html
prerender/welcome/2.html
...
图 2:正式 production bundle 中共生成 103 个 Prerender HTML,覆盖 welcome、basics、generics、methods、moretypes 等全部正式课程页面。
图 2:正式 production bundle 中共生成 103 个 Prerender HTML,覆盖 welcome、basics、generics、methods、moretypes 等全部正式课程页面。

这件事对我来说很重要。

因为如果只是:

Plaintext
首页 prerender
+
几个热门页面 prerender

那实际上仍然没有解决:

课程 URL 本身应该拥有独立页面身份

这个核心问题。

既然 Catalog 中正式存在 103 个课程页面,那么服务器最终也应该能够独立描述这 103 个页面。

Prerender HTML 里不只是一个空壳

下一步就是确认:

这些 HTML 到底有没有真正的内容。

这里我不想依赖浏览器截图。

因为浏览器最终显示出来的东西,可能已经经过 Angular 和 JavaScript 后续处理。

最直接的方法是:

Bash
curl https://go-dev.shuijingwanwq.com/tour/basics/1

然后直接检查服务器返回的原始 HTML。

结果可以看到:

HTML
<title>包 – 包、变量和函数 – Go 语言之旅</title>

页面中已经有:

HTML
<h2></h2>

正文也直接存在:

Plaintext
每个 Go 程序都是由包组成的。
程序从包 main 开始运行。

不仅如此,甚至示例源码也已经直接进入首次 HTML:

HTML
<textarea ...>
package main

import (
    "fmt"
    "math/rand"
)

func main() {
    fmt.Println("My favorite number is", rand.Intn(10))
}
</textarea>
图 3:直接通过 HTTP 获取 /tour/basics/1 时,返回的 HTML 已经包含本页标题、中文课程正文以及真实 Go 示例源码。
图 3:直接通过 HTTP 获取 /tour/basics/1 时,返回的 HTML 已经包含本页标题、中文课程正文以及真实 Go 示例源码。

这一点非常关键。

现在流程已经不是:

Plaintext
Google

拿到 shell

执行 Angular

等待课程内容出现

而是:

Plaintext
Google

GET /tour/basics/1

第一次 HTTP 响应

已经知道:
“这一页讲的是包”

JavaScript 后续能不能执行,已经不再决定搜索引擎是否能够得到最基础的课程语义。

示例源码为什么也要进入 Prerender

最开始如果只考虑 SEO,也许会觉得:

有标题和正文就够了,代码可以等 Angular 加载。

但 A Tour of Go 与普通文章又有一点不同。

代码就是课程内容的一部分。

例如一页讲:

Plaintext
package

另一页讲:

Plaintext
for

还有一页讲:

Plaintext
interface

示例代码本身就是页面主题的重要语义。

如果 Prerender 只输出:

Plaintext
课程标题
课程说明

却把右侧示例代码留空,那么页面仍然不是完整的课程初始状态。

所以最终希望首次 HTML 尽量接近用户真正应该看到的内容。

这也为后面另外一个问题埋下了伏笔:

HTML 里明明已经有源码,为什么浏览器第一次打开页面时,代码区域还是可能短暂显示为空?

这个问题后来成为 Prerender 与 Angular hydration 整合过程中一个很重要的 bug。

不过这是下一篇的内容。

仅有正文还不够,还要给每个 URL 一个明确的 SEO 身份

解决重复页面问题,不能只靠:

这两个页面正文不一样。

还需要让每个 URL 自己明确说明:

我是谁。

因此正式页面还增加了独立的:

  • <title>
  • meta description
  • canonical

例如 /tour/basics/1 当前直接返回:

HTML
<title>包 – 包、变量和函数 – Go 语言之旅</title>

canonical:

HTML
<link rel="canonical"
      href="https://go-dev.shuijingwanwq.com/tour/basics/1"/>

description:

HTML
<meta name="description"
      content="讲解 Go 程序由包组成、程序从 main 包开始运行,以及导入路径最后一项通常与包名相同的惯例。"/>
图 4:/tour/basics/1 首次 HTTP HTML 已直接包含独立 title、canonical 和 description。
图 4:/tour/basics/1 首次 HTTP HTML 已直接包含独立 title、canonical 和 description。

这一张图和第一张 Search Console 截图放在一起,我觉得特别有意义。

之前 Google 面对的是:

Plaintext
很多不同课程 URL

页面身份不够明确

部分被判定为重复网页

现在变成:

Plaintext
/tour/basics/1

独立 title
独立 description
独立 canonical
独立正文
独立示例源码

每一个 URL 都开始真正成为一份能够独立理解的页面。

Course SEO metadata 也不能简单靠模板拼接

还有一个问题是:

103 个页面的 description 不可能全部写成:

Plaintext
学习 Go 语言相关内容。

那样虽然形式上有了:

HTML
<meta name="description">

实际上仍然没有多少页面区分度。

如果希望 Google 真正理解页面主题,description 应该和当前课程内容对应。

例如“包”这一页描述的是:

Plaintext
讲解 Go 程序由包组成、程序从 main 包开始运行,
以及导入路径最后一项通常与包名相同的惯例。

另一页讲切片,就应该描述切片。

讲 goroutine,就应该描述 goroutine。

所以后来课程 SEO metadata 也变成了一套独立的 locale-level 内容资产。

这里有一个项目边界也很重要:

Course SEO metadata 不属于 TranslationUnit。

它不会进入:

Plaintext
TranslationUnit
→ Quality Check
→ Final Review
→ promotion

那套正式翻译 gate。

原因是它本来就是为了页面 SEO 额外生成的 locale-level metadata,而不是上游 present.Section 的正式翻译单元。

但是这并不意味着可以不审核。

它仍然属于最终 locale surface,需要保证:

  • 内容准确;
  • 不夸大;
  • 与课程实际内容一致;
  • glossary 术语保持一致;
  • 页面之间有足够区分度。

这也是为什么整个多语言工程不能只关注 TranslationUnit。

真正上线的一个 locale,还包括很多 TranslationUnit 之外的可见内容。

Prerender 不能破坏原来的 Angular SPA

做 Prerender 最危险的地方,其实不是:

能不能生成一份 HTML。

而是:

生成以后,Angular 还能不能正常接管?

理想状态需要同时满足:

Plaintext
首次 HTML
完整
        +
Angular 启动
正常
        +
SPA 下一页
正常

任何一个失败都不行。

如果为了 SEO 把 Angular 搞坏了:

不行。

如果为了 SPA,又让首次 HTML 重新退化成空 shell:

同样不行。

所以这次真正需要解决的是两种渲染模型之间的交接:

Plaintext
Server Prerender

Browser initial DOM

Angular bootstrap

Hydration / route takeover

CodeMirror 初始化

SPA 正常运行

而现实很快证明:

这条交接链并没有想象中那么简单。

搜索引擎的问题解决方向明确以后,浏览器问题反而出现了

从 SEO 角度来看,Prerender 的方向已经很清晰。

服务器首次响应能够提供:

  • 当前课程标题;
  • 当前课程正文;
  • Go 示例源码;
  • canonical;
  • description;
  • 页面真实 URL identity。

但是正常浏览器打开这些页面以后,又暴露出了一系列新的问题。

其中最明显的包括:

  • HTML 中明明已经有示例代码,首次打开时编辑器却可能短暂空白;
  • Angular 接管以后,页面会发生多次视觉变化;
  • footer 在 route DOM 被替换过程中发生移动;
  • 少数情况下“下一页”暂时没有正常响应;
  • CodeMirror 与 Prerender textarea 之间还需要处理接管关系。

更麻烦的是,当这些问题逐渐解决以后,真实 AdSense 环境又产生了另外一个很难在本地复现的问题:

Google 的广告脚本直接修改了课程布局祖先节点的 inline style,把整个课程页面高度压缩了。

于是 footer 又被拉进了第一屏。

这也说明了一件很现实的事情:

一个架构方案在 SEO 层面正确、在自动测试里通过,并不代表它在 Angular、CodeMirror、AdSense 同时存在的真实生产环境里就一定稳定。

最终还需要继续做一轮生产级的整合和收尾。

为什么最后还是值得做 Prerender

经历后面这些问题以后,如果重新问一次:

给 103 个课程页面做 Prerender 值不值得?

我的答案还是:

值得。

因为不做它的话,103 个 URL 的页面身份始终更多依赖客户端 JavaScript。

而现在至少已经能够做到:

Plaintext
一个正式课程 URL
=
一个独立 HTTP 页面身份
=
一个独立 Prerender HTML
=
独立 title
=
独立 description
=
独立 canonical
=
独立课程正文
=
独立示例源码

与此同时,用户在页面加载完成以后仍然保留:

Plaintext
Angular SPA
+
快速下一页
+
CodeMirror
+
Playground

这才是最终真正想要的结果:

不是为了 SEO 放弃 SPA,而是让 SPA 在首次 HTTP 层面也成为搜索引擎能够直接理解的独立页面。

而这一次 AdSense 优化,则成为了推动这个多年 SPA SEO 老问题真正进入正式解决阶段的另一个重要因素。

下一步:真正困难的是 Prerender 与 SPA 如何稳定共存

到这里,搜索引擎这一侧的方案已经基本成型。

但对于正常用户来说,还有最后一轮更棘手的问题。

Prerender、Angular、CodeMirror、手动 AdSense 广告同时进入真实页面以后,先后出现了:

Plaintext
首次源码空白

多次视觉变化

偶发下一页异常

footer 布局变化

真实 AdSense inline style 污染

这些问题并不是再重新选择一套架构就能解决的。

因为前面的几个核心决策已经确定:

  • SPA 要保留;
  • Prerender 要保留;
  • 手动课程广告要保留;
  • 与 upstream 的差异仍然要尽可能收敛。

剩下要做的是:

让这些已经确定的组件真正能够稳定地一起工作

A Tour of Go 多语言扩展复盘:为第三门语言建立标准化流程

A Tour of Go 多语言翻译项目

本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。

项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n

当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。