从 AJAX 到 Promise:手把手封装优雅的 getJSON 函数,再聊透 JS 引用拷贝的底层逻辑

51 阅读9分钟

在前端开发中,“获取数据” 是绕不开的核心场景。从早期的 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函数,满足以下要求:

  1. 只支持 GET 请求;
  2. 自动解析响应为 JSON;
  3. 支持 Promise 的then()/catch()
  4. 能处理网络错误、HTTP 错误、JSON 解析错误。

1. 先回顾 Promise 的核心逻辑

在封装前,我们必须明确 Promise 的工作原理 —— 这是封装的基础:

  • Promise 是一个 “状态机”,实例化时接收一个executor函数(参数是resolvereject);
  • 初始状态是pending,调用resolve()会转为fulfilled(成功),调用reject()会转为rejected(失败);
  • 状态一旦改变就不可再变,fulfilled状态会触发then()的第一个回调,rejected状态会触发catch()then()的第二个回调。

2. 封装步骤:从 “回调” 到 “Promise” 的改造

我们的思路是:把 AJAX 的 “回调逻辑”,映射到 Promise 的resolvereject中 —— 成功时调用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!

为什么修改userCopyuser也会变?这背后是 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复制对象时,发生了什么?

  1. 第一步:const user = { name: '张三', age: 20 }

    • JS 在堆内存中创建一个对象{ name: '张三', age: 20 },并分配一个地址(比如0x123456);
    • 栈内存中创建变量user,存储的不是对象本身,而是堆内存的地址0x123456
  2. 第二步:const userCopy = user

    • JS 在栈内存中创建变量userCopy,把user存储的地址0x123456,复制给userCopy
    • 此时,useruserCopy的栈内存地址相同,都指向堆内存中同一个对象。
  3. 第三步:userCopy.age = 25

    • 当修改userCopy.age时,JS 会先通过userCopy的地址0x123456,找到堆内存中的对象;
    • 直接修改堆内存中对象的age属性,从 20 变成 25;
    • 因为user也指向同一个堆对象,所以user.age也会变成 25。

这就是 “引用式拷贝” 的本质:复制的是 “堆地址”,而不是 “堆数据” ,两个变量共享同一个堆对象,修改一个会影响另一个。

3. 延伸:如何实现 “深拷贝”(真正复制对象)?

如果想让userCopyuser完全独立,需要实现 “深拷贝”—— 即把堆内存中的对象完整复制一份,让新变量指向新的堆地址。

(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); // ['篮球', '游戏'](不变)

缺点:不支持函数、undefinedDate等特殊类型,会把它们过滤或转成字符串。

(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(不变)

这样就能实现真正的 “深拷贝”,两个对象完全独立,修改一个不会影响另一个。

四、总结:从 “会用” 到 “懂原理” 的进阶

这篇文章我们从 “异步请求” 到 “内存底层”,串联了三个核心知识点:

  1. AJAX 与 fetch 的差异:fetch 是 Promise-based,解决了 AJAX 的回调地狱问题,但兼容性不如 AJAX;
  2. getJSON 封装思路:用 Promise 包裹 AJAX,统一处理错误、JSON 解析、超时,让旧 API 变得优雅;
  3. 引用式拷贝的本质:复杂数据类型在栈中存 “地址”,拷贝地址即引用拷贝,深拷贝需要复制堆数据。

前端开发中,“会用 API” 只是基础,“懂原理” 才能应对复杂场景。比如:

  • 当你知道 Promise 的状态机逻辑,就能轻松封装各种异步函数;
  • 当你理解栈和堆的存储机制,就能快速定位 “对象修改异常” 的 bug。