说到AJAX,很多人第一反应就是“那个让网页不用刷新就能更新内容的技术”。但如果你真的去深究它的底层运作机制,会发现这背后藏着一套相当精密的并发控制和异步协作逻辑。今天咱们不聊表面,直接钻进浏览器的黑盒子里,看看XMLHttpRequest和Fetch API是怎么在多线程环境下“跳舞”的,以及同源策略这个“保安”是怎么守大门的。
从单线程的困境说起
JavaScript一直是单线程的语言,这意味着在同一时刻,只能有一件事在发生。但网络请求是天生的“耗时操作”,如果你等着服务器响应再继续执行代码,用户体验大概就只剩下“转圈圈”三个字了。
早期的开发者们只能求助于setTimeout和setInterval来做轮询,但这简直是在浪费CPU cycles。真正的转折点出现在2000年左右,微软在IE5中引入了一个叫做XMLHttpRequest的对象。这个对象出现后,浏览器突然有了“后台打电话”的能力——主线程可以继续处理用户交互,而网络请求则在后台悄悄进行。
不过,这里有个常见的误解需要澄清:XMLHttpRequest本身并不是多线程的。它仍然运行在JavaScript的单线程环境中,只是通过事件循环(Event Loop)机制,将网络请求的完成事件挂起到任务队列中,等待主线程有空闲时才执行回调函数。真正的“并行”来自于浏览器引擎内部的多线程架构——网络层负责发送和接收数据,而JavaScript线程只负责处理结果。
XMLHttpRequest:老当益壮的幕后英雄
尽管Fetch API已经出现多年,但XMLHttpRequest(简称XHR)依然是许多大型项目(包括Vue、Angular的底层)的基石。理解XHR的工作机制,是掌握AJAX并发处理的第一步。
基础用法与并发控制
// 一个简单的XHR请求
function fetchData(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true); // true表示异步
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP Error: ${xhr.status}`));
}
};
xhr.onerror = function() {
reject(new Error('Network Error'));
};
xhr.send();
});
}
// 并发请求:同时发起3个请求
Promise.all([
fetchData('/api/users'),
fetchData('/api/posts'),
fetchData('/api/comments')
]).then(([users, posts, comments]) => {
console.log('所有数据加载完成', { users, posts, comments });
}).catch(err => {
console.error('至少有一个请求失败', err);
});
这里有个关键细节:Promise.all会并发执行所有请求,但任何一个失败都会导致整个Promise拒绝。如果你需要更细粒度的控制,可以用Promise.allSettled,它会让所有请求都执行完毕,然后返回每个请求的结果(无论成功还是失败)。
XHR的多请求并发陷阱
很多开发者在编写并发XHR时,会犯一个经典的错误:在循环中创建XHR对象但没有正确管理引用。
// 错误示范:闭包陷阱
const urls = ['/api/a', '/api/b', '/api/c'];
urls.forEach((url, index) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = function() {
// 这里的index在回调执行时可能已经变成了2
console.log(`请求${index}完成:`, xhr.responseText);
};
xhr.send();
});
这个代码的问题在于,当回调函数执行时,index变量的值已经变成了循环结束时的值(即2)。解决这个bug的方式是使用let代替var,或者将index作为参数传递给闭包:
// 正确示范:使用let创建块级作用域
const urls = ['/api/a', '/api/b', '/api/c'];
urls.forEach((url, index) => {
let xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = function() {
// 每个循环迭代都有独立的index值
console.log(`请求${index}完成:`, xhr.responseText);
};
xhr.send();
});
并发限制的现实约束
虽然JavaScript层面可以并发发起任意数量的XHR请求,但浏览器层面实际上有连接限制。大多数现代浏览器对同一个域名的并发连接数限制在6个左右。这意味着如果你尝试同时发起10个XHR请求,浏览器会自动排队,先发送前6个,剩下的等有空闲连接时再发送。
这个限制可以通过检查xhr.readyState来观察:
function trackConcurrentRequests() {
let activeRequests = 0;
const maxConcurrent = 6;
function sendRequest(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
activeRequests--;
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
}
};
activeRequests++;
console.log(`当前活跃请求数: ${activeRequests} / ${maxConcurrent}`);
xhr.send();
});
}
// 这里可以添加并发控制逻辑,当activeRequests >= maxConcurrent时等待
return sendRequest;
}
Fetch API:现代AJAX的优雅进化
2015年,Fetch API随着ES6一起进入浏览器视野。它的设计哲学很明确:更简洁、更基于Promise、更易于组合。
Fetch与XHR的核心差异
| 特性 | XMLHttpRequest | Fetch API |
|---|---|---|
| 返回值 | 回调函数 | Promise |
| 错误处理 | readyState + status | rejected Promise |
| 取消请求 | xhr.abort() |
AbortController |
| 请求拦截 | 困难 | 易于包装 |
| 类型安全 | 无 | 支持Request/Response对象 |
Fetch的并发控制实践
async function fetchWithConcurrencyLimit(urls, limit = 3) {
const results = [];
const executing = [];
for (const url of urls) {
const promise = fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
results.push(promise);
if (limit > 0) {
limit--;
const exec = promise.then(() => executing.splice(executing.indexOf(exec), 1));
executing.push(exec);
} else {
// 等待任意一个请求完成再发起下一个
const exec = Promise.race(executing).then(() => {
const index = executing.indexOf(exec);
if (index > -1) executing.splice(index, 1);
});
executing.push(exec);
}
}
return Promise.all(results);
}
// 使用示例
const dataUrls = [
'https://api.example.com/users',
'https://api.example.com/posts',
'https://api.example.com/comments',
'https://api.example.com/likes'
];
fetchWithConcurrencyLimit(dataUrls, 2)
.then(data => console.log('分批获取完成', data))
.catch(err => console.error('获取失败', err));
这段代码实现了一个信号量模式的并发控制。当同时发起的请求数达到上限时,新的请求会等待现有请求完成后再发起,而不是被浏览器默默排队。
AbortController:优雅地取消请求
XHR的abort()方法虽然能取消请求,但缺乏灵活性。Fetch API配合AbortController提供了更强大的取消机制:
function createCancellableRequest() {
const controller = new AbortController();
const signal = controller.signal;
const request = fetch('https://api.example.com/data', { signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被主动取消');
} else {
throw err;
}
});
// 可以在任意时刻取消
request.cancel = () => controller.abort();
return request;
}
// 使用
const req = createCancellableRequest();
req.then(data => console.log(data));
// 3秒后如果还没完成就取消
setTimeout(() => req.cancel(), 3000);
这在处理组件卸载、用户导航等场景时特别有用。想象一下,用户在列表页快速滚动,如果每个列表项都发出请求但没有取消机制,你会被大量的废弃请求淹没。
多线程环境下的异步协作机制
这里需要澄清一个重要的概念:JavaScript引擎是单线程的,但浏览器是多线程的。
浏览器内部的线程分工
当你调用fetch()或new XMLHttpRequest()时,实际上触发了浏览器内部多个线程的协作:
- 主线程(UI线程):执行JavaScript代码,处理用户交互,渲染页面
- 网络线程:负责实际的HTTP请求发送和响应接收
- 定时器线程:管理
setTimeout和setInterval - 事件触发线程:监听DOM事件、网络事件等,当事件发生时将回调推入任务队列
XHR和Fetch的工作原理都是:主线程发起请求后,立即返回,不阻塞;网络线程在后台完成请求;请求完成后,将回调函数推入任务队列;主线程在完成当前宏任务后,从任务队列中取出回调执行。
// 这段代码的执行流程展示了异步协作
console.log('1. 开始');
fetch('https://api.example.com/data')
.then(response => {
console.log('3. 数据到达');
return response.json();
})
.then(data => {
console.log('4. JSON解析完成', data);
});
console.log('2. 请求已发出,主线程继续执行');
// 输出顺序:1, 2, 3, 4
// 即使网络请求可能需要几百毫秒甚至更久
Event Loop的关键作用
理解Event Loop是理解AJAX并发机制的核心。JavaScript的Event Loop会在每个宏任务(如点击事件、定时器回调)执行完毕后,检查微任务队列(Promise的.then回调)是否有待执行的任务。
console.log('脚本开始');
setTimeout(() => {
console.log('定时器回调');
}, 0);
Promise.resolve().then(() => {
console.log('Promise微任务');
});
fetch('https://api.example.com/slow-endpoint')
.then(() => {
console.log('Fetch完成');
});
console.log('脚本结束');
// 输出顺序:
// 脚本开始
// 脚本结束
// Promise微任务
// Fetch完成(网络请求快于定时器时)
// 定时器回调
注意:setTimeout(..., 0)并不意味着0毫秒后执行,它只是将回调放入宏任务队列的末尾。而Promise的微任务优先级更高,会在当前宏任务结束后立即执行。
并发请求的竞态条件
当多个AJAX请求并发时,竞态条件是一个真实存在的问题。比如,你有一个搜索框,用户快速输入几个字符,每次按键都会发起一个新的搜索请求:
let currentRequestId = 0;
function search(query) {
const requestId = ++currentRequestId;
const controller = new AbortController();
console.log(`发起搜索请求 #${requestId},查询: "${query}"`);
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
// 关键检查:这个请求是否还是最新的?
if (requestId === currentRequestId) {
console.log(`请求 #${requestId} 的结果`, data);
updateSearchResults(data);
} else {
console.log(`请求 #${requestId} 已过时,丢弃结果`);
}
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error(`请求 #${requestId} 失败`, err);
}
});
}
// 用户快速输入"hello"
search('h'); // 请求1
search('he'); // 请求2
search('hel'); // 请求3
search('hell');// 请求4
search('hello');// 请求5
如果没有这个requestId检查,用户最终看到的可能是第一个请求” h “的结果,而不是最新查询的结果。这是一个典型的竞态条件问题,在高并发AJAX场景下非常常见。
同源策略:浏览器的安全守卫
如果你曾经在前端开发中遇到过CORS(跨域资源共享)错误,那你一定听说过同源策略。这个策略是现代Web安全的基石之一,但它也是开发者最常被“冒犯”的策略之一。
什么是同源?
两个URL是“同源”的,当且仅当它们的协议、域名和端口完全相同:
https://example.com/api/data — 同源
https://example.com:8080/api/data — 不同源(端口不同)
http://example.com/api/data — 不同源(协议不同)
https://api.example.com/data — 不同源(域名不同)
https://example.com/api/data — 同源
https://other.example.com/api/data — 不同源(子域名也算不同域名)
同源策略的核心限制是:一个源的JavaScript只能读取同源的资源,不能读取跨域的资源。这防止了恶意网站读取你在银行网站上的敏感信息。
CORS:同源策略的“许可证”机制
为了让跨域请求成为可能,W3C引入了CORS标准。当浏览器检测到跨域请求时,会自动在请求头中添加Origin字段,服务器则通过响应头Access-Control-Allow-Origin来授权:
// 前端代码:发起跨域请求
fetch('https://api.other-domain.com/data')
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
// 浏览器自动添加的请求头:
GET /data HTTP/1.1
Host: api.other-domain.com
Origin: https://your-domain.com // 浏览器自动添加
// 服务器响应头(如果允许跨域):
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://your-domain.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type
Content-Type: application/json
简单请求 vs 预检请求
CORS将请求分为两类:简单请求和预检请求。
简单请求不会触发预检,浏览器直接发送请求:
- 请求方法只能是GET、HEAD或POST
- 请求头只能是浏览器安全的头(如Content-Type必须是application/x-www-form-urlencoded、multipart/form-data或text/plain)
- 不能设置自定义头
预检请求会在正式请求之前先发送一个OPTIONS请求,询问服务器是否允许该跨域请求:
// 这会触发预检请求(因为设置了自定义头)
fetch('https://api.other-domain.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'value' // 自定义头触发预检
},
body: JSON.stringify({ key: 'value' })
});
浏览器会先发送:
OPTIONS /data HTTP/1.1
Host: api.other-domain.com
Origin: https://your-domain.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-Custom-Header
服务器必须响应:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://your-domain.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 86400 // 预检结果缓存时间(秒)
如果预检失败,浏览器会阻止后续的正式请求。这是CORS防止恶意跨域请求的关键机制。
常见CORS错误及解决方案
错误1:No ‘Access-Control-Allow-Origin’ header is present
原因:服务器没有设置CORS响应头。 解决方案:在服务器端配置CORS,或者使用反向代理:
// 使用Node.js + Express的解决方案
const express = require('express');
const cors = require('cors');
const app = express();
// 允许所有跨域请求(不推荐生产环境)
app.use(cors());
// 或者只允许特定来源
app.use(cors({
origin: 'https://your-domain.com',
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.get('/api/data', (req, res) => {
res.json({ message: 'Hello from server!' });
});
错误2:Response to preflight request doesn’t pass access control check
原因:预检请求的OPTIONS响应不符合要求。 解决方案:确保服务器正确处理OPTIONS请求,并返回正确的CORS头。
错误3:The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ‘*’
原因:前端代码设置了withCredentials: true,但服务器返回了Access-Control-Allow-Origin: *。
解决方案:服务器必须返回具体的Origin值,不能是通配符:
”`javascript // 正确的服务器配置 app.use(cors({ origin: function(req, callback) {
const allowedOrigins = ['https://your-domain.com', 'https://www.your-domain.com'];
const origin = req.headers.origin;
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, origin);
} else {
