侧边栏壁纸
  • 累计撰写 239 篇文章
  • 累计收到 2 条评论

为了解决单页应用不收录把全站重构成 Nuxt 3(Vue 3 SSR),搜索收录与快照却彻底崩盘?排查 useAsyncData 脱水失效客户端二次请求致骨架屏覆盖快照、useSeoMeta 异步竞态导致 head 占位符泄露、Nginx 反代漏头致 Canonical 全成 127.0.0.1 与软 404 惩罚我踩过的几个深坑

2026-10-28 / 0 评论 / 6 阅读
AI 摘要由 AI 生成

文章讲述作者将Vue 3单页应用迁移至Nuxt 3 SSR后,因搜索引擎收录和快照问题导致的崩溃。分析了使用useAsyncData时脱水失效、useSeoMeta异步竞态和Nginx反代漏头等问题,并提出解决方案,如生成唯一key、确保服务端缓存等,以优化搜索引擎收录。

上个月我把手头跑了两年多的 Vue 3 单页应用(SPA)整个迁移到了 Nuxt 3。原系统走的是 Vite 打包纯客户端渲染,虽然日常操作反应灵敏,但百度等爬虫抓取时基本只看得到一个挂载用的 <div id="app"></div>,哪怕外挂了无头浏览器做预渲染,也三天两头遇到超时 504。

为了彻底解决搜索引擎收录问题,我咬牙花了整整三周时间,把所有的状态管理、路由以及网络请求全部重构到了 Nuxt 3 服务端渲染(SSR)。本地调试通过、生产构建上线,首屏直出确实变快了。

原以为收录会一路走高,结果上线第二周,我打开百度搜索资源平台和 Google Search Console,抓取统计直接呈现断崖式下跌。我抓了几条收录快照仔细一翻,后背瞬间冒冷汗:快照里的文章正文全是灰色的骨架屏,页面标题统一变成了“文章详情 - 页面加载中”,网页底部的 Canonical 规范标签更是清一色指向 http://127.0.0.1:3000。

前后折腾了整整四天,抓包比对了几百次服务端响应与客户端注水日志,终于把这几个暗坑彻底揪了出来。


坑点一:useAsyncData 缺少全局 key 导致脱水失效,客户端二次发起网络请求并覆盖骨架屏

Nuxt 3 的服务端渲染依赖两阶段数据传递:在 Node.js 服务端执行 useAsyncData 或 useFetch 获取数据,把数据直接序列化注入到 HTML 底部的 Payload 数据包中,这被称为脱水(Dehydration);客户端浏览器接收到 HTML 后,Vue 运行时从 Payload 中读取缓存数据恢复状态,这被称为注水(Hydration)。

为了调用方便,很多同学都喜欢在全局封装一个公用请求工具:

// 错误示范:在封装方法中直接透传 useFetch,没有显式提供 key
export const useApiFetch = <T>(url: string, opts?: any) => {
  return useFetch<T>(url, {
    baseURL: 'https://api.example.com',
    ...opts,
  })
}

在普通的页面组件里,如果直接多次调用这个封装:

<!-- pages/posts/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: post, pending } = await useApiFetch(`/api/posts/${route.params.id}`)
</script>

<template>
  <div class="article-container">
    <div v-if="pending" class="skeleton-screen">
      <div class="skeleton-line"></div>
      <div class="skeleton-line"></div>
    </div>
    <div v-else class="article-body">
      <h1>{{ post?.title }}</h1>
      <div class="content" v-html="post?.content"></div>
    </div>
  </div>
</template>

这段代码在本地单次点击路由看起来没有任何破绽,但在搜索引擎眼里却是一个致命陷阱。

Nuxt 3 默认依赖调用文件和代码行号自动生成 key。当我们在同一个封装函数内部调用 useFetch 时,自动计算出的 key 可能会在多组件或动态路由复用时发生冲突。在客户端注水阶段,Vue 发现 Payload 中没有匹配到合法的缓存缓存块,或者认为数据已经失效,便在浏览器端强行重新触发一次网络请求。

当 pending 状态重新被置为 true,原本服务端直出完整正文的 DOM 节点,在客户端脚本执行的一瞬间被清空,替换成了骨架屏。支持 JS 渲染的现代搜索引擎爬虫在等待首屏资源完全稳定时,截取到的恰恰就是这块骨架屏。爬虫把骨架屏当成了网页真实正文,快照里根本没有文章字数,直接判定为无价值内容。

