上一篇文章里,我最终决定不为了 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 个页内广告而 Google 给出的广告示例,并不在我之前认为有大量空白的课程正文区域中。
它出现在了:
footer 之后。
这其实进一步印证了上一篇文章中的判断。
从人的视角来看,课程正文左侧明明存在大片空白。
但是从 Google 的自动广告布局判断来看,它没有选择这些位置,而是在整个课程主体和 footer 都结束以后,才找到了一个自己认为可以插入广告的区域。
不过这里必须说明一个重要区别。
这是 AdSense 后台的广告设置预览,并不代表我后来在真实公网课程页面上看到了 Auto Ads 自动在 footer 下方显示普通展示广告。
实际上直到这一轮广告优化基本完成,我仍然没有真正看到过 Auto Ads 在 A Tour of Go 课程页中自动插入普通页面展示广告。
我实际看到过的 Auto Ads 主要是其他形式,例如弹出式广告。
所以这张截图真正说明的是:
Google 的预览系统认为 footer 后面是一个可能的页内广告位置。
而不是:
Google 已经在生产站点 footer 后面稳定展示广告。
这两个结论不能混为一谈。
Google 认为“能放”,不等于这里就是一个好广告位
从纯技术角度看,把广告放在 footer 后面当然没有什么问题。
页面结构大致会变成:
课程正文
代码编辑器
运行结果
footer
广告Google 不需要侵入复杂的左右分栏结构。
也不需要判断左侧课程正文下面的空白到底是不是一个稳定的布局区域。
整个主体已经结束以后,再附加一个广告,从自动布局角度确实简单得多。
但从实际使用体验来看,这个位置并不理想。
A Tour of Go 是一个学习型页面。
用户进入课程页以后,主要操作路径是:
阅读课程
↓
查看或修改示例代码
↓
运行 / 格式化
↓
点击下一页而不是:
阅读课程
↓
滚动到整个页面最底部
↓
越过 footer
↓
继续寻找广告正常课程页面本身也能很直观地看到这种关系。

如果把核心展示广告继续放到 footer 后面,那么它虽然“技术上存在”,但与实际课程内容距离太远。
对于很多课程页面,用户甚至完全没有理由滚动到那个位置。
这时候我开始意识到:
自动广告系统解决的是“哪里可以插入广告”的问题,而站点自己还需要决定“哪里适合展示广告”。
两者并不是同一个问题。
我更希望广告属于当前课程,而不是属于整个页面尾部
最后逐渐明确了一个方向:
展示广告最好成为当前课程页面的一部分。
也就是说,它应该出现在课程内容区域内部,而不是成为 footer 之后额外附加的一块内容。
这样用户在正常完成课程阅读和操作时,就能够自然看到广告。
更重要的是,这种设计还可以建立一个非常明确的关系:
一个课程页面
↓
对应一个课程广告而不是:
整个 SPA Document
↓
不知道什么时候创建的一个广告这两个模型对于 SPA 来说差别很大。
因为从浏览器角度看,A Tour of Go 从:
/tour/basics/1切换到:
/tour/basics/2时,Document 并没有重新加载。
但是从用户角度看,这已经是一个新的课程页面。
因此我希望广告也遵循“课程页面”的生命周期,而不是整个浏览器 Document 的生命周期。
方案 A:使用 MutationObserver 跟踪 SPA 页面变化
保留 SPA 以后,第一版真正实现的方案,是通过 MutationObserver 监听页面 DOM。
基本思路类似:
MutationObserver
↓
观察页面 DOM 变化
↓
判断课程内容是否已经切换
↓
寻找当前广告挂载位置
↓
清理旧广告
↓
创建新的 ins.adsbygoogle这就是后来可以称为方案 A的一套实现。
它并不是一个停留在讨论阶段的设想。
代码真正部署以后,课程 SPA 切页能够产生新的广告,说明从最基本的功能目标来看,方案 A 是成立的。
而且它有一个很明显的优点:
不需要大幅介入原来的 Angular 路由逻辑。
只要观察最终 DOM 发生的变化,就可以在页面切换以后重新处理广告。
对于一个不希望大改 upstream Tour 的项目来说,这种做法一开始其实很有吸引力。
方案 A 的问题不是“完全不能用”,而是边界太模糊
真正的问题是在后续连续测试中逐渐出现的。
A Tour of Go 的 DOM 并不是只在点击“下一页”时才发生变化。
页面内部还有很多自己的动态行为,例如:
- Angular route 切换;
- CodeMirror 初始化;
- 编辑器内容变化;
- Playground 运行结果;
- 课程目录;
- Angular digest;
- 各类动态节点创建和销毁。
如果使用全局 MutationObserver 来观察这些变化,那么广告代码实际上是在做一件间接的事情:
页面 DOM 发生了变化
↓
判断这个变化意味着什么
↓
推测是不是课程已经切换
↓
再决定要不要处理广告这就意味着广告逻辑与 Tour 自身运行时 DOM 之间产生了比较强的耦合。
最初测试时大多数情况都正常。
但继续测试以后,开始遇到一些小概率、很难稳定复现的问题。
例如曾经出现过:
- 示例代码区域偶尔为空;
- 点击“下一页”偶尔没有正常响应;
- 页面在初始化和切换过程中出现一些异常状态。
这类问题最麻烦的地方,不只是它们存在。
而是:
它们并不能稳定重现。
有时刷新以后正常。
有时重新进入页面又没有问题。
这类偶发问题对于正式生产环境反而更加麻烦,因为很难明确证明某一次 DOM observer 回调与某个 Angular / CodeMirror 生命周期事件之间到底发生了怎样的时序关系。
这时候问题就从:
方案 A 能不能显示广告?
变成了:
为了显示广告,有没有必要让一个全局 DOM observer 长期参与整个 Tour 的运行生命周期?
答案开始倾向于否定。
为什么后来改成方案 B
继续分析以后,一个很明显的问题出现了:
Angular 本身其实已经知道:
- 当前课程 view 什么时候创建;
- 当前课程 view 什么时候销毁;
- 用户什么时候进入另一个 route-owned editor view。
既然应用自己已经掌握了这些信息,再通过 MutationObserver 从 DOM 外部反推:
“现在是不是已经换页了?”
就有些绕远了。
方案 A 大致相当于:
Angular 已经知道 view 生命周期
↓
DOM 随之变化
↓
MutationObserver 看到变化
↓
广告代码再猜测 Angular 刚刚做了什么而方案 B 希望直接变成:
Angular 创建课程 view
↓
创建广告
Angular 销毁课程 view
↓
销毁广告这样广告生命周期和课程生命周期之间就有了一条直接关系。
方案 B:把广告绑定到 Angular course view
最终确定的方案 B,核心其实很简单:
让广告成为 route-owned editor view 的一部分。
当前课程 view 创建以后:
mount()当前课程 view 被 Angular 销毁以后:
unmount()真正的代码也就是围绕这个生命周期建立的。

