前端Nuxt

Nuxt 4 SSR 客户端跳转 500 与数据全空:TDZ 与同源策略完整排查记录

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 执行完毕、渲染完成才收集 headpage 已初始化,正常
客户端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: ""    # 空 = 不注册任何跨域规则

这就造成了极具迷惑性的不对称表现:

访问方式请求发起方受同源策略约束?结果
整页刷新 / curlNode 服务端否✅ 正常
客户端 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 正常、客户端异常」时,按顺序过一遍:

  1. 打开 Network 面板筛 Fetch/XHR,看客户端跳转时的请求有没有 ERR_FAILED;有就用 Network.loadingFailed 的 corsErrorStatus 确认是否跨域。
  2. 检查浏览器请求的 URL 是否绝对地址(含 http:// 或域名)。同源部署下,浏览器端应该始终是相对路径。
  3. 确认没有依赖 nitro.devProxy 做生产环境的代理;用生产产物实测一次:curl -s -o /dev/null -w '%{http_code}' http://localhost:3999/api/v1/web/config。
  4. 把 useHead / useSeoMeta 移到 <script setup> 末尾,确认它们引用的变量都已声明。
  5. 别只看 curl 的 SSR 结果:它测不到同源策略,也测不到客户端求值时机。
  6. 拿不准时,用 headless 浏览器跑一次真实点击导航。

六、经验总结

6.1 关于「SSR 正常」这个假信号

SSR 通过 ≠ 页面能跑。 服务端与浏览器端至少有四处本质差异,任何一处出问题都只在其中一端显现:

差异只在浏览器暴露的典型问题
同源策略跨域请求被拦(本文 Bug 2)
求值时机head/响应式取值函数的 TDZ(本文 Bug 1)
hydrationDOM 不匹配、事件未绑定
客户端专有 APIwindow / 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:

  1. TDZ:head 取值函数被放在变量声明之前。SSR 惰性求值掩盖了它,客户端立即求值暴露了它。
  2. 同源策略:浏览器请求了跨域地址。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 排查实践