修复方案

在封装请求或直接调用时,必须基于业务实体的唯一标识生成稳定且幂等的 key,确保客户端注水能够精确命中服务端的脱水数据:

// 正确做法:显式生成唯一 key 并锁定服务端缓存
export const usePostDetail = (postId: string | number) => {
  return useAsyncData(
    `post-detail-${postId}`,
    () => $fetch(`/api/posts/${postId}`, { baseURL: 'https://api.example.com' }),
    {
      dedupe: 'defer',
      server: true, // 确保服务端一定执行
      lazy: false,   // 首屏关键内容坚决不走异步懒加载
    }
  )
}

在组件中调用时,配合 default 初始值,并避免在首屏用骨架屏覆盖已经渲染好的静态 DOM:

<script setup lang="ts">
const route = useRoute()
const postId = route.params.id as string
const { data: post } = await usePostDetail(postId)
</script>

<template>
  <article class="article-container">
    <h1 class="article-title">{{ post?.title }}</h1>
    <div class="article-content" v-html="post?.content"></div>
  </article>
</template>

坑点二:useSeoMeta 与异步数据发生竞态,默认占位标题被固化进服务端 HTML

很多页面为了避免渲染时字段不存在报错,习惯先声明一组默认的 SEO 标签,然后再去拉取数据:

// 错误示范:在异步等待之前执行同步 SEO 声明
const title = ref('文章详情 - 我的技术博客')
const description = ref('最新文章在线阅读')

useSeoMeta({
  title: () => title.value,
  description: () => description.value,
  ogTitle: () => title.value,
})

// 异步拉取真实数据
const { data: post } = await usePostDetail(route.params.id)

// 晚了一步的数据赋值
if (post.value) {
  title.value = post.value.title
  description.value = post.value.summary
}

这种写法在客户端单页切页时看似工作正常(浏览器标签栏随后会被更新),但在服务端渲染流水线中存在严重的异步竞态风险。

Nuxt 3 在处理服务端页面请求时,负责组装 HTML <head> 的 Unhead 引擎在组件 setup 遇到第一个未完成的 await 时,可能就已经开始序列化当前已经收集到的静态元数据。一旦数据拉取稍有耗时,或者在组件嵌套结构中放置不当,SSR 直出的 HTML 源码里 <title> 依然是未赋值前的“文章详情 - 我的技术博客”。

爬虫抓取页面只读取第一次返回的完整 HTML 数据包。当几百篇不同分类、不同内容的长文被抓取后,标题全部呈现为完全一致的默认模板字样,搜索引擎的防作弊过滤机制立刻触发,直接判定站点存在海量克隆死页。

修复方案

必须严格保证在真实数据完成响应之后,再调用 useSeoMeta,并且使用明确的计算属性或直接对象值传入:

// 正确做法:数据加载完毕后,原子化绑定 SEO 元数据
const route = useRoute()
const postId = route.params.id as string

const { data: post } = await usePostDetail(postId)

if (!post.value) {
  throw createError({
    statusCode: 404,
    statusMessage: '文章不存在或已下线',
    fatal: true
  })
}

// 确保此时 post.value 已经存在有效数据
useSeoMeta({
  title: `${post.value.title} - 我的技术博客`,
  description: post.value.summary,
  ogTitle: post.value.title,
  ogDescription: post.value.summary,
  robots: 'index, follow',
})

用 curl 命令行测试服务端的原始输出,可以直观检验 head 标签中的数据是否准确:

curl -s -A "Baiduspider" https://www.example.com/posts/1024 | grep -E "<title|<meta name=\"description\""

确保终端返回的结果直接展示文章真实标题与摘要,而不是任何临时填充物。


坑点三:Nginx 反向代理层丢失 Host 头,Canonical 与 Sitemap 全被识别成 127.0.0.1:3000

为了提升 SEO 效果,几乎每个 Nuxt 项目都会引入官方的 @nuxtjs/seo 工具链,或者单独配置 @nuxtjs/sitemap 模块来自动化生成站点地图和规范化链接(Canonical URL)。

