异步编程:从“回调地狱”到“async/await”的救赎之路

JavaScript是单线程的,但它却能同时处理很多事情。这是怎么做到的?今天我们就来聊聊异步编程,看看JS是怎么一边听歌一边刷网页的。从最原始的回调函数,到Promise,再到优雅的async/await,这不仅是技术的演进,更是一场"程序员不熬夜"的运动。

前言

你有没有经历过这种绝望:写了一个网络请求,结果后面的代码先执行了,请求的数据还没回来,页面已经渲染完了,一片空白。或者你见过这样的代码:

js 复制代码
getUser(function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetails(orders[0].id, function(details) {
      getProductInfo(details.productId, function(product) {
        console.log(product);
      });
    });
  });
});

这就是传说中的回调地狱------代码像楼梯一样往右歪,看得人头晕眼花。

今天我们就来走一遍JS异步编程的进化史,看看前辈们是怎么从地狱里爬出来的。

一、为什么需要异步?

JavaScript是单线程的,也就是说同一时间只能做一件事。如果所有事情都排队等着,那遇到一个耗时操作(比如网络请求、读取文件),整个页面就得卡住,用户点哪儿都没反应。

异步就是解决方案:遇到耗时操作,先丢给浏览器或Node去"慢慢做",JS主线程继续执行后面的代码。等耗时操作完成了,再通知JS:"嘿,我完事了,你处理一下结果吧。"

这就好比你点外卖:你不会站在店门口干等一小时,而是该干嘛干嘛,等外卖小哥打电话叫你,你再去取餐。异步就是这种"不干等"的机制。

二、回调函数:异步的原始形态

回调函数是最早的异步解决方案:把一个函数作为参数传给另一个函数,等异步操作完成后调用这个函数。

js 复制代码
function fetchData(callback) {
  setTimeout(() => {
    callback('数据来了');
  }, 1000);
}

fetchData(function(data) {
  console.log(data); // 一秒后输出:数据来了
});

看起来还行,对吧?但一旦有多个依赖的异步操作,就出事了。

回调地狱长什么样?

js 复制代码
// 先获取用户
getUser(function(user) {
  // 再根据用户ID获取订单
  getOrders(user.id, function(orders) {
    // 再获取第一个订单的详情
    getOrderDetails(orders[0].id, function(details) {
      // 再根据商品ID获取商品信息
      getProductInfo(details.productId, function(product) {
        // 终于拿到了
        console.log(product);
      });
    });
  });
});

代码往右飞,一眼看不到头。这还没算错误处理------每个回调都要处理错误,代码量直接翻倍。这种代码别说维护了,写的时候自己都要绕晕。

回调的痛点

  • 嵌套太深,代码可读性差
  • 错误处理困难,每个回调都要try-catch
  • 难以并行执行多个异步操作

三、Promise:打破地狱的"链式反应"

ES6引入了Promise,它像是一个"承诺":现在还没有结果,但将来一定会有(要么成功,要么失败)。

js 复制代码
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('数据来了');
    // 如果出错:reject('错误信息')
  }, 1000);
});

promise
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error(error);
  });

Promise最大的好处是链式调用,可以把嵌套的异步操作拍平:

js 复制代码
getUser()
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getProductInfo(details.productId))
  .then(product => console.log(product))
  .catch(error => console.error(error));

看,从"右飞"变成了"下飞",代码清晰多了。

Promise的几个关键点

  1. 状态不可逆:Promise有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。一旦从pending变成fulfilled或rejected,就不能再变了。

  2. 链式传递then返回的是一个新的Promise,所以可以一直链下去。

  3. 错误冒泡 :只要链尾有一个catch,前面任何一个环节出错都会落进来。

  4. 并行操作Promise.all等待所有完成,Promise.race等待最快的一个。

js 复制代码
// 并行请求
Promise.all([fetchUser(), fetchOrders(), fetchProduct()])
  .then(([user, orders, product]) => {
    console.log('全部完成', user, orders, product);
  });

Promise解决了回调地狱的问题,但还是有些繁琐------你需要写很多.then.catch,而且处理复杂的逻辑时,还是有点绕。

四、async/await:异步代码同步写

ES2017推出的async/await,是Promise的语法糖,让异步代码看起来像同步代码一样直观。

js 复制代码
async function getProductInfo() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const product = await getProductInfo(details.productId);
    console.log(product);
  } catch (error) {
    console.error(error);
  }
}

关键点

  • async标记的函数返回一个Promise
  • await后面跟一个Promise,它会"暂停"函数执行,直到Promise出结果
  • 错误处理直接用try/catch,和同步代码一模一样

这感觉就像:终于可以用写同步代码的姿势写异步了!不用再管什么then、catch,代码一下子就清爽了。

但注意:await会阻塞函数内部,但不阻塞外部

