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

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

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

作者:

在

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 的生产收尾

图 1:A Tour of Go 德语版 de-DE 已正式运行在生产环境

(56) A Tour of Go de-DE 上线复盘:第三门语言用了约 16 小时,下一门如何压到 8 小时?

图 1:fr-FR 首次 production 验收完成后,我没有立即开始 ko-KR,而是先连续完成新增语言与首次生产流程的优化

(57) 法语版上线后,我没有马上开始韩语:A Tour of Go 新增语言流程的一轮完善与简化

图 1:根据 ko-KR 期间 76 次 Git 提交估算实际工作时间

(58) 原本预计 6 小时,最后用了约 16~18 小时:A Tour of Go 韩语版上线复盘

图 2:英文 source 与当时发生 validation failure 的 ko-KR candidate 对比

(59) Validator 报错,到底该改译文还是改规则?一次 A Tour of Go 韩语翻译实战

图 2:法语 A Tour of Go 仓库目前已经归档,页面仍保留 553 Commits 和 61 Contributors 的历史

(60) 从法语 553 次提交到韩语两代离线:A Tour of Go 社区翻译为什么难以长期维护

【图 4:es-ES 首次 Production 收口后连续完成的流程优化 commit】

(61) 从 es-ES 首次上线复盘:我把 A Tour of Go 新增语言的 Production 流程又收敛了一轮

图 1:旧公网验收链路连续出现 curl 28 超时,最终导致 public-machine 阶段失败

(62) A Tour of Go Production 公网验收踩坑:从跨境 SOCKS 链路改为 zgocloud direct

图 1:两次 Production Machine Acceptance 都已经通过,但 Headless Chrome 分别在 / 和 /tour/ 判定页面没有完成渲染

(63) 页面明明能打开,为什么 Headless Chrome 仍然判定 Production 失败?

图 3:Bing 从 Google Search Console 中识别到新的 it-IT 站点,并同时发现已有的 1 个 sitemap

(64) GSC → Bing Webmaster Tools:多语言站点如何减少重复验证

图 3:英文站 3 小时内出现 565 个 Cloudflare 来源 URL

(65) IndexNow 实战:手工提交长期不显示,Cloudflare Crawler Hints 却很快出现在 Bing Webmaster Tools

【图 1:A Tour of Go 项目首页目前的语言版本列表】

(66) 从 nl-NL 到 tr-TR:A Tour of Go 连续上线三门语言后,我又优化了哪些流程

【图 4:Go local 页面中已经出现 French、German、Korean、Simplified Chinese】

(67) 从邮件无人回复到进入 Go 官方 Go local:我的 4 个 A Tour of Go 翻译站终于被收录

图8:最终只保留一把启用的新 API Key

(68) 腾讯云 EdgeOne API 自动化:从 CAM 子用户到最小权限 API Key 的完整配置

【图 7:Production Release Batch 最终汇总】

(69) 从同步 Go 官方上游到一个命令发布 10 个语言站:A Tour of Go Production 流程的再次收敛

【图 3:Cloudflare Smart Tiered Cache 已启用】

(70) Cloudflare 缓存 HIT 很快,MISS 却不稳定:回源阿里云杭州的一次真实优化

【图 3:简体中文站 Support 页面】

(71) 广告之外,社区项目如何长期活下去?我给 A Tour of Go 多语言项目加了 Support

图5:评论成功发布

(72) 从 Go Weekly 到 Reddit、DEV 和 Go Forum:第一次系统推广 A Tour of Go 多语言项目

【图 4:RTL 桌面课程布局修复后的效果】

(73) 第一次把 A Tour of Go 做成 RTL:阿拉伯语版本上线,以及我踩过的几个坑

多语言项目应该先做哪些语言?我的 66 个语言版本商业优先级排序

(74) 多语言项目应该先做哪些语言?我的 66 个语言版本商业优先级排序

当术语一致性遇到性能瓶颈:Go 多语言翻译项目的一次架构取舍

(75) 当术语一致性遇到性能瓶颈:Go 多语言翻译项目的一次架构取舍

前面几篇文章里,我记录了这一轮 A Tour of Go 广告优化过程中几个比较大的决策。