Nuxt 3 编译后,底层的 Nitro 引擎作为一个 Node 进程监听在宿主机的本地端口(比如 http://127.0.0.1:3000)。前端由 Nginx 承接 HTTPS 请求后,反向代理转发给 Nitro。

我最初图省事,在 Nginx 里写了一个最简略的反代规则:

# 踩坑的简陋反代配置
location / {
    proxy_pass http://127.0.0.1:3000;
}

部署上线后,查看网站源码的 head 部分,出现的景象让我当场愣住:

<link rel="canonical" href="http://127.0.0.1:3000/posts/1024">
<meta property="og:url" content="http://127.0.0.1:3000/posts/1024">

访问 /sitemap.xml 时,里面几千条收录链接的 <loc> 标签全部以 http://127.0.0.1:3000/ 开头!

Nitro 引擎在服务端推导当前页面的绝对完整 URL 时,内部调用的是 getRequestURL()。由于 Nginx 转发请求时没有重写 Host 请求头,也没有告知后端实际的访问协议,Nitro 只能读取本地套接字的环境变量,直接将 Host 识别成了 127.0.0.1:3000,协议识别成了非加密的 http。

搜索引擎爬虫拿到这份 Sitemap 去请求内网 IP,全部连接超时;而页面上的 Canonical 规范标签指向本地环回地址,相当于告诉所有搜索引擎:“不要收录当前公网页面,请去收录 127.0.0.1”。权重直接全部蒸发。

修复方案

一方面在 Nginx 转发中补齐所有代理上下文头信息,另一方面在 Nuxt 配置中显式锁定生产主域名。

在 Nginx 的站点配置文件中加入规范的代理头:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;

    # 关键:透传真实的客户端域名和协议
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # 支持长连接与 WebSocket
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

在 nuxt.config.ts 中配置静态基准域名,不给自动推导留任何出偏门的机会:

// nuxt.config.ts
export default defineNuxtConfig({
  site: {
    url: 'https://www.example.com',
    name: '我的技术博客',
  },
  nitro: {
    compressPublicAssets: true,
  },
})

重启 Nginx 和 Node 服务后,再次请求 sitemap.xml,确认所有 URL 恢复为带 https 的标准公网链接。


坑点四:文章下线走前端路由跳转,向爬虫抛出 HTTP 200 触发软 404 惩罚

在普通的客户端开发思维里,当文章不存在或者被管理员删除时,前端常规写法往往是直接跳到 404 页面:

// 错误示范:利用客户端导航处理 404
const { data: post } = await usePostDetail(postId)

if (!post.value) {
  // 客户端路由跳转,在服务端并不终止当前请求流程!
  navigateTo('/404')
}

在服务端渲染中,这种做法极具隐蔽性和杀伤力。

navigateTo('/404') 如果没有配置特定的重定向参数,在服务端虽然会触发内部重定向,但返回给浏览器的响应头往往被默认认定为普通的重定向跳转,甚至在某些异常分支下直接下发了 HTTP 200 状态码,附带一段提示“页面未找到”的 HTML。

搜索引擎爬虫如果访问到一篇被删除的文章,抓到了一个写着“文章已被删除”、但 HTTP 状态码却标着 200 OK 的页面,这在搜索引擎质量体系中被称为“软 404(Soft 404)”。

软 404 是各大搜索引擎严厉打击的对象。当爬虫在站点内连续抓到大量软 404 时,会判定该站点的 URL 管理极度混乱,不仅会降级这些死链的抓取频次,还会直接扣除整个域名的抓取预算(Crawl Budget),导致新发布的正经文章迟迟得不到分配配额。

修复方案

在 Nuxt 3 服务端渲染环境下,数据缺失时必须使用 createError 显式抛出致命错误,强制 Nitro 引擎向网络层返回真实的 HTTP 404 状态码:

// 正确做法:抛出致命 404 错误并中断渲染
const { data: post } = await usePostDetail(postId)

if (!post.value) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Post Not Found',
    fatal: true, // 核心属性:强制中断 SSR 并在服务端返回 404 状态码
  })
}

在项目根目录下创建统一的 error.vue,接管所有异常状态的展示:

<!-- error.vue -->
<script setup lang="ts">
import type { NuxtError } from '#app'

const props = defineProps<{
  error: NuxtError
}>()

const handleError = () => clearError({ redirect: '/' })
</script>

