前面几篇文章里,我记录了这一轮 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 真正进入生产以后,最后是怎样稳定下来的。
先把时间线分清楚
这一轮问题比较多,如果不先区分阶段,很容易把不同原因产生的现象混在一起。
实际过程大致是:
方案 A
MutationObserver 管理 SPA 广告
↓
实现复杂度不断增加
↓
出现少量难以稳定复现的问题
↓
切换方案 B
方案 B
Angular course view 管理广告生命周期
↓
加入 103 页 Prerender
↓
处理 Prerender → Angular → CodeMirror 的交接
↓
首次源码仍有很短暂的空白,但已可接受
↓
生产环境继续测试
↓
发现真实 AdSense 会修改课程祖先节点高度
↓
footer 被拉进第一屏
↓
检查历史方案 A
↓
发现 A 中其实还有一块有效的 layout protection
↓
只把这部分能力接回方案 B
↓
最终生产验证完成这样再看后面的几个问题,就比较容易理解了。
方案 A 为什么最终被放弃
保留 SPA 以后,第一套真正上线的广告实现是方案 A。
方案 A 的核心思路是使用:
MutationObserver观察页面 DOM。
大致流程类似:
监听页面变化
↓
判断当前课程状态
↓
寻找广告位置
↓
清理旧广告
↓
创建新广告
↓
继续监听页面变化这种方法有一个明显优势:
不需要大幅修改 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 创建:
mount()当前 course view 被销毁:
$destroy
↓
unmount()也就是说:
Angular course view
│
├── 创建
│ └── mount 广告
│
└── 销毁
└── unmount 广告用户从:
/tour/basics/1进入:
/tour/basics/2时,不再需要一个全局 observer 去猜:
DOM 变化到什么程度才算真正换页?
Angular 本身已经知道旧 view 被销毁、新 view 被创建。
广告直接跟着这个生命周期即可。
因此方案 B 的目标并不是增加更多保护逻辑,而恰恰是:
减少广告代码对整个 Tour DOM 的理解和干预。
而在切换到方案 B 后,之前方案 A 中那种偶发的“下一页无法点击”问题,后续没有再发现。
然后又加入了 103 页 Prerender
方案 B 确定以后,另一个长期存在的问题也进入了正式解决阶段:
SPA 的 SEO。
Google Search Console 曾把多个不同课程 URL 判断为重复页面。
因此最终决定:
103 个正式课程 URL
↓
103 个独立 Prerender HTML每一个课程 URL 在第一次 HTTP 响应中就直接包含:
- 当前页面 title;
- description;
- canonical;
- 课程正文;
- 示例源码。
这解决的是服务器首次响应和页面身份问题。
但也引出了一个新的浏览器问题:
Prerender HTML 已经有源码,并不代表 Angular 和 CodeMirror 接管过程完全没有视觉变化。
方案 B + Prerender 后,首次源码仍会短暂空白
在这一阶段测试时,可以看到一种比较典型的现象。
页面左侧课程正文已经出现。
右侧也已经进入当前示例文件。
但是代码区域会短暂处于空白状态。

