前言
程序不总是按我们期望的方式运行,在运行期间会出现不同的异常,最臭名昭著的异常交互也许是下面的蓝色界面,抛出一段普通用户无法读懂的神秘代码,我们能做的只有拔电源了。
再看看OSX对致命异常的处理就显得更加友好,他提示用户看得懂的错误
,也提供按钮引导用户操作
。
因此,异常处理是否合理会严重影响产品的使用体验,下面我将把注意力放在前端领域讨论前端异常的处理建议。
不正确的处理方式
我们先来看看三种不合理的异常处理方法
第一种:认为程序总是按预期执行,代码没有任何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);
}
复制代码
异常处理方法
刚才归纳的三种异常处理方法过于简单粗暴,对于不同类型异常,需要不同的处理方法。
下面分别从语法错误、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
复制代码
处理建议:根据异常等级,影响业务流程的弹出提示,否则记录日志。
对于语法异常,如果项目引入typescript
、eslint
等语法检测工具,会在编译阶段检测部分异常,如果运行阶段异常需要try...catch捕获异常并根据场景做不同处理。
HTTP请求异常
网络异常分两种,一种是网络中断异常,一种是服务端返回状态非200的业务异常。
网络中断: 可跳转到网络异常页面,提示用户检查网络并重试
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);
}
}
);
复制代码
资源加载异常
加载静态资源时,如果网络波动或者资源服务器异常,会导致资源加载失败,例如下面截图是加载异步组件失败日志,如果不捕获该异常,可能会出现白屏。
方法一:直接在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
异常,类似下面截图
正确方法是给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
定义不同的异常类,例如HttpException
、ParameterException
用于区分场景,这种方法在前端不适用,因为大多数前端项目都是基于函数写法。
我们分析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的异常处理方法我们可以学到,一个全局异常处理器应该包含:异常分类
、异常提示语/交互
、异常收集函数
,基于这三个要素,我们就可以编写自己的异常处理函数了。
总结
前端异常种类多样,不同类型需不同方法拦截,拦截异常后也需根据场景执行后续处理。异常处理原则都是防止异常丢失和提升用户体验,另外为了规范整个应用的异常交互,可以定义全局异常处理方法。