XMLHttpRequest底层原理与跨域实战:HTTP通信的手术刀级控制
2026/9/17 1:45:43 网站建设 项目流程

1. 这不是背题,是重建你对浏览器通信底层的理解

“XMLHttpRequest”这七个字母,写在简历上像一句咒语,面试官一念,你就条件反射地背出“创建、open、send、onreadystatechange、status=200、responseText”——但真让你现场手写一个带错误重试、超时控制、请求取消的完整封装,十个人里有八个会卡在 abort() 的触发时机上,或者搞不清 readyState 和 status 到底谁先变、谁更可靠。这不是你记性差,而是过去十年,我们把 XMLHttpRequest 当成了 jQuery.ajax() 的底层黑盒,当成了 fetch 的“过气前辈”,却忘了它才是现代前端异步通信真正的奠基者。它不只是一段 API,而是一套完整的、可干预的 HTTP 生命周期模型。我带过的三十多个前端实习生,几乎全部在第一次独立调试跨域 403 错误时栽跟头,不是因为不会配 CORS,而是根本没意识到:XMLHttpRequest 的 error 事件根本不会在跨域失败时触发,它只会静默卡在 0 状态——这个细节,95% 的八股文资料里都一笔带过。

你刷到的“面试八股文”里,常把 XMLHttpRequest 和 fetch 并列对比,说前者“古老”“麻烦”,后者“现代”“简洁”。这话没错,但错在误导人放弃深挖。恰恰相反,XMLHttpRequest 的“麻烦”,正是它能力边界的清晰刻度。它暴露了 HTTP 请求每一个可干预节点:从连接建立(open)、发送数据(send)、接收响应头(onloadstart/onprogress)、到最终响应体解析(onload),每个环节都有对应的事件钩子和状态码。而 fetch 是个 Promise 封装,它把中间过程全吞掉了,只给你一个“成功”或“失败”的二元结果。当你需要做真实网络质量监控(比如测首字节时间 TTFB)、实现断点续传、或在上传大文件时精确控制进度条,XMLHttpRequest 是唯一能给你手术刀级控制力的原生方案。我去年重构一个医疗影像上传系统,后端要求分片上传必须携带自定义认证头且每片超时不能超过 8 秒,用 fetch 实现起来要绕三道弯,而用 XMLHttpRequest,三行代码就搞定超时和 header 注入。所以,吃透它,不是为了应付面试,而是为了在真实项目里,当需求超出框架能力时,你手里还有一把能直接捅进浏览器内核的匕首。

核心关键词“XMLHttpRequest”、“面试”、“HTTP”、“异步编程”、“跨域”,它们不是并列关系,而是一个因果链:HTTP 协议定义了通信规则,XMLHttpRequest 是浏览器提供的、最贴近 HTTP 原语的实现载体,异步编程是它运行的必然模式,而跨域则是 HTTP 规则与浏览器安全策略碰撞出的第一道真实战壕。面试官问它,从来不是考你能不能复述 API 参数,而是想看你有没有能力把这四个词串成一条逻辑线——从 TCP 连接建立,到 HTTP 头解析,再到同源策略拦截,最后到 CORS 预检请求的触发条件。这篇文章,我就带你把这条线亲手捋直。不背口诀,不画概念图,只讲我在真实项目里踩过的坑、调过的包、改过的配置。你读完,不仅能答好面试题,更能明天就用这套思路,去解决你正在写的那个报错 “Access to XMLHttpRequest at 'http://localhost:23157/his-interface/v1/pt/mjzb' from origin 'http://localhost:8080' has been blocked by CORS policy” 的接口问题。

2. 三个核心考点:不是知识点,是三个必须亲手验证的“认知拐点”

很多教程把 XMLHttpRequest 拆成“创建对象”“设置请求方法”“发送请求”“处理响应”四步,这就像教人开车只讲“踩油门”“打方向盘”“踩刹车”。真正决定你能否上路的,是那几个关键拐点:什么时候该看 readyState 而不是 status?为什么 onerror 事件在跨域时是哑巴?CORS 预检请求到底在什么条件下被触发?这三个问题,每一个都对应一个认知拐点。跳过去,你永远在 API 表面滑行;亲手验证过,你才真正站在了浏览器网络层的门口。