需要特别区分:
这里的源码空白,不是方案 A 中那个难以稳定复现的偶发 Bug。
方案 A 已经被替换。
这一阶段的问题来自新的渲染链:
服务器 Prerender
↓
浏览器显示初始 DOM
↓
Angular bootstrap
↓
route 接管
↓
CodeMirror 初始化
↓
最终编辑器状态也就是说,服务器已经提供了源码,但正常浏览器还要经历 Angular 与 CodeMirror 的接管。
真正需要优化的是这个交接过程。
Prerender、Angular 和 CodeMirror 需要稳定交接
后续围绕这个问题做了几项调整。
Prerender 直接把源码保存在正式 textarea 中
不额外维护另一套隐藏 source carrier。
当前页面默认示例文件直接进入原本供编辑器使用的 textarea。
这样:
Prerender source和:
编辑器初始 source尽量保持同一个来源。
Angular route 等课程数据准备好以后再接管
避免 editor view 先创建出来,再等待 lesson 数据补齐。
尽量把流程收敛为:
lesson 数据准备完成
↓
进入 editor route而不是让中间空状态暴露给用户。
footer 移出 route-owned partial
footer 原来跟随课程 route DOM。
这样 Angular 替换当前课程 view 时,footer 也会一起销毁、重新创建。
后续把 footer 移到稳定页面 shell:
稳定 shell
├── header
├── Angular course view
└── footer这样 SPA 下一页只替换课程区域,footer 不再参与 route 生命周期。
首次源码的极短空白最终选择接受
经过这些调整以后,页面稳定性已经明显改善。
后续 SPA 点击“下一页”时,源码基本可以快速出现。
但是首次直接打开一个课程 URL 时,仍可能看到一个很短暂的代码空白过程。
这一点后来没有继续无限优化。
原因是继续追求完全没有任何 hydration 视觉变化,很可能需要进一步扩大对 Angular、CodeMirror 和 Prerender 流程的改造。
而当前实际体验已经变成:
首次打开
可能有很短的源码空白
↓
页面完成接管
↓
后续 SPA 翻页很快相比继续增加架构复杂度,我最终接受了这个小的视觉取舍。
这也是这一轮中一个比较重要的边界:
不是所有能够继续优化的细节,都值得继续增加长期维护成本。
还增加了 Prerender 的确定性测试
Prerender 还有另外一个工程问题:
同一个课程 URL 每次生成出来的 HTML,是否完全一致?
如果 Chrome 每次 Prerender 都产生不同 DOM:
- release SHA 会变化;
- Git diff 或构建 diff 会产生噪音;
- 很难判断页面到底是真的内容变化,还是浏览器渲染结果不稳定。
所以后来增加了完整 browser regression。
同一个正式页面:
/tour/basics/1使用两个独立:
- output root;
- Chrome profile;
分别完成 Prerender。
最终要求两份 HTML:
bytes.Equal同时还检查:
- 最终 Prerender HTML 中没有
.CodeMirrorruntime DOM; - textarea 内容必须等于当前
route.Files[0]; - 不存在额外的隐藏 source carrier。
这样 Prerender 才真正变成一项可重复验证的正式构建行为。
页面基本稳定以后,生产环境又出现了新的问题
到这里,Prerender 与方案 B 的整合已经基本完成。
源码能够正常显示。
SPA 下一页也正常。
但继续在真实生产环境测试以后,又出现了另外一种异常。
这一次源码已经完全正常。
问题变成了:
footer 被提前拉进了第一屏。

