在前端开发中,“获取数据” 是绕不开的核心场景。从早期的 XMLHttpRequest(俗称 AJAX)到现代的 fetch API,我们处理异步请求的方式一直在进化。但你真的理解两者的差异吗?当业务需要兼容旧环境,不得不使用 AJAX 时,如何用 Promise 让它变得优雅?另外,为什么有时候修改一个对象,另一个对象也会跟着变?这篇文章会从 “异步请求封装” 到 “内存底层逻辑”,带你吃透这几个高频知识点。
一、先搞懂:AJAX 与 fetch 到底差在哪?
提到获取 JSON 数据,很多人第一反应是用 fetch—— 毕竟写法简单,还自带 Promise 支持。但在一些需要兼容 IE 的场景(是的,有些业务还在兼容),AJAX(XMLHttpRequest)依然是刚需。我们先从 “使用体验” 和 “底层实现” 两个维度,把两者的差异讲透。
1. 核心差异:回调地狱 vs Promise 链式调用
先看一段真实的代码对比,同样是获取用户信息的 JSON 数据:
(1)AJAX 的 “回调式” 写法
javascript
运行
// 传统AJAX获取JSON
function getJSONWithAjax(url, successCallback, errorCallback) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.setRequestHeader('Content-Type', 'application/json');
// 成功回调
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
// 手动解析JSON,还要处理解析失败的情况
try {
const data = JSON.parse(xhr.responseText);
successCallback(data);
} catch (err) {
errorCallback(new Error('JSON解析失败'));
}
} else {
errorCallback(new Error(`请求失败,状态码:${xhr.status}`));
}
};
// 网络错误回调
xhr.onerror = function() {
errorCallback(new Error('网络错误'));
};
xhr.send();
}
// 使用时:多层回调容易陷入“回调地狱”
getJSONWithAjax('/user',
(user) => {
// 拿到用户后,再获取用户的订单
getJSONWithAjax(`/orders?userId=${user.id}`,
(orders) => {
console.log('用户订单:', orders);
},
(err) => console.error('获取订单失败:', err)
);
},
(err) => console.error('获取用户失败:', err)
);
这段代码的问题很明显:
- 依赖回调函数,多层请求嵌套时,代码会像 “金字塔” 一样不断右移,可读性差(即 “回调地狱”);
- 需要手动处理 JSON 解析、网络错误、HTTP 状态码,逻辑分散在不同的回调里;
- 无法用
try/catch统一捕获错误,只能在每个回调里单独处理。
(2)fetch 的 “Promise 式” 写法
javascript
运行
// fetch获取JSON
fetch('/user')
.then(response => {
// 先判断HTTP状态码是否成功
if (!response.ok) {
throw new Error(`请求失败,状态码:${response.status}`);
}
// 内置json()方法,自动解析JSON(返回Promise)
return response.json();
})
.then(user => {
// 链式调用,获取订单,代码扁平
return fetch(`/orders?userId=${user.id}`);
})
.then(response => {
if (!response.ok) throw new Error('获取订单失败');
return response.json();
})
.then(orders => {
console.log('用户订单:', orders);
})
.catch(err => {
// 所有错误统一在这里捕获
console.error('请求过程出错:', err);
});
fetch 的优势一目了然:
- 基于 Promise 实现,用
then()链式调用替代嵌套回调,代码更扁平; - 内置
response.json()方法,自动处理 JSON 解析,无需手动JSON.parse(); - 所有错误(网络错误、HTTP 错误、解析错误)都能通过末尾的
catch()统一捕获; - 语法更简洁,不需要手动创建
XMLHttpRequest实例、绑定多个事件监听。
2. 本质差异:API 设计理念的进化
| 对比维度 | AJAX(XMLHttpRequest) | fetch API |
|---|---|---|
| 异步模型 | 回调函数(Callback-based) | Promise(Promise-based) |
| 错误处理 | 分散在onload/onerror回调 | 统一catch()捕获 |
| JSON 解析 | 需手动JSON.parse()+try/catch | 内置response.json()方法 |
| 代码可读性 | 多层嵌套,易地狱 | 链式调用,代码扁平 |
| 浏览器兼容性 | 支持 IE7+,兼容性好 | 不支持 IE,现代浏览器支持 |
总结:fetch 是 AJAX 的 “现代替代品”,解决了回调地狱和错误处理分散的问题,但兼容性不如 AJAX。如果业务需要兼容旧浏览器,又想享受 Promise 的优雅,那 “封装一个支持 Promise 的 AJAX 版 getJSON 函数” 就是最佳方案。
二、实战:手把手封装支持 Promise 的 getJSON 函数
需求很明确:用 AJAX(XMLHttpRequest)实现一个getJSON函数,满足以下要求:
- 只支持 GET 请求;
- 自动解析响应为 JSON;
- 支持 Promise 的
then()/catch(); - 能处理网络错误、HTTP 错误、JSON 解析错误。
1. 先回顾 Promise 的核心逻辑
在封装前,我们必须明确 Promise 的工作原理 —— 这是封装的基础:
- Promise 是一个 “状态机”,实例化时接收一个
executor函数(参数是resolve和reject); - 初始状态是
pending,调用resolve()会转为fulfilled(成功),调用reject()会转为rejected(失败); - 状态一旦改变就不可再变,
fulfilled状态会触发then()的第一个回调,rejected状态会触发catch()或then()的第二个回调。
2. 封装步骤:从 “回调” 到 “Promise” 的改造
我们的思路是:把 AJAX 的 “回调逻辑”,映射到 Promise 的resolve和reject中 —— 成功时调用resolve传递数据,失败时调用reject传递错误。
最终封装代码
javascript
运行
/**
* 封装AJAX版getJSON函数,支持Promise
* @param {string} url - 请求的URL地址
* @param {Object} [options={}] - 可选配置(如请求头)
* @returns {Promise} - 返回Promise实例,成功 resolve JSON数据,失败 reject 错误信息
*/
function getJSON(url, options = {}) {
// 1. 返回Promise实例,把AJAX逻辑包在executor里
return new Promise((resolve, reject) => {
// 2. 创建XMLHttpRequest实例
const xhr = new XMLHttpRequest();
// 3. 配置请求:GET方法 + URL
xhr.open('GET', url, true); // 第三个参数true表示异步(默认)
// 4. 设置默认请求头(如果options里没有,就加一个)
const defaultHeaders = {
'Content-Type': 'application/json; charset=utf-8'
};
const headers = { ...defaultHeaders, ...options.headers };
// 5. 批量设置请求头
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
// 6. 处理响应(成功/失败)
xhr.onload = function() {
// 先判断HTTP状态码:200-299是成功
if (xhr.status >= 200 && xhr.status < 300) {
try {
// 解析JSON:如果解析失败,进入catch
const jsonData = JSON.parse(xhr.responseText);
// 解析成功,resolve传递数据
resolve(jsonData);
} catch (parseErr) {
// JSON解析失败,reject传递错误
reject(new Error(`JSON解析失败:${parseErr.message}`));
}
} else {
// HTTP状态码错误,reject传递状态码信息
reject(new Error(`请求失败 [${xhr.status}]:${xhr.statusText}`));
}
};
// 7. 处理网络错误(如断网)
xhr.onerror = function() {
reject(new Error('网络错误:无法连接到服务器'));
};
// 8. 处理请求超时(如果options里有timeout,就设置)
if (options.timeout) {
xhr.timeout = options.timeout;
xhr.ontimeout = function() {
reject(new Error(`请求超时:已超过${options.timeout}ms`));
};
}
// 9. 发送请求(GET请求没有请求体,传null)
xhr.send(null);
});
}
3. 如何使用?和 fetch 一样优雅!
封装后的getJSON函数,使用方式和 fetch 完全一致,支持链式调用和统一错误捕获:
javascript
运行
// 1. 基础使用:获取用户信息
getJSON('/user')
.then(user => {
console.log('用户信息:', user);
// 2. 链式调用:获取用户订单
return getJSON(`/orders?userId=${user.id}`, {
timeout: 5000, // 设置5秒超时
headers: {
'Authorization': 'Bearer ' + user.token // 自定义请求头
}
});
})
.then(orders => {
console.log('用户订单:', orders);
})
.catch(err => {
// 3. 统一捕获所有错误
console.error('请求出错:', err.message);
});
4. 封装的核心思考:为什么要这么写?
- 为什么用 Promise 包裹? 为了把 “回调式” 的 AJAX,转为 “链式调用” 的 Promise,解决回调地狱问题;
- 为什么要处理 JSON 解析错误? 因为
xhr.responseText可能不是合法的 JSON(比如后端返回了错误提示字符串),直接JSON.parse()会报错,必须用try/catch捕获; - 为什么要处理超时? 网络请求可能一直卡住,设置超时可以避免无限等待,提升用户体验;
- 为什么支持自定义请求头? 实际业务中可能需要传递
Authorization(token)、Accept等头信息,留好配置入口更灵活。
三、加餐:为什么修改对象 A,对象 B 也会变?聊透引用式拷贝
在处理 JSON 数据时,你可能遇到过这样的问题:
javascript
运行
// 从接口获取的用户数据(假设是getJSON返回的)
const user = { name: '张三', age: 20 };
// 复制一份用户数据,想修改副本
const userCopy = user;
userCopy.age = 25;
console.log(user.age); // 结果是25,而不是原来的20!
为什么修改userCopy,user也会变?这背后是 JS 的 “内存存储机制” 和 “引用式拷贝” 在起作用。
1. 先明确:JS 的内存分为 “栈” 和 “堆”
JS 中变量的存储,依赖两种内存空间:栈内存(Stack) 和 堆内存(Heap) ,它们的职责完全不同:
| 内存类型 | 存储内容 | 特点 | 示例 |
|---|---|---|---|
| 栈内存 | 简单数据类型(Number、String、Boolean、Undefined、Null)、引用类型的 “地址” | 连续存储、大小固定、访问速度快 | let a = 10;(10 存在栈)、let obj = {}(obj 的地址存在栈) |
| 堆内存 | 复杂数据类型(Object、Array、Function) | 离散存储、大小不固定、访问速度慢 | let obj = {}({} 本身存在堆) |
关键结论:复杂数据类型(比如对象),栈里存的不是 “数据本身”,而是 “数据在堆里的地址”—— 这个地址就是 “引用”。
2. 引用式拷贝:你复制的只是 “地址”,不是 “数据”
回到刚才的例子,我们用const userCopy = user复制对象时,发生了什么?
-
第一步:
const user = { name: '张三', age: 20 }- JS 在堆内存中创建一个对象
{ name: '张三', age: 20 },并分配一个地址(比如0x123456); - 在栈内存中创建变量
user,存储的不是对象本身,而是堆内存的地址0x123456。
- JS 在堆内存中创建一个对象
-
第二步:
const userCopy = user- JS 在栈内存中创建变量
userCopy,把user存储的地址0x123456,复制给userCopy; - 此时,
user和userCopy的栈内存地址相同,都指向堆内存中同一个对象。
- JS 在栈内存中创建变量
-
第三步:
userCopy.age = 25- 当修改
userCopy.age时,JS 会先通过userCopy的地址0x123456,找到堆内存中的对象; - 直接修改堆内存中对象的
age属性,从 20 变成 25; - 因为
user也指向同一个堆对象,所以user.age也会变成 25。
- 当修改
这就是 “引用式拷贝” 的本质:复制的是 “堆地址”,而不是 “堆数据” ,两个变量共享同一个堆对象,修改一个会影响另一个。
3. 延伸:如何实现 “深拷贝”(真正复制对象)?
如果想让userCopy和user完全独立,需要实现 “深拷贝”—— 即把堆内存中的对象完整复制一份,让新变量指向新的堆地址。
(1)简单场景:用JSON.parse(JSON.stringify())
javascript
运行
const user = { name: '张三', age: 20, hobbies: ['篮球', '游戏'] };
// 深拷贝:先转JSON字符串,再转对象
const userCopy = JSON.parse(JSON.stringify(user));
userCopy.age = 25;
userCopy.hobbies.push('读书');
console.log(user.age); // 20(不变)
console.log(user.hobbies); // ['篮球', '游戏'](不变)
缺点:不支持函数、undefined、Date等特殊类型,会把它们过滤或转成字符串。
(2)复杂场景:手写递归深拷贝
javascript
运行
function deepClone(target) {
// 1. 如果是简单类型,直接返回(栈数据,复制即值)
if (typeof target !== 'object' || target === null) {
return target;
}
// 2. 如果是复杂类型,创建新的容器(数组/对象)
let cloneTarget = Array.isArray(target) ? [] : {};
// 3. 递归遍历目标对象的所有属性,复制到新容器
for (let key in target) {
// 只复制自身属性,不复制原型链上的属性
if (target.hasOwnProperty(key)) {
// 递归:如果属性值是复杂类型,继续深拷贝
cloneTarget[key] = deepClone(target[key]);
}
}
return cloneTarget;
}
// 使用
const user = { name: '张三', age: 20, hobbies: ['篮球', '游戏'], info: { height: 180 } };
const userCopy = deepClone(user);
userCopy.info.height = 185;
console.log(user.info.height); // 180(不变)
这样就能实现真正的 “深拷贝”,两个对象完全独立,修改一个不会影响另一个。
四、总结:从 “会用” 到 “懂原理” 的进阶
这篇文章我们从 “异步请求” 到 “内存底层”,串联了三个核心知识点:
- AJAX 与 fetch 的差异:fetch 是 Promise-based,解决了 AJAX 的回调地狱问题,但兼容性不如 AJAX;
- getJSON 封装思路:用 Promise 包裹 AJAX,统一处理错误、JSON 解析、超时,让旧 API 变得优雅;
- 引用式拷贝的本质:复杂数据类型在栈中存 “地址”,拷贝地址即引用拷贝,深拷贝需要复制堆数据。
前端开发中,“会用 API” 只是基础,“懂原理” 才能应对复杂场景。比如:
- 当你知道 Promise 的状态机逻辑,就能轻松封装各种异步函数;
- 当你理解栈和堆的存储机制,就能快速定位 “对象修改异常” 的 bug。