前端异常处理总结

前言

程序不总是按我们期望的方式运行,在运行期间会出现不同的异常,最臭名昭著的异常交互也许是下面的蓝色界面,抛出一段普通用户无法读懂的神秘代码,我们能做的只有拔电源了。

image.png

再看看OSX对致命异常的处理就显得更加友好,他提示用户看得懂的错误,也提供按钮引导用户操作

image.png

因此,异常处理是否合理会严重影响产品的使用体验,下面我将把注意力放在前端领域讨论前端异常的处理建议。

不正确的处理方式

我们先来看看三种不合理的异常处理方法

第一种:认为程序总是按预期执行,代码没有任何try catch

例如下面代码会抛出ReferenceError异常,由于JS是单线程运行,异常会导致程序直接退出,控制台无法看到“后续逻辑”打印

  const a = b; // Uncaught ReferenceError: b is not defined
  console.log('后续逻辑')
复制代码

再看一个例子,假如外部依赖error.js出现异常,会导致vue无法初始化,用户直接感受就是白屏,这非常影响用户体验

// 引入了一个会出现异常的js
import error from 'error.js'

// 初始化vue
const app = createApp(App);
app.mount("#app");
复制代码

第二种:把所有代码try..catch,捕获的异常直接console.log,只要程序不崩溃,就当没事

这是相对第一种的极端写法,使用try...catch包裹所有代码块,这除了导致代码冗余外,还会引起性能问题。try...catch性能对比文章

另外catch异常后直接打印异常,会因为日志丢失导致线上异常跟踪困难。

  try {
    // 发送ajax请求
    const remoteData = await remoteRequest();
  } catch (e) {
    console.log(e)
  }
复制代码

还有一种写法是无论任何异常,catch到直接弹出提示用户

  try {
    // 发送ajax请求
    const remoteData = await remoteRequest();
  } catch (e) {
    // 弹窗提示
    toast(e)
  }
复制代码

这种做法同样恶劣,就像window系统的蓝屏,提示一堆普通用户无法读懂的错误信息。

第三种:记录大量无用日志

我们一般会用sentry等开源系统记录异常和实现异常告警。

但是也会出现滥用,不管什么异常都记录到系统,这直接导致日志系统大量无用日志,妨碍关注重要问题

  try {
    // 发送ajax请求
    const remoteData = await remoteRequest();
  } catch (e) {
   // 记录异常日志
    Sentry.captureException(e);
  }
复制代码

image.png

异常处理方法

刚才归纳的三种异常处理方法过于简单粗暴,对于不同类型异常,需要不同的处理方法。

下面分别从语法错误、HTTP请求异常、资源加载异常、Promise 异常、框架异常讲解处理方法。

语法错误

ECMA-262规范定义的七种错误类型:

Error:是所有错误的基类,其他错误都继承自该类型

EvalError :本对象代表了一个关于 eval 函数的错误.此异常不再会被JavaScript抛出,但是EvalError对象仍然保持兼容性.

try {
  throw new EvalError('Hello', 'someFile.js', 10);
} catch (e) {
  console.log(e instanceof EvalError); // true
  console.log(e.message);              // "Hello"
  console.log(e.name);                 // "EvalError"
  console.log(e.fileName);             // "someFile.js"
  console.log(e.lineNumber);           // 10
  console.log(e.columnNumber);         // 0
  console.log(e.stack);                // "@Scratchpad/2:2:9\n"
}
复制代码

这种异常较少看到,我就不做处理方法举例了。

RangeError :数值变量或参数超出其有效范围。

const price = 10;
// toFixd只接收 0-100的数字
price.toFixed(101); 
// Uncaught RangeError: toFixed() digits argument must be between 0 and 100"
复制代码

处理建议:如果项目集成eslint和typescript,上面写法是无法通过编译的,编译器会把低级错误在编译阶段就可以识别;

但是假设toFixd的参数是通过input输入框接收任意值,那就要做异常处理。

// 方法一:
try {
    const inputValue = document.getElementById('#input').value;
    const price = 10;
    price.toFixed(inputValue)
} catch(e) {
    if (e instanceof RangeError) {
        toast('只能输出0-100数字'); // 提示用户调整输入
    } else {
        throw e; // 假如还有未处理完的异常,抛给上层
    }
}

// 方法二:让程序更加健壮,降低异常发生
  let inputValue = "101";
  const price = 10;
  const isString = (num) => isNaN(num)
  const isRang = (num, min, max) => min <= Number(num) &&  Number(num)<= max;
  if (isString(inputValue)) {
    alert("只能输入数字类型");
  } else if (!isRang(Number(inputValue), 0, 100)) {
    alert("请输出0-100数字");
  } else {
    price.toFixed(inputValue)
  }
复制代码

例子用弹出框只是演示作用,正常场景对于用户输入验证,提示信息应在输入框下面

