手写 AJAX:从 XMLHttpRequest 到封装一个简易 axios

6 阅读4分钟

前言

在现代前端开发中,我们习惯了用 axiosfetch 发请求。但面试中经常被问到:

"请手写一个 AJAX 请求,并封装一个简易的 axios"

今天我们从最底层的 XMLHttpRequest 出发,逐步封装出一个功能完整的请求库,彻底搞懂 HTTP 请求的原理。


一、AJAX 基础回顾

1.1 什么是 AJAX?

AJAX 全称 Asynchronous JavaScript and XML(异步 JavaScript 和 XML)。

核心思想:

  • 异步:不阻塞主线程,请求在后台进行
  • 局部更新:不需要刷新整个页面
  • 数据格式:现在主要用 JSON,而不是 XML

1.2 XMLHttpRequest 的基本用法

<!-- index.html -->
<ul id="members"></ul>

<script>
// 1. 创建 XMLHttpRequest 实例
const xhr = new XMLHttpRequest();
console.log(xhr.readyState, '------');  // 0

// 2. 打开一个请求
// 参数:method, url, async(true=异步,false=同步)
xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', false);
console.log(xhr.readyState, '|------');  // 1

// 3. 发送请求
xhr.send();
console.log(xhr.readyState, '|---|---');  // 4(同步请求,直接完成)

// 4. 获取响应
console.log(xhr.responseText);
</script>

逐行解析:

const xhr = new XMLHttpRequest();
  • 创建 XMLHttpRequest 实例
  • 此时 readyState = 0(未初始化)
  • 这个对象是浏览器提供的原生 API
xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', false);
  • open 方法初始化一个请求
  • 三个参数:
    • method:HTTP 方法(GET/POST/PUT/DELETE 等)
    • url:请求地址
    • async:是否异步(true=异步,false=同步)
  • 此时 readyState = 1(已打开)
xhr.send();
  • 真正发送请求
  • 同步请求时,代码会阻塞直到响应返回
  • 异步请求时,需要监听 onreadystatechange 事件
console.log(xhr.responseText);
  • responseText 是服务器返回的响应体(字符串格式)
  • 同步请求可以直接获取,异步请求需要在 readyState = 4 时获取

1.3 readyState 的 5 个状态

状态值名称说明
0UNSENT实例已创建,未调用 open
1OPENED已调用 open,未调用 send
2HEADERS_RECEIVED已调用 send,已接收到响应头
3LOADING正在接收响应体
4DONE请求完成,响应就绪

1.4 异步请求的正确写法

const xhr = new XMLHttpRequest();

// 异步请求(第三个参数为 true)
xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', true);

// 监听状态变化
xhr.onreadystatechange = function() {
  console.log(xhr.readyState, '|---|---|');
  
  // readyState = 4 表示请求完成
  if (xhr.status === 200 && xhr.readyState === 4) {
    console.log(xhr.responseText);
    
    // 解析 JSON
    const data = JSON.parse(xhr.responseText);
    console.log(data);
    
    // 渲染到页面
    document.getElementById('members').innerHTML = 
      data.map(item => `<li>${item.login}</li>`).join('');
  }
}

xhr.send();

逐行解析:

xhr.onreadystatechange = function() {
  • 监听 readyState 变化
  • 每次状态改变都会触发这个回调
  • 会触发多次:0 → 1 → 2 → 3 → 4
if (xhr.status === 200 && xhr.readyState === 4) {
  • xhr.status:HTTP 状态码
    • 200:成功
    • 404:未找到
    • 500:服务器错误
  • xhr.readyState === 4:请求完成
  • 两个条件同时满足,才能安全地获取响应数据
const data = JSON.parse(xhr.responseText);
  • responseText 是字符串
  • 需要用 JSON.parse 解析成 JavaScript 对象
document.getElementById('members').innerHTML = 
  data.map(item => `<li>${item.login}</li>`).join('');
  • 将数据渲染到页面
  • map 生成 HTML 数组,join 拼接成字符串

二、封装基础版 AJAX

2.1 封装 GET 请求

function ajax(url) {
  return new Promise((resolve, reject) => {
    // 1. 创建 XMLHttpRequest 实例
    const xhr = new XMLHttpRequest();
    
    // 2. 初始化请求(异步)
    xhr.open('GET', url, true);
    
    // 3. 监听状态变化
    xhr.onreadystatechange = function() {
      // 4. 请求完成
      if (xhr.readyState === 4) {
        // 5. 判断状态码
        if (xhr.status >= 200 && xhr.status < 300) {
          // 6. 解析响应
          try {
            const data = JSON.parse(xhr.responseText);
            resolve(data);
          } catch (e) {
            resolve(xhr.responseText);  // 非 JSON 格式
          }
        } else {
          reject(new Error(`Request failed with status ${xhr.status}`));
        }
      }
    };
    
    // 7. 发送请求
    xhr.send();
  });
}

// 使用
ajax('https://api.github.com/orgs/lemoncode/members')
  .then(data => console.log(data))
  .catch(err => console.error(err));

逐行解析:

return new Promise((resolve, reject) => {
  • 返回 Promise,支持链式调用
  • 将回调地狱转为链式调用
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
  • 创建实例并初始化 GET 请求
  • true 表示异步
xhr.onreadystatechange = function() {
  if (xhr.readyState === 4) {
  • 监听状态变化
  • readyState === 4 表示请求完成
if (xhr.status >= 200 && xhr.status < 300) {
  • 判断 HTTP 状态码
  • 2xx 表示成功
  • 也可以用 xhr.status === 200 || xhr.status === 304
try {
  const data = JSON.parse(xhr.responseText);
  resolve(data);
} catch (e) {
  resolve(xhr.responseText);
}
  • 尝试解析 JSON
  • 如果不是 JSON,直接返回字符串
  • 避免解析错误导致请求失败
reject(new Error(`Request failed with status ${xhr.status}`));
  • 请求失败时 reject
  • 传递错误信息

2.2 支持 GET 和 POST

function ajax(method, url, data) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    // 1. 初始化请求
    xhr.open(method, url, true);
    
    // 2. 设置请求头(POST 必须)
    if (method === 'POST') {
      xhr.setRequestHeader('Content-Type', 'application/json');
    }
    
    // 3. 监听状态变化
    xhr.onreadystatechange = function() {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          try {
            const responseData = JSON.parse(xhr.responseText);
            resolve(responseData);
          } catch (e) {
            resolve(xhr.responseText);
          }
        } else {
          reject(new Error(`Request failed with status ${xhr.status}`));
        }
      }
    };
    
    // 4. 发送请求
    if (method === 'POST') {
      xhr.send(JSON.stringify(data));  // POST 发送请求体
    } else {
      xhr.send(null);  // GET 不发送请求体
    }
  });
}

// 使用
ajax('GET', 'https://api.github.com/users/shunwuyu')
  .then(data => console.log(data));

ajax('POST', 'https://jsonplaceholder.typicode.com/posts', {
  title: 'foo',
  body: 'bar',
  userId: 1
}).then(data => console.log(data));

逐行解析:

xhr.setRequestHeader('Content-Type', 'application/json');
  • 设置请求头
  • Content-Type: application/json 告诉服务器,请求体是 JSON 格式
  • POST 请求必须设置
if (method === 'POST') {
  xhr.send(JSON.stringify(data));
} else {
  xhr.send(null);
}
  • POST 请求:将数据序列化为 JSON 字符串发送
  • GET 请求:不发送请求体,传 null

GET vs POST 对比:

特性GETPOST
参数位置URL 查询字符串请求体
数据长度受 URL 长度限制(约 2KB)理论上无限制
缓存可被缓存不会被缓存
安全性参数暴露在 URL 中相对安全
幂等性幂等(多次请求结果相同)非幂等
适用场景获取数据提交数据

三、封装进阶版:简易 axios

3.1 设计思路

axios 的核心功能:

  1. 支持多种 HTTP 方法:GET/POST/PUT/DELETE
  2. 请求拦截器:发送请求前处理(如添加 token)
  3. 响应拦截器:收到响应后处理(如统一错误处理)
  4. 配置合并:默认配置 + 请求配置

3.2 完整实现

class MyAxios {
  constructor(config) {
    // 1. 默认配置
    this.defaults = {
      baseURL: '',
      timeout: 5000,
      headers: {
        'Content-Type': 'application/json'
      }
    };
    
    // 2. 合并用户配置
    Object.assign(this.defaults, config);
    
    // 3. 拦截器
    this.interceptors = {
      request: null,
      response: null
    };
  }
  
  // 4. 请求拦截器
  useRequestInterceptor(fn) {
    this.interceptors.request = fn;
  }
  
  // 5. 响应拦截器
  useResponseInterceptor(fn) {
    this.interceptors.response = fn;
  }
  
  // 6. 核心请求方法
  request(config) {
    // 合并配置
    const finalConfig = { ...this.defaults, ...config };
    
    // 请求拦截器
    if (this.interceptors.request) {
      finalConfig = this.interceptors.request(finalConfig);
    }
    
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      
      // 构建完整 URL
      let url = finalConfig.baseURL + finalConfig.url;
      
      // GET 请求拼接查询参数
      if (finalConfig.method === 'GET' && finalConfig.params) {
        const queryString = Object.entries(finalConfig.params)
          .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
          .join('&');
        url += (url.includes('?') ? '&' : '?') + queryString;
      }
      
      // 初始化请求
      xhr.open(finalConfig.method || 'GET', url, true);
      
      // 设置请求头
      Object.entries(finalConfig.headers).forEach(([key, value]) => {
        xhr.setRequestHeader(key, value);
      });
      
      // 超时处理
      xhr.timeout = finalConfig.timeout;
      xhr.ontimeout = () => reject(new Error('Request timeout'));
      
      // 监听响应
      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          const response = {
            data: xhr.responseText,
            status: xhr.status,
            statusText: xhr.statusText,
            headers: xhr.getAllResponseHeaders(),
            config: finalConfig,
            xhr: xhr
          };
          
          // 解析 JSON
          try {
            response.data = JSON.parse(xhr.responseText);
          } catch (e) {
            // 非 JSON 格式,保持原样
          }
          
          // 响应拦截器
          if (this.interceptors.response) {
            this.interceptors.response(response);
          }
          
          // 判断成功/失败
          if (xhr.status >= 200 && xhr.status < 300) {
            resolve(response);
          } else {
            reject(response);
          }
        }
      };
      
      // 发送请求
      const requestData = finalConfig.data ? JSON.stringify(finalConfig.data) : null;
      xhr.send(requestData);
    });
  }
  
  // 7. 快捷方法
  get(url, params) {
    return this.request({ method: 'GET', url, params });
  }
  
  post(url, data) {
    return this.request({ method: 'POST', url, data });
  }
  
  put(url, data) {
    return this.request({ method: 'PUT', url, data });
  }
  
  delete(url) {
    return this.request({ method: 'DELETE', url });
  }
}