js 复制代码
async function test() {
  console.log('1');
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('2'); // 一秒后才输出
}
console.log('3');
test();
console.log('4');
// 输出顺序:1,3,4,(一秒后)2

await只阻塞它所在的async函数,外面的代码照常执行。这正是异步的精髓:不干等。

五、事件循环:异步背后的幕后黑手

说了这么多,你有没有想过一个问题:异步操作完成之后,回调是怎么被调用的?这就要提到**事件循环(Event Loop)**了。

JS的执行机制大概是这样的:

  1. 主线程执行同步代码,遇到异步任务(比如setTimeout、网络请求)就交给Web APIs(浏览器)或libuv(Node)去处理。
  2. 异步任务完成后,回调函数被放入任务队列
  3. 主线程的同步代码执行完后,会不断从任务队列里取回调来执行。
  4. 这个过程不断重复,就是事件循环。

任务队列还分宏任务微任务

  • 宏任务:setTimeout、setInterval、I/O操作、UI渲染
  • 微任务:Promise.then、MutationObserver、queueMicrotask

执行顺序是:一个宏任务 → 所有微任务 → 渲染(如果有) → 下一个宏任务。

js 复制代码
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1,4,3,2

为什么?同步代码先执行(1,4)→ 微任务Promise.then(3)→ 下一个宏任务setTimeout(2)。

六、实战:封装一个带超时的fetch

我们来用async/await封装一个实用的网络请求函数:

js 复制代码
async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

// 使用
try {
  const data = await fetchWithTimeout('https://api.example.com/data', 3000);
  console.log(data);
} catch (error) {
  console.error(error.message);
}

这个函数既支持超时控制,又有完善的错误处理,用起来就像同步代码一样简单。

七、异步编程的最佳实践

  1. 能用async/await就用:比原生Promise更易读,错误处理也更自然。

  2. 避免"忘掉await":忘记await会得到一个Promise对象,而不是实际值,这个bug很难找。

  3. 并行任务用Promise.all:如果多个异步任务互不依赖,用Promise.all并行执行,而不是挨个await。

js 复制代码
// 慢:串行执行,总耗时2秒
const user = await getUser();
const orders = await getOrders();

// 快:并行执行,总耗时1秒(如果每个请求1秒)
const [user, orders] = await Promise.all([getUser(), getOrders()]);
  1. 错误处理要完整:async/await用try/catch,Promise用.catch(),不要漏掉。

  2. 避免在循环里用await:除非你确实需要串行执行,否则可以用Promise.all或for...of配合异步。

js 复制代码
// 这样会串行执行,很慢
for (const id of ids) {
  const item = await fetchItem(id);
  items.push(item);
}

// 并行执行,快很多
const items = await Promise.all(ids.map(id => fetchItem(id)));

八、总结:从地狱到天堂

JS异步编程的演进史,就是一部程序员与复杂性抗争的历史:

  • 回调函数:原始但容易陷入地狱
  • Promise:链式调用打破嵌套
  • async/await:让异步代码回归同步的直觉

现在,你应该能理解为什么异步这么重要,以及怎么优雅地处理异步了。记住:不要在回调里写回调,不要在地狱里挣扎,用Promise和async/await解救自己。

明天我们将深入JS的另一座大山------事件循环(Event Loop),彻底搞懂微任务、宏任务、渲染时机这些核心概念。到时候你会发现,那些让人头疼的异步面试题,不过是一层窗户纸。

如果你觉得今天的异步进化史讲得通透,点个赞让更多人看到。有疑问评论区见,我们明天见!

相关推荐
前端Hardy1 小时前
别再手动写 loading 了!封装一个自动防重提交的 Hook
前端·javascript·vue.js
前端Hardy1 小时前
前端如何实现“无感刷新”Token?90% 的人都做错了
前端·javascript·vue.js
秋水无痕1 小时前
# 手把手教你从零搭建 AI 对话系统 - React + Spring Boot 实战(二)
前端·后端·面试
SuperEugene2 小时前
Vue Router 实战规范:path/name/meta 配置 + 动态 / 嵌套路由,统一团队标准|状态管理与路由规范篇
开发语言·前端·javascript·vue.js·前端框架
小彭努力中2 小时前
194.Vue3 + OpenLayers 实战:动态位置 + 高度 + 角度,模拟卫星地面覆盖范围
前端·css·vue.js·openlayers·animate
用户851160276122 小时前
Spring IoC 和 AOP 的核心原理是什么?
面试
颜正义2 小时前
作为前端你还不会 Playwright 进行单元测试吗?
前端·测试
孟陬2 小时前
国外技术周刊 #3:“最差程序员”带动高效团队、不写代码的创业导师如何毁掉创新…
前端·后端·设计模式
张一凡932 小时前
easy-model -- "小而美"的React状态管理方案
前端·javascript·react.js