ReferenceError :表示无效引用。

// age没定义直接引用会导致异常
const name = age;
// Uncaught ReferenceError: age is not defined
复制代码

处理建议:错误在编译阶段就被发现,一般不需要处理。

SyntaxError :在解析代码的过程中发生的语法错误。

  try {
    // 服务端返回了一个不合规数据
    const remoteData = '{ key: "value" }'
    const jRes = JSON.parse(remoteData)
  } catch (e) {
   // 如果这些数据出错会导致业务无法进行,那就弹出阻塞窗口,让用户联系客服。
   confirmModel();
   // 如果接口不影响核心业务,可以把异常日志记录到sentry
   Sentry.captureException(e);
  }
复制代码

处理建议:这种错误常出现在服务端返回不合规JSON字符串,前端调用JSON.parse出现转换异常,对于数据异常没有统一的处理方法,如果该数据出错会导致业务无法往下执行,那你应该弹出阻断用户操作的对话框,并提供联系客服的方法。

如果数据异常不影响正常业务,那就把异常记录到日志服务器,开发收到日志后排查问题。

TypeError :变量或参数不属于有效类型。

  try {
    const a = {}
    this.name = a.b.c;
  } catch (e) {
    console.log(e) // Uncaught TypeError: Cannot read properties of undefined (reading 'c')
    this.name = '未知名字' // 设置默认值
    Sentry.captureException(e);
  }
复制代码

处理建议:如果异常不影响业务,可以考虑赋默认值,并把异常记录到日志服务器。

URIError :表示错误的原因:给 encodeURI()或  decodeURI()传递的参数无效。

// 传递不合法的字符串
decodeURIComponent("%");
// Uncaught URIError: URI malformed
复制代码

处理建议:根据异常等级,影响业务流程的弹出提示,否则记录日志。

对于语法异常,如果项目引入typescripteslint等语法检测工具,会在编译阶段检测部分异常,如果运行阶段异常需要try...catch捕获异常并根据场景做不同处理。

HTTP请求异常

网络异常分两种,一种是网络中断异常,一种是服务端返回状态非200的业务异常。

网络中断: 可跳转到网络异常页面,提示用户检查网络并重试

image.png

axios可通过error.message判断是否网络中断

if (error.message === 'Network Error') {
      // 跳转到网络错误页
}
复制代码

状态码非200的业务异常: 如果服务端返回404,500,401等异常,可使用拦截器对异常统一处理,但是记得把处理不了的异常reject出去

axios.interceptors.response.use(
  (response) => {
    return response;
  },
  function (error) {
    if (error.response.status === 404) {
      // 跳转到404页面
    } else if (error.response.status === 401) {
      // 跳转到登录页
    } else {
      // 把不能处理的异常reject出去
      return Promise.reject(error.response);
    }
  }
);
复制代码

资源加载异常

加载静态资源时,如果网络波动或者资源服务器异常,会导致资源加载失败,例如下面截图是加载异步组件失败日志,如果不捕获该异常,可能会出现白屏。

image.png

方法一:直接在script定义onerror,但这种方法侵入性太强

<script>
  function errorHandler(err) {
    // 可以提示用户刷新页面
  }
</script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.26.1/axios.jsdsf"  onerror="errorHandler(this)"></script>
复制代码

方法二:使用window.addEventListener监听全局异常

<script>
  window.addEventListener('error', (errorEvent) => {
    // 可以捕获不同类型错误,但是资源加载异常没有message字段,可以通过这个特性判断是否为资源加载异常
    if (!errorEvent.message) {
      // 资源加载异常,提示刷新重试
    }
}, true)
</script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.26.1/axios.jsdsf"></script>
复制代码

Promise异常

当Promise调用reject,但调用方没声明catch,,会抛出UnHandledRejection异常,类似下面截图

image.png

正确方法是给Promise定义catch捕获异常

const myPromise = new Promise((resolve, reject) => {
    reject('异常数据')
    // 获取throw异常,也可以被catch方法捕获
    // throw('throw的异常')
})

myPromise.catch(err => {
    console.log(err) // 打印 "异常数据"
})
复制代码

但是每个promise都定义catch,显然是不合理的,可以定义全局监听unhandledrejection捕获异常,做兜底操作。

  window.addEventListener('unhandledrejection', (e) => {
    console.log(e.reason); // 得到promise reject的数据
    e.preventDefault() // 停止异常继续打印
  });
复制代码

框架异常

Vue框架提供处理errorHandler函数收集组件渲染函数和侦听器执行期间抛出的未捕获错误。这个处理函数被调用时,可获取错误信息和相应的应用实例。

app.config.errorHandler = (err, vm, info) => {
  // 处理错误
  // `info` 是 Vue 特定的错误信息,比如错误所在的生命周期钩子
}
复制代码

下面我在组件的onMounted抛出异常

