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

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

图 4:最终的手动课程广告真实展示在课程内容区域内,而不是 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 多语言翻译项目的一次架构取舍

上一篇文章里,我最终决定不为了 Google AdSense 放弃 A Tour of Go 原来的 SPA 架构。

这个决定解决的是架构方向问题,却没有解决真正的广告问题。

保留 SPA 以后,接下来必须回答两个更具体的问题:

广告到底应该放在哪里?

以及:

用户点击“下一页”进入新的课程页面以后,广告应该由谁负责重新创建和销毁?

这两个问题后来经历了两套实际实现。

第一套可以称为方案 A:通过 MutationObserver 观察 SPA 的 DOM 变化,判断什么时候需要重新创建课程广告。

它确实实现了,也能够工作。

但是在继续测试以后,出现了一些概率很低、很难稳定复现的问题,例如示例代码偶尔为空、“下一页”偶尔无法正常响应等。

正因为方案 A 已经真正落地并暴露了这些问题,后来才进一步设计了方案 B:不再通过全局 DOM 变化反推 SPA 状态,而是直接把广告绑定到 Angular 当前课程 view 的生命周期。

Google AdSense 其实找到了一个广告位置

前面的排查已经确认:

  • AdSense Auto Ads 已经启用;
  • adsbygoogle.js 正常加载;
  • 课程页面虽然存在大片视觉空白,但普通展示广告始终没有真正显示出来。

当时我又进入了 Google AdSense 后台的广告设置预览。

这一次终于看到了一件很有意思的事情。

Google 的页面预览确实找到了一个可以放置页内广告的位置。

图 1:AdSense 广告设置预览中,Google 找到了 1 个页内广告位置,但位置位于课程 footer 的下方。
图 1:AdSense 广告设置预览中,Google 找到了 1 个页内广告位置,但位置位于课程 footer 的下方。

预览页面顶部明确显示:

Plaintext
1 个页内广告

而 Google 给出的广告示例,并不在我之前认为有大量空白的课程正文区域中。

它出现在了:

footer 之后。

这其实进一步印证了上一篇文章中的判断。

从人的视角来看,课程正文左侧明明存在大片空白。

但是从 Google 的自动广告布局判断来看,它没有选择这些位置,而是在整个课程主体和 footer 都结束以后,才找到了一个自己认为可以插入广告的区域。

不过这里必须说明一个重要区别。

这是 AdSense 后台的广告设置预览,并不代表我后来在真实公网课程页面上看到了 Auto Ads 自动在 footer 下方显示普通展示广告。

实际上直到这一轮广告优化基本完成,我仍然没有真正看到过 Auto Ads 在 A Tour of Go 课程页中自动插入普通页面展示广告。

我实际看到过的 Auto Ads 主要是其他形式,例如弹出式广告。

所以这张截图真正说明的是:

Google 的预览系统认为 footer 后面是一个可能的页内广告位置。

而不是:

Google 已经在生产站点 footer 后面稳定展示广告。

这两个结论不能混为一谈。

Google 认为“能放”,不等于这里就是一个好广告位

从纯技术角度看,把广告放在 footer 后面当然没有什么问题。

页面结构大致会变成:

Plaintext
课程正文
代码编辑器
运行结果

footer

广告

Google 不需要侵入复杂的左右分栏结构。

也不需要判断左侧课程正文下面的空白到底是不是一个稳定的布局区域。

整个主体已经结束以后,再附加一个广告,从自动布局角度确实简单得多。

但从实际使用体验来看,这个位置并不理想。

A Tour of Go 是一个学习型页面。

用户进入课程页以后,主要操作路径是:

Plaintext
阅读课程
↓
查看或修改示例代码
↓
运行 / 格式化
↓
点击下一页

而不是:

Plaintext
阅读课程
↓
滚动到整个页面最底部
↓
越过 footer
↓
继续寻找广告

正常课程页面本身也能很直观地看到这种关系。

图 2:正常的 A Tour of Go 课程页中,footer 已经位于课程主体和编辑器之后。
图 2:正常的 A Tour of Go 课程页中,footer 已经位于课程主体和编辑器之后。

如果把核心展示广告继续放到 footer 后面,那么它虽然“技术上存在”,但与实际课程内容距离太远。