courseAd directive。课程 view 创建时执行 mount(),Angular $destroy 时执行 unmount()。核心关系可以简化成:
lifecycle.mount(elm[0]);然后:
scope.$on('$destroy', function() {
lifecycle.unmount(elm[0]);
});这样以后,用户打开:
/tour/basics/1Angular 创建当前课程 view,同时创建当前课程的广告。
点击下一页以后:
/tour/basics/1
↓
$destroy
↓
unmount(oldAd)
↓
/tour/basics/2
↓
新 view
↓
mount(newAd)每一个课程页面都有自己独立的广告生命周期。
方案 A 是:
观察 DOM,然后推断课程生命周期。
方案 B 则变成:
直接使用课程生命周期。
这也是两套方案最本质的区别。
每一页都创建新的 adsbygoogle 节点
方案 B 还有一个重要原则:
不把同一个已经处理过的 ins.adsbygoogle 节点反复复用。
新的课程 view 建立以后,会创建新的:
<ins class="adsbygoogle">然后执行一次新的:
(adsbygoogle = window.adsbygoogle || []).push({});当旧 view 销毁时,这个广告节点随它一起退出。
这样模型就变成:
课程 1
├── ins.adsbygoogle #1
└── push({})
下一页
课程 2
├── ins.adsbygoogle #2
└── push({})
下一页
课程 3
├── ins.adsbygoogle #3
└── push({})而不是在整个 SPA 生命周期中反复搬运同一个已经被 Google 处理过的广告节点。
广告失败不能影响课程导航
方案 B 还明确了一条边界:
广告属于附加能力,不能影响 A Tour of Go 本身。
因此 courseAd directive 中专门做了异常隔离。
例如:
try {
lifecycle.mount(elm[0]);
} catch (error) {
report(error);
}销毁时同样如此。
也就是说,即使出现:
- AdSense 没有加载;
adsbygoogle.push()抛出异常;- 广告生命周期 helper 不可用;
- 某一次广告请求失败;
最多只应该影响:
这一块广告。
不能影响:
- 下一页;
- 上一页;
- 课程正文;
- 编辑器;
- Playground;
- Angular route。
这也是从方案 A 的偶发问题中得到的一个重要教训:
广告代码越靠近 Tour 的核心运行路径,越需要清楚地限制它的影响范围。
Auto Ads 为什么仍然保留
采用手动课程广告以后,还有一个重要决定:
Auto Ads 并没有关闭。
这里不仅仅是因为“手动广告和自动广告可以共存”。
还有一个更实际的原因。
我的 AdSense 并不是只服务:
go-dev.shuijingwanwq.com
ja-go-dev.shuijingwanwq.com自动广告在其他站点和域名,例如 www、en 等环境中已经正常使用。
也就是说,当时面对的是一个更大的现有 AdSense 使用体系。
如果只是因为 A Tour of Go 的课程页不适合 Auto Ads,就直接把自动广告整体关闭,可能会影响其他本来运行正常的页面。
这显然得不偿失。
所以最后采取的思路不是:
Auto Ads 有问题
↓
全部关闭
↓
所有页面都改手动广告而是:
现有 Auto Ads
继续保留
+
A Tour of Go 课程页
增加明确的手动广告位这样既不破坏现有站点已经正常工作的 Auto Ads,又可以单独解决 A Tour of Go 这种特殊 SPA 布局的问题。
换句话说:
不是用手动广告取代整个 AdSense 自动广告体系,而只是为 A Tour of Go 补上一块 Auto Ads 一直无法可靠处理的课程广告区域。
最终广告终于出现在真正想要的位置
经过后续实现和调整以后,手动课程广告最终确实能够在真实生产页面中展示。