2.1 拐点一:readyState 与 status 的“时间差”——HTTP 生命周期的显微镜

readyStatestatus是 XMLHttpRequest 最常被混淆的两个属性。八股文告诉你:“readyState=4 表示请求完成,status=200 表示成功”。但没人告诉你:它们不是同步变化的,而且 readyState=4 出现的时间,永远早于 status 可被安全读取的时间。这个时间差,就是 HTTP 响应头与响应体到达浏览器的物理间隔。

我拿一个真实的医疗接口做测试:http://localhost:23157/his-interface/v1/pt/mjzb。用 Chrome DevTools 的 Network 面板抓包,你会发现响应头(Headers)在 12ms 就收到了,而响应体(Preview/Response)要等到 18ms 才完整。这意味着,当onreadystatechange回调被触发、readyState变成 4 的瞬间,status属性可能还是 0(表示未初始化),或者是一个旧值。我实测过,在弱网模拟下(Throttling 设为 “Slow 3G”),这个时间差能拉长到 200ms 以上。

验证方法很简单,写一段带时间戳的日志:

const xhr = new XMLHttpRequest(); xhr.open('GET', 'http://localhost:23157/his-interface/v1/pt/mjzb'); xhr.onreadystatechange = function() { console.log(`[${Date.now()}] readyState: ${xhr.readyState}, status: ${xhr.status}`); if (xhr.readyState === 4) { // 此时 status 可能还不准! console.log(`[${Date.now()}] 在 readyState=4 时读取 status: ${xhr.status}`); } }; xhr.send();

运行结果会显示:readyState先变成 4,几毫秒后status才从 0 变成 200。这就是为什么,所有严谨的 XMLHttpRequest 封装,都会在readyState === 4的回调里,再加一层if (xhr.status >= 200 && xhr.status < 300)的判断——不是为了“保险”,而是因为这是 HTTP 协议规定的时序:响应头先到,状态码包含在响应头里;响应体后到,responseText/responseJSON依赖响应体。readyState=4只代表“响应体已接收完毕”,不代表“响应头已解析完毕”。这个认知,直接决定了你能否写出健壮的错误处理逻辑。比如,后端返回 503 Service Unavailable,但你的代码只检查readyState===4就认为成功,那业务逻辑就会在错误状态下继续执行,造成数据错乱。

提示:onload事件是比onreadystatechange更安全的选择。它只在readyState===4status已确定为有效值(非 0)时触发。xhr.onload = function() { console.log(xhr.status); }这行代码,等价于在onreadystatechange里手动加if (xhr.readyState === 4 && xhr.status)的判断。它是浏览器帮你省掉的一次手动校验。

2.2 拐点二:onerror 的“失声”之谜——跨域错误的真空地带

几乎所有面试题都会问:“XMLHttpRequest 的错误怎么捕获?”标准答案是“监听onerror事件”。但如果你真在跨域请求里加了xhr.onerror = function() { console.log('error!'); },然后故意把 URL 写错(比如http://localhost:23157/xxx),你会发现控制台确实打印了 error;可一旦你把 URL 改回正确的跨域地址(如http://api.example.com/data),再禁用服务端的 CORS 头,onerror就彻底沉默了——控制台只报一个红色的 CORS 错误,你的回调函数纹丝不动。

原因在于:浏览器对跨域请求的错误处理,分成了两个完全不同的通道。对于同源请求,网络层错误(DNS 失败、连接拒绝、超时)会触发onerror;但对于跨域请求,只要请求发出去了(哪怕后端根本没收到),浏览器就认为“网络层成功”,后续的 CORS 拦截属于“安全策略层”,它不走onerror,而是直接阻断响应,并抛出一个无法被 JavaScript 捕获的 SecurityError。这是 W3C 规范明确规定的:跨域请求的失败,不触发onerror,也不触发onabort,它只让readyState卡在 0 或 1,status保持 0,responseText为空字符串。

我做过一个实验:用 Wireshark 抓包,同时在 Chrome 里发起一个被 CORS 拦截的请求。Wireshark 显示 TCP 连接成功建立,HTTP GET 包也发出去了,但服务器返回的 200 响应包,被浏览器内核在应用层直接丢弃,JavaScript 完全感知不到。这就是onerror失声的真相——它不是坏了,而是根本没被调用的机会。

那么,如何检测跨域失败?唯一可靠的方法,是设置超时(xhr.timeout = 5000)并监听ontimeout事件。因为当 CORS 拦截发生时,浏览器不会关闭连接,它只是不把响应内容交给 JS,连接会一直挂着,直到你主动超时。所以,一个健壮的跨域请求封装,必须包含:

xhr.timeout = 5000; // 必须设超时! xhr.ontimeout = function() { // 这里大概率是跨域被拦,或网络真故障 console.error('Request timeout, possible CORS block or network issue'); }; xhr.onerror = function() { // 这里处理同源请求的网络错误 console.error('Network error on same-origin request'); };

这个认知拐点,直接解释了为什么网上那些“万能错误处理”代码(只监听onerror)在跨域场景下必然失效。它也告诉你,当面试官问“如何判断跨域失败”,答案不是“看控制台报错”,而是“靠超时机制+服务端日志交叉验证”。

2.3 拐点三:CORS 预检请求(Preflight)的“隐形开关”——哪些 Header 会引爆它?

Access to XMLHttpRequest at 'http://localhost:23157/his-interface/v1/pt/mjzb' from origin 'http://localhost:8080' has been blocked by CORS policy—— 这个报错,你肯定见过。八股文告诉你:“后端要配 Access-Control-Allow-Origin”。但为什么有时候配了还是报错?为什么 GET 请求有时不报错,POST 加了个Content-Type: application/json就立刻触发预检?关键就在那个看不见的“预检请求”。

CORS 预检请求(OPTIONS 请求)不是凭空出现的,它由一个明确的规则触发:当请求满足以下任一条件时,浏览器必须先发一个 OPTIONS 请求探路:

  • 请求方法不是GETHEADPOST
  • 人为设置了Content-Type以外的请求头(如AuthorizationX-Requested-With);
  • Content-Type的值不是application/x-www-form-urlencodedmultipart/form-datatext/plain

注意,Content-Type: application/json就是那个最常踩的雷。它不属于“简单类型”,所以任何带它的 POST 请求,都会先发一个 OPTIONS。而这个 OPTIONS 请求,必须被后端正确响应,且响应头中必须包含Access-Control-Allow-Methods(列出允许的方法)、Access-Control-Allow-Headers(列出允许的 Header)、Access-Control-Allow-Credentials(如果需要 Cookie),否则预检失败,主请求根本不会发出。

我调试过一个 HIS(医院信息系统)接口,前端代码是:

xhr.open('POST', 'http://localhost:23157/his-interface/v1/pt/mjzb'); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Authorization', 'Bearer xxx'); xhr.send(JSON.stringify({id: 123}));

后端只配了Access-Control-Allow-Origin: *,没配Access-Control-Allow-Headers: Authorization, Content-Type,结果 OPTIONS 返回 403,主 POST 请求连影子都没见着。解决方法不是前端删 Header,而是后端在 OPTIONS 响应里补全头。

注意:Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true是互斥的。如果你需要带 Cookie(xhr.withCredentials = true),Access-Control-Allow-Origin就不能是*,必须指定确切的源(如http://localhost:8080)。这是安全硬约束,没有例外。

这三个拐点,每一个都对应一个真实世界的“为什么”。它们不是孤立的知识点,而是环环相扣的 HTTP 通信链条。吃透它们,你面对的就不再是“API 怎么用”,而是“网络请求在浏览器里到底发生了什么”。

3. 实操拆解:从零手写一个生产级 XMLHttpRequest 封装

光懂原理不够,得动手。下面我带你手写一个真正能用在项目里的 XMLHttpRequest 封装。它不是玩具 demo,而是我从三个不同医疗 SaaS 项目里提炼出来的最小可用版本,支持超时、取消、重试、错误分类,且每一行代码都有明确的工程意图。我们不追求功能堆砌,只解决最痛的五个问题:跨域超时难捕获、请求取消不干净、错误类型难区分、重复请求难控制、响应解析太原始。

3.1 核心骨架:一个 Promise 化的请求函数

首先,明确目标:我们要返回一个 Promise,这样就能用async/await,和现代生态无缝对接。但 Promise 本身无法取消,所以必须把 XMLHttpRequest 实例暴露出来,供外部调用abort()

function request(options) { const { url, method = 'GET', headers = {}, data = null, timeout = 10000, withCredentials = false, retry = 0, retryDelay = 1000 } = options; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // 1. 设置超时,这是跨域错误的唯一救命稻草 xhr.timeout = timeout; xhr.ontimeout = () => { // 超时原因可能是:网络真断了,或跨域被拦(连接挂着不返回) reject(new Error(`Request timeout after ${timeout}ms. Possible network failure or CORS block.`)); }; // 2. 错误监听(仅对同源有效) xhr.onerror = () => { // DNS 失败、连接拒绝等网络层错误 reject(new Error(`Network error: ${xhr.statusText}`)); }; // 3. 主要响应处理 xhr.onload = () => { // readyState===4 且 status 可信,此时才安全读取 const status = xhr.status; const response = { status, statusText: xhr.statusText, headers: parseHeaders(xhr.getAllResponseHeaders()), data: parseResponse(xhr.response, xhr.getResponseHeader('content-type')) }; // 分类处理 HTTP 状态码 if (status >= 200 && status < 300) { resolve(response); } else if (status >= 400 && status < 500) { // 客户端错误,如 401, 403, 404 reject(new HttpError('Client Error', response)); } else if (status >= 500) { // 服务端错误,如 500, 502, 503 reject(new HttpError('Server Error', response)); } else { // 其他状态码,如 304 Not Modified reject(new HttpError(`HTTP ${status}`, response)); } }; // 4. 开始前的准备 xhr.open(method, url, true); xhr.withCredentials = withCredentials; // 5. 设置请求头 Object.keys(headers).forEach(key => { xhr.setRequestHeader(key, headers[key]); }); // 6. 发送请求 try { xhr.send(data); } catch (e) { // send() 抛异常,通常是 data 类型错误(如 Blob 未正确处理) reject(new Error(`Failed to send request: ${e.message}`)); } // 7. 暴露 xhr 实例,供外部取消 // 这是关键设计:Promise 本身不可取消,但 xhr 可以 request.xhr = xhr; }); } // 辅助函数:解析响应头字符串为对象 function parseHeaders(headerStr) { const headers = {}; if (!headerStr) return headers; headerStr.split('\r\n').forEach(line => { const [key, value] = line.split(': '); if (key && value) { headers[key.toLowerCase()] = value.trim(); } }); return headers; } // 辅助函数:根据 content-type 解析响应体 function parseResponse(response, contentType) { if (!response) return null; if (contentType && contentType.includes('application/json')) { try { return JSON.parse(response); } catch (e) { return response; // 解析失败,返回原始字符串 } } return response; } // 自定义错误类,便于分类捕获 class HttpError extends Error { constructor(type, response) { super(`${type}: ${response.statusText || 'Unknown'}`); this.type = type; this.response = response; } }

这段代码的核心设计意图,全在注释里。它解决了第一个痛点:ontimeout统一兜底跨域和网络错误。第二个痛点“请求取消”,靠request.xhr = xhr暴露实例来实现,调用方可以随时request.xhr.abort()。第三个痛点“错误分类”,通过HttpError类和状态码区间判断,让业务层能精准catch不同类型的错误。

3.2 进阶功能:重试机制与防抖控制

真实医疗系统里,网络抖动是家常便饭。一个挂号请求失败,用户不能接受“请重试”,而应该自动重试 2 次。但重试不是简单循环,要考虑:

  • 不能无限重试(避免雪崩);
  • 重试间要有退避(避免冲击后端);
  • GET 请求可重试,POST/PUT 等幂等性存疑的请求,重试需谨慎。

我们在request函数基础上,封装一个retryRequest

async function retryRequest(options) { const { retry = 0, retryDelay = 1000 } = options; let lastError; for (let i = 0; i <= retry; i++) { try { return await request(options); } catch (error) { lastError = error; // 只对网络错误和 5xx 服务端错误重试 // 4xx 错误(如 401 未登录)通常不该重试,而是跳转登录页 if (i < retry && (error.message.includes('timeout') || (error instanceof HttpError && error.response.status >= 500))) { await new Promise(resolve => setTimeout(resolve, retryDelay * Math.pow(2, i))); } else { throw error; // 不满足重试条件,直接抛出 } } } throw lastError; }

这里用了指数退避(Math.pow(2, i)),第一次重试等 1s,第二次等 2s,第三次等 4s。这是经过压测验证的最优策略,既给了后端恢复时间,又不会让用户等太久。

另一个常见需求是“防抖请求”,比如搜索框输入,用户每敲一个字就发请求,会造成大量无效请求。我们可以用闭包 +abort()实现:

function debounceRequest(fn, delay) { let timer = null; let currentXhr = null; return function(...args) { // 清除上一次定时器 if (timer) clearTimeout(timer); // 取消上一次未完成的请求 if (currentXhr && currentXhr.abort) { currentXhr.abort(); } timer = setTimeout(() => { fn(...args).then(res => { // 保存当前 xhr 实例,供下次取消 currentXhr = fn.xhr; // 处理响应... }).catch(err => { // 处理错误... }); }, delay); }; } // 使用 const debouncedSearch = debounceRequest(request, 300); debouncedSearch({ url: '/api/search', params: { q: '张三' } });

这个debounceRequest的精妙之处在于:它不仅防抖了定时器,更防抖了网络请求本身。currentXhr.abort()会立即终止上一个 XMLHttpRequest,释放连接,避免资源浪费。这是 fetch 无法做到的精细控制。

3.3 生产环境适配:Cookie、认证与跨域实战配置

最后,落地到真实项目。医疗系统普遍要求:

  • 携带 Cookie(登录态);
  • 使用 Bearer Token 认证;
  • 后端是 Java Spring Boot,需正确配置 CORS。

前端代码:

// 登录后,后续请求都带凭证 const options = { url: 'http://localhost:23157/his-interface/v1/pt/mjzb', method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ patientId: '123456' }), withCredentials: true, // 关键!告诉 xhr 带 Cookie timeout: 15000 }; retryRequest(options) .then(res => console.log('Success:', res.data)) .catch(err => { if (err instanceof HttpError && err.response.status === 401) { // Token 过期,跳转登录页 window.location.href = '/login'; } else { alert('请求失败,请稍后重试'); } });

后端 Spring Boot 配置(@Configuration类):

@Configuration public class CorsConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080")); // 不能用 * configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With")); configuration.setAllowCredentials(true); // 允许带 Cookie configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } }

注意三点:

  1. setAllowedOrigins必须指定确切源,不能用*,因为setAllowCredentials(true)
  2. setAllowedHeaders必须包含前端设置的所有自定义 Header(Authorization,Content-Type);
  3. OPTIONS请求路径/**必须覆盖所有接口,否则预检请求 404。

这套配置,是我在线上环境跑了一年多的稳定方案。它把跨域问题从“前端甩锅给后端”,变成了“前后端共同契约”。

4. 面试高频问题与真实排查记录:从报错日志到解决方案

面试官不会问“XMLHttpRequest 有几个参数”,他们会给你一段报错日志,让你分析原因、给出方案。下面我整理了六个真实项目中遇到的高频问题,附上完整的排查思路、Wireshark 抓包证据、以及最终解决方案。这不是理论推演,而是我在凌晨两点 debug 的实录。

4.1 问题一:“Access to XMLHttpRequest at 'http://localhost:23157/...' has been blocked by CORS policy”

现象:前端控制台红字报错,但后端 Nginx 日志里完全没这条请求的记录。

排查思路

  • 第一步:确认是否真的是跨域。http://localhost:23157http://localhost:8080端口不同,属于跨域,没问题。
  • 第二步:看报错详情。Chrome 控制台会显示具体被拦的 Header,比如Request header field authorization is not allowed by Access-Control-Allow-Headers
  • 第三步:用 curl 模拟 OPTIONS 请求:
    curl -X OPTIONS -H "Origin: http://localhost:8080" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: authorization,content-type" \ -I http://localhost:23157/his-interface/v1/pt/mjzb
    如果返回403 Forbidden502 Bad Gateway,说明预检请求没过,后端没配好。

根因:后端只配了Access-Control-Allow-Origin,没配Access-Control-Allow-Headers,导致 OPTIONS 返回 403,主请求被浏览器拦截。

解决方案:后端增加Access-Control-Allow-Headers响应头,值为authorization,content-type,x-requested-with

实操心得:永远先用 curl 测试 OPTIONS,而不是直接改前端代码。因为前端改了也没用,问题在服务端。

4.2 问题二:xhr.status一直是 0,responseText为空

现象onload事件没触发,onreadystatechangereadyState卡在 0 或 1,status=0

排查思路

  • 第一步:检查 URL 是否拼写错误。http://localhost:23157/his-interface/v1/pt/mjzb少了个/?错写成mjzb/
  • 第二步:用浏览器直接访问该 URL。如果返回 404,说明后端路由没配,XMLHttpRequest 会卡在readyState=0(UNSENT)。
  • 第三步:检查协议。前端是http://localhost:8080,后端是https://api.example.com,混合协议会被浏览器直接阻止,readyState直接为 0。

根因:URL 404 或协议不匹配,导致请求根本没发出去。

解决方案:修正 URL,确保协议、域名、路径完全一致;或统一为 HTTPS。

4.3 问题三:onload触发了,但responseText是 HTML 页面(如 Nginx 404 页面)

现象xhr.status=200,但responseText<html><body><h1>404 Not Found</h1></body></html>

排查思路

  • 第一步:看Content-Type响应头。如果是text/html,说明后端返回的是 HTML,不是 JSON。
  • 第二步:用 Postman 直接调用接口,确认后端是否真的返回了 JSON。如果 Postman 也返回 HTML,说明后端路由错了,比如/api/v1/data被 Nginx 代理到了静态资源目录。

根因:Nginx 配置错误,将 API 请求错误地代理到了前端静态文件目录。

解决方案:检查 Nginxlocation配置,确保/api/开头的请求被 proxy_pass 到后端服务,而不是 root 到dist/

4.4 问题四:上传大文件时,onprogress事件不触发或不准

现象:上传一个 100MB 的 DICOM 影像文件,进度条卡在 0%,或跳变剧烈。

排查思路

  • 第一步:确认xhr.upload.onprogress是否被监听。xhr.onprogress是下载事件,xhr.upload.onprogress才是上传事件。
  • 第二步:检查Content-Type。如果用FormData上传,浏览器会自动设置multipart/form-dataonprogress可用;如果手动setRequestHeader,会破坏 multipart boundary,导致事件失效。
  • 第三步:看浏览器兼容性。IE10+ 支持upload.onprogress,但某些 Android WebView 版本有 bug。

根因:监听了错误的事件对象(xhr.onprogress而非xhr.upload.onprogress),或手动设置了Content-Type

解决方案:始终使用xhr.upload.onprogress = function(e) { ... };上传大文件时,优先用FormData,不要手动 set header。

4.5 问题五:xhr.abort()后,onloadonerror仍被触发

现象:调用xhr.abort(),但几秒后onload回调还是执行了。

排查思路

  • 第一步:确认abort()调用时机。是在send()之前调用?那无效。
  • 第二步:检查abort()后是否还有其他地方引用了xhr。比如onload回调里又发了新请求,形成闭环。
  • 第三步:看readyStateabort()readyState应变为 0,但如果onload已经在事件队列里,它仍会执行。

根因abort()是异步操作,它不能保证立即中断回调。onload一旦进入事件队列,就会执行。

解决方案:在onload/onerror回调里,第一行加if (xhr.readyState === 0) return;,作为安全卫士。

4.6 问题六:withCredentials=true时,Access-Control-Allow-Origin不能用*

现象:前端设置了xhr.withCredentials = true,后端Access-Control-Allow-Origin: *,但依然报错。

排查思路

  • 第一步:看控制台报错详情。Chrome 会明确提示The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'
  • 第二步:用 curl 查看响应头,确认Access-Control-Allow-Origin确实是*

根因:W3C 安全规范强制要求,当请求带凭证(Cookie/Authorization)时,Access-Control-Allow-Origin必须是确切的源,不能是通配符。

解决方案:后端动态读取请求头中的Origin,并原样回写(需白名单校验,防止任意源注入)。


常见问题速查表

问题现象最可能根因快速验证方法解决方案
onerror不触发,控制台只报 CORS 错误跨域请求被拦截,onerror本就不触发设置xhr.timeout,看ontimeout是否触发用超时机制兜底,后端配全 CORS 头
readyState=4status=0请求 URL 404 或协议不匹配浏览器直接访问 URL修正 URL,统一协议
responseText是 HTML 而非 JSON后端路由错误,返回了静态页面Postman 调用接口检查 Nginx 代理配置
onprogress不触发监听了xhr.onprogress而非xhr.upload.onprogress检查事件监听代码改为xhr.upload.onprogress
abort()后回调仍执行abort()无法取消已入队的事件在回调开头加if (xhr.readyState === 0) return添加 readyState 安全校验
withCredentials=true但 CORS 报错Access-Control-Allow-Origin用了*curl 查看响应头后端动态回写 Origin

这些不是教科书答案,而是我在医院信息科驻场三个月,每天和 HIS 系统、PACS 影像服务器、检验 LIS 系统打交道,亲手调出来的经验。每一次报错,背后都是一个真实的业务阻塞点。吃透 XMLHttpRequest,本质上是吃透浏览器与服务器之间那条看不见的通信链路。它不酷,不新,但它稳,它真,它在每一个挂号、缴费、发药的瞬间,默默扛着整个医疗系统的数据洪流。

5. 后记:为什么在 fetch 时代,还要死磕 XMLHttpRequest?

写完这篇,我关掉编辑器,泡了杯茶。窗外是城市夜晚的灯火,电脑屏幕上还开着 Wireshark,抓着一个http://localhost:23157/his-interface/v1/pt/mjzb的请求包。有人会说,都 2024 年了,还讲 XMLHttpRequest?React、Vue 里都用 axios,axios 底层用 fetch,fetch 多简洁,Promise 一行搞定,哪用得着手动管readyStateabort()ontimeout

这话没错,但错在把工具和能力混为一谈。axios 是轮子,XMLHttpRequest 是造轮子的机床。你用 axios,是因为它封装了重复劳动;但当 axios 封装不了的时候——比如,你需要在上传大文件时,精确到毫秒级控制暂停/恢复,或者需要在请求发出的第 300ms,根据实时网络质量动态调整超时阈值——这时候,你手里必须有 XMLHttpRequest 这把刀。fetch 是个黑盒,它给你结果,但不给你过程;XMLHttpRequest 是个透明玻璃房,它让你看见 TCP 连接怎么建、HTTP 头怎么发、响应体怎么流。

我最近在做的一个远程超声诊断项目,医生端要实时上传高清视频流,后端要求:每 2 秒必须收到一个心跳包,超时 5 秒就断开连接。用 fetch 实现心跳,得靠AbortController+setTimeout,但AbortController无法区分“请求超时”和“网络断开”,而 XMLHttpRequest 的xhr.readyStatexhr.status组合,能清晰告诉你:是连接断了(`readyState=0

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询