最开始接触 async/await 时,很多人都会发出“终于有这个功能了!”的感叹。它的语法清晰、可读性强,用起来直观又顺手。
然而,用得越久,就会发现一些常见的“坑”时常在各种项目里出现:有些是代码审查时发现的,有些是和同事讨论时暴露的问题。这些都说明异步编程本质上并不简单。
下文就结合实际经验,列出了一些常见的异步陷阱,以及更高级的用法与思考方式,让代码更健壮,也更易维护。
从回调地狱到 async/await
还记得当初的回调地狱吗?JavaScript 进化到现在,已经让我们避免了深层嵌套的回调结构。

但功能变强大了,责任也跟着变大。下面是 async/await 中常见的三大“致命罪状”。
1. 同步式的瀑布请求
糟糕示例:顺序等待
在代码审查里经常看到这样的场景:本来可以并发执行的请求,却被一个接一个地串行处理。
// 🚫 不推荐
async function loadDashboard() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  const notifications = await fetchNotifications();
}
如果每个请求都要 200ms,这里就总共要 600ms,给用户的体验自然不佳。
改进:并发执行
对于相互独立的操作,应该使用并发来节省时间:
// ✅ 建议用 Promise.all
async function loadDashboard() {
  const [user, posts, notifications] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchNotifications()
  ]);
}
同样是获取用户、帖子和通知,响应速度立刻加快三倍左右。不过,并发并非万能。
- 依赖关系:如果一个请求需要另一个请求返回的数据,就必须顺序执行。
 - 竞态条件:如果多个请求会同时修改某个共享资源,可能导致数据不一致。
 
举例:危险的并发
// 🚫 并行可能导致竞态问题
await Promise.all([
  updateUserProfile(userId, { name: 'New Name' }),
  updateUserProfile(userId, { email: 'new@email.com' })
]);
// ✅ 先后执行以防数据冲突
const user = await updateUserProfile(userId, { name: 'New Name' });
await updateUserProfile(userId, { email: 'new@email.com' });
如果并行更新同一个用户资料,服务器可能会出现覆盖数据的情况。必须根据业务逻辑判断能否并发执行。
2. 隐形错误:如何平衡异常处理
常见误区:把错误直接“吞掉”
不少人喜欢在 catch 块里写个简单的 console.error(error),然后返回 null,让外层调用时貌似一切正常。
// 🚫 隐形错误
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    return await response.json();
  } catch (error) {
    console.error(error);
    return null;  // 🚨 难以排查的隐患
  }
}
看似“处理”了错误,但实际上把错误原因都藏起来了。网络断了?JSON 解析失败?服务器返回 500?外部代码只能拿到 null,毫无头绪。
更好的做法:区分场景处理
返回空值并非一直不对。如果它只是一个不关键的功能,比如推荐列表或活动通知,给用户一个空状态也许是更友好的方式。但如果是核心数据,就应该抛出异常或者做更明确的错误处理,让上层逻辑感知到问题。
高级开发者通常会这样写:
// ✅ 有针对性的错误处理
async function fetchData(options = {}) {
  const { isCritical = true } = options;
  
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error('Data fetch failed:', error);
    
    if (isCritical) {
      // 对关键数据抛出自定义错误,交由更高层级处理
      throw new ApiError('Failed to fetch critical data', { cause: error });
    } else {
      // 非关键数据,可返回一个降级的默认值
      return { type: 'fallback', data: [] };
    }
  }
}
// 用法示例:
// 关键数据(出错时会抛异常)
const userData = await fetchData({ isCritical: true });
// 非关键通知数据(出错时返回空列表)
const notifications = await fetchData({ isCritical: false });
这样就能同时兼顾稳定性和可维护性。关键数据绝不能“悄悄失败”,而次要功能可以“优雅退化”。
3. 内存泄漏的陷阱和现代化的清理方式
典型误区:无休止的轮询
假设写了一个定时轮询,几秒钟拉取一次数据:
// 🚫 隐形的内存杀手
async function startPolling() {
  setInterval(async () => {
    const data = await fetchData();
    updateUI(data);
  }, 5000);
}
表面看上去没什么问题,但这样会导致:
- 如果 
fetchData() 执行得很慢,可能会同时发起多次请求 
改进:AbortController + 轮询管理
下面这个示例借助 AbortController 实现了更安全的轮询:
class PollingManager {
  constructor(options = {}) {
    this.controller = new AbortController();
    this.interval = options.interval || 5000;
  }
  async start() {
    while (!this.controller.signal.aborted) {
      try {
        const response = await fetch('/api/data', {
          signal: this.controller.signal
        });
        const data = await response.json();
        updateUI(data);
        
        // 等待下一次轮询
        await new Promise(resolve => setTimeout(resolve, this.interval));
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log('Polling stopped');
          return;
        }
        console.error('Polling error:', error);
      }
    }
  }
  stop() {
    this.controller.abort();
  }
}
// 在 React 组件中使用
function DataComponent() {
  useEffect(() => {
    const poller = new PollingManager({ interval: 5000 });
    poller.start();
    
    // 组件卸载时停止轮询
    return () => poller.stop();
  }, []);
  
  return <div>Data: {/* ... */}</div>;
}
通过使用 AbortController,可以在需要时终止请求并及时释放资源,更好地控制组件的生命周期和内存占用。
高级开发者的工具箱
1. 重试(Retry)模式
网络环境不稳定或第三方服务时好时坏的情况下,只尝试一次就放弃不是好办法。可以加上重试和退避策略:
async function fetchWithRetry(url, options = {}) {
  const { maxRetries = 3, backoff = 1000 } = options;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fetch(url);
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;
      // 退避延迟,指数增长
      await new Promise(r => setTimeout(r, backoff * Math.pow(2, attempt)));
    }
  }
}
除了基本的指数退避,还可以考虑:
- 使用断路器(Circuit Breaker)模式保护系统
 
