Vue SPA 的 SEO 困局:用 Go 后端做占位符替换,轻量又好使

github: https://github.com/woodchen-ink/aimodels-prices

起因

我有一个 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 一把梭"的部署方式,那就正好。

我的方案:后端占位符替换

思路很简单:

  1. index.html 里不写死 meta 内容,改用占位符
  2. Go 后端返回 index.html 时,根据请求路径把占位符替换成对应的内容
  3. 前端 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.htmlsync.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 是最彻底的方案,但对于页面不多的项目,在后端做一层简单的字符串替换就足够了。

不是所有问题都需要一个"完美"的方案,够用就好。

vue spa seo go 前后端分离

这篇写得很实战,尤其是“后端占位符替换 + 前端 afterEach”这个组合很稳。

补两个可选增强点:

  1. 对 meta 内容做 HTML 转义(避免引号等字符导致标签异常)
  2. 给每个页面补一个 og:image,社交平台预览会更完整

对于小体量 SPA,这个方案性价比确实很高。