在前几篇文章中,我一直围绕 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。
例如:
/tour/welcome/1
/tour/basics/1
/tour/basics/2
/tour/moretypes/26
/tour/methods/24
/tour/concurrency/7从用户角度来看,这些当然是不同页面。
每个 URL:
- 有不同课程标题;
- 有不同正文;
- 有不同 Go 示例代码;
- 有不同上下页关系;
- 讲解完全不同的 Go 知识点。
整个正式课程一共有:
103个页面。
所以从内容模型上看,应该是:
103 个 URL
=
103 个独立课程页面问题在于:
浏览器最终看到 103 个不同页面,不代表搜索引擎第一次访问这些 URL 时,也能立刻看到 103 份明显不同的 HTML。
这正是传统客户端 SPA 比较麻烦的地方。
Search Console 曾经把多个课程 URL 判断成重复网页
Google Search Console 之前已经出现过比较明显的信号。
多个不同的 A Tour of Go 课程 URL 被归到了:
“重复网页,用户未选定规范网页”
这一类状态中。

截图里的示例并不是同一个页面的不同参数。
而是实际不同课程,例如:
/tour/moretypes/2
/tour/moretypes/21
/tour/moretypes/26
/tour/moretypes/8
/tour/moretypes/15
/tour/basics/12对学习者来说,这些页面之间显然差别很大。
但是搜索引擎曾经没有充分建立这种区别。
这其实正是 SPA SEO 中很典型的一类问题。
URL 变了,不代表第一次返回的 HTML 已经变了
传统服务器渲染页面通常是这样的:
GET /tour/basics/1
↓
服务器生成 basics/1 HTML
↓
浏览器获得完整课程内容再请求:
GET /tour/basics/2
↓
服务器生成 basics/2 HTML
↓
浏览器获得另一份完整课程内容也就是说,不同 URL 从第一次 HTTP 响应开始就已经明显不同。
而传统 SPA 更接近:
GET /tour/basics/1
↓
获得 SPA shell
↓
加载 JavaScript
↓
Angular 启动
↓
根据 route 渲染 basics/1另外一个 URL:
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 最终要返回什么广告,仍然需要理解当前页面的内容。
例如:
/tour/methods/24实际上讲的是 Go 的 image.Image 接口。
而:
/tour/concurrency/7讲的是等价二叉树与 Go 并发。
从内容主题来看,两者完全不同。
如果 Google 第一次请求这两个 URL 时就能直接得到:
- 本页标题;
- 本页正文;
- 本页示例代码;
- 本页 description;
显然会比先拿到一份高度相似的 SPA shell,在页面语义上更加清晰。
这里需要特别说明:
我并没有数据证明 Prerender 一定能够提高 AdSense 填充率,也没有证据证明它一定会让广告更加相关。
这不是一个可以直接下结论的因果关系。
更准确的考虑是:
如果搜索引擎和 Google 的其他系统第一次取得 URL 时,就能直接理解当前页面的真实内容,那么至少能够提供更加完整、明确的页面上下文。
从工程角度来看,这显然比 103 个 URL 首先返回高度类似的客户端 shell 更合理。
于是:
SEO 的长期问题 + 页面上下文对广告系统也可能有帮助
最终共同推动了这次 Prerender 实现。
目标不是放弃 SPA
这里很容易产生一个误解。
增加 Prerender,并不等于把上一篇文章刚刚决定保留的 SPA 又推翻了。
我真正希望实现的是:
搜索引擎 / 首次 HTTP 请求
↓
直接获得完整课程 HTML
正常浏览器
↓
先看到完整课程 HTML
↓
Angular 启动
↓
接管页面
↓
之后继续 SPA 导航也就是说:
首次访问像一个完整的独立页面,后续交互仍然保持 SPA。
这两个目标并不冲突。
甚至可以说,这才是当时比较理想的折中:
服务器端
提供完整页面身份
客户端
继续保持上游 SPA 体验不是只 Prerender 几个代表页面
既然决定解决这个问题,我不希望只给几个搜索流量比较高的课程做特殊处理。
正式课程本身就是 103 页。
所以最终采用的是完整覆盖:
103 个正式课程 URL
↓
103 个独立 Prerender HTML在正式 production bundle 中可以直接看到:
========== PRERENDER COUNT ==========
103并且目录中实际存在:
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
...
这件事对我来说很重要。
因为如果只是:
首页 prerender
+
几个热门页面 prerender那实际上仍然没有解决:
课程 URL 本身应该拥有独立页面身份
这个核心问题。
既然 Catalog 中正式存在 103 个课程页面,那么服务器最终也应该能够独立描述这 103 个页面。
Prerender HTML 里不只是一个空壳
下一步就是确认:
这些 HTML 到底有没有真正的内容。
这里我不想依赖浏览器截图。
因为浏览器最终显示出来的东西,可能已经经过 Angular 和 JavaScript 后续处理。
最直接的方法是:
curl https://go-dev.shuijingwanwq.com/tour/basics/1然后直接检查服务器返回的原始 HTML。
结果可以看到:
<title>包 – 包、变量和函数 – Go 语言之旅</title>页面中已经有:
<h2>包</h2>正文也直接存在:
每个 Go 程序都是由包组成的。
程序从包 main 开始运行。不仅如此,甚至示例源码也已经直接进入首次 HTML:
<textarea ...>
package main
import (
"fmt"
"math/rand"
)
func main() {
fmt.Println("My favorite number is", rand.Intn(10))
}
</textarea>
/tour/basics/1 时,返回的 HTML 已经包含本页标题、中文课程正文以及真实 Go 示例源码。这一点非常关键。
现在流程已经不是:
Google
↓
拿到 shell
↓
执行 Angular
↓
等待课程内容出现而是:
Google
↓
GET /tour/basics/1
↓
第一次 HTTP 响应
↓
已经知道:
“这一页讲的是包”JavaScript 后续能不能执行,已经不再决定搜索引擎是否能够得到最基础的课程语义。
示例源码为什么也要进入 Prerender
最开始如果只考虑 SEO,也许会觉得:
有标题和正文就够了,代码可以等 Angular 加载。
但 A Tour of Go 与普通文章又有一点不同。
代码就是课程内容的一部分。
例如一页讲:
package另一页讲:
for还有一页讲:
interface示例代码本身就是页面主题的重要语义。
如果 Prerender 只输出:
课程标题
课程说明却把右侧示例代码留空,那么页面仍然不是完整的课程初始状态。
所以最终希望首次 HTML 尽量接近用户真正应该看到的内容。
这也为后面另外一个问题埋下了伏笔:
HTML 里明明已经有源码,为什么浏览器第一次打开页面时,代码区域还是可能短暂显示为空?
这个问题后来成为 Prerender 与 Angular hydration 整合过程中一个很重要的 bug。
不过这是下一篇的内容。
仅有正文还不够,还要给每个 URL 一个明确的 SEO 身份
解决重复页面问题,不能只靠:
这两个页面正文不一样。
还需要让每个 URL 自己明确说明:
我是谁。
因此正式页面还增加了独立的:
<title>meta description- canonical
例如 /tour/basics/1 当前直接返回:
<title>包 – 包、变量和函数 – Go 语言之旅</title>canonical:
<link rel="canonical"
href="https://go-dev.shuijingwanwq.com/tour/basics/1"/>description:
<meta name="description"
content="讲解 Go 程序由包组成、程序从 main 包开始运行,以及导入路径最后一项通常与包名相同的惯例。"/>
/tour/basics/1 首次 HTTP HTML 已直接包含独立 title、canonical 和 description。这一张图和第一张 Search Console 截图放在一起,我觉得特别有意义。
之前 Google 面对的是:
很多不同课程 URL
↓
页面身份不够明确
↓
部分被判定为重复网页现在变成:
/tour/basics/1
↓
独立 title
独立 description
独立 canonical
独立正文
独立示例源码每一个 URL 都开始真正成为一份能够独立理解的页面。
Course SEO metadata 也不能简单靠模板拼接
还有一个问题是:
103 个页面的 description 不可能全部写成:
学习 Go 语言相关内容。那样虽然形式上有了:
<meta name="description">实际上仍然没有多少页面区分度。
如果希望 Google 真正理解页面主题,description 应该和当前课程内容对应。
例如“包”这一页描述的是:
讲解 Go 程序由包组成、程序从 main 包开始运行,
以及导入路径最后一项通常与包名相同的惯例。另一页讲切片,就应该描述切片。
讲 goroutine,就应该描述 goroutine。
所以后来课程 SEO metadata 也变成了一套独立的 locale-level 内容资产。
这里有一个项目边界也很重要:
Course SEO metadata 不属于 TranslationUnit。
它不会进入:
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 还能不能正常接管?
理想状态需要同时满足:
首次 HTML
完整
+
Angular 启动
正常
+
SPA 下一页
正常任何一个失败都不行。
如果为了 SEO 把 Angular 搞坏了:
不行。
如果为了 SPA,又让首次 HTML 重新退化成空 shell:
同样不行。
所以这次真正需要解决的是两种渲染模型之间的交接:
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。
而现在至少已经能够做到:
一个正式课程 URL
=
一个独立 HTTP 页面身份
=
一个独立 Prerender HTML
=
独立 title
=
独立 description
=
独立 canonical
=
独立课程正文
=
独立示例源码与此同时,用户在页面加载完成以后仍然保留:
Angular SPA
+
快速下一页
+
CodeMirror
+
Playground这才是最终真正想要的结果:
不是为了 SEO 放弃 SPA,而是让 SPA 在首次 HTTP 层面也成为搜索引擎能够直接理解的独立页面。
而这一次 AdSense 优化,则成为了推动这个多年 SPA SEO 老问题真正进入正式解决阶段的另一个重要因素。
下一步:真正困难的是 Prerender 与 SPA 如何稳定共存
到这里,搜索引擎这一侧的方案已经基本成型。
但对于正常用户来说,还有最后一轮更棘手的问题。
Prerender、Angular、CodeMirror、手动 AdSense 广告同时进入真实页面以后,先后出现了:
首次源码空白
↓
多次视觉变化
↓
偶发下一页异常
↓
footer 布局变化
↓
真实 AdSense inline style 污染这些问题并不是再重新选择一套架构就能解决的。
因为前面的几个核心决策已经确定:
- SPA 要保留;
- Prerender 要保留;
- 手动课程广告要保留;
- 与 upstream 的差异仍然要尽可能收敛。
剩下要做的是:
让这些已经确定的组件真正能够稳定地一起工作。
A Tour of Go 多语言翻译项目
本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。
项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n
当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。