最开始,是课程页明明存在大片视觉空白,但 Google AdSense 的普通展示广告始终不出现。

随后又先后经历了:

  • 是否为了 AdSense 放弃 SPA;
  • 最终决定继续保留 A Tour of Go 原来的 SPA 架构;
  • AdSense Preview 虽然找到了 footer 后面的候选广告位置,但这个位置并不符合实际课程体验;
  • 增加明确的手动课程广告位;
  • 方案 A 使用 MutationObserver 监听页面 DOM 变化并管理 SPA 广告;
  • 方案 A 能够工作,但实现逐渐变得复杂,并出现了一些低概率、难以稳定复现的问题;
  • 最终改成方案 B,让广告直接跟随 Angular course view 的生命周期;
  • 为了解决长期存在的 SPA SEO 问题,同时给 103 个正式课程页生成 Prerender HTML。

到这里,大的架构方向已经基本确定。

但是把方案 B、Prerender、Angular、CodeMirror 和真实 AdSense 一起放进生产环境以后,又连续出现了几轮新的问题。

这一篇主要记录的就是:

方案 B 真正进入生产以后,最后是怎样稳定下来的。

先把时间线分清楚

这一轮问题比较多,如果不先区分阶段,很容易把不同原因产生的现象混在一起。

实际过程大致是:

Plaintext
方案 A
MutationObserver 管理 SPA 广告
        ↓
实现复杂度不断增加
        ↓
出现少量难以稳定复现的问题
        ↓
切换方案 B

方案 B
Angular course view 管理广告生命周期
        ↓
加入 103 页 Prerender
        ↓
处理 Prerender → Angular → CodeMirror 的交接
        ↓
首次源码仍有很短暂的空白,但已可接受
        ↓
生产环境继续测试
        ↓
发现真实 AdSense 会修改课程祖先节点高度
        ↓
footer 被拉进第一屏
        ↓
检查历史方案 A
        ↓
发现 A 中其实还有一块有效的 layout protection
        ↓
只把这部分能力接回方案 B
        ↓
最终生产验证完成

这样再看后面的几个问题,就比较容易理解了。

方案 A 为什么最终被放弃

保留 SPA 以后,第一套真正上线的广告实现是方案 A。

方案 A 的核心思路是使用:

Plaintext
MutationObserver

观察页面 DOM。

大致流程类似:

Plaintext
监听页面变化
        ↓
判断当前课程状态
        ↓
寻找广告位置
        ↓
清理旧广告
        ↓
创建新广告
        ↓
继续监听页面变化

这种方法有一个明显优势:

不需要大幅修改 A Tour of Go 原来的 Angular 路由。

只要最终页面 DOM 发生变化,外部广告逻辑就有机会做出响应。

方案 A 确实实现成功过,并不是一个只存在于讨论阶段的设想。

SPA 点击“下一页”以后,也能够重新处理广告。

真正的问题是:

随着实现不断补充,它开始变得越来越复杂。

A Tour of Go 页面本身就会产生大量 DOM 变化:

  • Angular route 切换;
  • CodeMirror 初始化;
  • 编辑器更新;
  • 课程目录变化;
  • Playground 运行结果;
  • 各类动态元素创建与销毁。

如果广告逻辑依靠观察这些 DOM 变化来推断:

现在是不是已经进入了另一节课程?

就需要不断增加判断条件和保护逻辑。

后来测试过程中曾经遇到过一些小概率问题,例如:

  • 示例源码偶尔为空;
  • “下一页”偶尔看起来没有正常响应。

最麻烦的并不是这些问题本身,而是:

很难稳定复现。

刷新以后可能正常。

重新进入页面以后可能也正常。

连续翻很多页也未必再次出现。

因此当时只能推测:

问题很可能与方案 A 中越来越复杂的 DOM 监听、节点处理以及运行时机有关。

但因为无法稳定复现,也很难把每一个现象精确归因到某一条 observer 回调。

这时候继续给方案 A 打补丁的收益已经开始下降。

所以后来才决定:

