从回调函数到 Promise 再到 async/await:JavaScript 异步编程完整演进之路



前言

在前端开发中,异步编程是绕不开的核心能力 —— 从最早的 AJAX 数据请求,到如今复杂的前后端交互,异步逻辑贯穿所有业务场景。

本文将带你走一遍 JavaScript 异步编程的完整演进之路:从回调函数的痛点与闭包原理,到 Promise 如何解决回调地狱,再到 async/await 如何让异步代码写得像同步一样优雅。

读完你会彻底明白:为什么回调是起点,Promise 是进阶,而 async/await 是现代前端的最终方案。


一、异步编程的起点:ajax 与回调函数的应用与局限

1.1 「ajax 函数」的定义

希望把读取到的文件的响应体的内容输出,就是下面的写法:

function ajax(){
	const xhr = new XMLHttpRequest();
	xhr.open("get","2-回调函数.html");
	xhr.send();
	xhr.onload = function(){// 异步回调
	    console.log(xhr.response)
	}
 }
 ajax();// 调用,

关键步骤:
1-创建xhr对象,这是浏览器发起HTTP请求的 “工具”。XMLHttpRequest是浏览器原生提供的、用于发起 AJAX 请求的核心对象。
2-xhr.open+xhr.send:用于配置请求方法、URL,以及真正发送请求(发起一个GET请求,目标是2-回调函数.html(实际会获取该文件内容))。
3-xhr.onload:异步回调,会在请求完成(无论成功 / 失败)后触发,上例在里面打印了 xhr.response 来获取响应体内容。

1.2 如果再次调用ajax(),希望弹框怎么办?

function ajax(){
    const xhr = new XMLHttpRequest();
    xhr.open("get","2-回调函数.html");
    xhr.send();
    xhr.onload = function(){// 异步回调
        console.log(xhr.response)
    }
}
ajax();// 调用,希望把读取到的文件的响应体的内容输出,就是上述的写法
ajax();// 调用,希望弹框怎么办?就满足不了了,

也就说这里封装了一个ajax函数,可以获取到响应体数据,但是响应体数据在不同的调用场景下由调用者决定展示方式(输出还是弹框)这样写死了明显不合理,现在调用第一个ajax希望是输出,调用第二个ajax是弹框,所以就不能再直接写死,可以借助回调函数ajax()调用的时候把一个函数传过去。然后就可以被ajax函数接收和调用,其实就是闭包的一种应用:

1.3 通过回调函数实现灵活处理响应数据

function ajax(cb){
    const xhr = new XMLHttpRequest();
    xhr.open("get","2-回调函数.html");
    xhr.send();
    xhr.onload = function(){
        cb()
    }
}
ajax(function(){
    
});