对于很多课程页面,用户甚至完全没有理由滚动到那个位置。

这时候我开始意识到:

自动广告系统解决的是“哪里可以插入广告”的问题,而站点自己还需要决定“哪里适合展示广告”。

两者并不是同一个问题。

我更希望广告属于当前课程,而不是属于整个页面尾部

最后逐渐明确了一个方向:

展示广告最好成为当前课程页面的一部分。

也就是说,它应该出现在课程内容区域内部,而不是成为 footer 之后额外附加的一块内容。

这样用户在正常完成课程阅读和操作时,就能够自然看到广告。

更重要的是,这种设计还可以建立一个非常明确的关系:

Plaintext
一个课程页面
        ↓
对应一个课程广告

而不是:

Plaintext
整个 SPA Document
        ↓
不知道什么时候创建的一个广告

这两个模型对于 SPA 来说差别很大。

因为从浏览器角度看,A Tour of Go 从:

Plaintext
/tour/basics/1

切换到:

Plaintext
/tour/basics/2

时,Document 并没有重新加载。

但是从用户角度看,这已经是一个新的课程页面。

因此我希望广告也遵循“课程页面”的生命周期,而不是整个浏览器 Document 的生命周期。

方案 A:使用 MutationObserver 跟踪 SPA 页面变化

保留 SPA 以后,第一版真正实现的方案,是通过 MutationObserver 监听页面 DOM。

基本思路类似:

Plaintext
MutationObserver
        ↓
观察页面 DOM 变化
        ↓
判断课程内容是否已经切换
        ↓
寻找当前广告挂载位置
        ↓
清理旧广告
        ↓
创建新的 ins.adsbygoogle

这就是后来可以称为方案 A的一套实现。

它并不是一个停留在讨论阶段的设想。

代码真正部署以后,课程 SPA 切页能够产生新的广告,说明从最基本的功能目标来看,方案 A 是成立的。

而且它有一个很明显的优点:

不需要大幅介入原来的 Angular 路由逻辑。

只要观察最终 DOM 发生的变化,就可以在页面切换以后重新处理广告。

对于一个不希望大改 upstream Tour 的项目来说,这种做法一开始其实很有吸引力。

方案 A 的问题不是“完全不能用”,而是边界太模糊

真正的问题是在后续连续测试中逐渐出现的。

A Tour of Go 的 DOM 并不是只在点击“下一页”时才发生变化。

页面内部还有很多自己的动态行为,例如:

  • Angular route 切换;
  • CodeMirror 初始化;
  • 编辑器内容变化;
  • Playground 运行结果;
  • 课程目录;
  • Angular digest;
  • 各类动态节点创建和销毁。

如果使用全局 MutationObserver 来观察这些变化,那么广告代码实际上是在做一件间接的事情:

Plaintext
页面 DOM 发生了变化
        ↓
判断这个变化意味着什么
        ↓
推测是不是课程已经切换
        ↓
再决定要不要处理广告

这就意味着广告逻辑与 Tour 自身运行时 DOM 之间产生了比较强的耦合。

最初测试时大多数情况都正常。

但继续测试以后,开始遇到一些小概率、很难稳定复现的问题。

例如曾经出现过:

  • 示例代码区域偶尔为空;
  • 点击“下一页”偶尔没有正常响应;
  • 页面在初始化和切换过程中出现一些异常状态。

这类问题最麻烦的地方,不只是它们存在。

而是:

它们并不能稳定重现。

有时刷新以后正常。

有时重新进入页面又没有问题。

这类偶发问题对于正式生产环境反而更加麻烦,因为很难明确证明某一次 DOM observer 回调与某个 Angular / CodeMirror 生命周期事件之间到底发生了怎样的时序关系。

这时候问题就从:

方案 A 能不能显示广告?

变成了:

为了显示广告,有没有必要让一个全局 DOM observer 长期参与整个 Tour 的运行生命周期?

答案开始倾向于否定。

为什么后来改成方案 B

继续分析以后,一个很明显的问题出现了:

Angular 本身其实已经知道:

  • 当前课程 view 什么时候创建;
  • 当前课程 view 什么时候销毁;
  • 用户什么时候进入另一个 route-owned editor view。