不再让广告逻辑从 DOM 外部推测 Angular 正在做什么,而是直接使用 Angular 已经明确提供的课程生命周期。

这就是方案 B。

方案 B 把广告生命周期收缩到当前课程

方案 B 的核心比方案 A简单很多。

当前 course view 创建:

Plaintext
mount()

当前 course view 被销毁:

Plaintext
$destroy
↓
unmount()

也就是说:

Plaintext
Angular course view
        │
        ├── 创建
        │    └── mount 广告
        │
        └── 销毁
             └── unmount 广告

用户从:

Plaintext
/tour/basics/1

进入:

Plaintext
/tour/basics/2

时,不再需要一个全局 observer 去猜:

DOM 变化到什么程度才算真正换页?

Angular 本身已经知道旧 view 被销毁、新 view 被创建。

广告直接跟着这个生命周期即可。

因此方案 B 的目标并不是增加更多保护逻辑,而恰恰是:

减少广告代码对整个 Tour DOM 的理解和干预。

而在切换到方案 B 后,之前方案 A 中那种偶发的“下一页无法点击”问题,后续没有再发现。

然后又加入了 103 页 Prerender

方案 B 确定以后,另一个长期存在的问题也进入了正式解决阶段:

SPA 的 SEO。

Google Search Console 曾把多个不同课程 URL 判断为重复页面。

因此最终决定:

Plaintext
103 个正式课程 URL
        ↓
103 个独立 Prerender HTML

每一个课程 URL 在第一次 HTTP 响应中就直接包含:

  • 当前页面 title;
  • description;
  • canonical;
  • 课程正文;
  • 示例源码。

这解决的是服务器首次响应和页面身份问题。

但也引出了一个新的浏览器问题:

Prerender HTML 已经有源码,并不代表 Angular 和 CodeMirror 接管过程完全没有视觉变化。

方案 B + Prerender 后,首次源码仍会短暂空白

在这一阶段测试时,可以看到一种比较典型的现象。

页面左侧课程正文已经出现。

右侧也已经进入当前示例文件。

但是代码区域会短暂处于空白状态。

图 1:加入 Prerender 并采用方案 B 后,首次打开课程页时曾出现短暂的示例源码空白。
图 1:加入 Prerender 并采用方案 B 后,首次打开课程页时曾出现短暂的示例源码空白。

需要特别区分:

这里的源码空白,不是方案 A 中那个难以稳定复现的偶发 Bug。

方案 A 已经被替换。

这一阶段的问题来自新的渲染链:

Plaintext
服务器 Prerender
        ↓
浏览器显示初始 DOM
        ↓
Angular bootstrap
        ↓
route 接管
        ↓
CodeMirror 初始化
        ↓
最终编辑器状态

也就是说,服务器已经提供了源码,但正常浏览器还要经历 Angular 与 CodeMirror 的接管。

真正需要优化的是这个交接过程。

Prerender、Angular 和 CodeMirror 需要稳定交接

后续围绕这个问题做了几项调整。

Prerender 直接把源码保存在正式 textarea 中

不额外维护另一套隐藏 source carrier。

当前页面默认示例文件直接进入原本供编辑器使用的 textarea。

这样:

Plaintext
Prerender source

和:

Plaintext
编辑器初始 source

尽量保持同一个来源。

Angular route 等课程数据准备好以后再接管

避免 editor view 先创建出来,再等待 lesson 数据补齐。

尽量把流程收敛为:

Plaintext
lesson 数据准备完成
        ↓
进入 editor route

而不是让中间空状态暴露给用户。

footer 移出 route-owned partial

footer 原来跟随课程 route DOM。

这样 Angular 替换当前课程 view 时,footer 也会一起销毁、重新创建。

后续把 footer 移到稳定页面 shell:

Plaintext
稳定 shell
├── header
├── Angular course view
└── footer

这样 SPA 下一页只替换课程区域,footer 不再参与 route 生命周期。

首次源码的极短空白最终选择接受

经过这些调整以后,页面稳定性已经明显改善。

后续 SPA 点击“下一页”时,源码基本可以快速出现。

但是首次直接打开一个课程 URL 时,仍可能看到一个很短暂的代码空白过程。

