async/await 错误处理:try-catch 不是唯一答案

async/await 让异步代码更优雅,但错误处理却是个坑。本文教你多种实用的错误处理模式。

10 分钟阅读
小明

从一个线上事故说起

凌晨 2 点,手机响了。

"小明,线上挂了,用户反馈页面白屏!"

我迷迷糊糊打开电脑,查看错误日志:

Uncaught (in promise) TypeError: Cannot read property 'name' of undefined

问题出在一个 async 函数里,有个接口返回了 null,但代码直接用了 response.data.name

没有 try-catch,没有错误处理,一个未捕获的异常就这样把整个页面搞崩了。

这篇文章,我们就来彻底搞定 async/await 的错误处理。

基础:try-catch 的正确用法

最简单的 try-catch

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('获取用户失败:', error);
    return null;
  }
}

看起来很简单对吧?但这里有个问题:try 块里包含了太多代码

问题:try 块里到底该包什么?

看这段代码:

async function processUser(id) {
  try {
    const user = await fetchUser(id);
    const orders = await fetchOrders(user.id);
    const payments = await fetchPayments(orders);
    await sendEmail(user.email, payments);
    await updateDatabase(user.id, { lastLogin: new Date() });
    return { success: true };
  } catch (error) {
    // 这里的 error 可能来自任何一步
    // 你怎么知道是哪一步出了问题?
    console.error('处理失败:', error);
    return { success: false };
  }
}

问题很明显:

  1. 不知道错误来自哪一步
  2. 不同的错误需要不同的处理方式
  3. 部分步骤失败时,可能需要回滚之前的操作

更好的做法:精确捕获

async function processUser(id) {
  // 获取用户
  let user;
  try {
    user = await fetchUser(id);
  } catch (error) {
    console.error('获取用户失败:', error);
    return { success: false, step: 'fetchUser' };
  }

  // 获取订单
  let orders;
  try {
    orders = await fetchOrders(user.id);
  } catch (error) {
    console.error('获取订单失败:', error);
    return { success: false, step: 'fetchOrders' };
  }

  // ... 后续步骤类似

  return { success: true };
}

但这样写太啰嗦了!有没有更优雅的方式?

模式一:await-to-js 风格

灵感来自 Go 语言的错误处理风格:

// Go 语言
data, err := fetchData()
if err != nil {
    // 处理错误
}

我们可以封装一个工具函数:

/**
 * 包装 Promise,返回 [error, data] 格式
 * @param {Promise} promise 
 * @returns {Promise<[Error | null, T | null]>}
 */
async function to(promise) {
  try {
    const data = await promise;
    return [null, data];
  } catch (error) {
    return [error, null];
  }
}

使用方式:

async function processUser(id) {
  const [userError, user] = await to(fetchUser(id));
  if (userError) {
    console.error('获取用户失败:', userError);
    return { success: false, step: 'fetchUser' };
  }

  const [ordersError, orders] = await to(fetchOrders(user.id));
  if (ordersError) {
    console.error('获取订单失败:', ordersError);
    return { success: false, step: 'fetchOrders' };
  }

  const [paymentsError, payments] = await to(fetchPayments(orders));
  if (paymentsError) {
    console.error('获取支付记录失败:', paymentsError);
    return { success: false, step: 'fetchPayments' };
  }

  return { success: true, data: { user, orders, payments } };
}

优点

  • 每一步的错误都能精确捕获
  • 代码扁平,不需要嵌套的 try-catch
  • 可以在错误发生后继续执行其他操作

缺点

  • 每一步都要判断错误,略显繁琐

模式二:Promise 的 catch 方法

别忘了,async 函数返回的是 Promise,可以用 .catch() 来处理错误:

async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// 调用时处理错误
const user = await fetchUser(123).catch(error => {
  console.error('获取用户失败:', error);
  return null;  // 返回默认值
});

if (user) {
  console.log('用户名:', user.name);
}

这种方式特别适合需要提供默认值的场景:

async function getConfig() {
  const config = await fetchConfig().catch(() => ({
    theme: 'light',
    language: 'zh-CN',
  }));
  
  return config;
}

模式三:统一错误处理层

在实际项目中,我推荐在 API 层统一处理错误:

// api.js - 封装统一的请求函数
class ApiError extends Error {
  constructor(message, status, data) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.data = data;
  }
}

async function request(url, options = {}) {
  try {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });

    const data = await response.json();

    if (!response.ok) {
      throw new ApiError(
        data.message || '请求失败',
        response.status,
        data
      );
    }

    return data;
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    
    // 网络错误
    if (error.name === 'TypeError') {
      throw new ApiError('网络连接失败,请检查网络', 0, null);
    }
    
    throw new ApiError('未知错误', -1, error);
  }
}

// 业务 API
export const userApi = {
  getUser: (id) => request(`/api/users/${id}`),
  updateUser: (id, data) => request(`/api/users/${id}`, {
    method: 'PUT',
    body: JSON.stringify(data),
  }),
};

