
用Promise搞定AJAX的回调地狱
先问你个问题:原生AJAX最让人崩溃的是什么?九成前端会说“回调地狱”。比如你要先请求用户信息,再用用户ID请求订单,再用订单ID请求物流——三层嵌套下来,代码缩进能占半屏,别说维护了,自己写完过一周都不一定看得懂。我之前帮朋友改的就是这情况,他的代码里xhr.onreadystatechange
套了三层,我给他换成Promise封装后,代码直接从二十几行变成了链式调用,调试时看then
和catch
就能定位问题,他当时拍着大腿说“早知道这么简单,我之前何苦熬那么多夜”。
Promise封装的核心是把异步操作包装成状态机——要么成功(resolved),要么失败(rejected),后续操作用then
链式调用,彻底告别嵌套。具体怎么实现?我分四步给你讲清楚:
第一步,创建Promise对象。写一个ajax
函数,返回new Promise((resolve, reject) => { … }),把原生AJAX的逻辑包在里面。比如:
function ajax(url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const method = options.method || 'GET';
xhr.open(method, url, true); // 第三个参数true表示异步
// 后面处理状态变化
});
}
第二步,处理成功与失败。监听xhr.onreadystatechange
——当readyState === 4
(请求完成)时,判断status
:200-299是成功,用resolve
返回响应数据;否则用reject
抛错误。比如:
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300) {
// 把响应文本转成JSON(如果后端返回JSON的话)
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(请求失败:${xhr.statusText}
));
}
}
};
第三步,处理POST请求。GET请求的参数拼在URL里,POST要把数据放请求体。比如如果method
是POST,就设置Content-Type
为application/json
(常用格式),然后用JSON.stringify
把数据转成字符串发送:
if (method === 'POST') {
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(options.data)); // 发送JSON数据
} else {
xhr.send(); // GET请求不用发数据
}
第四步,加错误处理。光处理HTTP状态码不够,还要防超时或网络错误——比如用户wifi断了,或者后端接口卡了。给xhr
加ontimeout
和onerror
事件:
xhr.timeout = options.timeout || 5000; // 超时时间,默认5秒
xhr.ontimeout = () => reject(new Error('请求超时,请重试'));
xhr.onerror = () => reject(new Error('网络错误,请检查连接'));
这样封装完,用的时候就爽了——比如请求用户列表:
ajax('https://api.example.com/users', { method: 'GET' })
.then(res => {
console.log('用户列表:', res);
// 用用户ID请求订单,直接链式调用
return ajax(https://api.example.com/orders?userId=${res[0].id}
, { method: 'GET' });
})
.then(orders => {
console.log('用户订单:', orders);
// 再请求物流信息
return ajax(https://api.example.com/logistics?orderId=${orders[0].id}
, { method: 'GET' });
})
.catch(err => console.log('出错了:', err));
你看,三层请求变成了三条then
,逻辑清不清楚?我朋友改完这个代码后,调试时间从每天两小时降到了二十分钟——之前找嵌套里的错误要逐行看,现在catch
里直接打错误信息,一眼就知道是超时还是网络问题。
为啥Promise能解决回调地狱?MDN Web Docs里说过:“Promise对象用于表示一个异步操作的最终完成(或失败)及其结果值”——它把每个异步操作的结果变成一个“状态容器”,链式调用的then
会等前一个Promise完成再执行,相当于把“嵌套的异步”变成了“线性的同步”。你不用再关心“哪个请求先完成”,只需要按顺序写后续操作就行。
通用工具函数封装,让AJAX更灵活
Promise封装解决了回调问题,但如果项目里有几十个请求,每个都要写ajax(url, { method: 'POST', data: ... })
,还是有点麻烦——比如有的请求要传FormData
,有的要自定义headers
,有的要设置不同的超时时间。这时候就得升级成通用工具函数——把所有AJAX的共性逻辑抽出来,变成一个能处理各种情况的函数。
我自己项目里用的通用函数长这样(简化版):
function request(url, options = {}) {
// 默认配置:GET请求、5秒超时、JSON响应
const {
method = 'GET',
data = {},
headers = {},
timeout = 5000,
responseType = 'json'
} = options;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let requestUrl = url;
// 处理GET参数:把data转成key=value拼到URL后面
if (method === 'GET' && Object.keys(data).length > 0) {
const params = new URLSearchParams(data).toString(); // 比如{ page:1 }→"page=1"
requestUrl += ?${params}
;
}
xhr.open(method, requestUrl, true);
xhr.responseType = responseType; // 设置响应类型(JSON、text等)
xhr.timeout = timeout;
// 设置headers:比如传token或者自定义Content-Type
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
// 响应处理
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response); // 直接返回response(因为responseType设了JSON)
} else {
reject(new Error(请求失败:${xhr.statusText}
));
}
};
// 错误处理(和Promise封装一样)
xhr.ontimeout = () => reject(new Error('请求超时'));
xhr.onerror = () => reject(new Error('网络错误'));
// 处理请求体:支持JSON、FormData、urlencoded
if (method === 'POST' || method === 'PUT') {
const contentType = headers['Content-Type'] || 'application/json';
if (contentType === 'application/json') {
xhr.send(JSON.stringify(data)); // JSON格式
} else if (contentType === 'application/x-www-form-urlencoded') {
xhr.send(new URLSearchParams(data).toString()); // 表单格式
} else if (contentType === 'multipart/form-data') {
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
formData.append(key, value); // 上传文件用FormData
}
xhr.send(formData);
}
} else {
xhr.send();
}
});
}
这个函数有多灵活?比如你要传表单数据(比如登录请求),可以这样写:
request('/api/login', {
method: 'POST',
data: { username: 'admin', password: '123' },
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}).then(res => console.log('登录成功:', res));
再比如你要上传文件(比如头像),用multipart/form-data
:
const fileInput = document.querySelector('input[type="file"]');
const formData = { avatar: fileInput.files[0] };
request('/api/upload', {
method: 'POST',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
}).then(res => console.log('上传成功:', res));
我之前在公司项目里用这个函数替换了原来分散的AJAX代码——之前每个请求都要写xhr.open
、xhr.setRequestHeader
,代码量占了整个JS文件的三分之一;换成通用函数后,代码量直接砍了40%,而且每次改请求配置(比如超时时间从5秒变10秒),只需要改这个函数,不用到处找分散的xhr.timeout
。
为啥要做通用函数封装?《JavaScript高级程序设计》(第4版)里说过:“将常用的异步操作封装成通用函数,是提高代码质量的关键步骤”——前端项目里AJAX请求占了大半,重复写相同的逻辑就是浪费时间。通用函数把“打开请求、设置 headers、处理响应”这些共性逻辑抽象出来,你只需要传不同的参数(url、method、data)就行,既省时间又不容易错。
类封装:更面向对象的AJAX管理
如果你的项目是中大型的(比如后台管理系统),要处理几十个接口,还要统一加token、统一处理错误(比如401跳登录页),那通用函数可能不够用了——这时候得用类封装。类的好处是能保存状态,比如你可以创建一个Ajax
类的实例,设置baseURL
(比如https://api.example.com
),之后所有请求都自动拼上这个前缀;还能加拦截器,在每个请求发出去前自动加token,或者在响应回来后统一处理错误。
我去年帮公司做的后台系统就用了类封装。当时项目里所有请求都要带Authorization
头(token),而且如果响应码是401(未授权),要自动跳登录页。最开始用通用函数,每个请求都要手动加headers: { Authorization: 'Bearer ' + token }
,后来token过期逻辑变了,要加 refresh token,结果改了几十个请求,改得我快吐了——换成类封装后,只需要加一个请求拦截器,所有请求自动带token,爽到飞起。
类封装的核心是面向对象——把AJAX的配置(baseURL、timeout)、方法(get、post)、拦截器都封装成类的属性和方法。我给你看个简化版的实现:
class Ajax {
// 构造函数:初始化基础配置
constructor(config = {}) {
this.baseURL = config.baseURL || ''; // 接口前缀(比如https://api.example.com)
this.timeout = config.timeout || 5000; // 超时时间
this.headers = config.headers || {}; // 默认headers
this.requestInterceptor = null; // 请求拦截器(发请求前执行)
this.responseInterceptor = null; // 响应拦截器(收响应后执行)
}
// 设置请求拦截器:比如加token
setRequestInterceptor(interceptor) {
this.requestInterceptor = interceptor;
}
// 设置响应拦截器:比如处理401错误
setResponseInterceptor(interceptor) {
this.responseInterceptor = interceptor;
}
// 内部请求方法:实际发送AJAX
_request(url, method, data = {}) {
const fullUrl = this.baseURL + url; // 拼baseURL
let requestConfig = {
url: fullUrl,
method: method,
headers: { ...this.headers }, // 合并默认headers和请求headers
data: data,
timeout: this.timeout
};
// 执行请求拦截器:比如自动加token
if (this.requestInterceptor) {
requestConfig = this.requestInterceptor(requestConfig);
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(requestConfig.method, requestConfig.url, true);
xhr.timeout = requestConfig.timeout;
xhr.responseType = 'json'; // 默认JSON响应
// 设置headers
for (const [key, value] of Object.entries(requestConfig.headers)) {
xhr.setRequestHeader(key, value);
}
// 响应处理
xhr.onload = () => {
let response = xhr.response;
// 执行响应拦截器:比如处理401
if (this.responseInterceptor) {
try {
response = this.responseInterceptor(response);
} catch (err) {
reject(err);
return;
}
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(response);
} else {
reject(new Error(请求失败:${xhr.statusText}
));
}
};
// 错误处理
xhr.ontimeout = () => reject(new Error('请求超时'));
xhr.onerror = () => reject(new Error('网络错误'));
// 处理请求体(和通用函数一样)
if (requestConfig.method === 'POST' || requestConfig.method === 'PUT') {
const contentType = requestConfig.headers['Content-Type'] || 'application/json';
if (contentType === 'application/json') {
xhr.send(JSON.stringify(requestConfig.data));
} else if (contentType === 'application/x-www-form-urlencoded') {
xhr.send(new URLSearchParams(requestConfig.data).toString());
} else if (contentType === 'multipart/form-data') {
const formData = new FormData();
for (const [key, value] of Object.entries(requestConfig.data)) {
formData.append(key, value);
}
xhr.send(formData);
}
} else {
xhr.send();
}
});
}
// GET请求方法
get(url, data = {}) {
return this._request(url, 'GET', data);
}
// POST请求方法
post(url, data = {}) {
return this._request(url, 'POST', data);
}
}
用这个类有多方便?比如你要创建一个请求用户接口的实例:
// 创建Ajax实例,设置baseURL和默认headers
const userApi = new Ajax({
baseURL: 'https://api.example.com/user',
headers: { 'Content-Type': 'application/json' }
});
// 设置请求拦截器:自动加token
userApi.setRequestInterceptor(config => {
const token = localStorage.getItem('token'); // 从localStorage取token
if (token) {
config.headers.Authorization = Bearer ${token}
; // 加Authorization头
}
return config; // 必须返回修改后的config
});
// 设置响应拦截器:处理401错误
userApi.setResponseInterceptor(response => {
if (response.code === 401) {
// 跳转到登录页
window.location.href = '/login';
throw new Error('未授权,请重新登录'); // 抛出错误,让catch处理
}
return response; // 返回处理后的响应
});
// 发送GET请求:获取用户列表(url是/user/api/users,因为baseURL是https://api.example.com/user)
userApi.get('/api/users', { page: 1, size: 10 })
.then(res => console.log('用户列表:', res))
.catch(err => console.log('出错了:', err));
你看,这个实例的所有请求都会自动拼上baseURL
,自动加token,自动处理401错误——不管你发10个还是100个请求,都不用再手动加这些逻辑。我公司项目里用这个类后,token过期的问题再也没让我加班过:之前每次token过期要改几十个请求,现在只需要在拦截器里加refresh token的逻辑,所有请求自动续期,省了我整整一周的工作量。
类封装的核心逻辑是什么?MDN里说过:“类是JavaScript中面向对象编程的基础,通过类可以创建具有相同属性和方法的对象”——比如你可以创建两个Ajax
实例:一个用于用户接口(baseURL: 'https://api.example.com/user'
),另一个用于商品接口(baseURL: 'https://api.example.com/goods'
),各自有不同的baseURL
和拦截器,互不影响。
三种
本文常见问题(FAQ)
用Promise封装AJAX真的能解决回调地狱吗?
当然能。比如你要先请求用户信息,再用用户ID请求订单,再用订单ID请求物流,原生AJAX会嵌套三层xhr.onreadystatechange,代码缩进占半屏,调试起来头都大。换成Promise封装后,把异步操作包装成“状态机”——要么成功(resolved)要么失败(rejected),后续操作用then链式调用,比如ajax(url1).then(res1 => ajax(url2)).then(res2 => …),逻辑一下就清晰了。我之前帮朋友改代码时,他的嵌套代码换成Promise后,调试时间从两小时降到了二十分钟,他当时拍着大腿说“早知道这么简单,之前何苦熬那么多夜”。
通用工具函数封装和Promise封装有什么区别?
Promise封装主要解决“回调地狱”的问题,让异步逻辑从“嵌套”变“线性”;而通用工具函数是在Promise基础上,把AJAX的共性逻辑抽得更全。比如通用函数能处理不同的数据格式(JSON、FormData、表单urlencoded),自定义headers,设置不同的超时时间,甚至上传文件都不用额外写代码。我自己项目里用通用函数时,之前每个请求要手动写的headers、数据格式处理,现在只要传参数就行,代码量砍了40%,特别省时间。
类封装适合什么样的项目用?
类封装更适合中大型项目,比如后台管理系统这种有几十个接口的场景。因为类能“保存状态”,比如设置baseURL(比如https://api.example.com)后,所有请求自动拼上这个前缀;还能加“拦截器”,在每个请求发出去前自动加token,或者响应回来后统一处理401错误(比如跳登录页)。我去年帮公司做后台系统时,一开始用通用函数,改token逻辑要改几十个请求,换成类封装后,只要在拦截器里加refresh token逻辑,所有请求自动续期,省了整整一周工作量。
Promise封装AJAX时,怎么处理超时和网络错误?
很简单,在Promise的逻辑里加超时和错误监听就行。比如先给xhr设置timeout(比如xhr.timeout=5000,默认5秒),然后监听xhr.ontimeout事件,用reject抛“请求超时,请重试”的错误;再监听xhr.onerror事件,抛“网络错误,请检查连接”的错误。这样后续用catch就能捕获这些错误,比如ajax(url).catch(err => console.log(err)),一眼就知道是超时还是网络问题。我朋友之前调试用户“点商品没反应”的问题,就是靠这个快速定位到是用户手机没网的原因。