这一点后来没有继续无限优化。

原因是继续追求完全没有任何 hydration 视觉变化,很可能需要进一步扩大对 Angular、CodeMirror 和 Prerender 流程的改造。

而当前实际体验已经变成:

Plaintext
首次打开
可能有很短的源码空白
        ↓
页面完成接管
        ↓
后续 SPA 翻页很快

相比继续增加架构复杂度,我最终接受了这个小的视觉取舍。

这也是这一轮中一个比较重要的边界:

不是所有能够继续优化的细节,都值得继续增加长期维护成本。

还增加了 Prerender 的确定性测试

Prerender 还有另外一个工程问题:

同一个课程 URL 每次生成出来的 HTML,是否完全一致?

如果 Chrome 每次 Prerender 都产生不同 DOM:

  • release SHA 会变化;
  • Git diff 或构建 diff 会产生噪音;
  • 很难判断页面到底是真的内容变化,还是浏览器渲染结果不稳定。

所以后来增加了完整 browser regression。

同一个正式页面:

Plaintext
/tour/basics/1

使用两个独立:

  • output root;
  • Chrome profile;

分别完成 Prerender。

最终要求两份 HTML:

Plaintext
bytes.Equal

同时还检查:

  • 最终 Prerender HTML 中没有 .CodeMirror runtime DOM;
  • textarea 内容必须等于当前 route.Files[0];
  • 不存在额外的隐藏 source carrier。

这样 Prerender 才真正变成一项可重复验证的正式构建行为。

页面基本稳定以后,生产环境又出现了新的问题

到这里,Prerender 与方案 B 的整合已经基本完成。

源码能够正常显示。

SPA 下一页也正常。

但继续在真实生产环境测试以后,又出现了另外一种异常。

这一次源码已经完全正常。

问题变成了:

footer 被提前拉进了第一屏。

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

从截图可以看到:

  • 左侧课程正文正常;
  • 右侧示例源码正常;
  • CodeMirror 已经完成初始化;
  • footer 却出现在很靠上的位置;
  • footer 下面还有大片完全没有意义的空白。

这已经明显不是前面的 Prerender hydration 问题。

真正发生变化的是:

课程主体高度。

更奇怪的是,不同浏览器环境结果还不一样

这个问题当时还有一个很关键的线索。

普通浏览器里:

异常。

换到隐私窗口以后:

最终布局基本正常。

相同代码、相同课程页,却因为浏览器环境不同出现不同布局。

这就开始指向:

第三方脚本。

而当前课程页面里最重要的第三方脚本之一,自然就是 AdSense。

最终抓到了真实的 inline style 污染

继续检查几个核心课程布局节点的实际 style 后,终于发现了明确证据。

当时 #editor-container 被写入:

Plaintext
height: auto !important;
min-height: 0px !important;

#left-side 同样出现:

Plaintext
height: auto !important;
min-height: 0px !important;

两层 .relative-content 也出现了相同覆盖。

课程广告容器本身则出现:

Plaintext
height: auto !important;

当时实际测到的页面几何数据大致是:

Plaintext
viewportHeight = 963
footerTop      = 418.5

也就是说:

浏览器视口高度接近:

Plaintext
963px

但是 footer 顶部已经跑到了:

Plaintext
418.5px

课程主体原本应该至少撑住一个完整视口附近的高度。

现在却被压缩到了四百多像素。

这就解释了为什么 footer 会突然进入第一屏。

!important 让普通 CSS 很难可靠覆盖

如果第三方只是增加:

CSS
height: auto;

自己的 stylesheet 还有很多办法覆盖。

问题在于实际看到的是:

CSS
height: auto !important;
min-height: 0px !important;

这会直接击穿原来的高度约束。

更麻烦的是:

第三方脚本以后还可能再次写入。

所以哪怕手工清理一次:

Plaintext
height:auto!important

也不代表问题彻底解决。

真正需要的是:

当这个特定污染再次出现时,页面能够及时恢复。

这时候才重新检查方案 A 的历史实现

继续翻 Git 历史以后,发现一件很关键的事情。

方案 A 当时其实已经遇到过 AdSense 修改页面高度的问题。

