前端异常处理的设计

387 阅读7分钟

一、写在前面的几句唠叨

  • 异常处理, 或者说错误处理的重要性

    上学时, C语言考试, 结果正确是得分点, 而对于输入边界的控制, 则是满分点.

    本质就是要有异常处理的意识.

    而到了工作时, 也是一样. 写"正确流程"的代码, 很简单. 但想将各种已知和未知的异常都能处理好, 则非常考验水平.

  • 前端项目, 无论react/vue/angular etc., 都需要对ajax进行异常处理.

  • 本文讨论前端的统一异常处理, 以ajax为切入点, 延伸到其他场景.

  • 本文假定读者有至少3年前端开发经验, 有处理ajax报错的经验

二、先看案例

2.1 让我们将需求一点点展开, 层层推进

假设ajax response的结构是:

// sucess的结构
const successResponse = {
    code: 200,
    data: {
        name: 'zhangsan',
        age: 30
    }
}
// error 的结构
const errorResponse = {
    code: 500,
    message: '用户名不存在或密码错误'
}

要求:

  1. 当code是200时, 正常返回其中的data
  2. 当code是500时, 用Message组件显示该message

下面是最普通的处理

缺点: 在每个业务中, 都要处理一遍

{
    let userInfo;
    
    getUserInfo().then(res => {
        if (res.code === 200) {
            userInfo = res.data;
        } else if (res.code === 500) {
            Message.error(res.message);
        }
    });
}

// 有其他请求, 也需要重复类似的逻辑判断
{
      xxx().then(res => {
        if (res.code === 200) {
            yyy = res.data;
        } else if (res.code === 500) {
            Message.error(res.message);
        }
    });
}

以上写法, 也无法应对需求变更, 如:

  1. response结构变更: 缩短变量名 message -> msg
  2. Message改为Notice
  3. 增加另一种逻辑, code: 400

那么接下来很容易想到interceptor.

axios等各种http库, 都支持interceptor.

// 增加 response interceptor
api.interceptors.response.use(
  (response) => {
    const { code, data, message } = response.data
    // 简化数据结构 (根据后端返回格式调整)
    if (code === 200) {
      return data; // 直接返回业务数据
    } else if (code === 500) {
      Message.error(message); // 弹MessageBox
    }
    
    return response;
  },
  (error) => {
    // 错误处理
    if (error.response) {
      // 服务器返回了错误状态码 (4xx/5xx)
      const status = error.response.status;
      const message = error.response.data?.message || '请求错误';

      switch (status) {
        case 401:
          alert('未授权');
          break;
        case 500:
          alert('服务器错误');
          break;
        default:
          console.error(`请求错误 ${status}: ${message}`);
      }
    }

    return Promise.reject(error);
  }
);

以上设计解决了之前提出的几个问题.

但仍然存在其他问题, 如:

{
    const userInfo = await getUserInfo();
    // 这里, 不方便判断userInfo是获取成功还是失败了
}

继续改interceptor

const { code, data, message } = response.data 
if (code === 200) {
	return data; 
} else if (code === 500) {
	Message.error(message);
	return null; //  明确返回null
}

return response;

然后业务层就可以判断了

{
    const userInfo = await getUserInfo();
    // 这样是可以判断了
    if (!userInfo) {
       return;
    }
    showUserInfo(userInfo);
    yyy;
    zzz;
}

但好像每个请求, 都要做这类判断, 因为每次请求都可以报错.

不过对于以上需求来说, 到也算是实现了, 且不是太难看.


好, 现在继续加需求, 某一个api的调用处, 希望不显示MessageBox, 而是将对应的error message显示在dom上.

简而言之, 即某处调用需要对error单独处理, 不想要通用处理 (场景B)

{
    const userInfo = await getUserInfo();
    // 场景A, 同上 (用于与场景B对比)
    if (!userInfo) {
       return;
    }
    showUserInfo(userInfo);
    yyy;
    zzz;
}

{
    const userInfo = await getUserInfo();
    // 场景B, 想要显示在dom上, 不想显示Message
    if (!userInfo) {
       // 我们会发现, MessageBox的弹出是统一的, 已经无法阻止其弹出了.
       showErrorMessage(errMsg);  // 且, 无法得到errorMessage
       return;
    }
}

好, 为了解决上述问题, 可能会想到对api设置options

