阅读视图

发现新文章,点击刷新页面。

【AI 编程实战】第 10 篇:让应用更健壮 - 错误处理与边界情况

功能做完了,但用户体验还差一步——当网络出错、接口超时、数据异常时,应用会不会崩溃?用户能不能看到友好的提示?这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,构建健壮、友好、可维护的错误处理体系。

系列专栏【AI 编程实战】专栏目录

本篇主题:让应用更健壮 - 错误处理与边界情况

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:错误处理的重要性

1.1 没有错误处理的代码

// ❌ 危险:没有任何错误处理
const handleGenerate = async () => {
  const response = await chatService.generateReply({ text: inputText.value });
  replies.value = response.data.replies;
};

可能出现的问题

  • 网络断开 → 应用白屏
  • 接口超时 → 无任何反馈
  • 返回数据为空 → Cannot read property 'replies' of undefined
  • 用户点击多次 → 重复请求

1.2 完善的错误处理

// ✅ 健壮:完整的错误处理
const handleGenerate = async () => {
  if (loading.value) return;  // 防重复点击

  loading.value = true;
  try {
    const response = await chatService.generateReply({ text: inputText.value });

    if (response.code !== 200) {
      throw new Error(response.msg || '生成失败');
    }

    replies.value = response.data?.replies ?? [];  // 空值保护
  } catch (error) {
    const errMsg = error?.message || '网络错误,请稍后再试';
    toast.error(errMsg);
    console.error('生成回复失败:', error);
  } finally {
    loading.value = false;
  }
};

1.3 本文要解决的问题

问题 解决方案
网络错误 统一错误提示 + 重试机制
接口错误码 分类处理(401/403/500)
空值异常 可选链 + 空值合并
重复点击 loading 状态锁定
用户取消 区分取消和真正的错误

二、统一错误提示:Toast 封装

2.1 我的需求

我:项目里很多地方要显示提示,有成功、失败、警告等类型。
    每次都写 uni.showToast 太繁琐,能不能封装一下?

2.2 AI 设计方案

AI:封装一个 toast 工具,提供链式调用:

📐 接口设计:

toast.success('操作成功')
toast.error('操作失败')
toast.warning('请注意')
toast.info('提示信息')

📐 配置选项:

interface ToastOptions {
  duration?: number;      // 显示时长,默认 2000ms
  position?: 'top' | 'middle' | 'bottom';  // 位置
  mask?: boolean;         // 是否显示遮罩防止穿透
}

2.3 AI 生成代码

// src/utils/toast.ts
type ToastType = 'success' | 'error' | 'warning' | 'info';

interface ToastOptions {
  type?: ToastType;
  duration?: number;
  position?: 'top' | 'middle' | 'bottom';
  message: string;
  mask?: boolean;
}

export function showToast(message: string): void;
export function showToast(options: ToastOptions): void;
export function showToast(options: ToastOptions | string): void {
  const defaultOptions: ToastOptions = {
    type: 'info',
    duration: 2000,
    position: 'middle',
    message: '',
    mask: false,
  };

  const mergedOptions = typeof options === 'string'
    ? { ...defaultOptions, message: options }
    : { ...defaultOptions, ...options };

  // 映射 position 到 UniApp 支持的格式
  const positionMap = {
    top: 'top',
    middle: 'center',
    bottom: 'bottom',
  };

  uni.showToast({
    title: mergedOptions.message,
    duration: mergedOptions.duration,
    position: positionMap[mergedOptions.position ?? 'middle'],
    icon: 'none',
    mask: mergedOptions.mask,
  });
}

// 便捷方法
type SimpleToastOptions = Omit<ToastOptions, 'type' | 'message'>;

export const toast = {
  success: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'success', message }),
  error: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'error', message }),
  warning: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'warning', message }),
  info: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'info', message }),
};

2.4 使用示例

import { toast } from '@/utils/toast';

// 简单用法
toast.success('保存成功');
toast.error('网络错误,请稍后再试');

// 带配置
toast.info('正在处理...', { duration: 3000, mask: true });

三、接口错误分类处理

3.1 我的需求

我:后端返回不同的错误码:
    - 401:未登录或 token 过期
    - 403:没有权限(如 VIP 功能)
    - 500:服务器错误

    不同错误码要有不同的处理逻辑。

3.2 AI 设计错误分类

AI:在 HTTP 层统一处理常见错误码,业务层处理特殊错误:

📐 错误分类:

【HTTP 层处理】(在 http.ts 中)
- 401 → 清除登录态,跳转登录
- 网络错误 → 显示"网络错误"提示

【业务层处理】(在页面中)
- 403 → 引导开通 VIP
- 业务错误 → 显示具体错误信息

3.3 HTTP 层错误处理