当获取到响应体数据以后,调用cb()并且把响应体数据传过去:cb(xhr.response)ajax(function(){ 这里就可以接收数据:ajax(function(res){,然后想输出就输出,想弹框就弹框。

function ajax(cb){
    const xhr = new XMLHttpRequest();
    xhr.open("get","2-回调函数.html");
    xhr.send();
    xhr.onload = function(){
        cb(xhr.response)
    }
}
// 封装 Ajax 请求流程,让使用更简洁。
// 1-创建xhr对象,这是浏览器发起HTTP请求的 “工具”。
// 2-xhr.open+xhr.send:发起一个GET请求,目标是2-回调函数.html(实际会获取该文件内容)。
// 3-xhr.onload:异步回调,请求成功完成后触发。此时调用传入的cb(即外部传的回调函数),并把 xhr.response(请求结果)作为参数传递。
ajax(function(res){// 第一次调用:控制台输出
    console.log(res);
});
ajax(function(res){// 第二次调用:弹窗展示
    alert(res);
})
// 调用 ajax 发起请求,同时指定 “请求成功后要做的事”(通过回调函数 cb 实现)。
// 第一个ajax调用:传入匿名函数 (res) => { console.log(res) } 作为 cb。当 Ajax 请求完成(xhr.onload 触发),cb(xhr.response) 会执行 → 也就是 console.log(res),把请求结果打印出来。
// 第二个 ajax 调用:传入匿名函数 (res) => { alert(res) } 作为 cb。同样,请求完成后会执行 alert(res),用弹窗显示结果。

定义了一个ajax函数,函数体里有异步行为,然后在函数的外面调用,异步获取到的数据怎么在外面使用?可以调用ajax函数的时候传回调函数,这就是旧的方案:单纯使用回调函数来解决异步编程的问题。这种解决方案有个问题,阅读性差,如果嵌套了多层就很容易看不懂了。

1.4 闭包的应用

闭包的核心特征是:内层函数可以访问外层函数作用域中的变量 / 函数,即使外层函数已经执行完毕。

这段代码中存在闭包的应用,主要体现在 ajax 函数内部的 xhr.onload 回调与外部传入的 cb 函数之间的关系上:

function ajax(cb) {
  const xhr = new XMLHttpRequest();  
  xhr.open("get", "2-回调函数.html");  
  xhr.send();  
  xhr.onload = function() {  // 内层函数(异步回调)
    cb(xhr.response);  // 访问外层的 cb 和 xhr
  }
}
// 调用时传入的回调函数
ajax(function(res) {  
  console.log(res); 
});
  • 外层函数:ajax(cb),它的作用域中有两个关键变量:cb(外部传入的回调函数)和 xhr实例。
  • 内层函数:xhr.onload 对应的匿名函数(异步执行,等请求完成后才会触发)。
  • 当 ajax 函数执行完毕后,理论上它的作用域应该被销毁,但由于 xhr.onload 这个内层函数还在引用cbxhr,JavaScript 引擎会保留 ajax 函数的作用域 —— 这就形成了闭包。

闭包在这里的作用:确保 xhr.onload 回调在异步执行时(请求完成后),仍然能访问到 ajax 函数作用域中的 cb(外部传入的回调)和 xhr(获取响应结果 xhr.response)。

简单说:闭包让异步回调能 “记住” 外层 ajax 函数中的变量,否则 xhr.onload 触发时,cbxhr 早就被销毁了,无法执行 cb(xhr.response) 这行代码。

总结:这段代码中,xhr.onload 回调通过闭包 “抓住” 了外层 ajax 函数的 cbxhr 变量,确保异步请求完成后能正确调用外部传入的回调函数并传递结果。这是闭包在异步编程中最常见的应用场景之一 ——让异步操作能访问到定义时的上下文环境。

1.5 回调方案的问题

“嵌套多层就会很难懂”,这是回调函数方案的核心问题,俗称 “回调地狱(Callback Hell)”。举个极端但好懂的例子:如果需求是 “先调用 ajax1 获取结果,再用结果作为参数调用 ajax2,再用 ajax2 结果调用 ajax3”,代码会变成这样:

ajax(function(res1) {  
  ajax(function(res2) {  
    ajax(function(res3) {  
      // ... 一层套一层,极其难维护  
    });  
  });  
});  

可读性直线下降:嵌套层级越深,代码越像 “金字塔”,逻辑flow被打乱。
异常处理麻烦:每一层回调的错误,很难统一捕获、处理。

1.6 总结

这段代码的逻辑是:
1-封装 ajax 函数,用 回调函数 cb 处理 “异步请求完成后的数据使用”。
2-调用时传入不同回调,分别用 console.logalert 处理结果。
3-但这种方案的缺点是 “多层嵌套会让代码难以阅读、维护” → 这也是后来 PromiseAsync/Await 等方案诞生的原因,用来解决回调地狱问题。简单说,这是 “用回调函数解决异步” 的经典写法,能跑通逻辑,但在复杂场景会很棘手。

二、Promise:异步编程的进阶方案

在第一部分我们看到,传统回调函数虽然能解决异步数据处理问题,但多层嵌套会带来回调地狱,代码可读性和可维护性急剧下降。而 Promise 正是为了解决这类问题而生的异步编程新方案。

2.1 Promise 的核心认知

从两个维度快速理解 Promise:

  1. 语法层面:Promise 是 JavaScript 原生提供的构造函数,可以通过 new Promise() 实例化一个 promise 对象。
  2. 功能层面:promise 对象的核心作用是封装一个异步操作,并在未来某个时刻获取该操作的成功结果或失败原因,让异步逻辑可以用更线性、更清晰的方式组织。

关于 Promise 的详细语法、状态机制、then/catch 等 API 用法,我已经在另一篇文章中做了完整讲解:

Promise 从 A+ 规范到 JS 实战:完整原理与 API 全解析

2.2 Promise 的状态与状态变更

Promise 的核心是状态管理,理解状态才能真正掌握异步流程。

2.2.1 Promise 的构造与执行器函数

Promise 是 JavaScript 内置的构造函数,我们通过 new Promise() 来创建实例,这个过程中会传入一个执行器函数,它是理解 Promise 运行机制的第一步。

  1. 执行器函数是同步执行的
    当我们调用 new Promise() 时,传入的执行器函数会立即同步执行,不会进入异步队列。

    // 执行器函数会在 new Promise 时立刻执行
    new Promise(function(){
        console.log("1-执行器函数,执行器函数不允许省略");
    });
    console.log("2-over"); // 先输出 1,再输出 2-over
    

    这一点很关键:执行器函数是同步的,而 .then() 回调才是异步的,很多初学者容易混淆。

  2. Promise 实例的核心属性
    new Promise() 会返回一个 Promise 实例,它有两个最重要的内部属性:
    state:表示 Promise 的当前状态(pending/fulfilled/rejected)
    value:存储 Promise 的结果数据(成功时是 value,失败时是 reason)

    const p1 = new Promise(function(){
        // 未调用 resolve/reject 时,状态保持为 pending
    });
    console.log(p1); // 状态为 pending,值为 undefined
    

2.2.2 Promise的三种状态

promise的状态分为三种,分别是:pending(初始状态)、fulfilled(成功的状态)、rejected(失败的状态)

const p1 = new Promise(()=>{});
console.log(p1);// 状态为:pending,值为undefined,什么情况下状态会成功或者失败呢?

2.2.3 更改状态的三种方案(成功一种,失败两种)

只有三种方式可以将 Promise 状态从 pending 变更为最终态:

方案 1:调用 resolve() → 变为 fulfilled(成功)
执行器函数接收 resolve 作为参数,调用它会将状态改为成功,并传入成功结果。

const p = new Promise(function(resolve, reject){
    resolve(1); // 调用 resolve,将状态从pending变为fulfilled,成功值为1
});
console.log(p); // Promise {<fulfilled>: 1}

方案 2:调用 reject() → 变为 rejected(失败)
执行器函数接收 reject 作为参数,调用它会将状态改为失败,并传入失败原因。

const p = new Promise(function(resolve, reject){
    reject(2); // 调用reject,将状态从pending变为rejected,失败的值为2
});
console.log(p); // Promise {<rejected>: 2}

方案 3:执行器内抛出异常 → 自动变为 rejected(失败)
如果执行器函数中出现代码错误(如类型错误)或手动 throw 异常,Promise 会自动变为 rejected

// 自动类型错误
const p = new Promise(function(){
    let a = 1;
    a(); // 类型错误,将状态从pending变为rejected,失败的值为异常TypeError 信息
});
console.log(p); // 注意!执行器函数中有异常,后面的程序还会继续执行,打印p
// Promise {<rejected>: TypeError: a is not a function}

// 手动抛出异常
const p = new Promise(function(){
    throw "异常"; // 将状态从pending变为rejected,失败值为字符串 "异常"
});
console.log(p); // Promise {<rejected>: "异常"}

2.2.4 状态只允许更改一次!

Promise 状态只能变更一次,一旦从 pending 变为 fulfilledrejected,就会永久锁定,后续的状态修改操作都会被忽略。

const p = new Promise(function(resolve,reject){
 	resolve(1); // 先变为 fulfilled
 	reject(2); // 无效,状态已锁定
});
console.log(p);// fullfiled 1 所以打印的就是成功,值为1。

// 已经变成成功了,下面再变为失败就变不了了。即使手动抛异常也无济于事。
const p = new Promise(function(resolve,reject){
 	resolve(1);
 	reject(2);
   throw "异常"; // 无效
});
console.log(p);// fullfiled 1 所以打印的就是成功,值为1。

// ⚠️ 注意:状态变更后,执行器函数内的剩余代码仍会继续执行,只是无法再改变 Promise 状态:
const p = new Promise(function(resolve,reject){
 	resolve(1);
 	reject(2);
 	console.log(111111)// // 会正常输出
 	throw "异常"; // 抛出异常,但状态已锁定
 	console.log(22222)// 不会执行,因为异常中断了后续代码
});
console.log(p);// fullfiled 1

2.2.5 代码练习:then () 方法的基础使用

.then() 是 Promise 原型上的方法,用于监听状态变更后的结果,它接收两个函数参数:
第一个函数:成功回调,当 Promise 变为 fulfilled 时异步执行,参数为成功值 value
第二个函数:失败回调,当 Promise 变为 rejected 时异步执行,参数为失败原因 reason

const p = new Promise(function(resolve,reject){
   // 未调用 resolve/reject 时,状态为 pending,值为 undefined
});
console.log(p);
==================================================================
const p = new Promise(function(resolve,reject){
    
});
console.log(p);// 发现原型上有个then方法,所以就可以调用原型上的方法:
// p.then()可以接收两个参数,参数的类型均为函数。
// 第一个函数称为成功回调(当p的状态由pending-->fulfilled后异步执行),接收的参数是成功值
// 第二个函数称为失败回调(当p的状态由pending-->rejected后异步执行),接收的参数是失败值 
p.then(function(value){// 成功值一般叫value
    console.log("成功回调",value);
},function(reason){// 失败值一般叫reason
    console.log("失败回调",reason)
})
=================================================================
const p = new Promise(function(resolve,reject){
    resolve(100);
});
p.then(function(value){
    console.log("成功回调",value);// 输出成功回调 100
},function(reason){
    console.log("失败回调",reason)
})
=================================================================
const p = new Promise(function(resolve,reject){
    resolve(100);
    reject(200);
});
p.then(function(value){
    console.log("成功回调",value);// 还是输出成功回调 100,因为状态只能更改一次
},function(reason){
    console.log("失败回调",reason)
})
=================================================================
const p = new Promise(function(resolve,reject){
    reject(200);
    throw "异常";
});
p.then(function(value){
    console.log("成功回调",value);
},function(reason){
    console.log("失败回调",reason)// 输出失败回调 200
})
=================================================================
const p = new Promise(function(resolve,reject){
    throw "异常";
});
p.then(function(value){
    console.log("成功回调",value);
},function(reason){
    console.log("失败回调",reason)// 输出失败回调 异常
})

通过 .then() 我们可以监听 Promise 状态变更后的结果,这也是后续异步流程处理的基础。

2.2.6 then() 方法返回的是一个新 Promise 实例(重点)

2.3 用 Promise 改造 AJAX 示例

我们可以把第一部分的 AJAX 异步逻辑,用 Promise 重新封装,直观感受它和传统回调的差异:

// 用 Promise 封装 AJAX 请求
function ajaxPromise() {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("get", "2-回调函数.html");
    xhr.send();
    xhr.onload = function() {
      // 请求完成后,将响应体传递给 resolve,标记异步操作成功
      resolve(xhr.response);
    };
    // 增加错误处理,标记异步操作失败
    xhr.onerror = function() {
      reject(new Error("网络请求失败"));
    };
  });
}

// 调用时用 then 接收成功结果,catch 处理错误
ajaxPromise()
  .then(function(res) {
    console.log("成功获取数据:", res);
  })
  .catch(function(err) {
    console.error("请求出错:", err);
  });

核心变化:

  • 不再需要在调用时传入回调函数,而是通过 then() 链式调用处理结果。
  • resolve(xhr.response) 会把结果传递给 then 的回调参数 res,和之前 cb(xhr.response) 的作用一致。
  • 多层异步操作时,可以通过链式 then 避免嵌套,从根本上解决 “回调地狱” 问题。

三、async/await:异步编程的终极优雅方案

3.1 为什么还需要 async/await?

虽然 Promise 解决了回调地狱,但依然是基于回调 + 链式调用的写法:依然要写 .then().catch(),流程控制、条件判断、循环等写起来还是不够直观。

async/await 本质:是 Promise 的语法糖;让异步代码写起来和同步代码几乎一模一样;可读性、可维护性达到目前最高水平。

3.2 基本用法

// 函数前面加 async,就表示这是一个异步函数
async function getDate() {
  try {
    // await 后面跟一个 Promise
    const res = await ajaxPromise();
    console.log(res);
  } catch (err) {
    // 统一捕获错误
    console.error(err);
  }
}

getData();

特点:代码线性、扁平,没有嵌套;用 try/catch 统一处理异常,非常符合同步代码习惯;阅读和调试都极其方便。

3.3 用 async/await 改造 AJAX 示例

直接基于我们前面封装好的 ajaxPromise

// 前面封装好的 Promise 版 AJAX
function ajaxPromise() {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("get", "2-回调函数.html");
    xhr.send();
    xhr.onload = () => resolve(xhr.response);
    xhr.onerror = () => reject(new Error("请求失败"));
  });
}