既然应用自己已经掌握了这些信息,再通过 MutationObserver 从 DOM 外部反推:

“现在是不是已经换页了?”

就有些绕远了。

方案 A 大致相当于:

Plaintext
Angular 已经知道 view 生命周期
        ↓
DOM 随之变化
        ↓
MutationObserver 看到变化
        ↓
广告代码再猜测 Angular 刚刚做了什么

而方案 B 希望直接变成:

Plaintext
Angular 创建课程 view
        ↓
创建广告

Angular 销毁课程 view
        ↓
销毁广告

这样广告生命周期和课程生命周期之间就有了一条直接关系。

方案 B:把广告绑定到 Angular course view

最终确定的方案 B,核心其实很简单:

让广告成为 route-owned editor view 的一部分。

当前课程 view 创建以后:

Plaintext
mount()

当前课程 view 被 Angular 销毁以后:

Plaintext
unmount()

真正的代码也就是围绕这个生命周期建立的。

图 3:方案 B 中的 courseAd directive。课程 view 创建时执行 mount(),Angular $destroy 时执行 unmount()。
图 3:方案 B 中的 courseAd directive。课程 view 创建时执行 mount(),Angular $destroy 时执行 unmount()。

核心关系可以简化成:

JavaScript
lifecycle.mount(elm[0]);

然后:

JavaScript
scope.$on('$destroy', function() {
    lifecycle.unmount(elm[0]);
});

这样以后,用户打开:

Plaintext
/tour/basics/1

Angular 创建当前课程 view,同时创建当前课程的广告。

点击下一页以后:

Plaintext
/tour/basics/1
        ↓
$destroy
        ↓
unmount(oldAd)
        ↓
/tour/basics/2
        ↓
新 view
        ↓
mount(newAd)

每一个课程页面都有自己独立的广告生命周期。

方案 A 是:

观察 DOM,然后推断课程生命周期。

方案 B 则变成:

直接使用课程生命周期。

这也是两套方案最本质的区别。

每一页都创建新的 adsbygoogle 节点

方案 B 还有一个重要原则:

不把同一个已经处理过的 ins.adsbygoogle 节点反复复用。

新的课程 view 建立以后,会创建新的:

HTML
<ins class="adsbygoogle">

然后执行一次新的:

JavaScript
(adsbygoogle = window.adsbygoogle || []).push({});

当旧 view 销毁时,这个广告节点随它一起退出。

这样模型就变成:

Plaintext
课程 1
├── ins.adsbygoogle #1
└── push({})

下一页

课程 2
├── ins.adsbygoogle #2
└── push({})

下一页

课程 3
├── ins.adsbygoogle #3
└── push({})

而不是在整个 SPA 生命周期中反复搬运同一个已经被 Google 处理过的广告节点。

广告失败不能影响课程导航

方案 B 还明确了一条边界:

广告属于附加能力,不能影响 A Tour of Go 本身。

因此 courseAd directive 中专门做了异常隔离。

例如:

JavaScript
try {
    lifecycle.mount(elm[0]);
} catch (error) {
    report(error);
}

销毁时同样如此。

也就是说,即使出现:

  • AdSense 没有加载;
  • adsbygoogle.push() 抛出异常;
  • 广告生命周期 helper 不可用;
  • 某一次广告请求失败;

最多只应该影响:

这一块广告。

不能影响:

  • 下一页;
  • 上一页;
  • 课程正文;
  • 编辑器;
  • Playground;
  • Angular route。

这也是从方案 A 的偶发问题中得到的一个重要教训:

广告代码越靠近 Tour 的核心运行路径,越需要清楚地限制它的影响范围。

Auto Ads 为什么仍然保留

采用手动课程广告以后,还有一个重要决定:

Auto Ads 并没有关闭。

这里不仅仅是因为“手动广告和自动广告可以共存”。

还有一个更实际的原因。

我的 AdSense 并不是只服务:

Plaintext
go-dev.shuijingwanwq.com
ja-go-dev.shuijingwanwq.com

自动广告在其他站点和域名,例如 www、en 等环境中已经正常使用。

也就是说,当时面对的是一个更大的现有 AdSense 使用体系。