// src/http/http.ts
export function http<T>(options: CustomRequestOptions) {
  return new Promise<IResData<T>>((resolve, reject) => {
    uni.request({
      ...options,
      success(res) {
        // 2xx 状态码视为成功
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data as IResData<T>);
        }
        // 401 未授权
        else if (res.statusCode === 401) {
          // 清除登录态
          uni.removeStorageSync('token');
          // 可以触发全局事件或跳转登录
          reject(res);
        }
        // 其他错误
        else {
          if (!options.hideErrorToast) {
            const data = res.data as IResData<T>;
            uni.showToast({
              icon: 'none',
              title: data.msg || '请求错误',
            });
          }
          reject(res);
        }
      },
      // 网络错误
      fail(err) {
        uni.showToast({
          icon: 'none',
          title: '网络错误,换个网络试试',
        });
        reject(err);
      },
    });
  });
}

3.4 业务层错误处理

// 页面中处理 403 等业务错误
const handleGenerate = async () => {
  loading.value = true;

  try {
    const response = await chatService.generateReply(params);

    if (response.code !== 200) {
      throw new Error(response.msg || '生成失败');
    }

    replies.value = response.data?.replies ?? [];
  } catch (error: any) {
    console.error('生成回复失败:', error);

    // 提取错误信息
    const errMsg = error?.data?.message
      || error?.msg
      || error?.message
      || '生成失败,请稍后再试';

    // 根据状态码分类处理
    const statusCode = error?.statusCode || error?.data?.code || error?.code;

    if (statusCode === 403) {
      // 权限不足,引导开通 VIP
      const message = errMsg || '免费次数已用完,请开通VIP';
      promptOpenVip(message);
    } else {
      // 其他错误,显示提示
      toast.error(errMsg);
    }
  } finally {
    loading.value = false;
  }
};

四、空值保护:防御性编程

4.1 常见的空值错误

// ❌ 危险:可能报错
const username = response.data.user.username;
// 如果 response.data 是 undefined,就会报错

const firstReply = replies[0].text;
// 如果 replies 是空数组,就会报错

4.2 AI 教我防御性编程

AI:用可选链(?.)和空值合并(??)来保护:

📐 防御性编程规则:

1. 访问对象属性 → 用 ?.
2. 提供默认值 → 用 ??
3. 数组访问 → 先检查长度或用 ?.[0]

4.3 实践示例

// ✅ 安全:可选链 + 空值合并
const username = response?.data?.user?.username ?? '未知用户';

// ✅ 安全:数组保护
const replies = response.data?.replies ?? [];
const firstReply = replies.length > 0 ? replies[0].text : '';

// ✅ 安全:解构时提供默认值
const { data = {} } = response ?? {};
const { replies = [], analysis = '' } = data;

// ✅ 安全:函数参数默认值
const updateUserInfo = (info: Partial<UserInfo> = {}) => {
  // info 一定是对象,不会是 undefined
};

4.4 实际代码中的应用

// 生成回复的完整错误处理
const handleGenerate = async () => {
  // ... 请求逻辑

  const data = response.data ?? {};

  // 安全提取分析结果
  analysisResult.value = data.analysis || '';

  // 安全提取回复列表,并添加 id
  const repliesFromApi = Array.isArray(data.replies)
    ? data.replies.slice(0, MEMORY_REPLY_COUNT)
    : [];

  replies.value = repliesFromApi.map((r, idx) => ({
    ...r,
    id: r.id ?? idx,  // 如果没有 id,用索引代替
  }));

  // 安全更新配额信息
  userStore.updateFreeQuota({
    free_reply_total: data.freeReplyTotal,
    free_reply_used: data.freeReplyUsed,
    free_reply_remaining: data.remainingTimes,
    vip_status: data.isVip ? 'vip' : 'none',
    vip_expire_time: data.vipExpireTime ?? null,
  });
};

五、用户取消 vs 真正的错误

5.1 问题场景

我:用户选择图片时点了"取消",会触发 fail 回调,
    显示"图片选择失败"的错误提示。
    但这不是真正的错误,不应该提示。

5.2 AI 分析区分方法

AI:通过错误信息来区分用户取消和真正的错误:

📊 错误信息分析:

【用户取消】
- chooseMedia:fail cancel
- chooseImage:fail cancel
- chooseImage:fail(code: 0)

【真正的错误】
- chooseImage:fail no permission
- chooseMedia:fail system error
- 其他错误信息

5.3 实现代码

const addImages = () => {
  const { run } = useUpload('', {}, {
    count: remainingCount,
    selectOnly: true,
    onSelect: (images) => {
      // 正常处理选中的图片
      images.forEach((img) => uploadChosenImage(img));
    },
    onError: (err) => {
      // 判断是否为用户主动取消
      const errMsg = typeof err === 'object' && err !== null && 'errMsg' in err
        ? (err as any).errMsg
        : '';
      const errCode = typeof err === 'object' && err !== null && 'code' in err
        ? (err as any).code
        : -1;

      const isUserCancel =
        errMsg === 'chooseMedia:fail cancel' ||
        errMsg === 'chooseImage:fail cancel' ||
        (errMsg === 'chooseImage:fail' && errCode === 0);

      if (!isUserCancel) {
        // 只有真正的错误才提示
        console.error('图片选择失败:', err);
        toast.error('图片选择失败');
      }
      // 用户取消时静默处理,不显示任何提示
    },
  });

  run();
};

