整理 Ajax 的工作原理和跨域的几种方案。浏览器的同源策略是安全底线——所有合法的跨域方案,最终都需要服务端的配合。

XMLHttpRequest

fetchaxios 是现在的首选,但 XHR 是这一切的起点,了解底层原理有助于理解网络请求的全貌。

// GET
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data', true); // true=异步, false=同步(不推荐,会冻结页面)
xhr.onreadystatechange = function () {
  if (xhr.readyState === 4) {
    if (xhr.status === 200) {
      console.log(JSON.parse(xhr.responseText));
    } else if (xhr.status === 404) {
      console.error('404 Not Found');
    } else {
      console.error('状态码:' + xhr.status);
    }
  }
};
xhr.send(null);

readyState 五个阶段

常量说明
0UNSENT已创建,尚未调用 open()
1OPENED已调用 open(),尚未 send()
2HEADERS_RECEIVED已收到响应头
3LOADING正在接收响应体
4DONE完成

几乎只需要关心 readyState === 4

POST 请求

const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/data', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
  if (xhr.readyState === 4) {
    if (xhr.status >= 200 && xhr.status < 300) {
      console.log(JSON.parse(xhr.responseText));
    }
  }
};
xhr.send(JSON.stringify({ userName: 'zhangsan', age: 18 }));

其他 XHR 能力

// 超时
xhr.timeout = 5000;
xhr.ontimeout = () => console.error('请求超时');
 
// 进度(上传/下载)
xhr.upload.onprogress = (e) => {
  if (e.lengthComputable) {
    console.log(`上传进度:${((e.loaded / e.total) * 100).toFixed(0)}%`);
  }
};
 
// 响应类型
xhr.responseType = 'blob'; // text / json / blob / arraybuffer / document
 
// 中止请求
xhr.abort();

同源策略

Ajax 请求时,浏览器强制要求当前页面和目标服务器必须同协议、同域名、同端口

当前页面:http://a.com:8080/page
请求 https://a.com:8080/api  → ❌ 协议不同 (https vs http)
请求 http://b.com:8080/api   → ❌ 域名不同 (b.com vs a.com)
请求 http://a.com:3000/api   → ❌ 端口不同 (3000 vs 8080)
请求 http://a.com:8080/api   → ✅ 同源

例外: <img><link><script> 标签不受同源策略限制——它们可以跨域加载资源。这是 JSONP 的基础,也是为什么这几个标签常被用于第三方服务的嵌入(CDN、统计打点、广告)。

所有合法的跨域方案,都必须经过服务端的允许和配合。未经服务端允许就能跨域,意味着浏览器存在安全漏洞。

跨域方案一:JSONP

利用 <script> 标签可跨域 + 服务端可动态拼接 JS 代码的特性:

<script>
  // 前端:声明全局回调
  window.myCallback = function (data) {
    console.log(data); // { x: 100, y: 200 }
  };
</script>
<script src="https://xxx.com/api?callback=myCallback"></script>
<!-- 服务端返回:myCallback({ x: 100, y: 200 }) -->

服务端做的事:读取 URL 中的 callback 参数,把 JSON 数据包裹成函数调用的形式返回。浏览器收到后当作普通 JS 执行,调用你提前声明好的回调。

jQuery 封装:

$.ajax({
  url: 'https://xxx.com/api',
  dataType: 'jsonp',
  jsonpCallback: 'myCallback',
  success(data) {
    console.log(data);
  },
});

JSONP 的局限:

  • 只支持 GET(<script> 标签只有 GET)
  • 需要服务端配合返回特定格式
  • 错误处理困难——<script> 标签没有标准的错误回调(onerror 支持不完善)
  • 存在 XSS 风险——服务端返回的内容会被当作 JS 执行

现在几乎不再使用 JSONP,但了解它的原理有助于理解为什么 CORS 被设计出来。

跨域方案二:CORS

CORS(Cross-Origin Resource Sharing)是 W3C 标准。服务端通过设置 HTTP 响应头,明确告知浏览器”允许哪些域名的跨域请求”。

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Headers: Content-Type, X-Requested-With, Authorization
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Access-Control-Expose-Headers: X-Total-Count, X-Custom-Header
配置说明
Allow-Origin生产环境不用 *。需携带 cookie 时必须指定具体域名
Allow-Credentials设为 trueAllow-Origin 不能是 *,必须是具体域名
Allow-Methods服务器允许的 HTTP 方法
Allow-Headers允许的请求头。Content-Type: application/json 就属于自定义头
Max-AgeOPTIONS 预检的缓存时间,减少预检次数
Expose-Headers默认情况下 JS 只能读取响应体,需要通过这个字段暴露额外的响应头

简单请求 vs 预检请求

浏览器会在发送某些请求前先发一个 OPTIONS 请求来”询问”服务器是否允许:

条件简单请求(不发预检)需要预检
方法GETHEADPOSTPUTDELETEPATCH
Content-Typetext/plainmultipart/form-dataapplication/x-www-form-urlencodedapplication/json
自定义 Header不允许允许(会触发预检)
ReadableStream不允许允许

POST 请求发两次(先 OPTIONS 再 POST)是正常的——不是 bug。用 Max-Age 缓存预检结果可以避免每次都发两次。

跨域方案三:代理

开发环境中最常见的做法:通过 dev server 将前端请求转发到目标服务器。服务端之间的请求不受同源策略限制。

Vite 配置:

export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://backend.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
};

webpack devServer 类似。生产环境通常用 Nginx 做反向代理。

现代请求方案:fetch

ES6 引入的 fetch 是 XHR 的现代替代方案,基于 Promise:

// 基本用法
fetch('/api/data')
  .then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .then((data) => console.log(data))
  .catch((err) => console.error(err));
 
// 带配置
fetch('/api/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'zhangsan' }),
  signal: abortController.signal, // 取消请求
});
 
// 注意:fetch 只在网络错误时 reject,HTTP 4xx/5xx 不会 reject
// 需要用 res.ok 或 res.status 来判断

相关笔记