// async/await 调用
async function fetchData() {
  try {
    const result = await ajaxPromise();
    console.log("数据:", result);
  } catch (error) {
    console.error("出错:", error);
  }
}

fetchData();

3.4 串行多个请求(对比最明显)

回调地狱

ajax(res1 => {
  ajax(res2 => {
    ajax(res3 => {
      console.log(res3)
    })
  })
})

Promise 链式

ajaxPromise()
  .then(() => ajaxPromise())
  .then(() => ajaxPromise())
  .then(res => console.log(res))

async/await:一眼就能看出:代码从上到下顺序执行,完全像同步代码。

async function getAll() {
  await ajaxPromise();
  await ajaxPromise();
  const res = await ajaxPromise();
  console.log(res);
}

四、三种方案完整对比

方案 写法特点 可读性 错误处理 适用场景
传统回调 嵌套多层(回调地狱),需手动传递回调函数 差(层级越深越难维护) 分散在各层回调中,无统一处理方式 简单异步场景、异步编程入门学习
Promise 链式调用(.then()/.catch()),无嵌套 较好(线性流程,逻辑清晰) 统一通过 .catch() 捕获所有环节错误 中大型项目、复杂异步流程(串行/并行请求)
async/await 类同步写法,无嵌套、无链式调用 极好(与同步代码逻辑一致,易读易调试) 通过 try/catch 统一捕获,符合同步编程习惯 现代前端开发、主流首选方案,适配各类异步场景

