Nuxt 4 SSR 页面客户端跳转 500、数据全空的两个真实 bug:TDZ 与同源策略完整排查记录
环境:Nuxt 4.5 + Vue 3.5 + Nitro(SSR)+ Spring Boot 4.1 单体后端,同域名部署(Nginx 反代)
现象:SSR 首屏(curl / 整页刷新)一切正常,但浏览器里一点链接跳转就出问题 —— 首页整页变成500 - Internal Server Error;修好后又变成「页面能开但数据全是 0」
结论:这是两个互相独立的 bug 叠加。一个是我自己写出的暂时性死区(TDZ),另一个是浏览器同源策略导致的跨域请求被拦。两者都只在浏览器里才暴露,只用 curl 检查 SSR 永远发现不了
一、现象描述
1.1 表面症状
排查过程中先后出现过三种表现,很容易误判成同一个问题:
| # | 症状 | 触发方式 |
|---|---|---|
| A | 整页显示 500 Internal Server Error,页面正文为 Cannot access 'z' before initialization | 浏览器打开首页 |
| B | 页面能开、样式正常,但「共 0 篇文章」「0 个分类」「暂无文章」 | 客户端点击导航跳转 |
| C | 打开文章页显示 500 - 文章加载失败 | 客户端点击文章链接 |
关键迷惑点:同一个 URL,用「不执行 JS 的方式」请求,拿到的是 HTTP 200 + 完整正确的 HTML。
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3999/
200 # ← 服务端一切正常
而浏览器打开同一个地址,页面却渲染成 500。这一点用 CDP 同时观测「文档响应状态」与「最终页面状态」会更直观:
// 通过 CDP 读取:浏览器实际收到的文档响应 vs 最终渲染出的页面
文档响应: {"url":"http://127.0.0.1:3999/","status":200}
页面状态: {"title":"500 - Internal Server Error | Nuxt",
"h1":"500","bodyLen":66}
同一个 200 的响应,最终变成了 500 的错误页 —— 说明服务端渲染没问题,问题出在浏览器执行 JS 的阶段。这个矛盾是整条排查链的起点。
1.2 两个容易产生的误解
| 误解 | 实际情况 |
|---|---|
「报错堆栈里有 .dispose(),一定是 ECharts 图表没清理干净」 | 项目里确实有图表清理代码,但和这个错误无关;真正的报错位置在 head 管理库的源码里 |
「Nuxt 的 nitro.devProxy 会代理 API,所以浏览器请求同源地址不会跨域」 | nitro.devProxy 只在 dev 生效,生产构建产物里根本没有这个代理。这个错误假设会直接导致下面的 bug 2 |
二、Bug 1:Cannot access 'z' before initialization
2.1 定位:从构建产物反查源码
压缩后的报错只给了 z、CMMA5GON.js 这类无意义符号,光看堆栈无从下手。真正有效的一步是在构建产物里按字符位置反查:
# 报错行 CMMA5GON.js:1 TypeError: Cannot read properties of undefined (reading 'dispose')
# 先在产物目录里找到这个 chunk,再把报错列号附近的代码取出来
node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/BF0E3Y0H.js
取出的代码指向 head 管理库的 clientUseHead:
let entry
watchEffect(() => {
if (entry) entry.patch(i)
else entry = head.push(i, options) // ← entry 只在这里赋值
})
if (vm) {
onBeforeUnmount(() => {
entry.dispose() // ← watchEffect 未跑过时 entry 是 undefined
})
}
watchEffect 默认 flush: 'pre',是惰性的(下一个微任务才执行)。组件若在同一 tick 内挂载又卸载,卸载回调先跑,entry 仍是 undefined。
这个缺陷在上游最新版依然存在:我把
@unhead/vue@3.4.1拉下来逐字对比,这段代码一模一样。所以升级解决不了,只能本地打补丁(见 2.4)。
不过 —— 修这个 .dispose() 之后,首页仍然是 500,只是报错变成了 Cannot access 'z' before initialization。真正的元凶是另一个。
2.2 真因:<script setup> 里的暂时性死区
继续在产物里搜 TDZ 模式,在首页对应的 chunk 里找到了这段:
// 构建产物 index 页 chunk(变量名已被压缩)
... [{ rel: `next`, href: A(`/?page=${z.value + 1}`, R) }] });
let z = s(() => Math.max(1, Number(j.query.page) || 1)), { data: B } = ...
z(源码里的 page computed)被使用在声明之前 —— 典型的 TDZ。
回看源码,问题一目了然。我为了做 SEO,把 useHead 加在了文件上部,而它引用的变量声明在下方:
// ❌ 错误顺序
useCanonical({ query: () => (page.value > 1 ? `page=${page.value}` : '') }) // 引用 page
useHead({
link: () => [
...(page.value > 1 ? [...] : []), // 引用 page
...(total.value > ... ? [...] : []), // 引用 total
],
})
const page = computed(() => Math.max(1, Number(route.query.page) || 1)) // ← 到这才声明
const total = computed(() => data.value?.data?.total || 0)
2.3 为什么 SSR 正常、只有客户端炸
这是本 bug 最值得记住的一点:
| 环境 | useHead 取值函数的求值时机 | 结果 |
|---|---|---|
| 服务端(SSR) | 惰性 —— 等 setup 执行完毕、渲染完成才收集 head | page 已初始化,正常 |
| 客户端 | unhead 注册后立即解析一次取值函数 | page 尚在 TDZ,抛错 |
所以 curl / 整页刷新看起来完全正常,只有浏览器端 hydration 时才炸。这就是为什么只测 SSR 会漏掉这类 bug。
同一个文件里我引入的 [slug].vue 有完全相同的缺陷:
useSeoMeta({
ogImage: () => coverImage.value || undefined, // 引用 coverImage
...
})
const coverImage = computed(() => { ... }) // ← 声明在下方
2.4 修复
办法很朴素:把 head 相关调用统一移到 <script setup> 末尾,并写明原因防止后人再挪回去。
// ---------------------------------------------------------------------------
// 注意:所有 useHead / useSeoMeta / useCanonical 必须放在文件末尾。
// unhead 在客户端注册 head 后会立即解析取值函数,若这些函数引用了下方才用
// const/let 声明的变量,就会命中暂时性死区(TDZ)并抛出
// "Cannot access 'x' before initialization",整页被替换成 500 错误页。
// 把它们统一放在末尾可从根本上规避这个顺序陷阱。
// ---------------------------------------------------------------------------
useSeoMeta({ ... })
useCanonical({ query: () => (page.value > 1 ? `page=${page.value}` : '') })
也可以选择「声明全放前面」或「取值函数加
?.兜底」,但统一放末尾是唯一不会随文件增删而再次失效的写法。
顺带给上游的 .dispose() 缺陷打了补丁,用 patch-package 固化,避免重装依赖后失效:
onBeforeUnmount(() => {
- entry.dispose();
+ entry?.dispose();
});
package.json:
{
"scripts": {
"postinstall": "nuxt prepare && patch-package"
},
"devDependencies": {
"patch-package": "^8.0.1"
}
}
三、Bug 2:同源策略导致客户端请求全部被拦
3.1 更隐蔽的症状
修完 TDZ,首页不再 500 了,但新的问题浮出来:客户端一点跳转,页面数据全变 0。
=== 点击 → /archives ===
title: 文章归档 · GyHack
body : ... 共 0 篇文章,按时间整理 暂无文章 ...
更诡异的是:同一个 /archives,整页刷新(硬导航)显示「共 7 篇文章」完全正常,客户端跳转就是 0。
3.2 关键证据:抓浏览器的网络层
页面 JS 没有抛任何异常,控制台也"看起来"干净。真正的原因是 HTTP 请求在浏览器网络层就被拦了,根本没到业务代码。用 CDP 监听 Network.loadingFailed 才拿到真相:
{
"errorText": "net::ERR_FAILED",
"corsErrorStatus": { "corsError": "MissingAllowOriginHeader" },
"type": "Fetch"
}
MissingAllowOriginHeader —— 跨域请求被浏览器拦截:响应缺少 Access-Control-Allow-Origin。
3.3 根因:浏览器在请求一个「跨域」地址
页面在 http://127.0.0.1:3999,而 API 请求拼的是绝对地址 http://127.0.0.1:8080:
const base = useRuntimeConfig().public.apiBase // http://127.0.0.1:8080
await $fetch(`${base}/api/v1/web/post/...`) // ← 端口不同 = 跨域
而后端 CORS 白名单默认为空(同域名部署本来就不需要跨域):
gyhack:
cors:
allowed-origins: "" # 空 = 不注册任何跨域规则
这就造成了极具迷惑性的不对称表现:
| 访问方式 | 请求发起方 | 受同源策略约束? | 结果 |
|---|---|---|---|
| 整页刷新 / curl | Node 服务端 | 否 | ✅ 正常 |
| 客户端 SPA 跳转 | 浏览器 | 是 | ❌ 全部 ERR_FAILED |
3.4 一个被广泛误解的点:nitro.devProxy 不适用于生产
Nuxt 的 nitro.devProxy 配置看起来能解决问题:
nitro: {
devProxy: {
'/api': { target: 'http://127.0.0.1:8080/api', changeOrigin: true },
},
}
但它只在 nuxt dev 生效。 我实测过生产构建产物:
$ node .output/server/index.mjs # 生产模式
$ curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3999/api/v1/web/config
404 # ← 根本没被代理
所以不能依赖它来保证同源,否则本地 dev 正常、一上生产就全线报错。
3.5 修复:真正的同源架构
思路:服务端用后端地址直连(不受同源限制,可用内网地址),浏览器一律走同源相对路径。
① 统一的基地址解析(app/composables/useApiBase.ts)
export function useApiBase(): string {
const config = useRuntimeConfig()
if (import.meta.server) {
// SSR 直连后端:不受同源策略限制,可用内网地址
return String(config.apiBase || '').replace(/\/+$/, '')
}
// 浏览器默认同源(空串 = 相对路径),避免跨域
const browserBase = config.public.apiBaseBrowser
return browserBase ? String(browserBase).replace(/\/+$/, '') : ''
}
/** base 为空时得到同源相对路径,如 /api/v1/web/config */
export function apiUrl(path: string, base = useApiBase()): string {
return base ? `${base}${path}` : path
}
② Nitro 侧补齐同源代理(替代只在 dev 生效的 devProxy)
// server/routes/api/[...].ts
export default defineEventHandler((event) => proxyToBackend(event))
// server/utils/proxy.ts
export function proxyToBackend(event: H3Event) {
const target = String(useRuntimeConfig().apiBase || '').replace(/\/+$/, '')
// 透传真实 IP:后端限流与审计依赖它
const headers = getRequestHeaders(event)
const forwarded: Record<string, string> = {
'x-forwarded-host': String(headers.host || ''),
'x-forwarded-proto': String(headers['x-forwarded-proto'] || 'http'),
}
const realIp = headers['x-real-ip'] || (headers['x-forwarded-for'] || '').split(',')[0]?.trim()
if (realIp) forwarded['x-real-ip'] = realIp
return proxyRequest(event, `${target}${event.path}`, { headers: forwarded })
}
⚠️ 不要图省事写成全站 catch-all(
server/routes/[...].ts)。那样会连页面路由一起截获,所有页面都会变成后端响应。必须分别挂在/api与/uploads两个前缀上。
③ 生产环境 Nginx 会先接住请求,代理路由不会被命中,因此零额外开销:
location /api/ { proxy_pass http://127.0.0.1:8080; }
location ^~ /uploads/ { proxy_pass http://127.0.0.1:8080; }
④ 别忘了图片和头像。/uploads/** 同样是浏览器请求,同类问题只是 <img> 不报 CORS 错、表现为图片加载失败而已:
// 统一从 useApiBase() 取,不要直接拼 runtimeConfig.public.apiBase
const mediaBase = useApiBase()
const coverSrc = computed(() =>
props.post.coverUrl?.startsWith('/') ? `${mediaBase}${props.post.coverUrl}` : props.post.coverUrl,
)
3.6 关于最初的 ERR_QUIC_PROTOCOL_ERROR
本次排查的起点是一条图片请求报 net::ERR_QUIC_PROTOCOL_ERROR。这里需要如实说明:它是 HTTP/3(QUIC)传输层的问题,与 Bug 2 的 CORS 拦截不是同一个原因,只是同属「网络层、只在浏览器暴露」这一类。
ERR_QUIC_PROTOCOL_ERROR 的常见成因与排错(UDP 443 未放行、中间设备干扰 QUIC、CDN 协商异常)在另一篇里已有完整整理,此处不重复。本文不把未验证的推断混进结论。
四、让这两个 bug 无处遁形的排查方法
这两个 bug 有个共同点:它们只在浏览器里才暴露。curl、单元测试、vue-tsc、ESLint 全都通过 —— 我前一轮就是只用 curl 检查了 SSR 就宣布"验证通过",结果漏掉了它们。
真正有效的手段是用 CDP 驱动 headless Chrome,分别覆盖「硬导航」和「SPA 点击跳转」两条路径:
# 启动 headless Chrome
chrome --headless=new --remote-debugging-port=9222 \
--user-data-dir=/tmp/cdp-profile about:blank
# 硬导航 + SPA 点击双路径回归
node scripts/repro-spa.mjs http://127.0.0.1:3999 / /archives /about /category/3
核心是订阅这几类 CDP 事件:
| 事件 | 捕获内容 |
|---|---|
Runtime.exceptionThrown | 未捕获异常(TDZ 就是从这里抓到的) |
Runtime.consoleAPICalled (type=error) | console.error |
Network.loadingFailed | 请求失败 + corsErrorStatus(同源问题就是靠它定位的) |
Network.responseReceived | 实际 HTTP 状态码与响应体 |
还有一条经验:用 Network 域对比「浏览器拿到的文档状态码」与「curl 拿到的」。当发现 文档响应: {status: 200} 而页面标题却是 500 - Internal Server Error 时,就能立刻把故障范围从"服务端"切开到"客户端",省掉大量无效排查。
五、自查清单
遇到「SSR 正常、客户端异常」时,按顺序过一遍:
- 打开 Network 面板筛 Fetch/XHR,看客户端跳转时的请求有没有
ERR_FAILED;有就用Network.loadingFailed的corsErrorStatus确认是否跨域。 - 检查浏览器请求的 URL 是否绝对地址(含
http://或域名)。同源部署下,浏览器端应该始终是相对路径。 - 确认没有依赖
nitro.devProxy做生产环境的代理;用生产产物实测一次:curl -s -o /dev/null -w '%{http_code}' http://localhost:3999/api/v1/web/config。 - 把
useHead/useSeoMeta移到<script setup>末尾,确认它们引用的变量都已声明。 - 别只看 curl 的 SSR 结果:它测不到同源策略,也测不到客户端求值时机。
- 拿不准时,用 headless 浏览器跑一次真实点击导航。
六、经验总结
6.1 关于「SSR 正常」这个假信号
SSR 通过 ≠ 页面能跑。 服务端与浏览器端至少有四处本质差异,任何一处出问题都只在其中一端显现:
| 差异 | 只在浏览器暴露的典型问题 |
|---|---|
| 同源策略 | 跨域请求被拦(本文 Bug 2) |
| 求值时机 | head/响应式取值函数的 TDZ(本文 Bug 1) |
| hydration | DOM 不匹配、事件未绑定 |
| 客户端专有 API | window / localStorage 未做守卫 |
6.2 关于调试方法本身
- 压缩产物的符号反查很有效:拿到 chunk 文件 + 报错列号,把附近代码取出来,往往比读堆栈更快定位到真实调用点。
- 不要相信"控制台没有报错":被浏览器网络层拦掉的请求不一定产生 JS 异常,必须看 Network 事件。
- 不要相信框架配置的生效范围:
nitro.devProxy只作用于 dev,这类"名字看起来通用、实际只在某模式生效"的配置很容易踩。
6.3 关于改动的自我约束
这次两个 bug 都是我在做 SEO 优化时引入的,共同特征是新代码引入了「新增的引用关系」:
- Bug 1:新增 head 调用,引用了后声明的变量;
- Bug 2:新增
useApiBase抽象,但浏览器分支返回了跨域地址(更早的版本则是直接拼绝对地址)。
对应两条纪律:改动涉及 head/订阅类 API 时统一放末尾;任何浏览器端请求一律同源相对路径,并在新增请求时复用统一的 base 解析函数,不直接拼配置项。
七、相关文件索引
| 角色 | 路径 |
|---|---|
| 基地址解析(服务端/浏览器分流) | app/composables/useApiBase.ts |
| 统一请求封装 | app/composables/useApi.ts |
| 站点级数据预取 | app/composables/useSiteWideData.ts |
| canonical / og 生成 | app/composables/useSeo.ts |
| 同源代理(API) | server/routes/api/[...].ts |
| 同源代理(媒体) | server/routes/uploads/[...].ts |
| 代理实现 | server/utils/proxy.ts |
| 运行时配置 | nuxt.config.ts(apiBase / apiBaseBrowser / siteUrl) |
| upstream 补丁 | patches/@unhead+vue+3.2.3.patch |
| 浏览器回归脚本 | scripts/repro-spa.mjs、scripts/repro-navigate.mjs |
| 反向代理配置 | deploy/nginx-gyhack.conf |
八、结语
首页 500 与「客户端跳转后数据全空」,表面看是同一类故障,实际是两个彼此独立的 bug:
- TDZ:head 取值函数被放在变量声明之前。SSR 惰性求值掩盖了它,客户端立即求值暴露了它。
- 同源策略:浏览器请求了跨域地址。SSR 不受同源限制掩盖了它,客户端跳转暴露了它。
二者共同点是 只在浏览器里才出现,也正是「只跑 curl 就算验证通过」会漏掉的原因。修完之后端到端回归的结果:
硬导航 / → title 正常, errors: 0
点击 → /archives → 共 7 篇文章, errors: 0
点击 → /category/3 → 2 篇文章, errors: 0
点击 → / → 7 篇文章 / 4 个分类 / 11 个标签, errors: 0
点击 → 文章页 → 正文 + 目录, errors: 0
硬导航 /不存在 → 404 文章不存在或已下线 ✅
教训一句话:只用 curl 检查 SSR 会漏掉整类 bug —— 验证必须覆盖浏览器真实路径。
文档版本:2026-09-26 · 对应 Nuxt 4.5.1 + @unhead/vue 3.2.3 + Spring Boot 4.1.0 排查实践
道痕归属
本文标题Nuxt 4 SSR 客户端跳转 500 与数据全空:TDZ 与同源策略完整排查记录
发表日期2026-09-26
许可协议CC BY-NC-SA 4.0
原文链接https://www.gyhack.com/nuxt-4-ssr-客户端跳转-500-与数据全空tdz-与同源策略完整排查记录
本站文章除特别注明外均为原创,转载请注明出处并保留原文链接;禁止商用。
本篇道痕
- 字数8636 字
- 阅读时长约 22 分钟
- 分类前端
- 许可CC BY-NC-SA 4.0