这张截图和第一张 AdSense Preview 放在一起看,差别非常明显。
Google Preview 最初给出的思路是:
课程
↓
footer
↓
广告而最终自己选择的是:
课程正文
↓
课程广告
↓
footer广告因此真正成为课程页面的一部分。
从页面体验来看,这个位置也更加自然:
- 用户无需越过 footer;
- 广告与当前课程位于同一个视觉区域;
- 不侵入右侧代码编辑器;
- 不需要为了广告重构整个 SPA;
- 每次 SPA 翻页都有自己的独立广告生命周期。
从方案 A 到方案 B,真正改变的是控制方式
回头来看,这一轮最重要的变化其实不是增加了一个 <ins>。
方案 A 已经证明了一件事:
SPA 课程页面完全可以在不整页刷新的情况下重新请求广告。
所以方案 B 并不是因为方案 A“完全不能用”才重新开始。
真正促使我继续调整的是:
一个能够工作的方案,还不一定是一个适合长期生产维护的方案。
方案 A 的控制模型是:
观察整个页面
↓
识别 DOM 变化
↓
推断课程状态
↓
处理广告方案 B 则把它缩小成:
当前课程 view 创建
↓
mount 广告
当前课程 view 销毁
↓
unmount 广告广告最终不再需要理解整个 Tour 的 DOM 到底正在发生什么。
它只需要理解:
自己所属的这个 course view 是否还存在。
对于一个需要长期跟随 upstream 的项目来说,这种边界明显更加清晰。
方案 B 解决了广告生命周期,但新的问题还在后面
到这里,方案 B 的主体已经确定。
它解决了:
- 课程展示广告的位置;
- SPA 页面切换后的广告创建;
- 旧广告销毁;
- 广告节点不累积;
- 广告异常与课程功能隔离;
- 不再依赖全局 DOM 变化判断课程切换;
- 不需要为了 AdSense 放弃 SPA。
但与此同时,A Tour of Go 还有一个更早就存在的问题没有真正解决。
虽然用户进入:
/tour/basics/1
/tour/basics/2
/tour/methods/1以后最终能够看到不同内容,但它依然是一个典型的客户端 SPA。
搜索引擎对这种页面一直不算友好。
而这一轮广告优化又增加了另一个考虑:
如果服务器第一次返回某个课程 URL 时,就已经包含当前课程的:
- 标题;
- 正文;
- 示例代码;
- metadata;
那么除了有助于搜索引擎理解这些 URL 之间真正的内容差异以外,也可能让 Google 更容易理解当前页面的具体主题,从而为后续广告匹配提供更明确的页面上下文。
这并不能保证一定提高广告填充率或者相关性。
但是从页面语义完整性来看,显然比让 Google 首先拿到大量高度相似的 SPA shell 更合理。
于是整个优化工作进入了下一个早已存在的问题:
SPA 的 SEO。
A Tour of Go 多语言翻译项目
本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。
项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n
当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。