从截图可以看到:
- 左侧课程正文正常;
- 右侧示例源码正常;
- CodeMirror 已经完成初始化;
- footer 却出现在很靠上的位置;
- footer 下面还有大片完全没有意义的空白。
这已经明显不是前面的 Prerender hydration 问题。
真正发生变化的是:
课程主体高度。
更奇怪的是,不同浏览器环境结果还不一样
这个问题当时还有一个很关键的线索。
普通浏览器里:
异常。
换到隐私窗口以后:
最终布局基本正常。
相同代码、相同课程页,却因为浏览器环境不同出现不同布局。
这就开始指向:
第三方脚本。
而当前课程页面里最重要的第三方脚本之一,自然就是 AdSense。
最终抓到了真实的 inline style 污染
继续检查几个核心课程布局节点的实际 style 后,终于发现了明确证据。
当时 #editor-container 被写入:
height: auto !important;
min-height: 0px !important;#left-side 同样出现:
height: auto !important;
min-height: 0px !important;两层 .relative-content 也出现了相同覆盖。
课程广告容器本身则出现:
height: auto !important;当时实际测到的页面几何数据大致是:
viewportHeight = 963
footerTop = 418.5也就是说:
浏览器视口高度接近:
963px但是 footer 顶部已经跑到了:
418.5px课程主体原本应该至少撑住一个完整视口附近的高度。
现在却被压缩到了四百多像素。
这就解释了为什么 footer 会突然进入第一屏。
!important 让普通 CSS 很难可靠覆盖
如果第三方只是增加:
height: auto;自己的 stylesheet 还有很多办法覆盖。
问题在于实际看到的是:
height: auto !important;
min-height: 0px !important;这会直接击穿原来的高度约束。
更麻烦的是:
第三方脚本以后还可能再次写入。
所以哪怕手工清理一次:
height:auto!important也不代表问题彻底解决。
真正需要的是:
当这个特定污染再次出现时,页面能够及时恢复。
这时候才重新检查方案 A 的历史实现
继续翻 Git 历史以后,发现一件很关键的事情。
方案 A 当时其实已经遇到过 AdSense 修改页面高度的问题。
所以方案 A 中除了:
全局 MutationObserver
→ 判断 SPA 页面变化
→ 管理广告生命周期以外,还存在另一块逻辑:
layout protection它专门负责检查几个关键布局祖先节点。
如果发现:
height: auto !important或者:
min-height: 0px !important就把这两个精确的异常属性移除。
同时监听这些节点后续的 style 变化,防止 AdSense 再次写入。
这里就出现了一个很有意思的问题。
从方案 A 切换到方案 B 时,删除得太彻底了
方案 A 最大的问题是实现过于复杂。
其中最需要移除的部分是:
监听页面整体 DOM
↓
推断 SPA route
↓
管理广告生命周期所以方案 B 切换时,删除全局 DOM observer 是正确方向。
但是当时也顺带把方案 A 中的:
AdSense layout protection一起移除了。
后来生产环境证明:
这两块逻辑虽然都用了 MutationObserver,但它们解决的其实完全不是同一个问题。
第一类 observer:
从整个 DOM 变化里推断 SPA 状态。
这一类复杂度高,最终由方案 B 的 Angular 生命周期替代。
第二类 observer:
只观察几个明确的布局节点,只处理两个已经确认过的异常 style。
这一类其实是一种非常窄的防御机制。
问题不在于:
有没有 MutationObserver。
而在于:
Observer 到底在观察什么、为什么观察、生命周期边界在哪里。
最终没有恢复方案 A
找到方案 A 的历史代码以后,并没有把整个方案 A 恢复回来。
因为广告生命周期方面,方案 B 已经更加清晰。
最终采取的是:
保留方案 B,只把方案 A 中已经被生产环境验证过的 layout protection 单独取回来。
这样最终结构变成:
Angular course view
│
├── mount()
│ ├── 创建当前课程广告
│ └── 建立当前 view 的 layout protection
│
└── $destroy
├── unmount 广告
└── disconnect layout protectionSPA 广告生命周期仍然由 Angular 决定。
MutationObserver 不再负责判断 SPA 是否切页。
它只负责一个非常有限的任务:
防止真实 AdSense 再次写入已经确认会破坏课程布局的两个高度属性。
layout protection 只保护四个明确节点
新的实现没有恢复全局:
document.body MutationObserver而是只找到当前课程广告所在 editor 的几个明确祖先:
#editor-container
.relative-content
#left-side
.relative-content除此之外不扩大观察范围。
并且它不会:
element.removeAttribute("style")这样粗暴删除整个 style。
它只处理两个精确条件。
如果:
height == auto
并且 priority == important才删除 height。
如果:
min-height == 0px
并且 priority == important才删除 min-height。
其他 inline style 全部保留。
这能够尽可能避免:
为了解决 AdSense 的一个副作用,又误伤第三方或者 Tour 自己的其他正常样式。
observer 也必须跟着 course view 销毁
重新引入局部 MutationObserver 以后,还有一个必须严格控制的问题:
不能让 observer 随着 SPA 翻页不断累积。
因此它和广告本身一样,也属于当前 course mount。
页面 1:
mount
↓
observer #1点击下一页:
$destroy
↓
disconnect observer #1页面 2:
mount
↓
observer #2不能出现:
observer #1
observer #2
observer #3
observer #4
...这种旧方案最容易重新演变出来的复杂状态。
这一次必须把偶发问题变成自动化测试
方案 A 给我的一个很直接的教训是:
“连续人工测试很多次没有复现”并不能证明这类问题不存在。
因为这些问题往往涉及:
- SPA view 生命周期;
- DOM mutation;
- observer;
- CodeMirror;
- 第三方脚本;
- 浏览器运行时序。
因此最终专门增加了两组真实浏览器回归测试。