import { onMounted } from 'vue'
export default {
  setup() {
    onMounted(async () => {
        throw new Error('组件内抛出异常')
    })
  },
};
复制代码

errorHandler接收的异常信息

Error: 组件内抛出异常
    at _callee2$ (HelloWorld.vue?fdab:46:1)
    at tryCatch (runtime.js?96cf:63:1)
    at Generator.invoke [as _invoke] (runtime.js?96cf:293:1)
    at Generator.eval [as next] (runtime.js?96cf:118:1)
    at asyncGeneratorStep (asyncToGenerator.js?1da1:3:1)
    at _next (asyncToGenerator.js?1da1:25:1)
    at eval (asyncToGenerator.js?1da1:32:1)
    at new Promise (<anonymous>)
    at eval (asyncToGenerator.js?1da1:21:1)
    at callWithErrorHandling (runtime-core.esm-bundler.js?5c40:154:1) Proxy {…}[[Handler]]: Object[[Target]]: Object[[IsRevoked]]: false 'mounted hook' 'errorHandler'
复制代码

异常处理原则

到这里估计大家已经有点晕,面对不同异常,处理手段各异,有些还要根据业务场景调整;当你面对不熟悉的场景,可以遵循下面原则设计异常处理方法。

1. 永远不要吞没异常

try {
    throw new Error('error')
} catch(e) {
    // 不要吞没异常,改为日志记录
    console.log(e)
}
复制代码

2. 只处理你能处理的异常,否则向上抛出

axios.interceptors.response.use(
  (response) => {
    return response;
  },
  function (error) {
    if (error.response.status === 404) {
      // 跳转到404页面
    } else if (error.response.status === 401) {
      // 跳转到登录页
    } else {
      // 把不能处理的异常reject或者throw
      return Promise.reject(error.response);
      // throw new Error('其他异常')
    }
  }
);
复制代码

3. 定义全局拦截器兜底,做好日志记录

// Vue框架异常
app.config.errorHandler = (err, vm, info) => {}
// 资源加载异常
window.addEventListener('error', (errorEvent) => {})
// promise异常
window.addEventListener('unhandledrejection', (e) => {});
复制代码

4. 做好异常引导: 可恢复异常指引用户调整输入,网络错误指引用户刷新,致命异常弹出客服联系

异常统一处理

在java中异常处理一般继承RuntimeException定义不同的异常类,例如HttpExceptionParameterException用于区分场景,这种方法在前端不适用,因为大多数前端项目都是基于函数写法。

我们分析vue的异常处理,下面代码我只保留关键部分便于讲解,完整源码参考链接 errorHandling.ts

// 定义异常类型
export const enum ErrorCodes {
  SETUP_FUNCTION,
  RENDER_FUNCTION,
  WATCH_GETTER,
  WATCH_CALLBACK,
  WATCH_CLEANUP,
  ...
}

// 异常提示语对照表
export const ErrorTypeStrings: Record<number | string, string> = {
  [LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook',
  [ErrorCodes.FUNCTION_REF]: 'ref function',
  [ErrorCodes.ASYNC_COMPONENT_LOADER]: 'async component loader',
  [ErrorCodes.SCHEDULER]:
    'scheduler flush. This is likely a Vue internals bug. ' +
    'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/core'
}

// 异步组件异常收集
export function callWithAsyncErrorHandling(): any[] {}

// 异常统一处理函数
export function handleError(
  err: unknown, // 异常信息
  instance: ComponentInternalInstance | null, // 异常发生组件实例
  type: ErrorTypes, // 异常类型
  throwInDev = true // 开发模式下是否抛出异常
) {
    // 获取全局定义的异常处理方法,发生异常通知该函数
    const appErrorHandler = instance.appContext.config.errorHandler
    if (appErrorHandler) {
      callWithErrorHandling(
        appErrorHandler,
        null,
        ErrorCodes.APP_ERROR_HANDLER,
        [err, exposedInstance, errorInfo]
      )
      return
    }
  }
  // 打印异常到控制台
  logError(err, type, contextVNode, throwInDev)
}
复制代码

下面是调用方法,捕获异常后调用handeError统一处理

  if (isSSR) {
    return setupResult
      .then((resolvedResult: unknown) => {
        handleSetupResult(instance, resolvedResult, isSSR)
      })
      .catch(e => {
        handleError(e, instance, ErrorCodes.SETUP_FUNCTION)
      })
  }
复制代码

从vue的异常处理方法我们可以学到,一个全局异常处理器应该包含:异常分类异常提示语/交互异常收集函数,基于这三个要素,我们就可以编写自己的异常处理函数了。

总结

前端异常种类多样,不同类型需不同方法拦截,拦截异常后也需根据场景执行后续处理。异常处理原则都是防止异常丢失和提升用户体验,另外为了规范整个应用的异常交互,可以定义全局异常处理方法。

分类:
前端