<template>
  <div class="error-page">
    <h2>{{ error.statusCode }}</h2>
    <p>{{ error.message || '访问的资源不存在' }}</p>
    <button @click="handleError">返回首页</button>
  </div>
</template>

修复后用 curl 专门请求一个随机的不存在路由进行验收:

curl -I https://www.example.com/posts/not-exist-999999

检查首行输出是否严格为 HTTP/2 404 或 HTTP/1.1 404 Not Found,绝不能出现 200 或 302。


坑点五:SSR HTML 接入 CDN 缓存未做爬虫隔离,登录态数据投毒快照

全站上了 Nuxt 3 服务端渲染之后,每个动态请求都需要 Node.js 进程在后台跑一段完整 Vue 代码并拼接字符串,CPU 消耗远高于传统的静态 HTML。

为了抗并发并压降 TTFB(首字节时间),我给 Nginx 加了一层缓存策略,对热门文章页缓存 30 秒:

# 隐患配置:无差别缓存动态 HTML
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=ssr_cache:20m max_size=1g inactive=10m;

location / {
    proxy_cache ssr_cache;
    proxy_cache_valid 200 30s;
    proxy_cache_key $request_uri;
    proxy_pass http://127.0.0.1:3000;
}

结果上线当天下午就出了严重事故。

某位已经登录博客的管理员在浏览器里打开了一篇新发布的文章,由于页面顶部导航栏带有“欢迎回来,管理员【老李】”以及后台管理入口的快捷链接,Node 服务端把带有这套私有信息的 HTML 渲染了出来。Nginx 的缓存机制直接把这套 HTML 缓存了 30 秒。

恰巧在这个缓存有效期内,百度蜘蛛(Baiduspider)沿着新链接过来抓取快照。Nginx 直接把缓存里包含管理员名称和后台入口的 HTML 发送给了百度。隔天一搜关键词,搜索结果快照正上方赫然写着管理员的名字。

修复方案

针对带用户态的混合应用,服务端直出必须做严格的动静分离与快照隔离:

  1. 用户信息彻底剥离出 SSR 主流流程:页面顶部导航栏的用户信息、登录状态、私信通知等私密字段,在模板中使用 <ClientOnly> 标签包裹,或者在客户端 onMounted 中通过异步接口拉取,坚决不参与服务端的初次 HTML 渲染。
  2. 在 Nginx 缓存层区分 Cookie 与请求身份:针对带有登录 Cookie(如 token、session_id)的请求,配置 proxy_cache_bypass 直接穿透缓存回源;而针对公开爬虫与普通访客,才允许走公共缓存。
# 规范的 Nginx 动静隔离缓存配置
set $no_cache 0;

# 存在登录凭证,绕过缓存
if ($http_cookie ~* "auth_token|session") {
    set $no_cache 1;
}

location / {
    proxy_cache ssr_cache;
    proxy_cache_bypass $no_cache;
    proxy_no_cache $no_cache;
    proxy_cache_valid 200 60s;
    proxy_cache_key "$scheme$request_method$host$request_uri";

    # 通知浏览器和下游代理,该内容需要根据 Cookie 区分
    add_header Vary "Cookie, Accept-Encoding";

    proxy_pass http://127.0.0.1:3000;
}

踩坑复盘后的排查准则

踩完这几波坑,全站的抓取收录在一周之后陆续重回正轨,快照恢复正常,核心词索引量也稳步反弹。

迁移到 Nuxt 3 做技术 SEO,不能仅仅满足于在浏览器里肉眼看到画面出来就完事。每次有新改动上线,我都习惯按这三道工序跑一遍验证:

  1. 禁用 JavaScript 浏览测试:在 Chrome DevTools 里关闭 JavaScript 执行,刷新页面,肉眼确认所有正文标题、段落、图片与内链依然完好展现,没有任何被骨架屏遮挡的死角。
  2. curl 模拟爬虫抓取源码:用 curl -s -A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" 直接读取后端下发的原始文本流,检查 <title>、<meta> 以及正文关键词是否首屏存在。
  3. HTTP 状态码严格校验:对正常页面、下线页面、多语言页面分别执行 curl -I,验证状态码是否各就其位,杜绝任何假性 200 软 404 与错误的内网重定向。

评论一下?

OωO
取消