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 两次:
GET https://api.storyblok.com/v2/cdn/spaces/me?token=…
GET https://api.storyblok.com/v2/cdn/stories/blog%2F<slug>?token=…&cv=…原因在于页面加载数据的方式:
<!-- 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 发挥作用
<!-- 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 篇文章的列表驱动,且包含完整富文本正文,仅仅为了渲染三张卡片。
// 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 站点
- 用 DevTools 打开一个预渲染页面,在 Network 面板按 CMS 或 API 域名过滤。静态页面上这里应当保持为空。
- 或者在控制台粘贴:
performance.getEntriesByType('resource')
.filter((entry) => entry.name.includes('api.storyblok.com')) // your CMS or API host
.map((entry) => entry.name)- 检查页面中是否存在未被
useAsyncData或useFetch包裹的顶层await。 - 查看
/<route>/_payload.json的大小:它会在每次访问时被下载和解析。 - 在 Search Console 中,“测试实际网址”后点击“查看测试页面”,可以看到 Googlebot 实际渲染的内容。
要点总结
- 在预渲染的 Nuxt 应用中,
<script setup>里的裸await会执行两次:构建时一次,浏览器中一次。 - 用
useAsyncData或useFetch包裹数据加载,让 payload 供给 hydration。 - 对于已经预渲染的数据,不要让可能在浏览器中运行的代码抛出 fatal 错误。
- 保持列表 payload 精简:卡片不需要文章正文。
作者已请求 Google 重新抓取那三篇文章,并表示会在 Search Console 给出结果后更新原文。