首先, interceptor需要改一下

    const {
      headers,
      config: { showMsgBox = false },
    } = response;
    
    const { code, data, message } = response.data 
    if (code === 200) {
      return data; 
    } else if (code === 500) {
      if (showMsgBox) {
          Message.error(message);
          return null;
      }
      return message; //  需要返回message
    }
    
    return response;

再修改下调用处

{
    const userInfo = await getUserInfo({showMsgBox: false});
    let errMsg;
    // 需要判断result的类型
    if (typeof userInfo === 'string') {
        errMsg = userInfo;
    }
    // 场景B, 想要显示在dom上, 不想显示Message
    if (errMsg) {
       showErrorMessageOnDom(errMsg);
       return;
    }
}

以上算是实现了需求, 但很强行, 存在以下问题:

  1. 需要新增showMsgBox, 及相应的逻辑处理代码

  2. Api response的结构根据业务不同, 返回的数据结构也不同(interceptor中, 已经有obj, null, string三种形式了)

  3. 业务层还是需要判断result类型

  4. 未来, 随着showMsgBox此类业务的增加, interceptor需要返回更多种类型, 业务层也需要做更多判断

为了不出现类型不好判断的情况, 可能需要对数据进行包装, 如下:

    const {
      headers,
      config: { showMsgBox = false },
    } = response;
    
    const { code, data, message } = response.data 
    if (code === 200) {
      return {type: 'data', data};  // 1
    } else if (code === 500) {
      if (showMsgBox) {
          Message.error(message);
          return null;
      }
      return {type: 'message', data: message}; // 2
    }
    
    return {type: 'raw', response};  // 3

以上, 算是解决了, 但无论是增加新业务, 还是修改旧业务, 对心智负担的增加还是很明显.

(Q&A)

2.2 归纳分析

好了, 为什么以上的方案, 看似每一步都在正确的路上, 为什么结果还是不如意.

为了更好的理解, 我们先看另一个知识点, 回顾下ajax从callback向promise的发展.

// Callback形式
$.ajax({
  url: '/api/users',
  type: 'GET',
  dataType: 'json',
  success: function(users) {
    console.log('用户列表:', users);
  },
  error: function(xhr, status, error) {
    console.error('AJAX Error:', error);
  }
});

// Promise形式
$.get('/api/users')
  .done(function(data, textStatus, jqXHR) {
    console.log('Success:', data);
  })
  .fail(function(jqXHR, textStatus, errorThrown) {
    console.error('Error:', textStatus, errorThrown);
  });

好, 可以看到Promise的写法, 其实也是存在callback的, done和fail两个方法的参数, 就是callback.

所以本质区别并不是在于写法, 而在于指定callback的时机.

Callback写法, 必须在请求发出前指定handler, 且无法修改, 是一种前置.

而Promise写法, 可以在后续过程中, 再指定handler, 可以后置.

const getUsersPromise = $.get('/api/users');

// 得到promsie后, 可以在后续任何流程中指定handler
getUsersPromise
  .done(function(data, textStatus, jqXHR) {
    console.log('Success:', data);
  })
  .fail(function(jqXHR, textStatus, errorThrown) {
    console.error('Error:', textStatus, errorThrown);
  });

同样的, 我们前面看似正确的设计, 也一直是前置的.

(Q&A)

三、统一异常处理的设计

3.1 灵感来源

以上啰嗦了那么多, 是为了大家有代入感.

下面介绍下用定制 error/异常 解决以上问题的设计.

其实就是来源于java等语言的异常处理.

先看一个java "读文件" 的代码, 重点是对异常的处理.

import java.io.*;

public class FileReaderExample {

    public static void main(String[] args) {
        String filePath = "example.txt";

        try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (FileNotFoundException e) {
            System.err.println("错误:找不到文件 '" + filePath + "'");
            e.printStackTrace();
        } catch (IOException e) {
            System.err.println("错误:读取文件时发生 I/O 异常");
            e.printStackTrace();
        } catch (Exception e) {
            System.err.println("未知错误:" + e.getMessage());
            e.printStackTrace();
        }
    }
}

同样的, ajax 除了正常返回结果, 我们可以认为其他情况都是异常, 且可能会触发很多种异常.

Ajax will throw Error

3.2 代码实现

好, 接下来将以上构想, 用代码实现一下.

首先, 我们先构造一个Model

class ErrorMessage {
    constructor(public msg) {}
}

