我的错误处理方案演进

388 阅读4分钟

我一直在思考如何处理前端项目中的错误,尤其是接口请求时的「业务错误」,从最开始在 axios 响应拦截器中判断并提示;到将错误从拦截器层层向上抛,在最顶层使用 addEventListener("unhandledrejection", () => {}) 统一处理;再到现在回归最原始的 if (error) {} 判断,下面说说我的演进过程,以及我现在认为最好的错误处理方案,给有类似需求的同学一个参考。

最初-冗余的错误判断

刚开始写代码时是这样处理业务错误的

handleClick = () => {
  const resp = await submitValues(values);
  if (resp.code !== 0) {
    // 请求失败,提示错误信息
    message.error(resp.msg);
    return;
  }
  // 请求成功
  console.log(data);
};

接口请求返回 resp,结构是 { code: number; msg: string; data: unknown };请求成功,则 code 为 0,否则非 0,并且 msg 给出具体的错误提示。

如果一个页面的请求比较多,这类模板代码就会非常多,于是演化成了这样。

统一-在响应拦截器处理错误

这可能是目前大部分同学正在使用的方案,好处是不再有过多的模板代码,业务代码简洁干净。

interceptors.response.use(() => {
  // 判断 code 非 0, antd message.error(msg); 否则 return Promise.resolve();
}, { global: false });

handleClick = () => {
  const data = await submitValues(values);
  // 请求成功
  console.log(data);
};

如果请求 code 非0,在响应拦截器就被处理掉,不会出现在业务代码。但是缺点如下

  1. 当希望该请求静默失败,或自定义处理错误时麻烦,需要额外拓展,比如支持在请求时指定「不处理错误」
  2. 请求方法与视图耦合,不便于复用。我移动端 PC 端逻辑一样,一个用 antd 一个用 antd-mobile,结果请求方法依赖 antd

但是大部分同学应该不存在「复用」的需求,所以 2 属于一个伪问题,该方案依然能满足需求,这也是该方案一直盛行的原因。

进阶-在最顶层而非最底层统一处理

如果将前端代码划分层级,那应该是这样的

视图层 <- 模型层 <- 服务层 <- 基础库层

基础库层即对 axios 进行封装,暴露出的 request 方法;服务层为调用 request 方法请求后端数据并返回;模型层大概率没有,或者是 reduxdva 这类状态管理库;视图层就是写 jsx 的地方了,在这里交互,渲染状态。

前面提到的「响应拦截器」就位于「基础库层」,如果在该层抛出一个 Promise.rejct(),会被上一层所捕获(更确切的说法是被调用基础库的方法捕获),如果上一层没有 try catch 处理,又会继续向上抛,直到视图层,如果视图层仍没有 try catch,那么就会到达全局,在控制台打印一个 Uncaught Error

image.png

借助这种类似「冒泡」的机制,既可以在顶层监听 unhandledrejection 实现统一处理,还可以在任意一层 try catch 自定义处理,同时底层库不再与视图耦合,具备更强的复用性。

// 基础库层
interceptors.response.use(() => {
  // 判断 code 非 0, throw Promise.reject(new Error(msg)); 否则 return Promise.resolve();
}, { global: false });
export function request() {}

// 服务层
function submitValues(values) {
  return request.post('/api/user', values);
}

// 模型层
class UserAdding {
  submit() {
    return submitValues(this.values);
  }
}

// 视图层
handleClick = () => {
  const data = await userAddingStore.submit();
  // 请求成功
  console.log(data);
}

// main.tsx 或者一些根文件
window.addEventListener('unhandledrejection', (error) => {
  if (error.message) {
    message.error(error.message);
  }
});

当我实现该方案时是非常兴奋的,以为找到了错误处理的最终方案,在实践中,它的确满足了我的预期,在 PC 与移动端间复用了基础库层、服务层与模型层;默认有错误提示,try catch 可静默请求或自定义错误展示。

但是仍存在一些问题,一些库如 antd 中的表单 form.validateFields 也会抛出 Promise.reject,导致错误收集平台将这类错误也收集了,或许我应该给业务错误一个标志来区分。

这个方案在我们项目运行了 2 年多时间。

学习-类似的思路更好的实现

rust 函数支持返回一个 Result,它包含了成功与失败的可能性,需要分别处理,比如

let result = do_something();
match result {
  Err(_) => {
    // 请求错误
  },
  Ok(data) => {
    // 请求成功
  }
}

或者用宏来简化写法

let data = do_something().unwrap();
// 请求成功

unwrap 可以理解成 判断 result 是否是失败,如果失败,就返回错误,否则继续往下执行

也是类似「冒泡」机制。在更上一层(调用该代码的函数),继续 unwrap 或者对错误进行处理。

参考该方案,我实现了如下代码

type Result<T> = T extends null
  ? {
      error: Error;
      data: null;
    }
  : {
      error: null;
      data: T;
    };
/** 构造一个结果对象 */
export const Result = {
  /** 构造成功结果 */
  Ok: <T>(value: T) => {
    return {
      data: value,
      error: null,
    } as Result<T>;
  },
  /** 构造失败结果 */
  Err: (message: string) => {
    return {
      data: null,
      error: new Error(message),
    } as Result<null>;
  },
};

所有函数的返回值,都是由 Result.OkResult.Err 返回的

function fetchUser() {
  if (Math.random() < 0.5) {
    return Result.Ok({ name: "litao" });
  }
  return Result.Err("请求超时");
}
(async () => {
  const result = fetchUser();
  if (result.error) {
    console.log(result.error.message);
    return;
  }
  console.log(result.data.name);
})();

看似回到最开始的方案,每次请求都要进行判断,多出很多模板代码,但在类型上却更加正确。上面代码如果不先判断 result.error,直接访问 result.data.name 会有错误提示

image.png

如果能再实现类似 unwrap 的方法,这个问题我认为是完美地解决了。

并且这个方案有个 js 版简化写法

/**
 * @template T
 * @typedef {[T, T extends null ? Error : null]} RequestResult<T>
 */
function fetchUser() {
  if (Math.random() < 0.5) {
    // return Result.Ok({ name: 'litao' });
    /** @type {RequestResult<{ name: string }>} */
    const result = [{ name: 'litao' }, null];
    return result;
  }
  /** @type {RequestResult<null>} */
  const result = [null, new Error('名称已存在')];
  return result;
}

(() => {
  const [data, error] = fetchUser();
  if (error) {
    return;
  }
  console.log(data.name);
})();

同样的如果不先处理 error,下面 data.name 就会提示 data 可能是 null。该方案需要开启 strictNullChecks

image.png

点击实际体验

这个方案我正在项目中试行,后续有其他想法或问题会继续更新,欢迎讨论。