所以方案 A 中除了:

Plaintext
全局 MutationObserver
→ 判断 SPA 页面变化
→ 管理广告生命周期

以外,还存在另一块逻辑:

Plaintext
layout protection

它专门负责检查几个关键布局祖先节点。

如果发现:

Plaintext
height: auto !important

或者:

Plaintext
min-height: 0px !important

就把这两个精确的异常属性移除。

同时监听这些节点后续的 style 变化,防止 AdSense 再次写入。

这里就出现了一个很有意思的问题。

从方案 A 切换到方案 B 时,删除得太彻底了

方案 A 最大的问题是实现过于复杂。

其中最需要移除的部分是:

Plaintext
监听页面整体 DOM
        ↓
推断 SPA route
        ↓
管理广告生命周期

所以方案 B 切换时,删除全局 DOM observer 是正确方向。

但是当时也顺带把方案 A 中的:

Plaintext
AdSense layout protection

一起移除了。

后来生产环境证明:

这两块逻辑虽然都用了 MutationObserver,但它们解决的其实完全不是同一个问题。

第一类 observer:

从整个 DOM 变化里推断 SPA 状态。

这一类复杂度高,最终由方案 B 的 Angular 生命周期替代。

第二类 observer:

只观察几个明确的布局节点,只处理两个已经确认过的异常 style。

这一类其实是一种非常窄的防御机制。

问题不在于:

有没有 MutationObserver。

而在于:

Observer 到底在观察什么、为什么观察、生命周期边界在哪里。

最终没有恢复方案 A

找到方案 A 的历史代码以后,并没有把整个方案 A 恢复回来。

因为广告生命周期方面,方案 B 已经更加清晰。

最终采取的是:

保留方案 B,只把方案 A 中已经被生产环境验证过的 layout protection 单独取回来。

这样最终结构变成:

Plaintext
Angular course view
        │
        ├── mount()
        │      ├── 创建当前课程广告
        │      └── 建立当前 view 的 layout protection
        │
        └── $destroy
               ├── unmount 广告
               └── disconnect layout protection

SPA 广告生命周期仍然由 Angular 决定。

MutationObserver 不再负责判断 SPA 是否切页。

它只负责一个非常有限的任务:

防止真实 AdSense 再次写入已经确认会破坏课程布局的两个高度属性。

layout protection 只保护四个明确节点

新的实现没有恢复全局:

Plaintext
document.body MutationObserver

而是只找到当前课程广告所在 editor 的几个明确祖先:

Plaintext
#editor-container
.relative-content
#left-side
.relative-content

除此之外不扩大观察范围。

并且它不会:

JavaScript
element.removeAttribute("style")

这样粗暴删除整个 style。

它只处理两个精确条件。

如果:

Plaintext
height == auto
并且 priority == important

才删除 height。

如果:

Plaintext
min-height == 0px
并且 priority == important

才删除 min-height。

其他 inline style 全部保留。

这能够尽可能避免:

为了解决 AdSense 的一个副作用,又误伤第三方或者 Tour 自己的其他正常样式。

observer 也必须跟着 course view 销毁

重新引入局部 MutationObserver 以后,还有一个必须严格控制的问题:

不能让 observer 随着 SPA 翻页不断累积。

因此它和广告本身一样,也属于当前 course mount。

页面 1:

Plaintext
mount
↓
observer #1

点击下一页:

Plaintext
$destroy
↓
disconnect observer #1

页面 2:

Plaintext
mount
↓
observer #2

不能出现:

Plaintext
observer #1
observer #2
observer #3
observer #4
...

这种旧方案最容易重新演变出来的复杂状态。

这一次必须把偶发问题变成自动化测试

方案 A 给我的一个很直接的教训是:

“连续人工测试很多次没有复现”并不能证明这类问题不存在。

因为这些问题往往涉及:

  • SPA view 生命周期;
  • DOM mutation;
  • observer;
  • CodeMirror;
  • 第三方脚本;
  • 浏览器运行时序。

因此最终专门增加了两组真实浏览器回归测试。

