Skip to content

Nuxt 预渲染页面为何仍在浏览器里请求 CMS? ​

译注:本文编译自 dev.to 作者 dibodev 的技术复盘文章,原文发布于 2026 年 9 月 26 日。作者记录了自己用 Nuxt 4 静态生成(SSG)加 Storyblok CMS 搭建的站点中,预渲染页面在浏览器端仍重复调用 CMS API 的问题,以及由此可能引发的 SEO 风险与修复方案。原文链接见文末。

问题:三篇文章被 Google 归并到同一个 canonical ​

作者在 Google Search Console 中发现,最新五篇博客里有三篇被标记为“Duplicate, Google chose different canonical than user”。更反常的是,Google 为这三篇主题完全不同的文章(B2B 软件定价、时间追踪、驾校在线预约)选择了同一个 canonical——一篇关于雷恩小企业的旧文章。

三个不同主题折叠到同一个 URL,看起来不像“内容相似”,更像是 Google 把同一个页面看了三遍。

作者的站点是 Nuxt 4 静态构建(SSG),CMS 使用 Storyblok。常规检查都正常:

  • 每个 URL 返回 200,且各自带有独立的 <title>、<h1>、canonical 和 hreflang 标签;
  • 静态 HTML 中包含完整文章内容;
  • Search Console 的“测试实际网址”能正确渲染每篇文章。

问题出在打开其中一篇预渲染文章的 Network 面板之后。

每次页面浏览触发两次 CMS 调用 ​

文章内容明明已经在 HTML 里,浏览器却在每次访问时调用 Storyblok API 两次:

http
GET https://api.storyblok.com/v2/cdn/spaces/me?token=…
GET https://api.storyblok.com/v2/cdn/stories/blog%2F<slug>?token=…&cv=…

原因在于页面加载数据的方式:

vue
<!-- pages/blog/[slug].vue (before, simplified) -->
<script setup lang="ts">
const route = useRoute()
const slug = String(route.params.slug)
const article = ref<Article | null>(null)

try {
  const response = await StoryblokArticleService.getArticleBySlug(slug)
  article.value = mapStoryblokArticle(response.story)
} catch {
  throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
}
</script>

<script setup> 中的顶层 await 在预渲染阶段确实会执行,因此生成的 HTML 是完整的。但页面 hydration 时,<script setup> 会在浏览器中再次运行,而没有任何机制告诉 Nuxt 这份数据已经存在。useAsyncData 和 useFetch 会把结果存入页面 payload,而裸 await 不会。于是每次访问都会重新拉取 story,外加服务里用来获取 Storyblok 缓存版本的 spaces/me 调用。

catch 让情况更糟:如果浏览器端请求失败(限流、网络抖动、请求被拦截),fatal 404 会把一个本来完好的预渲染文章替换成错误页。

为什么这可能影响索引 ​

Googlebot 会渲染 JavaScript:先抓取 HTML,再像浏览器一样运行应用,而且常常连续处理大量 URL。如果渲染过程中 CMS 调用失败,所有受影响的 URL 都会显示同一个错误页。多个 URL 呈现相同内容,正是被聚类为重复内容、并为整组选择一个 canonical 的典型情形。

作者也坦承,无法证明这就是本次问题的直接原因:目前实时测试渲染正常,运行同样代码的旧文章也确实被索引了。但这是一个真实存在的失败模式,修复成本很小,而且顺带能去掉每次页面浏览的两次 API 调用。

修复:让 payload 发挥作用 ​

vue
<!-- pages/blog/[slug].vue (after, simplified) -->
<script setup lang="ts">
const route = useRoute()
const { locale } = useI18n()
const slug = String(route.params.slug)

const { data: article } = await useAsyncData(
  `blog-article-${locale.value}-${slug}`,
  () => StoryblokArticleService.getLocalizedArticle(slug, locale.value),
)

if (!article.value) {
  throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
}
</script>

在 nuxt generate 期间,处理函数只运行一次,结果写入 /blog/<slug>/_payload.json。页面 hydration 时,以及客户端导航到预渲染路由时,Nuxt 会读取该 payload,而不是再次运行处理函数。服务改为返回 null 而非抛错,由于浏览器读取的是 payload,404 只会在构建时故事确实不存在的情况下触发。

两个关键细节:

  • key 在服务端和浏览器端必须完全一致,且每个页面唯一(这里是 locale + slug);
  • 作者的项目页面存在完全相同的模式,也做了同样修复。

结果是:首次加载和站内导航时,浏览器端对 Storyblok 的调用为零。

附带收获:每篇文章曾附带 217 KB JSON ​

作者顺便检查了 payload 本身:每个文章页重达 217 KB。其中“相关文章”区块由 24 篇文章的列表驱动,且包含完整富文本正文,仅仅为了渲染三张卡片。

typescript
// composables/useArticlesWithTranslations.ts
function withoutContent(article: Article): Article {
  return { ...article, content: null }
}

const articles = response.stories
  .map(mapStoryblokArticle) // reading time is computed here, from the body
  .map(withoutContent) // cards only need title, excerpt, cover, tags and date

优化后,文章 payload 从 217 KB 降到 32 KB,博客索引页从 112 KB 降到 12 KB。

如何检查自己的 Nuxt 站点 ​

  1. 用 DevTools 打开一个预渲染页面,在 Network 面板按 CMS 或 API 域名过滤。静态页面上这里应当保持为空。
  2. 或者在控制台粘贴:
javascript
performance.getEntriesByType('resource')
  .filter((entry) => entry.name.includes('api.storyblok.com')) // your CMS or API host
  .map((entry) => entry.name)
  1. 检查页面中是否存在未被 useAsyncData 或 useFetch 包裹的顶层 await。
  2. 查看 /<route>/_payload.json 的大小:它会在每次访问时被下载和解析。
  3. 在 Search Console 中,“测试实际网址”后点击“查看测试页面”,可以看到 Googlebot 实际渲染的内容。

要点总结 ​

  • 在预渲染的 Nuxt 应用中,<script setup> 里的裸 await 会执行两次:构建时一次,浏览器中一次。
  • 用 useAsyncData 或 useFetch 包裹数据加载,让 payload 供给 hydration。
  • 对于已经预渲染的数据,不要让可能在浏览器中运行的代码抛出 fatal 错误。
  • 保持列表 payload 精简:卡片不需要文章正文。

作者已请求 Google 重新抓取那三篇文章,并表示会在 Search Console 给出结果后更新原文。

原文链接 ​

https://dev.to/dibodev/my-nuxt-pages-were-prerendered-why-were-they-still-calling-the-cms-in-the-browser-3046