当我们的项目中假设出现了下面几种场景:
这些情况都是非常影响用户体验的,对于用户来说,是难以接受的,用户可能就此流失掉了。
因此前端非常有必要针对异常做一下处理!!
异常(Exception)。在程序中,因为语法的疏忽或其他原因而导致程序中途崩溃的情况,称之为异常。
从根本上来说,异常就是一个数据类型,它有一个Error类,而其他异常则是它的子类。
在JS运行时可能会发生的错误有很多类型,每一种错误都有对应的错误类型,而当错误发生的时候就会抛出响应的错误对象。
以下是ECMA-262 白皮书 13 版中描述了 8 种异常类型:
ECMA-262 第 3 版中引入了 try-catch 语句,作为 JavaScript 中处理异常的一种标准方式,基本的语法如下所示。这和 Java 中的 try-catch 语句是全完相同的。
try {
// 可能会导致错误的代码
} catch (error) {
// 在错误发生时怎么处理
}
如果 try 块中的任何代码发生了错误,就会立即退出代码执行过程,然后执行 catch 块。此时 catch 块会接收到一个包含错误信息的对象,这个对象中包含的信息因浏览器而异,但共同的是有一个保存着错误信息的 message 属性。
finally 子句在 try-catch 语句中是可选的,但是 finally 子句一经使用,其代码无论如何都会执行。换句话说,try 语句块中代码全部正常执行,finally 子句会执行;如果因为出错执行了 catch 语句,finally 子句照样会执行。只要代码中包含 finally 子句,则无论 try 或 catch 语句中包含什么代码——甚至是 return 语句,都不会阻止 finally 子句执行。来看下面函数的执行结果:
function testFinally {
try {
return "出去玩";
} catch (error) {
return "看电视";
} finally {
return "做作业";
}
return "睡觉";
}
表面上调用这个函数会返回 "出去玩",因为返回 "出去玩" 的语句位于 try 语句块中,而执行此语句又不会出错。实际上返回 "做作业",因为最后还有 finally 子句,结果就会导致 try 块里的 return 语句被忽略,也就是说调用的结果只能返回 "做作业"。如果把 finally 语句拿掉,这个函数将返回 "出去玩"。因此,在使用 finally 子句之前,一定要非常清楚你想让代码怎么样。(思考一下如果 catch 块和 finally 块都抛出异常,catch 块的异常是否能抛出)
但令人遗憾的是,try-catch 无法处理异步代码和一些其他场景。接下来让我具体分析几种异常场景及其处理方案。
当 JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行window.onerror()。
/**
* @param {String} message 错误信息
* @param {String} source 出错文件
* @param {Number} lineno 行号
* @param {Number} colno 列号
* @param {Object} error Error对象(对象)
*/
window.onerror = function (message, source, lineno, colno, error) {
console.log('捕获到异常:', { message, source, lineno, colno, error })
}
同步错误可以捕获到,但是,请注意 window.error 无法捕获静态资源异常和 JS 代码错误。
方法一:onerror 来捕获
这样可以拿到静态资源的错误,但缺点很明显,代码的侵入性太强了,每一个静态资源标签都要加上 onerror 方法。
方法二:addEventListener("error")
error
由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕捉到才行,但是这种方式虽然可以捕捉到网络请求的异常,但是无法判断 HTTP 的状态是 404 还是其他比如 500 等等,所以还需要配合服务端日志才进行排查分析才可以。
Promise 中的异常不能被 try-catch 和 window.onerror 捕获,这时候我们就需要监听 unhandledrejection 来帮我们捕获这部分错误。
window.addEventListener('unhandledrejection', function (e) {
e.preventDefault()
console.log('捕获到 promise 错误了')
console.log('错误的原因是', e.reason)
console.log('Promise 对象是', e.promise)
return true
})
Promise.reject('promise error')
new Promise((resolve, reject) => {
reject('promise error')
})
new Promise((resolve) => {
resolve()
}).then(() => {
throw 'promise error'
})
以最常用的 HTTP 请求库 axios 为例,模拟接口响应 401 的情况:
// 请求
axios.get('/api/test/401')
Uncaught (in promise) Error: Request failed with status code 401
at createError (axios.js:1207)
at settle (axios.js:1177)
at XMLHttpRequest.handleLoad (axios.js:1037)
可以看出来 axios 的异常可以当做 Promise 异常来处理:
// 请求
axios.get('http://localhost:3000/api/uitest/sentry/401')
.then((data) => console.log('接口请求成功', data))
.catch((e) => console.log('接口请求出错', e))
# 结果
接口请求出错 Error: Request failed with status code 401
at createError (createError.js:17)
at settle (settle.js:18)
at XMLHttpRequest.handleLoad (xhr.js:62)
一般接口 401 就代表用户未登录,就需要跳转到登录页,让用户进行重新登录,但如果每个请求方法都需要写一遍跳转登录页的逻辑就很麻烦了,这时候就会考虑使用 axios 的拦截器来做统一梳理,同理能统一处理的异常也可以在放在拦截器里处理。
// Add a response interceptor
axios.interceptors.response.use(
function (response) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
},
function (error) {
if (error.response.status === 401) {
goLogin() // 跳转登录页
} else if (error.response.status === 502) {
alert(error.response.data.message || '系统升级中,请稍后重试')
}
return Promise.reject(error.response)
}
)
React 处理异常的方式不同。虽然 try-catch 适用于许多非普通 JavaScript 应用程序,但它只适用于命令式代码。因为 React 组件是声明性的,所以 try-catch 不是一个可靠的选项。为了弥补这一点,React 实现了所谓的错误边界。错误边界是 React 组件,它“捕获子组件树中的任何地方的 JavaScript 错误”,同时还记录错误并显示回退用户界面。
class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
componentDidCatch(error, info) {
// 展示出错的UI
this.setState({ hasError: true }) // 将错误信息上报到日志服务器
logErrorToMyService(error, info)
}
render() {
if (this.state.hasError) {
// 可以展示自定义的错误样式
return Something went wrong.
}
return this.props.children
}
}
但是需要注意的是, error boundaries 并不会捕捉下面这些错误:
我们可以这样使用 ErrorBoundary:
全局异常捕获
Vue.config.errorHandler
Vue.config.errorHandler = (err, vm, info) => {
console.error('通过vue errorHandler捕获的错误')
console.error(err)
console.error(vm)
console.error(info)
}
组件内异常捕获
组件内使用errorCaptured
异常处理时需分清是致命错误还是非致命错误。
异常捕获之后,接下来就是数据上报的工作了。
目前主要由以下3种上报信息方式:
方案 |
优点 |
缺点 |
img请求 |
兼容性 |
部分浏览器丢点,延迟页面卸载; get请求长度限制 |
Fetch/XHR |
兼容性 |
Fetch丢点同步 XHR不丢点,但延迟页面卸载 |
Navigator.sendBeacon() |
不丢点不会延迟页面卸载 |
兼容性 |
丢点:在浏览器点击跳转时,跳转前的点击上报请求都会进行一个三次握手,如果此时,网络较慢、服务器运行缓慢或者上报请求还在处理阶段,这时,如果页面被卸载了,浏览器都会自动对当前的请求进行abort。这样,这个http的请求就没有建立,导致上报没有真正发出。
下面的例子展示了一个理论上的统计代码——在卸载事件处理器中尝试通过一个同步的 XMLHttpRequest 向服务器发送数据。这导致了页面卸载被延迟。
window.addEventListener('unload', logData, false)
function logData() {
var client = new XMLHttpRequest()
client.open('POST', '/log', false) // 第三个参数表明是同步的 xhr
client.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8')
client.send(analyticsData)
}
这就是 sendBeacon() 方法存在的意义。使用 sendBeacon() 方法会使用户代理在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能。这就解决了提交分析数据时的所有的问题:数据可靠,传输异步并且不会影响下一页面的加载。此外,代码实际上还要比其他技术简单许多!
下面的例子展示了一个理论上的统计代码模式——通过使用 sendBeacon() 方法向服务器发送数据。
window.addEventListener('unload', logData, false);
function logData() {
navigator.sendBeacon("/log", analyticsData);
}
所以这里的推荐是优先检查浏览器是否支持Navigator.sendBeacon(),不支持的话再使用其他上报方式。
参考资料:
ecma-262: https://www.ecma-international.org/publications-and-standards/standards/ecma-262/
ES6th 白皮书: https://262.ecma-international.org/6.0
https://juejin.cn/post/6932620551827488775
https://juejin.cn/post/6936562262480158728