2. 资源管理
启动异步操作简单,关键是如何“优雅地”停止它们。通过统一的 ResourceManager 或类似模式,可以集中处理一些关闭连接、清理定时器、取消任务等逻辑:
class ResourceManager {
  cleanup() {
    // 在这里集中处理各种操作的终止和释放
  }
}
真实场景中的模式
1. 统一的加载状态管理
不要在每个组件都写一堆 “正在加载”、“错误” 判断。可以抽象出一个自定义 Hook 或者统一的加载管理逻辑:
const { data, loading, error } = useAsyncData(() => fetchUserData());
这样可以:
2. 数据同步器(Data Synchronizer)
对于实时性要求高的应用,与其一个个写请求,不如建立一个数据同步管理器,统一处理轮询/订阅/数据合并等逻辑:
const syncManager = new DataSyncManager({
  onSync: (data) => updateUI(data),
  onError: (error) => showError(error),
  syncInterval: 5000
});
几条核心原则
常见问题
1. 什么时候用 Promise.all,什么时候用 Promise.allSettled?
Promise.all 适合所有请求都必须成功的场景Promise.allSettled 允许部分失败,适合容忍部分请求出错的需求
2. 在 React 中如何优雅地清理?
- 使用 
AbortController 终止 HTTP 请求 
展望
- 检查代码:查找本可并发却写成串行的请求;检查错误处理是否含糊不清;关注异步操作的清理是否充分。
 - 改进模式:引入更健壮的错误处理,增加重试逻辑,优化资源释放。
 - 考虑扩展性:当用户量或请求量激增时,如何保证依旧能流畅运行?如果某些服务变慢甚至挂掉,该如何部分降级?
 
为什么要在意这些细节
或许有人会说,“我的小项目没这么复杂,用不着搞这些”。但真正的好代码是能经得住放大和演进的。
- 开发者体验:清晰的异步逻辑有助于日后维护和团队协作
 - 资源利用:合理的并发和清理机制能节约服务器和客户端资源
 
这些并不是纸上谈兵,而是大量实战总结出来的硬道理。随着项目的规模和复杂度不断提升,这些异步编程模式会是你写出高质量前端代码的核心基石。
该文章在 2024/12/30 12:24:25 编辑过