前提:建立一条可对照的请求
本文以本机前端 http://127.0.0.1:5173 和你自行控制的示例 API http://127.0.0.1:8080 为例,API 应提供只读 /health 路径。没有该服务时,curl 连接失败是预期结果,不代表示例可直接验证业务接口。所有命令只针对示例地址,不携带真实账户或密钥;在实际排查中替换为自己有权诊断的地址。文档核对日期为 2026-09-08。
打开开发者工具的 Network,清空列表后只触发一次动作,记录请求的最终 URL、方法、状态码、发起位置和响应类型。临时勾选保留日志有助于追踪重定向,禁用缓存可用于复现旧资源问题,结束后恢复原设置。复制请求或导出 HAR 前必须移除认证头、Cookie 和敏感正文;同事需要的是能定位问题的证据,不是你的登录状态。
源由协议、主机和端口共同确定。localhost 与 127.0.0.1 也不是同一个主机,因此切换地址可能同时改变 CORS、Cookie 和允许来源匹配。先固定一个入口,避免一边改配置一边换地址,导致每次观察的实际上都是另一条请求。
console.table({
pageOrigin: location.origin,
healthURL: new URL('/api/health', location.origin).href
})在自己页面的 Console 执行,只输出公开的页面来源和示例 URL。生产环境不要输出完整 import.meta.env。 示例未实机验证。
第一步:区分连接、HTTP 与解析失败
Network 中没有请求,先查事件有没有触发、表单是否刷新页面、代码是否提前抛错。显示连接拒绝或 DNS 错误时,浏览器还没有拿到正常 HTTP 响应,先核对服务地址和监听状态。HTTPS 页面访问 HTTP API 还可能被混合内容规则阻止,应查看控制台具体原因,不能统称为跨域。
如果响应可被脚本读取,Fetch 遇到四零四或五零零通常仍返回 Response,需要主动检查 ok。下面先检查状态和内容类型,再解析 JSON;这样“Unexpected token '<'”就能追到服务器返回了 HTML,例如代理规则没匹配而落到前端首页,而不是误判 JSON 库坏了。二零四没有正文,不应该硬做 JSON 解析,调用方应允许空结果。
下面只访问当前页面同源的只读示例路由。若报 HTTP 状态错误,转查接口路由和服务端日志;若报非 JSON,查看实际响应头与正文开头;若直接 TypeError 且响应不可读,结合 Network 和 Console 检查网络、CORS 或浏览器策略。不要在生产日志中原样打印响应正文来找错误。
async function loadHealth() {
const response = await fetch('/api/health', { credentials: 'same-origin' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
if (response.status === 204) return null
const type = response.headers.get('content-type') ?? ''
if (!type.includes('application/json')) throw new Error('健康接口未返回 JSON')
return response.json()
}
loadHealth().then(console.log).catch(error => console.error(error.message))适用于示例健康接口,约定 application/json。网络读取仍可能产生服务访问日志,成功 JSON 的字段需按接口契约再校验。 示例未实机验证。
第二步:观察预检,不用 curl 成功替代浏览器验证
跨源并不一定预检,满足简单请求条件的请求可以直接发送;设置某些请求头或使用 application/json 的跨源写请求通常会先发送 OPTIONS。浏览器在预检中声明后续方法和头名称,服务器据此返回许可。先在 Network 找 OPTIONS,看它是否被重定向到登录页、被鉴权中间件拦截,或由网关直接返回错误。
下面模拟带自定义头的只读 GET 所需预检,声明 X-Demo-Trace。成功响应的示例包含允许来源、方法和头名,但二零四本身不等于许可正确。还要核对允许来源是否与浏览器精确一致。真实 GET 的响应也需要正确的允许来源头,仅让 OPTIONS 返回成功不会让后续响应自动可读。
curl 不实现浏览器的同源访问限制。curl 读到了正文,只证明该工具从当前网络位置收到了 HTTP 数据;若未带 Origin,甚至还没触发服务端同一分支。用 curl 对照响应头,再回到原页面验证脚本能否读取响应。不要通过 no-cors 规避问题,该模式通常得到不透明响应,JavaScript 无法像普通 JSON 接口那样读取状态和正文。
curl -i --max-time 10 -X OPTIONS 'http://127.0.0.1:8080/health' -H 'Origin: http://127.0.0.1:5173' -H 'Access-Control-Request-Method: GET' -H 'Access-Control-Request-Headers: X-Demo-Trace'只发 OPTIONS,不自动继续 GET。Windows PowerShell 使用 curl.exe 替代 curl,避免旧版本别名。 示例未实机验证。
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://127.0.0.1:5173
Access-Control-Allow-Methods: GET
Access-Control-Allow-Headers: X-Demo-Trace
Vary: Origin服务端按允许名单验证来源后返回;Vary: Origin 适用于根据 Origin 动态选择允许来源的响应。 示例未实机验证。
curl -i --max-time 10 'http://127.0.0.1:8080/health' -H 'Origin: http://127.0.0.1:5173' -H 'X-Demo-Trace: handbook'不自动跟随重定向,有助于发现三零二登录跳转;需确认 health 是只读接口。Windows 使用 curl.exe。 示例未实机验证。
第三步:凭据、缓存与服务端边界
需要跨源 Cookie 时,前端 credentials=include 只是请求携带凭据的意图,仍受 Cookie 的域、路径、Secure、SameSite 以及浏览器第三方 Cookie 政策影响。服务器需允许精确来源并启用凭据许可,不能用通配符来源匹配携带凭据的读取。预检通常不带登录 Cookie,因此把 OPTIONS 强制送去登录常会阻断正常流程。
CORS 控制浏览器是否允许页面读取响应,不是接口鉴权,也不是完整的跨站请求伪造防护。某些请求可能已经到达服务端执行,只是响应被浏览器阻止读取;所以排错不要反复点击会产生订单或写数据的按钮,应使用健康检查或专门的测试路由。认证和业务授权仍由服务端独立处理。
配置在服务、网关和 CDN 多处叠加时,要检查最终响应是否出现重复的允许来源头,尤其错误响应是否绕过了统一设置。基于来源动态返回时也要让缓存区分来源,否则可能把别的来源的许可缓存过来。修正后测试允许来源和不允许来源各一次,既验证可用性,也验证限制仍按设计生效。
第四步:开发代理与生产链路分开验证
开发时让浏览器请求 /api/health,由 Vite 转到本地 API,可以建立同源前端入口。下面配置把 /api 前缀去掉,因此浏览器请求 /api/health,到后端变成 /health。若后端本来就带 /api 路由,就不要删除前缀。写完后重启开发服务,Network 中看到的仍应是前端来源地址,服务器终端则可以观察代理错误。
changeOrigin 改的是代理请求目标相关的主机头,不是给任意后端自动加上跨域许可。前端若写死绝对 API 地址,会绕过这条代理。不要误改 server.cors 期待它修复远端 API;那控制的是谁能访问 Vite 服务。开发代理配置不会变成打包后的生产反向代理,生产必须由真实网关、后端或托管平台提供相应路由。
回退代理时只撤回本轮 server.proxy 改动,恢复原公开 API 地址并重启,再用同一条健康请求比较。如果开发能通而生产不能,重点看生产域名下 /api 是否真的存在,而不是反复重装依赖。
import { defineConfig } from 'vite'
export default defineConfig({
server: {
host: '127.0.0.1',
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})这是需合并的配置片段,保留现有 Vue 插件等设置。仅用于受控本机开发 API。 示例未实机验证。
第五步:追溯变量来源与构建产物
Vite 的客户端变量默认通过 VITE_ 前缀暴露,进入浏览器的值都是公开数据。自定义值通常是字符串,字符串 false 在布尔判断中仍为真,要显式比较。声明 TypeScript 接口只影响编辑器检查,不会自动注入变量或验证真实值。示例只保存公开路由和开关,任何密钥、数据库口令和私有令牌都应留在服务端。
变量加载时,启动进程已有的环境变量优先级高;模式专用文件覆盖通用文件,同模式本地覆盖文件可继续覆盖。改 .env 后重启开发服务。build 默认使用 production 模式,--mode staging 切换模式文件,但 staging 模式不等于开发构建,MODE 与 NODE_ENV 是不同概念。构建时替换到客户端产物中的地址不会因为服务器后来改了环境变量就自动更新。
若测试站点仍请求旧地址,先在 Network 核对加载的脚本是否是这次构建,再查构建任务的 mode、注入环境和缓存。不要只修改服务器上的 .env 就宣布修好;需要重新生成对应产物并按既定部署流程替换。若系统确实使用运行时配置 JSON,则另查那份配置的加载与缓存机制,不能套用纯构建期结论。
VITE_API_BASE=/api
VITE_SHOW_DEBUG=false保存后需按 staging 模式启动或构建;确保本地覆盖文件被适当忽略,忽略不等于可以把秘密放到客户端变量。 示例未实机验证。
const rawBase = import.meta.env.VITE_API_BASE || '/api'
const base = rawBase.replace(/\/$/, '')
const showDebug = import.meta.env.VITE_SHOW_DEBUG === 'true'
const healthURL = `${base}/health`
export { healthURL, showDebug }约定 base 是公开路径或公开 API URL;此示例不自动发请求,不输出全部环境变量。 示例未实机验证。
pnpm exec vite build --mode staging
pnpm exec vite preview --host 127.0.0.1 --port 4173 --strictPort按顺序执行,第一条成功后才执行第二条;直接 vite build 不包含项目额外类型检查,需另完成正式验证脚本。 示例未实机验证。
验收与回退:同一条请求闭环
修复完成后,用原页面入口触发同一健康请求:最终 URL 正确,必要的预检通过,实际响应可读,结构符合预期,控制台没有相关错误。再检查未授权来源仍被限制、业务鉴权仍生效。生产验收还要包含刷新页面和缓存更新后的请求,防止只是旧标签页碰巧正常。
保留修改前的公开配置值、模式与产物标识。若修复引入错误,撤回具体配置补丁,恢复上一份已验证的产物;开发时重新启动服务,生产时遵循原有回滚流程。环境变量涉及敏感数据泄漏时,仅删除变量不足以使已公开的凭据失效,应由凭据管理流程处理。本文不包含真实凭据,也不在设备上执行这些服务管理操作。
完整案例:同源路径返回了首页
假设页面点击检查服务,控制台提示无法解析 JSON,Network 却显示请求成功。先固定浏览器入口和请求路径,查看 Content-Type 与响应正文开头。如果返回的是 HTML 首页,说明请求已得到一个 HTTP 响应,但没有命中期望的健康接口。构造案例中前端请求 /api/health,而开发代理只匹配 /backend,所以请求落进页面回退。不能将这种情况归咎于 CORS,因为此时脚本可以读取响应,只是内容不符合契约。
下一步同时查看浏览器请求路径与后端定义的 /health。若决定让前端统一使用 /api,开发配置就要匹配此前缀,并且仅在后端没有前缀时做移除。重启自己启动的开发服务后,仍从原按钮触发一次,预期响应类型变成 JSON 且字段符合健康契约。再看后端受控测试日志是否出现 /health,以确认重写没有多删路径。以上是示意链路,未运行任何本地服务或真实接口。
import http from 'node:http'
http.createServer((req, res) => {
const allowed = req.headers.origin === 'http://127.0.0.1:5173'
res.setHeader('Vary', 'Origin')
if (allowed) {
res.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:5173')
}
if (req.method === 'OPTIONS' && req.url === '/health') {
if (!allowed) { res.writeHead(403); res.end(); return }
res.setHeader('Access-Control-Allow-Methods', 'GET')
res.setHeader('Access-Control-Allow-Headers', 'X-Demo-Trace')
res.writeHead(204); res.end(); return
}
if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
if (req.url === '/empty') { res.writeHead(204); res.end(); return }
if (req.url === '/html') {
res.writeHead(200, {'Content-Type':'text/html'}); res.end('<p>示例页面</p>'); return
}
if (req.url !== '/health') { res.writeHead(404); res.end(); return }
res.writeHead(200, {'Content-Type':'application/json'})
res.end(JSON.stringify({ok:true}))
}).listen(8080, '127.0.0.1')保存后用 node health-demo.mjs 启动;占用本机端口,不是生产服务或鉴权实现;Ctrl+C 中断仅自己的进程属 danger。示意未实机验证。
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '127.0.0.1', port: 5173, strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: path => path.replace(/^\/api(?=\/|$)/, '')
}
}
}
})完整最小项目配置;既有项目仅合并相应 server.proxy,保留其他设置。示意未实机验证。
把接口替身限制为可理解的边界
最小复现不必依赖真实登录系统。可以让自己控制的本机替身只返回一个固定健康对象,并提供空响应或错误状态作为独立测试分支。写下每条路由的预期方法、状态、内容类型和是否有正文,使浏览器逻辑与服务逻辑有共同契约。不存在的路由应返回明确错误,不能把所有路径都伪装成成功,否则前缀配置错误又会被隐藏。
先测正常 JSON,再测没有正文的成功响应,然后测错误状态和非 JSON 正文。正常 JSON 仍需校验字段,例如 ok 应为布尔值,不能把字符串 false 当作成功。空响应要在解析前处理;错误状态应进入错误展示,不能沿用上次成功标志。这样完成的是读取链路最小复现,尚未涵盖身份、Cookie、重试或真实服务可用性。增加这些能力时逐项扩展契约,不要一次塞进整个业务接口。
跨源复现只改变来源这一项
同源替身通过后,再将浏览器来源与 API 来源分开,保持健康路由和返回正文不变。带自定义追踪头的读取会让预检成为可观察分支,先看 OPTIONS 的许可,再看实际 GET 的许可。若来源从 localhost 改为数字回环地址,允许名单必须对应新的完整源;只改端口同样是新来源。记录完整源有助于解释“昨天能用、今天不能”的入口变化。
如果预检成功而实际响应仍不可读,检查实际 GET 的头,不要反复修 OPTIONS。若携带凭据,进一步核对精确来源和凭据许可,以及浏览器是否真的附上符合策略的 Cookie。服务端允许列表的否定样本也要测,例如另一个本机端口应无法读取;但这不能证明接口拒绝非浏览器客户端访问。接口身份与操作权限要独立验证,CORS 不能成为私人写作接口的唯一保护层。
开发正确而构建错误:沿配置走到产物
假设开发环境中请求走 /api,预览构建产物时却请求旧的公开地址。先记录构建模式,再查看实际构建进程注入的公开变量,而非只看工作目录里某一个环境文件。文件中的值可能被进程环境覆盖,开发与生产模式也会选择不同文件。修改运行服务器的环境不一定会改掉已经编译入脚本的字符串,所以应追到产物及浏览器实际加载的脚本。
为验证配置,可以在本机示例页面仅显示最终公开 API 路径和模式名,不展示全部环境对象。构建成功后检查该值,并通过 Network 确认实际请求一致;文字显示正确而请求仍错误,说明代码可能还有另一处硬编码。macOS 与 GNU/Linux shell 的环境赋值方式应按当前终端使用,Windows 语法另行处理;DSM 托管静态文件时不会自动重新运行 Vite 变量替换。不要用服务器上存在同名文件来推断浏览器已收到新配置。
关闭排障现场的验收记录
最终记录应包含原始症状、最早断点、唯一有效改动和正反样本结果。比如本案例可以填“同源路径落入首页回退,修正代理匹配后应返回约定 JSON”,未运行时结果仍填待测。正式部署需要独立确认生产路由,因为开发代理只在开发服务里工作。若线上路径没有对应后端,应该补齐既定生产路由或采用正确公开地址,不能期待打包把开发代理一同带上。
结束时恢复开发者工具临时禁用缓存与保留日志设置,停止自己的测试服务,并检查示例替身没有被误纳入发布目标。恢复旧配置要同时处理源码、环境模式与对应产物,避免混搭。若出现访问保护变化,先恢复上一份已验证的规则与产物,再继续调查;不要关闭证书校验或放开整个来源列表来维持表面成功。浏览器、托管和真实设备均未在本文中实测,读者应按这套判据记录自己的结果。
| 页面入口 | 请求 | 预期 | 实际 |
| --- | --- | --- | --- |
| 127.0.0.1:5173 | /api/health | 200 JSON {ok:true} | 待测 |
| 127.0.0.1:5173 | /api/empty | 204 无正文 | 待测 |
| 127.0.0.1:5173 | /api/html | 200 HTML,解析器报类型错误 | 待测 |
| 127.0.0.1:5173 | /api/missing | 404 | 待测 |
| 127.0.0.1:5173 | 跨源 :8080/health + X-Demo-Trace | OPTIONS许可,GET可读 | 待测 |
| 127.0.0.1:5174 | 跨源 :8080/health + X-Demo-Trace | 预检403,页面不可读 | 待测 |
记录:构建mode、最终URL、首跳状态、Content-Type、响应是否可读。
跨源请求可在对应页面控制台执行:
fetch('http://127.0.0.1:8080/health', {headers:{'X-Demo-Trace':'demo'}})
.then(r => r.json()).then(console.log).catch(e => console.error(e.message))
该请求为 read;健康替身没有业务身份认证。示意模板,未实机验证;复制到自己的受控笔记后填写待测项,不会自动修改线上配置。