整理 Ajax 的工作原理和跨域的几种方案。浏览器的同源策略是安全底线——所有合法的跨域方案,最终都需要服务端的配合。
XMLHttpRequest
fetch 和 axios 是现在的首选,但 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 五个阶段
| 值 | 常量 | 说明 |
|---|---|---|
| 0 | UNSENT | 已创建,尚未调用 open() |
| 1 | OPENED | 已调用 open(),尚未 send() |
| 2 | HEADERS_RECEIVED | 已收到响应头 |
| 3 | LOADING | 正在接收响应体 |
| 4 | DONE | 完成 |
几乎只需要关心 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 | 设为 true 时 Allow-Origin 不能是 *,必须是具体域名 |
Allow-Methods | 服务器允许的 HTTP 方法 |
Allow-Headers | 允许的请求头。Content-Type: application/json 就属于自定义头 |
Max-Age | OPTIONS 预检的缓存时间,减少预检次数 |
Expose-Headers | 默认情况下 JS 只能读取响应体,需要通过这个字段暴露额外的响应头 |
简单请求 vs 预检请求
浏览器会在发送某些请求前先发一个 OPTIONS 请求来”询问”服务器是否允许:
| 条件 | 简单请求(不发预检) | 需要预检 |
|---|---|---|
| 方法 | GET、HEAD、POST | PUT、DELETE、PATCH 等 |
| Content-Type | text/plain、multipart/form-data、application/x-www-form-urlencoded | application/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 来判断