一句话总结:
回调函数:异步编程的基础,但容易写出回调地狱。
Promise:解决回调地狱,让异步流程线性化。
async/await:在 Promise 基础上,让异步代码写得和同步代码一样优雅。

五、拓展:Promise 与 jQuery 异步方案的对比与关联

在 Promise 成为 ES6 语言标准之前,jQuery 早已在前端领域广泛使用,并通过 Deferred 对象与 $.ajax 提供了一套成熟的异步解决方案。我们可以从历史渊源、核心共性与关键差异三个维度,理解两者的关系与价值。

5.1 历史渊源:Promise 思想的早期实践

  • jQuery 在 2011 年推出的 Deferred 对象,是Promise 设计思想的重要先驱,它率先提出了「状态管理 + 链式回调」的异步模式,解决了早期回调嵌套问题。
  • 后来 ES6 原生 Promise 规范(Promises/A+)正是吸收了包括 jQuery Deferred 在内的多种异步方案的优点,最终成为 JavaScript 语言级标准。
  • jQuery 3.0 之后也做了兼容调整,让 Deferred 对象符合 Promises/A+ 规范,可以和原生 Promise 混用。

5.2 核心共性:解决异步问题的思路一致

  1. 状态管理思想

    • jQuery Deferred:pending → resolved/rejected
    • 原生 Promise:pending → fulfilled/rejected
    • 两者都用「状态流转」来标记异步操作的进展,只有状态改变后才会触发回调。
  2. 链式调用避免回调地狱

    • jQuery:$.ajax().done().fail().always()
    • Promise:.then().catch().finally()
    • 都通过链式写法,把多层嵌套展平为线性流程,提升可读性。
  3. 分离「发起请求」与「处理结果」

    两者都将异步操作的发起(发送请求)和结果处理(成功 / 失败回调)解耦,让代码更模块化、可复用。

