引言
本文旨在探讨如何将传统的 Ajax 请求封装为 Promise 形式。我们将从 XMLHttpRequest 基础知识入手,逐步实现一个功能完善的 Promise 化 Ajax 请求库。
本文会介绍 XMLHttpRequest,但它实际上是过时 Web 规范的产物,应该只在旧版本浏览器中使用。
1. XMLHttpRequest 基础
所有现代浏览器都通过 XMLHttpRequest 构造函数原生支持 XHR 对象:
let xhr = new XMLHttpRequest();
1.1 XMLHttpRequest 概述
1.1.1 使用 XHR
使用 XHR 对象首先要调用 open 方法,open 方法接受三个参数:请求的类型、请求的 URL、是否异步发送请求,然后必须要调用 send()方法发生定义好的请求,send 方法接收一个参数,是作为请求体,如果不需要请求体,则传 null,这个参数再某些浏览器是必须的,下面是一个例子:
js
let xhr = new XMLHttpRequest();
xhr.open("GET", "http://example.com", true);
xhr.send(null);
因为这个请求时同步的,所以 javascript 代码会等待服务器响应后再继续执行,收到响应后,XHR 的对象的以下属性会被填充上数据:
- responseText:作为响应体返回的文本
- responseXML:如果响应的内容类型是 text/xml 或 application/xml,则保存响应的 XML 文档
- status:响应的 HTTP 状态码
- statusText:HTTP 状态的说明
收到响应后,首先要检查 status 属性以确保响应成功返回。一般来说 HTTP 状态码为 2xx 或 304 时,响应才算成功。 XHR 对象上有一个 readyState 属性,该属性表示当前 XMLHttpRequest 的状态。这个属性有如下可能的值:
- 0: 请求未初始化
- 1: 服务器连接已建立
- 2: 请求已接收
- 3: 请求处理中
- 4: 响应已完成
readyState 的值会改变,当请求完成时,会触发 onreadystatechange 事件。一般来说,我们唯一要关心的状态是 readyState 为 4 时。来看下面的例子:
javascript
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
console.log(xhr.responseText);
} else {
console.log("Error: " + xhr.status);
}
}
};
收到响应之前如果想取消异步请求,可以调用 abort()方法。
javascript
xhr.abort();
1.1.2 HTTP 头部
每个 HTTP 请求和响应都会携带一些头部字段,这些字段可能对开发者有用。XHR 对象会通过一些方法暴露与请求和响应相关的头部字段。
设置请求头部 :使用 setRequestHeader()
方法可以设置请求头部信息。这个方法接收两个参数:头部字段的名称和值。必须在 open()
方法之后、send()
方法之前调用 setRequestHeader()
。
javascript
let xhr = new XMLHttpRequest();
xhr.open("GET", "http://example.com", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader("X-Custom-Header", "CustomValue");
xhr.send(null);
获取响应头部 :可以使用 getResponseHeader()
方法和 getAllResponseHeaders()
方法获取响应头部信息。
javascript
// 获取特定的响应头部
let contentType = xhr.getResponseHeader("Content-Type");
// 获取所有的响应头部
let allHeaders = xhr.getAllResponseHeaders();
1.1.3 GET 请求
GET 请求是最常见的 HTTP 请求类型,通常用于从服务器获取数据。在 GET 请求中,查询参数会附加到 URL 的末尾。
javascript
let xhr = new XMLHttpRequest();
// 构建带查询参数的URL
let url = "http://example.com/api/data?name=John&age=30";
// 或者使用URLSearchParams构建查询字符串
let params = new URLSearchParams();
params.append("name", "John");
params.append("age", 30);
let url = `http://example.com/api/data?${params.toString()}`;
xhr.open("GET", url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};
xhr.send(null);
注意事项:
- GET 请求不应该用于发送敏感数据,因为查询参数会显示在 URL 中
- 浏览器对 URL 长度有限制,所以 GET 请求的数据量应该较小
- GET 请求可以被缓存、收藏和保存在浏览器历史记录中
1.1.4 POST 请求
POST 请求通常用于向服务器发送数据,特别是当数据量较大或包含敏感信息时。在 POST 请求中,数据会包含在请求体中,而不是 URL 中。
javascript
let xhr = new XMLHttpRequest();
xhr.open("POST", "http://example.com/api/submit", true);
// 设置Content-Type头部
xhr.setRequestHeader("Content-Type", "application/json");
// 准备要发送的数据
let data = JSON.stringify({
name: "xl",
age: 30,
});
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 201) {
console.log("Success:", xhr.responseText);
} else {
console.log("Error:", xhr.status);
}
}
};
// 发送数据
xhr.send(data);
常见的 Content-Type 类型:
application/x-www-form-urlencoded
:表单提交的默认编码方式application/json
:JSON 格式的数据multipart/form-data
:用于上传文件text/plain
:纯文本
POST 请求相比 GET 请求要占用更多资源。从性能方面说,发送相同数量的数据, GET 请求比 POST 请求要快两倍。
1.1.5 XMLHttpRequest Level 2
XMLHttpRequest Level 2 是对原始 XHR 对象的扩展,添加了许多新功能,使得 Ajax 请求更加强大和灵活。
1. FormData 类型
FormData 对象用于模拟表单数据,可以更方便地发送表单数据或文件上传。
javascript
// 创建空的FormData对象
let formData = new FormData();
// 添加字段
formData.append("username", "John");
formData.append("email", "john@example.com");
// 添加文件
let fileInput = document.getElementById("fileInput");
formData.append("file", fileInput.files[0]);
// 发送FormData
let xhr = new XMLHttpRequest();
xhr.open("POST", "http://example.com/upload", true);
// 使用FormData时不需要设置Content-Type,浏览器会自动设置
xhr.send(formData);
2. 超时
XHR Level 2 添加了超时功能,可以设置请求的超时时间,避免请求长时间挂起。
javascript
let xhr = new XMLHttpRequest();
xhr.open("GET", "http://example.com/api/data", true);
// 设置超时时间(毫秒)
xhr.timeout = 5000; // 5秒超时
// 超时处理函数
xhr.ontimeout = function () {
console.log("请求超时");
};
xhr.send(null);
3. overrideMimeType()方法
overrideMimeType()
方法用于重写服务器返回的 MIME 类型,这在服务器返回的 MIME 类型不正确时非常有用。
javascript
let xhr = new XMLHttpRequest();
xhr.open("GET", "http://example.com/data.json", true);
// 强制将响应解析为JSON
xhr.overrideMimeType("application/json");
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
let jsonResponse = JSON.parse(xhr.responseText);
console.log(jsonResponse);
}
};
xhr.send(null);
4. 进度事件
XHR Level 2 添加了进度事件,可以监控请求的进度。
javascript
let xhr = new XMLHttpRequest();
xhr.open("GET", "http://example.com/large-file", true);
// 上传进度
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
let percentComplete = (event.loaded / event.total) * 100;
console.log(`上传进度: ${percentComplete.toFixed(2)}%`);
}
};
// 下载进度
xhr.onprogress = function (event) {
if (event.lengthComputable) {
let percentComplete = (event.loaded / event.total) * 100;
console.log(`下载进度: ${percentComplete.toFixed(2)}%`);
}
};
xhr.send(null);
1.2 传统 Ajax 的局限性
尽管 XMLHttpRequest 为 Web 应用程序带来了革命性的变化,但它也存在一些局限性:
1.2.1 回调地狱
传统的 Ajax 使用回调函数处理异步操作,当需要执行多个连续的异步操作时,会导致回调嵌套,形成所谓的"回调地狱"(Callback Hell)。
javascript
// 回调地狱示例
xhr1.onreadystatechange = function () {
if (xhr1.readyState === 4 && xhr1.status === 200) {
let data1 = JSON.parse(xhr1.responseText);
xhr2.onreadystatechange = function () {
if (xhr2.readyState === 4 && xhr2.status === 200) {
let data2 = JSON.parse(xhr2.responseText);
xhr3.onreadystatechange = function () {
if (xhr3.readyState === 4 && xhr3.status === 200) {
let data3 = JSON.parse(xhr3.responseText);
// 处理最终结果
}
};
xhr3.open("GET", `http://example.com/api/data3?id=${data2.id}`, true);
xhr3.send(null);
}
};
xhr2.open("GET", `http://example.com/api/data2?id=${data1.id}`, true);
xhr2.send(null);
}
};
xhr1.open("GET", "http://example.com/api/data1", true);
xhr1.send(null);
1.2.2 错误处理复杂
在传统 Ajax 中,错误处理分散在多个回调函数中,使得统一处理错误变得困难。
1.2.3 缺乏标准的取消机制
虽然可以使用 abort()
方法取消请求,但缺乏一种标准的、优雅的方式来管理和取消多个请求。
1.2.4 不支持请求/响应拦截
传统 Ajax 不提供内置的请求和响应拦截机制,这使得统一处理请求头、认证令牌、响应转换等变得困难。
1.2.5 代码冗余
每次发起 Ajax 请求都需要编写类似的样板代码,导致代码冗余和维护困难。
这些局限性促使开发者寻求更好的解决方案,如 Fetch API 和 axios 等库。
2. Promise 封装实现
2.1 设计思路
2.1.1 核心目标
- Promise 化:将基于回调的 XMLHttpRequest 转换为基于 Promise 的 API
- 易用性:提供简洁的接口,减少样板代码
- 可配置性:支持常见的请求配置选项
- 错误处理:统一处理各类网络错误和 HTTP 错误
2.1.2 功能需求
- 返回 Promise 实例
- 创建 XMLHttpRequest 对象
- 处理请求的各种状态
- 支持 GET、POST 等 HTTP 方法
2.1.3 实现思路
- 创建一个函数,接收 URL、HTTP 方法、数据和配置选项作为参数
- 在函数内部创建并返回一个 Promise 实例
- 在 Promise 的执行器函数中处理 XMLHttpRequest 的生命周期
- 根据请求结果调用 resolve 或 reject
- 添加超时处理和错误处理机制
2.2 核心实现
2.2.1 完整实现代码
javascript
/**
* 将传统的Ajax请求封装为Promise形式
*
* @param {string} url - 请求URL
* @param {string} method - 请求方法,默认为GET
* @param {Object|null} data - 请求数据,对象会被自动序列化为JSON
* @param {Object} options - 配置选项
* @param {number} [options.timeout=10000] - 请求超时时间(毫秒)
* @param {string} [options.responseType=''] - 响应类型
* @returns {Promise<any>} 返回Promise,成功时解析为响应数据(自动反序列化),失败时拒绝并提供错误信息
*/
function ajaxPromise(url, method = "GET", data = null, options = {}) {
// 存储xhr实例,用于手动中断请求
let xhr;
// 创建主请求Promise
const fetchPromise = new Promise((resolve, reject) => {
const defaultOptions = {
timeout: 10000,
responseType: "",
};
const mergedOptions = { ...defaultOptions, ...options };
xhr = new XMLHttpRequest();
xhr.timeout = mergedOptions.timeout;
xhr.open(method, url, true);
if (mergedOptions.responseType) {
xhr.responseType = mergedOptions.responseType;
}
// 处理请求数据
let processedData = data;
if (
data &&
typeof data === "object" &&
!(data instanceof FormData) &&
!(data instanceof Blob) &&
!(data instanceof ArrayBuffer)
) {
processedData = JSON.stringify(data);
}
// 处理加载完成事件
xhr.onload = function () {
if (xhr.readyState === 4) {
// 确保请求完成
if (xhr.status >= 200 && xhr.status < 300) {
let response = xhr.response;
// 如果响应类型未设置,尝试解析JSON
if (xhr.responseType === "" || xhr.responseType === "text") {
if (response) {
try {
response = JSON.parse(response);
} catch (e) {
console.warn("JSON解析失败,返回原始响应:", e);
}
}
}
resolve(response);
} else {
reject({
status: xhr.status,
statusText: xhr.statusText,
response: xhr.response,
});
}
}
};
// 处理网络错误
xhr.onerror = function () {
reject({
status: 0,
statusText: "网络错误",
error: new Error("网络请求失败"),
});
};
// 处理超时
xhr.ontimeout = function () {
reject({
status: 0,
statusText: "请求超时",
error: new Error("请求超时"),
});
};
// 发送请求
xhr.send(processedData);
});
// 创建超时Promise
const timeoutPromise = new Promise((_, reject) => {
const { timeout, timeoutMessage } = { ...options };
const timer = setTimeout(() => {
reject({
status: 0,
statusText: "请求超时",
error: new Error("请求超时"),
isManualTimeout: true,
});
}, timeout || 10000);
});
// 使用Promise.race竞争,谁先完成就返回谁的结果
return Promise.race([fetchPromise, timeoutPromise]).catch((error) => {
console.log(error);
// 如果是手动超时,尝试中断XHR请求
if (error && error.isManualTimeout && xhr) {
try {
xhr.abort();
} catch (e) {
console.warn("中断请求失败:", e);
}
}
// 继续抛出错误
return Promise.reject(error);
});
}
2.2.2 使用示例
js
// GET请求
async function testGet() {
try {
// 设置1毫秒的超时时间,确保会触发超时
const result = await ajaxPromise(
"https://example.com/todos/1",
"GET",
null,
{ timeout: 10000 }
);
console.log("get-result", result);
} catch (error) {
console.log("get-result", error);
}
}
// POST请求
async function testPost() {
try {
const data = {
title: "测试标题",
body: "测试内容",
userId: 1,
};
const result = await ajaxPromise("https://example.com/posts", "POST", data);
console.log("post-result", result);
} catch (error) {
console.log("post-result", error);
}
}
总结
随着前端技术的发展,Fetch API 和 axios 等工具已经成为主流的网络请求方案,但理解 XMLHttpRequest 的 Promise 封装过程,有助于我们更深入地理解 JavaScript 的异步编程模型。