fetch 正常,页面却 404?Nuxt 3 + CDN 跨域下的 preload CORS 陷阱

fetch 正常,页面却 404?Nuxt 3 + CDN 跨域下的 preload CORS 陷阱

Nuxt 3 项目使用 cdnURL 跨域部署时,<link rel="preload" as="fetch"> 对 CORS 的校验比普通 fetch 更严格。缺少 crossorigin 属性导致 _payload.json 被浏览器拦截,引发 404、Hydration mismatch、i18n 加载失败等一系列连锁反应。最终通过关闭 experimental.payloadExtractioni18n.lazy 彻底修复。


一、案发:线上博客突然全站 404

昨天,我像往常一样打开 Google Search Console 去看网址数据,查看新上的博客是否索引,结果被眼前的一幕震惊了------博客详情页(/blog/*)全部索引失败--- 404 报错

更诡异的是,这个问题不是偶发,而是每个页面都是。展示结果看到的是 "Blog Post Not Found",谷歌控制台里却同时抛出了一堆看似不相干的错误:

  • CORS 策略拦截
  • Hydration mismatch
  • i18n 翻译文件加载失败
  • 动态 JS chunk 加载失败

最离谱的是,我手动在控制台执行 fetch('https://cdn.example.com/.../_payload.json')返回 200,CORS 头也完全正常。赶紧打开网站也是正常访问!

fetch 正常,谷歌却 404?这到底是怎么回事?


二、现场:五个看似无关的错误

先还原一下控制台的"案发现场":

1. CORS 策略拦截

plaintext 复制代码
Access to link element resource at
'https://cdn.example.com/.../_payload.json?...'
from origin 'https://example.com'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

2. Hydration 不匹配

plaintext 复制代码
Hydration completed but contains mismatches.

3. 博客 404

plaintext 复制代码
Blog Post Not Found

4. 动态模块加载失败

plaintext 复制代码
Uncaught (in promise) TypeError:
Failed to fetch dynamically imported module:
https://cdn.example.com/.../_nuxt/DAR7SY7M.js

5. i18n 翻译文件加载失败

plaintext 复制代码
[nuxt] Cannot load payload https://cdn.../_payload.json?... TypeError: Failed to fetch
[i18n] Failed to load locale messages: base/en.json, falling back to en
[i18n] Fallback to en also failed for: base/en.json

五个错误,五种不同的技术领域。是 CDN 炸了?还是 Nuxt 3 的 bug?或者是 CORS 配置没配好?


三、侦查:找到第一块倒下的骨牌

3.1 确认现场:跨域 CDN 架构

我们的 nuxt.config.ts 中配置了:

ts 复制代码
app: {
  cdnURL: "https://cdn.example.com/project/pages/public",
}

这意味着:

  • 所有静态资源(JS chunk、CSS、_payload.json、i18n JSON 等)从 cdn.example.com 加载
  • 主站域名是 example.com
  • 资源域和主域不同源,天然存在跨域场景

我反复确认了 CDN 的 CORS 配置:手动 fetch 某个 _payload.json,响应头里确实有 Access-Control-Allow-Origin: *

但错误信息里的关键词不是 fetch,而是:

Access to **link element resource** at ... has been blocked by CORS policy

link element resource ------ 这明确指向了 <link> 标签,而不是 XHR/fetch 请求。

原来,Nuxt 3 在 SSR 时会生成这样的标签来预加载数据:

html 复制代码
<link rel="preload" as="fetch" href=".../_payload.json">

浏览器对 <link rel="preload" as="fetch"> 的 CORS 处理机制比直接 fetch 更严格 。如果跨域的 preload <link> 标签缺少 crossorigin="anonymous" 属性,即使服务器返回了正确的 CORS 头,浏览器也会拦截该 preload 请求。

而 Nuxt 3 在某些版本中使用 app.cdnURL 时,不会自动给 payload preload 添加 crossorigin 属性

3.3 还原现场:一块骨牌引发的连锁崩溃

Nuxt 3 的 Payload Extraction 机制

  • 预渲染(prerender)时,Nuxt 会把每个页面的 useAsyncData / useFetch 数据提取到独立的 _payload.json 文件中
  • 客户端 hydration 时,Nuxt 会加载对应的 _payload.json,把 SSR 数据注入客户端状态,避免重复请求

一旦 _payload.json 加载失败,连锁反应就开始了:

graph TD A[preload 请求 _payload.json] -->|CORS 拦截| B[客户端拿不到 SSR 数据] B --> C[Nuxt 认为数据缺失] C --> D[重新执行 useAsyncData] D --> E[执行 queryCollection] E --> F[客户端无 SQLite/WASM 环境] F --> G[查询返回 null] G --> H[throw createError 404] H --> I[页面显示 Blog Post Not Found] I --> J[SSR HTML 与客户端 DOM 不一致] J --> K[Hydration mismatch]

一句话总结_payload.json 加载失败是第一块倒下的骨牌。后续的 404、hydration mismatch、i18n 失败,全是它的次生灾害。

3.4 帮凶:i18n 懒加载和动态 JS Chunk

我们的 i18n 配置开启了 lazy: true,翻译文件(en.jsoncn.json)在运行时从 CDN 动态加载。跨域 + 网络波动,让这些 JSON 请求也频频失败。

动态 JS chunk(Vite 代码分割的产物)通过 import() 加载,ES Module 动态导入对跨域 CORS 的校验同样严格,某些 edge 节点或带 query string 的请求就会失败。


四、收网:两行配置解决问题

.app/nuxt.config.ts 中做了两处改动:

diff 复制代码
export default defineNuxtConfig({
  // ... 其他配置保持不变

  experimental: {
    viewTransition: true,
    sharedPrerenderData: true,
+   payloadExtraction: false, // ✅ 禁用 payload 提取,SSR 数据直接内联到 HTML
    extraPageMetaExtractionKeys: [],
  },

  i18n: {
    // ... 其他配置保持不变
-   lazy: true,
+   lazy: false, // ✅ 禁用懒加载,翻译文件打包进 JS bundle
  },
})

五、检验:为什么这两行能解决问题?

改动一:experimental.payloadExtraction: false

Nuxt 3 默认在 prerender 模式下会提取 _payload.json。设为 false 后:

  • 不再生成 _payload.json 文件
  • 不再生成对应的 <link rel="preload"> 标签
  • SSR 获取的数据(如 queryCollection("blog") 的结果)直接序列化并嵌入到 HTML 的 <script> 标签中
  • 客户端 hydration 时,直接从 HTML 读取内联数据,无需发起任何额外网络请求

效果:

  • ❌ 不再请求 _payload.json → 消除 CORS 拦截问题
  • ❌ 不再依赖 preload link → 消除 link element resource 错误
  • ✅ SSR 数据 100% 传递到客户端 → queryCollection 不会返回 null → 消除 404
  • ✅ SSR 和客户端数据完全一致 → 消除 hydration mismatch

改动二:i18n.lazy: false

lazy: false 后:

  • 所有语言的翻译文件在构建时被打包进 JS bundle
  • 客户端不再需要发起 base/en.json 等请求

效果:

  • ❌ 不再从 CDN 加载 i18n JSON → 消除翻译文件加载失败
  • ✅ 翻译内容随 JS bundle 一起加载,不受网络/CORS 影响
  • ⚠️ 代价:JS bundle 体积会略微增大。对于我们的项目,这个代价完全可以接受。

六、效果验证

检查项 修复前 修复后
GSC 控制台 CORS 错误 ❌ 大量出现 ✅ 消失
博客详情页 404 ❌ 频繁出现 ✅ 恢复正常
Hydration mismatch ❌ 每次刷新都报 ✅ 不再出现
i18n 翻译加载失败 ❌ 每次刷新都报 ✅ 消失,多语言正常切换
动态 JS chunk 加载失败 ❌ 偶发 ✅ 不再出现
页面加载性能 - ✅ 无感知下降(内联 payload 反而减少了一次请求)

七、结案陈词:三个血泪教训

1. fetch 正常 ≠ 所有请求方式都正常

<link rel="preload" as="fetch"> 和 ES Module import() 对 CORS 的要求可能与 fetch 不同,特别是 crossorigin 属性的处理。错误信息中的每一个词都是线索,link element 明确指向 preload 标签,而非 XHR/fetch。

2. 找到第一块倒下的骨牌

Nuxt 的 _payload.json 是 hydration 数据传递的关键链路。一旦断裂,会触发一系列看似无关的错误。排查时不要被表象迷惑,要追溯最初的报错

3. cdnURL + 跨域场景需要格外小心

如果必须使用跨域 CDN,建议:

  • 确保 CDN 对所有静态资源(.js.json、带 query string 的请求)都返回 Access-Control-Allow-Origin
  • 或者通过 Nuxt 配置规避跨域请求(如禁用 payload extraction、禁用 i18n lazy loading)

最彻底的长期方案 :在 CDN(Cloudflare / OSS / Nginx)侧统一配置完整的 CORS 响应头。这样未来如果需要重新开启 payloadExtractioni18n.lazy 也不会有问题。

但是我们的架构比较复杂,改动Nginx配置比较麻烦,能在项目解决就不麻烦到中台。万一没生效还影响了主站点就不好了。

另外,Nuxt 3.11+ 修复了部分 cdnURL 下 preload crossorigin 的问题,建议关注官方更新,适时升级。


八、参考配置(修改前后对比)

修改前

ts 复制代码
export default defineNuxtConfig({
  app: {
    cdnURL: "https://cdn.example.com/project/pages/public",
  },
  experimental: {
    viewTransition: true,
    sharedPrerenderData: true,
    extraPageMetaExtractionKeys: [],
  },
  i18n: {
    lazy: true,
    // ...
  },
})

修改后

ts 复制代码
export default defineNuxtConfig({
  app: {
    cdnURL: "https://cdn.example.com/project/pages/public",
  },
  experimental: {
    viewTransition: true,
    sharedPrerenderData: true,
    payloadExtraction: false, // ✅ 新增
    extraPageMetaExtractionKeys: [],
  },
  i18n: {
    lazy: false, // ✅ 修改
    // ...
  },
})

你在 SSR 项目里遇到过类似的 Hydration 问题吗?是怎么解决的?欢迎在评论区交流 👇

如果这篇对你有帮助,点赞 + 收藏 支持一下,我会继续分享更多踩坑实录 🔧

相关推荐
橘子星1 小时前
打破串行枷锁:深入理解 JS 同步、异步与 Promise 实战
前端·javascript
用户059540174461 小时前
LangChain 记忆模块踩坑实录:靠自动化测试,我把上下文丢失率从 30% 降到 0
前端·css
如果超人不会飞1 小时前
新手避坑:使用 TinyRobot 入门阶段常见误区总结
前端·vue.js
嘟嘟07171 小时前
二叉树从入门到实战:四大遍历 + 递归思想详解
前端
渣波1 小时前
全栈开发的“影分身”之术(mock):别再手动造数据了,你的 CRUD 不配让我等!
前端·javascript
亿元程序员1 小时前
小伙伴说这个撕胶带游戏很火很解压,于是我连夜做了一个Cocos教程...
前端
如果超人不会飞1 小时前
一文读懂 TinyRobot:前端 AI 组件库定位、价值与适用场景
前端·vue.js
如果超人不会飞1 小时前
用TinyRobot Welcome组件打造贴心的AI助手欢迎页
前端·vue.js
悟空瞎说1 小时前
Compose内嵌Flutter混合开发详解:页面嵌入、引擎缓存与双向通信完整实战
前端