图 3:方案 B SPA 生命周期与 AdSense layout protection 两项浏览器回归测试均通过。
图 3:方案 B SPA 生命周期与 AdSense layout protection 两项浏览器回归测试均通过。

第一项:

Plaintext
TestCourseAdSPALifecycleInBrowser

验证方案 B 本身:

  • 当前课程创建广告;
  • SPA 下一页创建新的广告生命周期;
  • 旧广告正确清理;
  • 多次导航不产生节点累积;
  • 广告异常不会中断正常导航。

第二项:

Plaintext
TestCourseAdLayoutProtectionInBrowser

验证后来重新接入的局部布局保护:

  • 第一次写入高度污染能够清理;
  • 再次写入仍然能够清理;
  • SPA 切换以后旧 observer 已失效;
  • 新 view observer 正常;
  • 多次 SPA 导航不会累积保护实例;
  • footer 仍然保持在课程主体之后。

最终:

Plaintext
PASS

这样以后如果再修改 course ad 相关代码,就不需要等到生产环境偶然再次出现 footer 异常才知道发生了回归。

自动测试通过以后,还必须看真实 Google 广告

不过 browser test 通过以后,我仍然没有立即把这一轮工作判定完成。

因为引发问题的是:

真实 AdSense。

自动化测试只能模拟:

Plaintext
给节点写入相同的异常 inline style
↓
验证 layout protection 能否恢复

它不能证明:

Plaintext
真实 Google 广告
+
Angular
+
CodeMirror
+
Prerender
+
SPA navigation

一起运行时一定正常。

所以最后仍然需要在 production 中继续测试。

一开始真实请求大量返回 unfilled

新的代码部署以后,课程布局已经恢复正常。

footer 也没有再进入第一屏。

但是手动广告却经常没有显示。

继续检查广告节点可以看到:

Plaintext
data-ad-status="unfilled"

同时又有:

Plaintext
data-adsbygoogle-status="done"

这说明:

广告请求已经处理完成。

只是 Google 这一轮没有返回实际广告。

进一步查看网络请求,还确认了:

Plaintext
slotname=4728596962

以及类似:

Plaintext
format=847x280

这样的实际请求参数。

而且从一个 SPA 课程进入下一页以后,请求中的当前 URL 也会变成新的课程地址。

所以这实际上证明:

方案 B 的 fresh request 正常工作。

整个链路已经是:

Plaintext
新 course view
        ↓
新 ins.adsbygoogle
        ↓
新的 Google request
        ↓
Google 返回 unfilled

而不是:

Plaintext
SPA 下一页
↓
根本没有新的广告请求

这两种情况必须区分。

最后,真实广告终于成功显示

继续测试以后,Google 最终还是返回了一次真实展示广告。

图 4:方案 B 最终在真实生产环境中成功展示 Google 广告,同时源码、SPA 和 footer 布局全部正常。
图 4:方案 B 最终在真实生产环境中成功展示 Google 广告,同时源码、SPA 和 footer 布局全部正常。

这张截图基本可以作为整轮优化的最终验收。

它同时证明了几件事情。

手动课程广告位确实能够真实工作

这不是:

  • AdSense Preview;
  • 自己写的 placeholder;
  • 测试 iframe。

而是真实 Google 广告已经显示。

方案 B 没有阻止 AdSense

前面连续出现 unfilled 并不是因为方案 B 无法加载广告。

真实 filled 最终证明广告链路是完整的。

layout protection 也没有误伤广告

新的保护逻辑只清理几个祖先节点中两个非常精确的高度污染值。

它没有破坏:

  • 广告 iframe;
  • 广告请求;
  • 广告尺寸;
  • 正常展示。

footer 最终保持正常

真实广告出现以后:

Plaintext
课程正文
正常

示例源码
正常

广告
正常

页面高度
正常

footer
正常

这才是真正需要达到的状态。

广告尺寸与填充率暂时不继续调整

最终成功显示的广告比前一天经常看到的小型正方形广告明显更宽。

当时实际请求过:

Plaintext
847x280

这样的广告尺寸。

同时当天又遇到了不少:

Plaintext
unfilled

因此很自然会产生一个新的问题:

更宽的广告尺寸,会不会导致实际填充率下降?

这一点目前没有继续调整。

因为当天为了验收已经进行了大量:

  • 刷新;
  • 强制刷新;
  • SPA 连续翻页;
  • 广告请求测试。

这些都不是自然用户流量。

再加上当前站点流量和广告展示样本都还不大,仅凭这一轮测试很容易产生误判。

所以当前决定是:

先保持现有广告尺寸不动。

等积累几天自然访问以后,再结合:

  • Coverage;
  • 广告请求数;
  • 展示次数;
  • 实际展示尺寸;

判断是否有必要重新缩小桌面广告宽度。

这一项已经不再阻塞当前广告实现正式收尾。

最终没有恢复复杂的 DOM 管理方案

回过头来看,从方案 A 到方案 B 最重要的变化,并不是:

从 MutationObserver 改成不用 MutationObserver。

因为最终方案 B 里仍然存在一个很小范围的 observer。

真正变化的是:

职责边界。

方案 A:

Plaintext
全局观察 DOM
↓
理解整个 SPA 正在发生什么
↓
管理广告
↓
同时处理各种页面副作用

随着功能增加,实现越来越复杂,也出现了难以稳定复现的问题。

最终方案 B:

Plaintext
Angular
负责课程生命周期

course ad helper
负责创建 / 销毁广告

局部 MutationObserver
只负责清理两个已经确认的 AdSense 高度污染值

每一层只需要理解自己的问题。

这也是为什么最后没有因为出现高度污染,就重新退回方案 A。

真正需要恢复的不是:

方案 A。

而只是:

方案 A 中那一块已经被真实生产环境证明有价值的 layout protection。

旧方案中的代码也应该按职责重新判断

这一轮还有一个让我觉得很值得记录的经验。

重构旧实现时,很容易把代码简单分成:

Plaintext
旧代码
=
应该删除

但真实情况往往没有这么简单。

方案 A 中:

Plaintext
全局 DOM observer 管理 SPA 广告

最终证明不适合作为长期方案。

但同一个方案 A 中:

Plaintext
局部保护课程高度

却已经提前解决过一个真实的 AdSense 生产问题。

所以后来最合理的做法不是:

把 A 全部恢复。

也不是:

因为已经切到 B,所以 A 的任何代码都不能再使用。

而是:

重新检查旧代码到底承担什么职责,把仍然有价值的那一部分以新的生命周期边界接入当前架构。

这样既没有退回复杂的方案 A,也没有因为重构而丢失已经积累下来的生产经验。

最终同步到 zh-CN 与 ja-JP

最后的布局保护修复形成正式提交:

Plaintext
91a2583
fix: 恢复课程广告布局保护

随后同步到正式 production。

zh-CN 最终确认:

  • 页面布局正常;
  • footer 不再进入第一屏;
  • 示例源码正常;
  • SPA 下一页正常;
  • 真实手动广告能够显示。

ja-JP 同样同步到当前版本:

  • 页面最终布局正常;
  • footer 不进入第一屏;
  • 示例源码正常;
  • 最新共享静态资源已经生效。

具体 production publish、shared-assets、Cloudflare 缓存和健康检查流程之前已经多次记录,这里不再重复展开。

这一篇真正关注的还是:

用户最终在浏览器里得到的页面是否稳定。

从“想办法让广告出现”到“广告不能破坏课程”

这一轮工作最开始的问题其实很简单:

为什么 A Tour of Go 没有普通展示广告?

后来一步一步演变成:

Plaintext
要不要为了广告放弃 SPA?
↓
如何给 SPA 添加明确广告位?
↓
方案 A 为什么越来越复杂?
↓
方案 B 如何收敛生命周期?
↓
如何让搜索引擎理解 103 个课程页?
↓
Prerender 怎样与 Angular / CodeMirror 共存?
↓
真实 AdSense 为什么又会破坏页面高度?
↓
怎样只恢复必要的保护而不退回方案 A?

最后真正确定下来的优先级也越来越明确:

Plaintext
课程核心功能
        >
页面稳定性
        >
SEO 页面身份
        >
广告展示

广告当然希望尽可能正常展示。