如果只是因为 A Tour of Go 的课程页不适合 Auto Ads,就直接把自动广告整体关闭,可能会影响其他本来运行正常的页面。

这显然得不偿失。

所以最后采取的思路不是:

Plaintext
Auto Ads 有问题
↓
全部关闭
↓
所有页面都改手动广告

而是:

Plaintext
现有 Auto Ads
继续保留
        +
A Tour of Go 课程页
增加明确的手动广告位

这样既不破坏现有站点已经正常工作的 Auto Ads,又可以单独解决 A Tour of Go 这种特殊 SPA 布局的问题。

换句话说:

不是用手动广告取代整个 AdSense 自动广告体系,而只是为 A Tour of Go 补上一块 Auto Ads 一直无法可靠处理的课程广告区域。

最终广告终于出现在真正想要的位置

经过后续实现和调整以后,手动课程广告最终确实能够在真实生产页面中展示。

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

这张截图和第一张 AdSense Preview 放在一起看,差别非常明显。

Google Preview 最初给出的思路是:

Plaintext
课程
↓
footer
↓
广告

而最终自己选择的是:

Plaintext
课程正文
↓
课程广告
↓
footer

广告因此真正成为课程页面的一部分。

从页面体验来看,这个位置也更加自然:

  • 用户无需越过 footer;
  • 广告与当前课程位于同一个视觉区域;
  • 不侵入右侧代码编辑器;
  • 不需要为了广告重构整个 SPA;
  • 每次 SPA 翻页都有自己的独立广告生命周期。

从方案 A 到方案 B,真正改变的是控制方式

回头来看,这一轮最重要的变化其实不是增加了一个 <ins>。

方案 A 已经证明了一件事:

SPA 课程页面完全可以在不整页刷新的情况下重新请求广告。

所以方案 B 并不是因为方案 A“完全不能用”才重新开始。

真正促使我继续调整的是:

一个能够工作的方案,还不一定是一个适合长期生产维护的方案。

方案 A 的控制模型是:

Plaintext
观察整个页面
↓
识别 DOM 变化
↓
推断课程状态
↓
处理广告

方案 B 则把它缩小成:

Plaintext
当前课程 view 创建
↓
mount 广告

当前课程 view 销毁
↓
unmount 广告

广告最终不再需要理解整个 Tour 的 DOM 到底正在发生什么。

它只需要理解:

自己所属的这个 course view 是否还存在。

对于一个需要长期跟随 upstream 的项目来说,这种边界明显更加清晰。

方案 B 解决了广告生命周期,但新的问题还在后面

到这里,方案 B 的主体已经确定。

它解决了:

  • 课程展示广告的位置;
  • SPA 页面切换后的广告创建;
  • 旧广告销毁;
  • 广告节点不累积;
  • 广告异常与课程功能隔离;
  • 不再依赖全局 DOM 变化判断课程切换;
  • 不需要为了 AdSense 放弃 SPA。

但与此同时,A Tour of Go 还有一个更早就存在的问题没有真正解决。

虽然用户进入:

Plaintext
/tour/basics/1
/tour/basics/2
/tour/methods/1

以后最终能够看到不同内容,但它依然是一个典型的客户端 SPA。

搜索引擎对这种页面一直不算友好。

而这一轮广告优化又增加了另一个考虑:

如果服务器第一次返回某个课程 URL 时,就已经包含当前课程的:

  • 标题;
  • 正文;
  • 示例代码;
  • metadata;

那么除了有助于搜索引擎理解这些 URL 之间真正的内容差异以外,也可能让 Google 更容易理解当前页面的具体主题,从而为后续广告匹配提供更明确的页面上下文。

这并不能保证一定提高广告填充率或者相关性。

但是从页面语义完整性来看,显然比让 Google 首先拿到大量高度相似的 SPA shell 更合理。

于是整个优化工作进入了下一个早已存在的问题:

SPA 的 SEO。

为了 AdSense,要不要放弃 SPA?A Tour of Go 页面架构的一次取舍 SPA 的 SEO 老问题:为什么最后还是给 103 个 A Tour of Go 课程页生成了 Prerender HTML

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 官方无隶属、授权或背书关系。