怎样用 XMLHttpRequest 手写一个返回 Promise 的 Ajax?
创建 XMLHttpRequest,调用 open 设置方法和地址,open 之后、send 之前设置请求头,再用 send 发出。load 事件表示收到了响应,但 404、500 也会触发 load,所以要自己检查 status 在 200 到 299 之间才 resolve;error、timeout、abort 事件分别对应网络错误、超时和取消,都应该 reject。
简答
比如写一个
ajax({ url: '/api/user', method: 'GET' }),返回 Promise,成功时拿到数据,失败时进入catch。底层用浏览器的XMLHttpRequest。步骤是:
new XMLHttpRequest()创建请求,open(method, url)设置方法和地址,需要的话用setRequestHeader加请求头,最后send(body)发出。MDN 写明,setRequestHeader必须在open之后、send之前调用。结果通过事件告诉我们:load表示收到了响应,error表示网络错误,timeout表示超时,abort表示被取消。最容易写错的是成功的判断。服务器返回 404、500 也是一个完整的响应,同样触发
load,所以load里要再看status,在 200 到 299 之间才resolve,否则reject。这和fetch收到 404 不会reject是同一个道理。另外,网络错误时
status是 0;超时用xhr.timeout设置毫秒数;要能取消,就把xhr.abort()暴露出去。现在新代码一般直接用fetch,手写 XHR 主要是为了理解请求的过程,以及需要监听上传进度时使用。
手写 ajax
下面的 ajax 把 XMLHttpRequest 的构造函数作为参数传进来,浏览器里不用传。为了在 Node 里运行,示例用一个假的 XHR 类,根据地址模拟成功、404 和网络错误。
function ajax({ url, method = 'GET', headers = {}, body = null, timeout = 0 }, XHR = XMLHttpRequest) {
return new Promise((resolve, reject) => {
const xhr = new XHR();
xhr.open(method, url);
// 请求头只能在 open 之后、send 之前设置。
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
xhr.timeout = timeout;
xhr.responseType = 'json';
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
else reject(new Error(`请求失败,状态码 ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.ontimeout = () => reject(new Error('请求超时'));
xhr.onabort = () => reject(new Error('请求已取消'));
xhr.send(body);
});
}
class FakeXHR {
open(method, url) {
this.url = url;
this.headers = {};
}
setRequestHeader(name, value) {
this.headers[name] = value;
}
send() {
setTimeout(() => {
if (this.url === '/offline') {
this.status = 0;
this.onerror();
return;
}
this.status = this.url === '/missing' ? 404 : 200;
this.response = this.status === 200 ? { name: '小明' } : null;
this.onload();
}, 10);
}
}
const show = (promise) =>
promise.then(
(data) => `成功:${data.name}`,
(error) => `失败:${error.message}`,
);
Promise.all([
show(ajax({ url: '/user' }, FakeXHR)),
show(ajax({ url: '/missing' }, FakeXHR)),
show(ajax({ url: '/offline' }, FakeXHR)),
]).then((lines) => {
console.log(lines.join(' | ')); // 成功:小明 | 失败:请求失败,状态码 404 | 失败:网络错误
});
/missing 返回 404,假 XHR 调用的是 onload,不是 onerror,这和真实浏览器一致。XHR 规范里,只要收到了服务器的响应,就会触发 load;只有连不上服务器、跨域被拦这类没拿到响应的情况,才会触发 error。所以 404 能进入 catch,靠的是 onload 里对 status 的判断。
为什么设置 responseType = 'json'?设置以后,xhr.response 就是浏览器帮我们解析好的对象,不用自己 JSON.parse(xhr.responseText)。如果服务器返回的不是合法 JSON,response 是 null。
readyState 和 onreadystatechange 还要用吗
老写法监听 onreadystatechange,判断 xhr.readyState === 4 表示请求结束。readyState 是请求的阶段,0 是还没调用 open,4 是完成。可是请求结束不一定是成功,网络错误、超时、取消时 readyState 也会变成 4,这时还得再看 status 是不是 0,写起来容易漏。
所以现在更推荐分别监听 load、error、timeout、abort,每种结果对应一个事件,不用自己区分。
怎样支持取消和上传进度
取消只需要调用 xhr.abort(),会触发 abort 事件,Promise 随之 reject。可以让 ajax 返回 { promise, abort: () => xhr.abort() },或者接收一个 AbortSignal,在它的 abort 事件里调用 xhr.abort()。
上传进度是 XHR 相比 fetch 仍然有用的地方。xhr.upload 上可以监听 progress 事件,事件对象里的 loaded 和 total 是已上传和总共的字节数,用来显示上传进度条。
常见追问
fetch 和 XHR 在错误处理上有什么相同之处?
两者都只在网络层面失败时才算出错,HTTP 状态码 404、500 都算正常拿到了响应。所以都要自己检查状态码,
fetch里看的是response.ok,见《为什么 fetch 收到 404 不一定进入 catch?》。
跨域请求失败时,能拿到具体原因吗?
拿不到。被 CORS 拦下的请求在脚本里表现为网络错误,
status是 0,具体原因只会显示在开发者工具的控制台里,这是为了不让脚本探测别的站点的信息。