在组件中使用:

async function loadUser() {
  try {
    const user = await userApi.getUser(123);
    setState({ user, loading: false });
  } catch (error) {
    if (error.status === 404) {
      setState({ error: '用户不存在', loading: false });
    } else if (error.status === 401) {
      router.push('/login');
    } else {
      setState({ error: error.message, loading: false });
    }
  }
}

模式四:全局错误捕获

有些错误你可能忘记处理了,设置全局捕获作为最后防线:

// 捕获未处理的 Promise rejection
window.addEventListener('unhandledrejection', event => {
  console.error('未处理的 Promise 错误:', event.reason);
  
  // 上报到监控系统
  reportError({
    type: 'unhandledrejection',
    error: event.reason,
    url: window.location.href,
  });
  
  // 可以选择阻止默认行为(在控制台显示错误)
  // event.preventDefault();
});

注意:这只是兜底方案,不能替代正常的错误处理!

模式五:重试机制

有些错误是临时的,比如网络抖动。这时候可以实现自动重试:

async function retry(fn, options = {}) {
  const {
    maxRetries = 3,
    delay = 1000,
    backoff = 2,  // 指数退避系数
    shouldRetry = () => true,
  } = options;

  let lastError;
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      
      if (attempt === maxRetries) {
        break;
      }
      
      if (!shouldRetry(error)) {
        break;
      }
      
      const waitTime = delay * Math.pow(backoff, attempt);
      console.log(`重试 ${attempt + 1}/${maxRetries},等待 ${waitTime}ms`);
      await sleep(waitTime);
    }
  }
  
  throw lastError;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用
const user = await retry(
  () => fetchUser(123),
  {
    maxRetries: 3,
    delay: 1000,
    shouldRetry: error => error.status >= 500,  // 只重试服务端错误
  }
);

模式六:组合错误处理

在复杂场景下,可能需要组合多种模式:

// 工具函数
const to = (promise) => promise.then(d => [null, d]).catch(e => [e, null]);

// API 层:统一错误处理 + 重试
async function robustFetch(url, options) {
  return retry(
    async () => {
      const response = await fetch(url, options);
      if (!response.ok) {
        const error = new Error(`HTTP ${response.status}`);
        error.status = response.status;
        throw error;
      }
      return response.json();
    },
    {
      maxRetries: 3,
      shouldRetry: (error) => error.status >= 500,
    }
  );
}

// 业务层:精确捕获
async function checkout(cartId) {
  // 1. 验证购物车
  const [cartError, cart] = await to(validateCart(cartId));
  if (cartError) {
    return { success: false, error: '购物车验证失败' };
  }

  // 2. 创建订单
  const [orderError, order] = await to(createOrder(cart));
  if (orderError) {
    return { success: false, error: '订单创建失败' };
  }

  // 3. 处理支付(失败需要取消订单)
  const [paymentError, payment] = await to(processPayment(order));
  if (paymentError) {
    await cancelOrder(order.id);  // 回滚
    return { success: false, error: '支付失败' };
  }

  return { success: true, orderId: order.id };
}

React/Vue 中的最佳实践

React 中使用 Error Boundary

// ErrorBoundary.jsx
class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    reportError(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <div>出错了:{this.state.error.message}</div>;
    }
    return this.props.children;
  }
}

// 使用
<ErrorBoundary>
  <UserProfile userId={123} />
</ErrorBoundary>

Vue 中使用 errorCaptured

<script setup>
import { onErrorCaptured, ref } from 'vue';

const error = ref(null);

onErrorCaptured((err, instance, info) => {
  error.value = err;
  reportError(err, info);
  return false; // 阻止错误继续向上传播
});
</script>

<template>
  <div v-if="error">出错了:{{ error.message }}</div>
  <slot v-else />
</template>

总结:我的错误处理策略

经过多年的实践,我总结出这套策略:

  1. API 层:统一封装,统一错误格式
  2. 业务层:使用 to() 函数精确捕获
  3. 组件层:使用 Error Boundary 兜底
  4. 全局层:监听 unhandledrejection
  5. 临时错误:实现重试机制
┌─────────────────────────────────────────────┐
│  全局错误监听 (unhandledrejection)           │
│  ┌─────────────────────────────────────────┐│
│  │  Error Boundary                          ││
│  │  ┌─────────────────────────────────────┐││
│  │  │  业务层:to() 精确捕获               │││
│  │  │  ┌─────────────────────────────────┐│││
│  │  │  │  API 层:统一封装 + 重试         ││││
│  │  │  └─────────────────────────────────┘│││
│  │  └─────────────────────────────────────┘││
│  └─────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

记住:好的错误处理应该是分层的、可预测的、有兜底的

下一篇,我们来聊聊"程序员的春节"——如何在假期保持学习状态又不累垮自己。