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

前言

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

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

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


一、AJAX 基础回顾

1.1 什么是 AJAX?

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

核心思想:

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

1.2 XMLHttpRequest 的基本用法

html 复制代码
<!-- 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>

逐行解析:

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

1.3 readyState 的 5 个状态

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

1.4 异步请求的正确写法

js 复制代码
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();

逐行解析:

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

二、封装基础版 AJAX

2.1 封装 GET 请求

js 复制代码
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));

逐行解析:

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

2.2 支持 GET 和 POST

js 复制代码
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));

逐行解析:

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

GET vs POST 对比:

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

三、封装进阶版:简易 axios

3.1 设计思路

axios 的核心功能:

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

3.2 完整实现

js 复制代码
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);
  });

逐行解析:

js 复制代码
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 合并用户配置
js 复制代码
this.interceptors = {
  request: null,
  response: null
};
  • 拦截器对象
  • request:请求拦截器函数
  • response:响应拦截器函数
js 复制代码
useRequestInterceptor(fn) {
  this.interceptors.request = fn;
}
  • 注册请求拦截器
  • 在发送请求前执行
js 复制代码
request(config) {
  const finalConfig = { ...this.defaults, ...config };
  • 合并默认配置和请求配置
  • 请求配置优先级更高
js 复制代码
if (this.interceptors.request) {
  finalConfig = this.interceptors.request(finalConfig);
}
  • 执行请求拦截器
  • 可以修改配置(如添加 token)
js 复制代码
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 编码特殊字符
js 复制代码
xhr.timeout = finalConfig.timeout;
xhr.ontimeout = () => reject(new Error('Request timeout'));
  • 设置超时时间
  • 超时后 reject
js 复制代码
const response = {
  data: xhr.responseText,
  status: xhr.status,
  statusText: xhr.statusText,
  headers: xhr.getAllResponseHeaders(),
  config: finalConfig,
  xhr: xhr
};
  • 构建响应对象
  • 与 axios 的响应结构一致
js 复制代码
try {
  response.data = JSON.parse(xhr.responseText);
} catch (e) {
  // 非 JSON 格式,保持原样
}
  • 尝试解析 JSON
  • 失败则保持字符串
js 复制代码
get(url, params) {
  return this.request({ method: 'GET', url, params });
}
  • 快捷方法
  • 简化 GET 请求的调用

四、扩展:fetch API

4.1 fetch 的基本用法

js 复制代码
// 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

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

4.3 fetch 的缺陷

js 复制代码
// 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:如何实现请求取消?

js 复制代码
// 使用 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:如何实现请求重试?

js 复制代码
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:如何实现并发限制?

js 复制代码
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,但有缺陷(不支持超时、进度监听)

封装步骤

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

方案对比

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

相关推荐
cxxcode1 小时前
CSS Loader 与样式处理链路
前端·css
电商API_180079052471 小时前
电商ERP 自动同步订单功能实现拆解与技术手段
服务器·前端·网络·爬虫
cxxcode1 小时前
CSS 隔离与 qiankun 样式问题总结
前端
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY3
前端
不负岁月无痕1 小时前
STL -- C++ 红黑树封装 Map 和 Set
java·c语言·开发语言·c++·面试
miss1 小时前
海康威视 IoT 平台前端实战:视频流、海量设备与实时数据可视化
前端·javascript·物联网
用户2849709525571 小时前
基于MQTT+SpringBoot的实验室设备数据采集与实时监控系统实战
前端
前端肥猿1 小时前
控制台中添加插件面板
前端
IT_陈寒2 小时前
SpringBoot自动配置的坑,我的Bean怎么不生效?
前端·人工智能·后端