// 创建实例
const axios = new MyAxios({
  baseURL: 'https://api.github.com',
  timeout: 10000
});

// 使用拦截器
axios.useRequestInterceptor((config) => {
  console.log('请求拦截器:', config);
  // 添加 token
  config.headers['Authorization'] = 'Bearer xxx';
  return config;
});

axios.useResponseInterceptor((response) => {
  console.log('响应拦截器:', response);
  // 统一处理错误
  return response;
});

// 发起请求
axios.get('/users/shunwuyu')
  .then(response => {
    console.log('用户信息:', response.data);
  })
  .catch(error => {
    console.error('请求失败:', error);
  });

逐行解析:

class MyAxios {
  constructor(config) {
    this.defaults = {
      baseURL: '',
      timeout: 5000,
      headers: {
        'Content-Type': 'application/json'
      }
    };
    Object.assign(this.defaults, config);
  • 定义默认配置
  • baseURL:基础 URL,避免重复写
  • timeout:超时时间
  • headers:默认请求头
  • Object.assign 合并用户配置
this.interceptors = {
  request: null,
  response: null
};
  • 拦截器对象
  • request:请求拦截器函数
  • response:响应拦截器函数
useRequestInterceptor(fn) {
  this.interceptors.request = fn;
}
  • 注册请求拦截器
  • 在发送请求前执行
request(config) {
  const finalConfig = { ...this.defaults, ...config };
  • 合并默认配置和请求配置
  • 请求配置优先级更高
if (this.interceptors.request) {
  finalConfig = this.interceptors.request(finalConfig);
}
  • 执行请求拦截器
  • 可以修改配置(如添加 token)
let url = finalConfig.baseURL + finalConfig.url;

if (finalConfig.method === 'GET' && finalConfig.params) {
  const queryString = Object.entries(finalConfig.params)
    .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
    .join('&');
  url += (url.includes('?') ? '&' : '?') + queryString;
}
  • 构建完整 URL
  • GET 请求拼接查询参数
  • encodeURIComponent 编码特殊字符
xhr.timeout = finalConfig.timeout;
xhr.ontimeout = () => reject(new Error('Request timeout'));
  • 设置超时时间
  • 超时后 reject
const response = {
  data: xhr.responseText,
  status: xhr.status,
  statusText: xhr.statusText,
  headers: xhr.getAllResponseHeaders(),
  config: finalConfig,
  xhr: xhr
};
  • 构建响应对象
  • 与 axios 的响应结构一致
try {
  response.data = JSON.parse(xhr.responseText);
} catch (e) {
  // 非 JSON 格式,保持原样
}
  • 尝试解析 JSON
  • 失败则保持字符串
get(url, params) {
  return this.request({ method: 'GET', url, params });
}
  • 快捷方法
  • 简化 GET 请求的调用

四、扩展:fetch API

4.1 fetch 的基本用法

// GET 请求
fetch('https://api.github.com/users/shunwuyu')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();  // 解析 JSON
  })
  .then(data => console.log(data))
  .catch(error => console.error(error));

// POST 请求
fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    title: 'foo',
    body: 'bar',
    userId: 1
  })
})
  .then(response => response.json())
  .then(data => console.log(data));