第一项:
TestCourseAdSPALifecycleInBrowser验证方案 B 本身:
- 当前课程创建广告;
- SPA 下一页创建新的广告生命周期;
- 旧广告正确清理;
- 多次导航不产生节点累积;
- 广告异常不会中断正常导航。
第二项:
TestCourseAdLayoutProtectionInBrowser验证后来重新接入的局部布局保护:
- 第一次写入高度污染能够清理;
- 再次写入仍然能够清理;
- SPA 切换以后旧 observer 已失效;
- 新 view observer 正常;
- 多次 SPA 导航不会累积保护实例;
- footer 仍然保持在课程主体之后。
最终:
PASS这样以后如果再修改 course ad 相关代码,就不需要等到生产环境偶然再次出现 footer 异常才知道发生了回归。
自动测试通过以后,还必须看真实 Google 广告
不过 browser test 通过以后,我仍然没有立即把这一轮工作判定完成。
因为引发问题的是:
真实 AdSense。
自动化测试只能模拟:
给节点写入相同的异常 inline style
↓
验证 layout protection 能否恢复它不能证明:
真实 Google 广告
+
Angular
+
CodeMirror
+
Prerender
+
SPA navigation一起运行时一定正常。
所以最后仍然需要在 production 中继续测试。
一开始真实请求大量返回 unfilled
新的代码部署以后,课程布局已经恢复正常。
footer 也没有再进入第一屏。
但是手动广告却经常没有显示。
继续检查广告节点可以看到:
data-ad-status="unfilled"同时又有:
data-adsbygoogle-status="done"这说明:
广告请求已经处理完成。
只是 Google 这一轮没有返回实际广告。
进一步查看网络请求,还确认了:
slotname=4728596962以及类似:
format=847x280这样的实际请求参数。
而且从一个 SPA 课程进入下一页以后,请求中的当前 URL 也会变成新的课程地址。
所以这实际上证明:
方案 B 的 fresh request 正常工作。
整个链路已经是:
新 course view
↓
新 ins.adsbygoogle
↓
新的 Google request
↓
Google 返回 unfilled而不是:
SPA 下一页
↓
根本没有新的广告请求这两种情况必须区分。
最后,真实广告终于成功显示
继续测试以后,Google 最终还是返回了一次真实展示广告。

