前端知库

怎样用 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 和网络错误。

JavaScript
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,具体原因只会显示在开发者工具的控制台里,这是为了不让脚本探测别的站点的信息。