起因
我有一个 Vue 3 + Go (Gin) 的项目——AI模型价格汇总站(网址: https://ai-prices.sunai.net/ )。功能没什么问题,但一直有个心结:搜索引擎收录效果很差。
本来是无所谓的, 因为只是给One hub调一下接口, 今天查一个模型价格, google搜了一下 模型名 price, 发现有一些第三方网站也能搜到.
而自己写的这个页面, 打开 Google Search Console 一看,所有页面的标题都是 "AI模型价格汇总",描述也完全一样。/prices 页面和 /providers 页面在搜索引擎眼里长得一模一样。
这其实是所有 Vue/React 单页应用的通病。
问题在哪
SPA 的运作方式是:服务器永远返回同一个 index.html,然后由前端 JS 接管路由和渲染。
很多教程会教你在 Vue Router 的 afterEach 钩子里动态修改 document.title 和 meta 标签:
router.afterEach((to) => {
document.title = to.meta.title || '默认标题'
document.querySelector('meta[name="description"]')
.setAttribute('content', to.meta.description)
})
这对用户来说完全没问题——浏览器标签页上的标题确实变了。但对搜索引擎爬虫来说,大部分爬虫拿到的是服务端返回的原始 HTML,它们不会执行 JavaScript。所以爬虫看到的永远是 index.html 里写死的那份 meta 信息。
Google 的爬虫确实能执行 JS,但有延迟,而且不保证每次都渲染。百度的爬虫就更别提了,基本不执行 JS。
常见方案和它们的代价
解决 SPA SEO 通常有这些路子:
- SSR(Nuxt.js / Next.js):效果最好,但要重写整个前端,代价太大
- 预渲染(Prerender):构建时生成静态 HTML,但页面多了构建很慢,动态内容也搞不定
- Prerender.io 等第三方服务:判断 User-Agent,爬虫来了就返回预渲染页面,要花钱,还得维护一套额外的东西
我的项目就四五个页面,SSR 杀鸡用牛刀,预渲染又要引入新依赖。想了想,我的后端本来就在做 SPA fallback——所有非静态文件请求都返回 index.html,那能不能在返回之前做点手脚?
前提:我的项目架构刚好适合这么干
这个方案能成立,有一个关键前提:前端构建成静态文件后,由 Go 后端统一提供访问路由。
我的部署方式是:Vue 项目 npm run build 之后产出 dist/ 目录(一堆 JS、CSS 和一个 index.html),然后把这些静态文件放到 Go 服务旁边。Go 的 Gin 框架通过 NoRoute 处理所有非 API 请求——如果请求的是一个实际存在的静态文件(JS、CSS、图片),就直接返回文件;否则一律返回 index.html,交给前端路由接管。
r.NoRoute(func(c *gin.Context) {
// API 请求返回 404
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.JSON(404, gin.H{"error": "API endpoint not found"})
return
}
// 静态文件存在就直接返回
path := filepath.Join(staticDir, c.Request.URL.Path)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
c.File(path)
return
}
// 其他所有请求都返回 index.html(SPA fallback)
seo.RenderIndex(c, staticDir)
})
整个项目就一个 Go 进程,既当 API 服务器,又当静态文件服务器,没有 Nginx,没有 Node 进程。这意味着每一个页面请求都会经过 Go 代码——这就是我们动手脚的地方。
如果你的前端是用 Nginx 或者 CDN 单独托管的,那这个方案就用不上了,因为请求根本不经过你的后端。但如果你也是这种"Go 一把梭"的部署方式,那就正好。
我的方案:后端占位符替换
思路很简单:
index.html里不写死 meta 内容,改用占位符- Go 后端返回
index.html时,根据请求路径把占位符替换成对应的内容 - 前端 Router 的
afterEach继续保留,负责 SPA 内部导航时的更新
两层配合,爬虫和用户都照顾到了。
第一步:index.html 里埋占位符
<meta name="description" content="{{SEO_DESCRIPTION}}">
<meta name="keywords" content="{{SEO_KEYWORDS}}">
<link rel="canonical" href="{{SEO_CANONICAL}}">
<meta property="og:title" content="{{SEO_TITLE}}">
<meta property="og:description" content="{{SEO_DESCRIPTION}}">
<meta property="og:url" content="{{SEO_CANONICAL}}">
<title>{{SEO_TITLE}}</title>
就是把原来写死的内容换成 {{SEO_TITLE}}、{{SEO_DESCRIPTION}} 这类标记。格式随意,只要不和正常内容冲突就行。
第二步:Go 后端定义各页面的 SEO 数据
新建一个 seo/seo.go:
package seo
type PageMeta struct {
Title string
Description string
Keywords string
}
var routeMeta = map[string]PageMeta{
"/": {
Title: "AI模型价格 - AI模型价格汇总",
Description: "汇总 OpenAI、Claude、Gemini 等几十家模型的价格...",
Keywords: "AI模型价格,GPT价格,Claude价格",
},
"/prices": {
Title: "价格列表 - AI模型价格汇总",
Description: "查看和对比各大AI模型的详细价格信息...",
Keywords: "AI模型价格列表,Token价格",
},
// ... 其他页面
}
一个 map,路径做 key,SEO 信息做 value。简单粗暴。
第三步:返回 index.html 前做字符串替换
func RenderIndex(c *gin.Context, staticDir string) {
tmpl := loadTemplate(staticDir) // 读一次,sync.Once 缓存
path := c.Request.URL.Path
meta, ok := routeMeta[path]
if !ok {
meta = defaultMeta // 没匹配到就用首页的
}
html := tmpl
html = strings.ReplaceAll(html, "{{SEO_TITLE}}", meta.Title)
html = strings.ReplaceAll(html, "{{SEO_DESCRIPTION}}", meta.Description)
html = strings.ReplaceAll(html, "{{SEO_KEYWORDS}}", meta.Keywords)
html = strings.ReplaceAll(html, "{{SEO_CANONICAL}}", baseURL+path)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, html)
}
index.html 用 sync.Once 只读一次磁盘,后续请求全走内存,性能开销几乎为零。strings.ReplaceAll 替换四个占位符,没有正则,没有模板引擎,就是纯字符串操作。
第四步:改一行调用
原来 main.go 的 SPA fallback 是:
c.File(indexPath) // 直接返回静态文件
改成:
seo.RenderIndex(c, staticDir) // 替换后返回
完事。
前端配合
Vue Router 的 afterEach 保持不动。它负责的是用户在站内点击导航时更新浏览器标签页标题和 meta——这时候不会重新请求 HTML,纯客户端操作,后端的替换管不到这里。
两层各管各的,互不冲突。
效果
现在用 curl 直接请求不同页面:
curl https://ai-prices.sunai.net/
curl https://ai-prices.sunai.net/prices
curl https://ai-prices.sunai.net/providers
每个页面返回的 HTML 里,title、description、OG 标签、canonical 全都是对应页面的内容。搜索引擎拿到的就是正确的信息。
顺手加的东西
既然在搞 SEO,索性把该做的都做了:
- Open Graph 标签:分享到社交平台时有正确的预览卡片
- Twitter Card 标签:同上
- JSON-LD 结构化数据:让搜索引擎更好地理解网站类型
- robots.txt:告诉爬虫哪些能爬哪些别爬(比如
/login和/api/就没必要收录) - sitemap.xml:给爬虫一份站点地图
这个方案的优缺点
优点:
- 零依赖,不用装任何 npm 包或 Go 库
- 改动极小,前端一个文件、后端两个文件
- 性能好,
sync.Once读一次文件,后面全是内存里的字符串替换 - 不侵入现有架构,该怎么写 Vue 还怎么写
局限:
- SEO 数据是在 Go 代码里硬编码的,新增页面要改代码重新部署
- 只适合页面数量可控的场景,几十个页面没问题,但如果是博客这种有几千篇文章的,还是得上 SSR 或者预渲染
对于这个四五个页面的小项目来说,刚刚好。
总结
SPA 的 SEO 问题本质上就是一个问题:爬虫拿到的 HTML 和用户看到的不一样。
解决思路也很直接:让爬虫拿到的 HTML 里就有正确的信息。SSR 是最彻底的方案,但对于页面不多的项目,在后端做一层简单的字符串替换就足够了。
不是所有问题都需要一个"完美"的方案,够用就好。