interceptor修改如下

    const { code, data, message } = response.data 
    if (code === 200) {
      return data;  // 1
    } else if (code === 500) {
      throw new ErrorMessage(message);  // 2 抛出异常
    }
    
    return response;  // 3

其次, 需要有个统一的errorHandler兜底, 就是最顶层的catch, 一切未被catch的error, 都会进入这里, 这一点非常重要.

比如 vue global errorHandler.

  const app = createApp();
  app.config.errorHandler = (error) => {
      if (error instanceof ErrorMessage)
           MessageBox.error(error.msg);
       else if (error instanceof XXXError) {
           // ...
       }
  };

然后 业务层如下

{
    // 场景A   (perfect, 非常简捷)
    const userInfo = await getUserInfo();
}

{
    // 场景B
    try {
        const data = await xxx();
    } catch (error) {
        if (error instanceof ErrorMessage) {
            // 这里优先于globalErrorHandler处理
            showErrorMessageOnDom(error.msg);
        }
        else
            throw error;  // 其他不想管的异常, 继续向上抛
    }
}

显而易见的优点:

  1. 保留业务处的对异常的控制权

  2. 心智负担低

    1. 该是谁处理的, 就找谁. common处理就找common的位置, 业务特殊处理的就在业务对应位置, 不串, 不乱.
    2. 以后无论是 增加新的error处理, 还是对旧处理进行变更, 也都很容易找到对应位置
  3. 阻断后续代码的执行

    1. 这是异常天然的特性
    2. 如果出错了不想停, 还想继续执行, 也可以用try catch包起来
  4. 更接近原生语义, 隐藏细节

    1. 需要导入的包更少

      • MessageBox.error(error.msg); 这种代码, 往往需要导入UI库.

      Import {MessageBox} from 'ant-design';

      • 还可能需要对error.msg进行国际化后, 再显示

      Import {t} from 'i18n';

      t('error.user_not_found')

      • 以上细节, 都在errorHandler里处理了

(Q&A)

四、思路打开 扩展用法

以上代码都是围绕ajax展开的, 但异常本身, 并不限于此.

4.1 Assert/断言

例如最常用的Assert.

function parseDatetime(value: string) {
    if (!value) {
        throw new AssertError('value is required');
    }
    return new Date(value);
}

globalErrorHandler里增加对AssertError的处理

  const app = createApp();
  app.config.errorHandler = (error) => {
      if (error instanceof ErrorMessage)
           MessageBox.error(error.msg);
       else if (error instanceof AssertError) {
           reportToSentry(error);  // 上报给sentry
       }
       else if (error instanceof XXXError) {
           // ...
       }
  };

然后 parseDatetime 就可以随便被调用了, 异常也能得到正确处理.

4.2 其他Message, 或者Notice

//  手动抛出, 请求发送前的校验
function validate(params) {
    if (!params.id) {
        throw new WarnMessage('ID is required');
    }
}

4.3 路由跳转

如: getUserInfo失败时, 跳到登录页面

//  请求发送前的校验
function getUserInfo() {
    const token = getToken();
    if (!token) throw NavMessage('/login');
    return Http.get('/api/userinfo', {headers: {token}});
}

// 处理token过期:
// interceptor中, 发现ajax返回401, 直接抛异常就好了
if (status === 401) throw NavMessage('/login');

4.4 服务端表单检验

将服务端校验信息, 回显到formitem上.

这是个挺常见的需求, 但很多公司会弹个Message来代替, 没有细化.

效果如下图所示

当用户post表单后, 由server端进行校验, 并返回如下结构化信息

{
  "type": "VALIDATION",
  "data": {
    "hedgedMarginRatio": "error.overlength",
    "freeMarginRatio": "error.required",
  },
}

具体需求:

  • 当server validation未通过时

    • 在formitem上显示error message
    • 不能关闭窗口
  • 还需要结合client validation

    • 统一前后端校验

代码实现:

与上面几个异常相比, 要复杂一些, 我们只介绍与异常处理相关的部分, 其他Form等处理就略过.

首先, 是Model

class ValidationMessage {
    constructor(public data) {}
}

其次是axios interceptor

    const { code, data, message, type } = response.data 
    if (code === 200) {
      return data;
    } else if (type === 'MESSAGE') {
      throw new ErrorMessage(message);
    } else if (type === 'VALIDATION') {
      throw new ValidationMessage(data);  // 抛出异常
    }
    
    return response;