这张截图基本可以作为整轮优化的最终验收。
它同时证明了几件事情。
手动课程广告位确实能够真实工作
这不是:
- AdSense Preview;
- 自己写的 placeholder;
- 测试 iframe。
而是真实 Google 广告已经显示。
方案 B 没有阻止 AdSense
前面连续出现 unfilled 并不是因为方案 B 无法加载广告。
真实 filled 最终证明广告链路是完整的。
layout protection 也没有误伤广告
新的保护逻辑只清理几个祖先节点中两个非常精确的高度污染值。
它没有破坏:
- 广告 iframe;
- 广告请求;
- 广告尺寸;
- 正常展示。
footer 最终保持正常
真实广告出现以后:
课程正文
正常
示例源码
正常
广告
正常
页面高度
正常
footer
正常这才是真正需要达到的状态。
广告尺寸与填充率暂时不继续调整
最终成功显示的广告比前一天经常看到的小型正方形广告明显更宽。
当时实际请求过:
847x280这样的广告尺寸。
同时当天又遇到了不少:
unfilled因此很自然会产生一个新的问题:
更宽的广告尺寸,会不会导致实际填充率下降?
这一点目前没有继续调整。
因为当天为了验收已经进行了大量:
- 刷新;
- 强制刷新;
- SPA 连续翻页;
- 广告请求测试。
这些都不是自然用户流量。
再加上当前站点流量和广告展示样本都还不大,仅凭这一轮测试很容易产生误判。
所以当前决定是:
先保持现有广告尺寸不动。
等积累几天自然访问以后,再结合:
- Coverage;
- 广告请求数;
- 展示次数;
- 实际展示尺寸;
判断是否有必要重新缩小桌面广告宽度。
这一项已经不再阻塞当前广告实现正式收尾。
最终没有恢复复杂的 DOM 管理方案
回过头来看,从方案 A 到方案 B 最重要的变化,并不是:
从 MutationObserver 改成不用 MutationObserver。
因为最终方案 B 里仍然存在一个很小范围的 observer。
真正变化的是:
职责边界。
方案 A:
全局观察 DOM
↓
理解整个 SPA 正在发生什么
↓
管理广告
↓
同时处理各种页面副作用随着功能增加,实现越来越复杂,也出现了难以稳定复现的问题。
最终方案 B:
Angular
负责课程生命周期
course ad helper
负责创建 / 销毁广告
局部 MutationObserver
只负责清理两个已经确认的 AdSense 高度污染值每一层只需要理解自己的问题。
这也是为什么最后没有因为出现高度污染,就重新退回方案 A。
真正需要恢复的不是:
方案 A。
而只是:
方案 A 中那一块已经被真实生产环境证明有价值的 layout protection。
旧方案中的代码也应该按职责重新判断
这一轮还有一个让我觉得很值得记录的经验。
重构旧实现时,很容易把代码简单分成:
旧代码
=
应该删除但真实情况往往没有这么简单。
方案 A 中:
全局 DOM observer 管理 SPA 广告最终证明不适合作为长期方案。
但同一个方案 A 中:
局部保护课程高度却已经提前解决过一个真实的 AdSense 生产问题。
所以后来最合理的做法不是:
把 A 全部恢复。
也不是:
因为已经切到 B,所以 A 的任何代码都不能再使用。
而是:
重新检查旧代码到底承担什么职责,把仍然有价值的那一部分以新的生命周期边界接入当前架构。
这样既没有退回复杂的方案 A,也没有因为重构而丢失已经积累下来的生产经验。
最终同步到 zh-CN 与 ja-JP
最后的布局保护修复形成正式提交:
91a2583
fix: 恢复课程广告布局保护随后同步到正式 production。
zh-CN 最终确认:
- 页面布局正常;
- footer 不再进入第一屏;
- 示例源码正常;
- SPA 下一页正常;
- 真实手动广告能够显示。
ja-JP 同样同步到当前版本:
- 页面最终布局正常;
- footer 不进入第一屏;
- 示例源码正常;
- 最新共享静态资源已经生效。
具体 production publish、shared-assets、Cloudflare 缓存和健康检查流程之前已经多次记录,这里不再重复展开。
这一篇真正关注的还是:
用户最终在浏览器里得到的页面是否稳定。
从“想办法让广告出现”到“广告不能破坏课程”
这一轮工作最开始的问题其实很简单:
为什么 A Tour of Go 没有普通展示广告?
后来一步一步演变成:
要不要为了广告放弃 SPA?
↓
如何给 SPA 添加明确广告位?
↓
方案 A 为什么越来越复杂?
↓
方案 B 如何收敛生命周期?
↓
如何让搜索引擎理解 103 个课程页?
↓
Prerender 怎样与 Angular / CodeMirror 共存?
↓
真实 AdSense 为什么又会破坏页面高度?
↓
怎样只恢复必要的保护而不退回方案 A?最后真正确定下来的优先级也越来越明确:
课程核心功能
>
页面稳定性
>
SEO 页面身份
>
广告展示广告当然希望尽可能正常展示。
但如果为了广告导致:
- 示例源码异常;
- “下一页”失效;
- 页面高度塌缩;
- footer 跑进第一屏;
那么即使广告本身成功加载,这套实现也不能算成功。
最终可以接受的状态应该是:
Google 返回 filled 时页面正常;返回 unfilled 时页面也正常;广告代码发生异常时课程仍然正常;SPA 连续翻页时广告能够跟随当前课程,而不会逐渐侵入整个应用生命周期。
做到这里以后,这一轮从“整个 A Tour of Go 没有普通展示广告”开始的优化,才算真正完成了。
A Tour of Go 多语言翻译项目
本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。
项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n
当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。