5.3 关键差异:库实现 vs 语言标准

  1. 出身与定位不同:jQuery 的 Deferred 是第三方库内部实现,完全服务于 jQuery 生态,依赖 jQuery 环境才能使用;而原生 Promise 是 ES6 语言级标准,不依赖任何库,在所有现代 JS 环境(浏览器、Node.js 等)中都能直接运行。
  2. 通用性不同:jQuery 的异步方案主要绑定在 AJAX 请求和 DOM 操作场景,适用范围较窄;原生 Promise 则是通用异步抽象,能封装所有异步场景(定时器、文件读写、网络请求、Web Worker 等)。
  3. 规范与兼容性不同:早期 jQuery Deferred 是自定义实现,并不符合 Promises/A+ 规范,直到 jQuery 3.0 才做了兼容调整;而原生 Promise 从诞生起就严格遵循 Promises/A+ 规范,是 async/await 语法的底层基础,生态兼容性更稳定。
  4. 错误处理与生态地位不同:jQuery 用 .fail() 捕获错误、.always() 做收尾,错误处理分散在链式调用中;原生 Promise 用 .catch() 统一捕获整条链上的错误,.finally() 做兜底,更健壮。如今 jQuery 已逐渐衰落,而原生 Promise 是现代前端异步编程的基石,是所有主流框架的底层依赖。