但如果为了广告导致:

  • 示例源码异常;
  • “下一页”失效;
  • 页面高度塌缩;
  • footer 跑进第一屏;

那么即使广告本身成功加载,这套实现也不能算成功。

最终可以接受的状态应该是:

Google 返回 filled 时页面正常;返回 unfilled 时页面也正常;广告代码发生异常时课程仍然正常;SPA 连续翻页时广告能够跟随当前课程,而不会逐渐侵入整个应用生命周期。

做到这里以后,这一轮从“整个 A Tour of Go 没有普通展示广告”开始的优化,才算真正完成了。

SPA 的 SEO 老问题:为什么最后还是给 103 个 A Tour of Go 课程页生成了 Prerender HTML A Tour of Go de-DE 上线复盘:第三门语言用了约 16 小时,下一门如何压到 8 小时?

Go 学习与文档多语言本地化项目

本系列完整记录 Go 学习与文档多语言本地化项目从 A Tour of Go 起步,逐步扩展到站点首页、项目说明页、学习内容、文档以及未来内容包的实际开发过程,包括架构设计、统一术语治理、整页翻译、结构保护、自动校验、独立质量审核、生产发布与长期维护。

项目源码:
✅ GitHub:shuijingwan/go-tour-i18n

语言版本:

当前跨项目目标语言池共包含 66 个语言版本。Go 项目本身目前提供 65 个社区 Production 语言版本,下面每一门 Go 语言版本都直接链接到其当前公开站点。语言列表沿用项目首页现有的展示顺序。

英语(美国)仅作为跨项目目标语言纳入列表,并不是单独的 Go Production 语言版本。当前 Go 的英文源内容仍为官方 generic English,因此英语(美国)链接到 Go 官方站点,而不是不存在的本地社区部署。

  1. 阿姆哈拉语
  2. 阿拉伯语
  3. 孟加拉语
  4. 葡萄牙语(巴西)
  5. 保加利亚语
  6. 加泰罗尼亚语
  7. 克罗地亚语
  8. 捷克语
  9. 丹麦语
  10. 荷兰语
  11. 英语(美国)(Go 官方英文站点)
  12. 英语(澳大利亚)
  13. 英语(加拿大)
  14. 英语(印度)
  15. 英语(新加坡)
  16. 英语(南非)
  17. 英语(英国)
  18. 爱沙尼亚语
  19. 菲律宾语
  20. 芬兰语
  21. 法语(法国)
  22. 法语(加拿大)
  23. 德语(德国)
  24. 德语(奥地利)
  25. 德语(瑞士)
  26. 希腊语
  27. 古吉拉特语
  28. 希伯来语
  29. 印地语
  30. 匈牙利语
  31. 印度尼西亚语
  32. 意大利语
  33. 日语
  34. 卡纳达语
  35. 哈萨克语
  36. 韩语
  37. 西班牙语(拉丁美洲)
  38. 拉脱维亚语
  39. 立陶宛语
  40. 马来语
  41. 马拉雅拉姆语
  42. 马拉地语
  43. 书面挪威语(Bokmål)
  44. 波斯语
  45. 波兰语
  46. 葡萄牙语(葡萄牙)
  47. 旁遮普语(果鲁穆奇文,印度)
  48. 罗马尼亚语
  49. 俄语
  50. 塞尔维亚语(西里尔文)
  51. 简体中文(中国大陆)
  52. 简体中文(新加坡)
  53. 斯洛伐克语
  54. 斯洛文尼亚语
  55. 西班牙语(西班牙)
  56. 斯瓦希里语(坦桑尼亚)
  57. 瑞典语
  58. 泰米尔语
  59. 泰卢固语
  60. 泰语
  61. 繁体中文(台湾)
  62. 繁体中文(香港)
  63. 土耳其语
  64. 乌克兰语
  65. 乌尔都语
  66. 越南语

项目正在持续扩展新的 Go 学习与文档内容,并长期维护各语言版本的术语一致性、翻译质量、独立审核、生产发布与后续更新。

本项目属于非官方社区多语言本地化项目,与 Go 官方无隶属、授权或背书关系。