4.2 fetch vs XMLHttpRequest

特性XMLHttpRequestfetch
API 设计基于回调基于 Promise
代码复杂度较高较低
错误处理需要判断 status需要判断 response.ok
超时控制✅ 支持❌ 不支持(需手动实现)
进度监听✅ 支持❌ 不支持
兼容性所有浏览器IE 不支持
默认携带 Cookie✅ 是❌ 否(需手动配置)

4.3 fetch 的缺陷

// 1. 不会自动 reject HTTP 错误
fetch('https://api.github.com/404')
  .then(response => {
    console.log(response.ok);  // false
    console.log(response.status);  // 404
    // 不会进入 catch,需要手动判断
  });

// 2. 不支持超时
// 需要手动实现
function fetchWithTimeout(url, timeout = 5000) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error('Timeout')), timeout)
    )
  ]);
}

// 3. 默认不携带 Cookie
fetch('https://api.example.com', {
  credentials: 'include'  // 需要手动配置
});

这就是为什么我们还需要 XMLHttpRequest 和 axios。


五、经典面试题

面试题 1:如何实现请求取消?

// 使用 AbortController
const controller = new AbortController();
const signal = controller.signal;

fetch('https://api.github.com/users/shunwuyu', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('请求被取消');
    }
  });

// 取消请求
controller.abort();

面试题 2:如何实现请求重试?

function fetchWithRetry(url, retries = 3, delay = 1000) {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    })
    .catch(error => {
      if (retries > 0) {
        console.log(`重试剩余次数: ${retries}`);
        return new Promise(resolve => 
          setTimeout(resolve, delay)
        ).then(() => fetchWithRetry(url, retries - 1, delay));
      }
      throw error;
    });
}

fetchWithRetry('https://api.github.com/users/shunwuyu', 3, 2000)
  .then(data => console.log(data))
  .catch(error => console.error('最终失败:', error));

面试题 3:如何实现并发限制?

async function asyncPool(limit, items, iteratorFn) {
  const results = [];
  const executing = new Set();
  
  for (const item of items) {
    const p = Promise.resolve().then(() => iteratorFn(item));
    results.push(p);
    executing.add(p);
    
    const clean = () => executing.delete(p);
    p.then(clean).catch(clean);
    
    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }
  
  return Promise.all(results);
}

// 使用:最多同时 2 个请求
asyncPool(2, [1, 2, 3, 4, 5], item => {
  return fetch(`https://api.example.com/data/${item}`)
    .then(res => res.json());
}).then(results => console.log(results));

六、总结

核心要点

  1. XMLHttpRequest 是浏览器提供的原生 HTTP 请求 API
  2. readyState 有 5 个状态,4 表示请求完成
  3. status 是 HTTP 状态码,2xx 表示成功
  4. 封装 axios 需要支持配置合并、拦截器、快捷方法
  5. fetch 是基于 Promise 的现代 API,但有缺陷(不支持超时、进度监听)

封装步骤

1. 创建 XMLHttpRequest 实例
2. 初始化请求(open)
3. 设置请求头(setRequestHeader)
4. 监听状态变化(onreadystatechange)
5. 发送请求(send)
6. 解析响应(JSON.parse)
7. 返回 Promise

方案对比

方案优点缺点适用场景
XMLHttpRequest功能完整,兼容性好API 复杂,基于回调需要兼容老浏览器
fetchAPI 简洁,基于 Promise不支持超时、进度监听现代浏览器
axios功能强大,拦截器机制需要引入第三方库生产环境