六、Promise 封装:统一回调风格

6.1 我的需求

我:uni.login 等 API 用的是回调风格,写起来很繁琐。
    能不能封装成 Promise 风格?

6.2 AI 封装示例

// src/utils/wechat.ts
/**
 * 获取微信登录凭证
 * 将回调风格封装成 Promise
 */
export const requestWechatLoginCode = () =>
  new Promise<string>((resolve, reject) => {
    uni.login({
      provider: 'weixin',
      onlyAuthorize: true,
      success: (res) => {
        if (res?.code) {
          resolve(res.code);
        } else {
          reject(new Error('未获取到有效的登录凭证'));
        }
      },
      fail: (err) => {
        reject(err);
      },
    });
  });

// 使用时
try {
  const code = await requestWechatLoginCode();
  await userStore.wechatLogin(code);
} catch (error) {
  toast.error('登录失败');
}

6.3 剪贴板封装

// src/utils/clipboard.ts
import { isApp } from '@/utils/platform';

declare const plus: any;

/**
 * 跨平台复制文本到剪贴板
 */
export function copyText(text: string): Promise<void> {
  // App 端使用 plus API
  if (isApp && typeof plus !== 'undefined' && plus?.navigator) {
    return new Promise((resolve, reject) => {
      try {
        plus.navigator.setClipboard(text);
        resolve();
      } catch (error) {
        reject(error);
      }
    });
  }

  // 小程序/H5 使用 uni API
  return new Promise((resolve, reject) => {
    uni.setClipboardData({
      data: text,
      success: () => resolve(),
      fail: (err) => reject(err),
    });
  });
}

// 使用时
const handleCopy = async (text: string) => {
  try {
    await copyText(text);
    toast.success('已复制');
  } catch (error) {
    console.error('复制失败:', error);
    toast.error('复制失败,请稍后再试');
  }
};

七、Loading 状态管理

7.1 防止重复点击

const loading = ref(false);

const handleSubmit = async () => {
  // 防止重复点击
  if (loading.value) return;

  loading.value = true;
  try {
    await doSubmit();
  } finally {
    loading.value = false;
  }
};

7.2 按钮状态联动

<template>
  <XButton
    :text="loading ? '处理中...' : '提交'"
    :loading="loading"
    :disabled="loading || !canSubmit"
    @click="handleSubmit"
  />
</template>

7.3 多个 Loading 状态

// 页面有多个独立的加载状态
const isGenerating = ref(false);      // 生成回复
const isUploadingImages = ref(false); // 上传图片
const isSaving = ref(false);          // 保存数据

// 计算总体加载状态
const isLoading = computed(() =>
  isGenerating.value || isUploadingImages.value || isSaving.value
);

// 细粒度控制按钮状态
<XButton
  :disabled="isGenerating || isUploadingImages"
  @click="handleGenerate"
/>

八、错误处理最佳实践

8.1 错误处理清单

层级 处理内容 示例
HTTP 层 网络错误、401 http.ts 统一处理
业务层 403、业务错误码 页面 catch 块处理
UI 层 用户提示、状态恢复 toast + loading

8.2 代码模板

// 标准的异步操作模板
const handleAsyncAction = async () => {
  // 1. 防重复
  if (loading.value) return;

  // 2. 前置校验
  if (!canSubmit.value) {
    toast.warning('请填写完整信息');
    return;
  }

  loading.value = true;

  try {
    // 3. 执行操作
    const response = await doAction();

    // 4. 校验响应
    if (response.code !== 200) {
      throw new Error(response.msg || '操作失败');
    }

    // 5. 处理成功
    const data = response.data ?? {};
    processData(data);
    toast.success('操作成功');

  } catch (error: any) {
    // 6. 分类处理错误
    console.error('操作失败:', error);

    const statusCode = error?.statusCode || error?.code;
    const errMsg = error?.msg || error?.message || '操作失败';

    if (statusCode === 403) {
      handleNoPermission();
    } else {
      toast.error(errMsg);
    }

  } finally {
    // 7. 恢复状态
    loading.value = false;
  }
};

8.3 总结

技术 用途 示例
try-catch-finally 捕获和恢复 异步操作的标准模式
可选链 ?. 安全访问属性 response?.data?.user
空值合并 ?? 提供默认值 value ?? defaultValue
Promise 封装 统一回调风格 requestWechatLoginCode()
错误分类 差异化处理 401/403/500 不同处理
Loading 状态 防重复 + 用户反馈 if (loading) return

错误处理不是"事后补救",而是设计阶段就要考虑的架构问题

好的错误处理让用户感受到应用的专业和可靠。

如果这篇文章对你有帮助,请点赞、收藏、转发!

❌