5.4 学习启示:从历史看技术演进

jQuery Deferred 是「过渡方案」:它在浏览器原生能力不足的年代,用库的方式解决了异步痛点,但始终受限于 jQuery 生态。

原生 Promise 是「最终标准」:它将优秀的异步思想沉淀为语言能力,不仅解决了回调地狱,还为更优雅的 async/await 铺平了道路。

对我们学习的启发:技术方案总是从「库级封装」走向「语言级标准」,理解这一点,能帮我们更好地判断哪些方案是长期值得投入的。


总结

本文从最基础的 AJAX 异步请求出发,完整梳理了 JavaScript 异步编程的演进路径:

  • 回调函数:作为异步编程的起点,通过闭包实现了异步数据的灵活处理,但多层嵌套会导致 “回调地狱”,可读性与可维护性极差。
  • Promise:作为进阶方案,用状态管理与链式调用解决了回调地狱问题,将异步流程线性化,为现代异步编程奠定了基础。
  • async/await:作为终极优雅方案,在 Promise 基础上用类同步的写法,让异步代码的可读性与调试体验达到了最优。
  • 拓展思考:jQuery 的 Deferred 是 Promise 思想的早期实践,它证明了 “状态 + 链式” 异步方案的价值,但最终被语言标准的原生 Promise 所取代,体现了技术从 “库级封装” 走向 “语言级标准” 的演进规律。

通过这一路径,我们能清晰看到:异步编程的本质,是在不断追求更清晰、更健壮、更接近人类思维的代码表达方式。从回调到 Promise,再到 async/await,每一次演进都是为了让开发者能更轻松地驾驭复杂的异步逻辑,这也是前端工程化与现代化的核心体现之一。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