然后是 Modal 大致如下

<template>
  <NModal :loading="loading">
    <NForm ref="formRef">
      <NFormTabs>
        <NFormTabPane key="basic" :tab="$tl('basic')">
          <NInput v-model:value="model.name" field="name" />
        </NFormTabPane>
      </NFormTabs>
    </NForm>
  </NModal>
</template>

<script>
const { model, submit } = useModalForm();

submit(() => ajaxUpdate(model));
</script>

只上代码, 简捷明了.

写业务时, 只需要关心业务, 而不需要关心通用的逻辑.

显然, 对ValidationError处理的逻辑, 在onSubmit中了.

主要代码如下

  async function submit(onSubmit) {
    try {
      await validate();   // client validation
      await onSubmit?.(model);  // ajax api
    } catch (error) {
      if (error instanceof ValidationError) {
        setValidationData(error.data);
        _formRefValue().setErrorTabs();
        _formRefValue().switchToFirstErrorTab();
        throw new IgnoredMessage('handled validation error');
      } else throw error;
    }
  }

validate方法是client validation.

无论前端还是后端校验, 都会throw ValidationError, 那么处理也就统一了.

但有异常抛出时, 后续代码也不会执行, 所以modal也不会关闭.

五、再次对比 callback/promise

处理后置的魅力

之前的设计一直在前置那里搞, 却想实现后置的需求, 缘木求鱼.

关于Promise, refer to zhuanlan.zhihu.com/p/78826691

六、聊下IgnoredMessage

刚才也提到了, 异常有个特性, 会中断后续代码的执行, 例如下面代码

当func1抛出异常时, func2是没有执行机会的

try {
    func1();  // throw
    func2();
} catch (error) {
}

之前示例中解决多余的if判断, 就是用这个特性解决的

{
    const userInfo = await getUserInfo();
    // 多余的if判断
    if (!userInfo) {
       return;
    }
    showUserInfo();
    yyy;
    zzz;
}

所以, 有时, 当业务代码想优雅的中断执行, 就可以用IgnoreMessage

// model
class IgnoreMessage() {
    constructor(public msg) {}
}

// 业务层
try {
    func1();  // throw IgnoreMessage
    func2();
} catch (error) {
}

顺嘴提一个代码小技巧

若有以下需求

const user = getUserInfo();
if (user) {
    const company = getCompany(user.id);
    if (company) {
        const customers = getCompanyCustomers(company.id);
        if (customers) {
            //...
        }
    }
}

可以看到上面的每个步骤都依赖之前的结果, 所以层层嵌套.

现在我们知道了, throw error就行了, 某一步出错了, 后续代码都不会执行

代码就可以写成这样:

try {
    const user = getUserInfo();
    const company = getCompany(user.id);
    const customers = getCompanyCustomers(company.id);
} catch (error) {}

如果就不用异常, 还有个 do/while(false) 的写法:

do {
    const user = getUserInfo();
    if (!user) break;
    const company = getCompany(user.id);
    if (!company) break;
    const customers = getCompanyCustomers(company.id);
    if (!customers) break;
    //...
} while(false);

比层层嵌套要好很多.

类似于goto, 但没有goto心智负担重.

七、再聊语义

编程语言为什么称为语言, 因为代码即是表达.

好的代码, 如诗一样美.

7.1 代码执行顺序

回顾最开始的需求:

  • 当code是200时, 正常返回其中的data

  • 当code是500时, 且有message属性时, 用Message组件显示该message

    • 某些特定业务时, 需要单独显示message, 而不用MessageBox

下图是以上需求的正确表达

whiteboard_exported_image.png

而之前错误的设计, 一直很别扭, 在尝试用各种方法堵漏.

造成这种局面的本质原因, 就是因为语义表达的不顺畅, "代码执行的顺序"没设计好

7.2 该干什么, 就只干什么

这里并不是要聊unix的哲学, 而还是在聊语义.

这样才能设计出更解耦, 更易用, 更易维护的架构.

"好的设计的标准, 就是看起来, 它本应如此, 而不会有其他的样子"


感觉这个思路应该很容易想到的, 但现实发现很多前端小伙伴还在用前置方式解决后置需求

又或者在业务处对res.status===200进行判断.

希望对大家有所帮助