前端开发中的Error以及异常捕获

25次阅读

共计 9989 个字符,预计需要花费 25 分钟才能阅读完成。

本文首发于公众号:符合预期的 CoyPan

写在前面
在前端项目中,由于 JavaScript 本身是一个弱类型语言,加上浏览器环境的复杂性,网络问题等等,很容易发生错误。做好网页错误监控,不断优化代码,提高代码健壮性是一项很重要的工作。本文将从 Error 开始,讲到如何捕获页面中的异常。文章较长,细节较多,请耐心观看。
前端开发中的 Error
JavaScript 中的 Error
JavaScript 中,Error 是一个构造函数,通过它创建一个错误对象。当运行时错误产生时,Error 的实例对象会被抛出。构造一个 Error 的语法如下:
// message: 错误描述
// fileName: 可选。被创建的 Error 对象的 fileName 属性值。默认是调用 Error 构造器代码所在的文件的名字。
// lineNumber: 可选。被创建的 Error 对象的 lineNumber 属性值。默认是调用 Error 构造器代码所在的文件的行号。

new Error([message[, fileName[, lineNumber]]])
ECMAScript 标准:
Error 有两个标准属性:

Error.prototype.name:错误的名字

Error.prototype.message:错误的描述

例如,在 chrome 控制台中输入以下代码:
var a = new Error(‘ 错误测试 ’);
console.log(a); // Error: 错误测试
// at <anonymous>:1:9
console.log(a.name); // Error
console.log(a.message); // 错误测试
Error 只有一个标准方法:

Error.prototype.toString:返回表示一个表示错误的字符串。
接上面的代码:
a.toString(); // “Error: 错误测试 ”
非标准的属性
各个浏览器厂商对于 Error 都有自己的实现。比如下面这些属性:

Error.prototype.fileName:产生错误的文件名。

Error.prototype.lineNumber:产生错误的行号。

Error.prototype.columnNumber:产生错误的列号。

Error.prototype.stack:堆栈信息。这个比较常用。

这些属性均不是标准属性,在生产环境中谨慎使用。不过现代浏览器差不多都支持了。
Error 的种类
除了通用的 Error 构造函数外,JavaScript 还有 7 个其他类型的错误构造函数。

InternalError: 创建一个代表 Javascript 引擎内部错误的异常抛出的实例。如: “ 递归太多 ”。非 ECMAScript 标准。
RangeError: 数值变量或参数超出其有效范围。例子:var a = new Array(-1);
EvalError: 与 eval()相关的错误。eval()本身没有正确执行。
ReferenceError: 引用错误。例子:console.log(b);
SyntaxError: 语法错误。例子:var a = ;
TypeError: 变量或参数不属于有效范围。例子:[1,2].split(‘.’)
URIError: 给 encodeURI 或 decodeURl()传递的参数无效。例子:decodeURI(‘%2’)

当 JavaScript 运行过程中出错时,会抛出上 8 种 (上述 7 种加上通用错误类型) 错误中的其中一种错误。错误类型可以通过 error.name 拿到。
你也可以基于 Error 构造自己的错误类型,这里就不展开了。
其他错误
上面介绍的都是 JavaScript 本身运行时会发生的错误。页面中还会有其他的异常,比如错误地操作了 DOM。
DOMException
DOMException 是 W3C DOM 核心对象,表示调用一个 Web Api 时发生的异常。什么是 Web Api 呢?最常见的就是 DOM 元素的一系列方法,其他还有 XMLHttpRequest、Fetch 等等等等,这里就不一一说明了。直接看下面一个操作 DOM 的例子:
var node = document.querySelector(‘#app’);
var refnode = node.nextSibling;
var newnode = document.createElement(‘div’);
node.insertBefore(newnode, refnode);

// 报错:Uncaught DOMException: Failed to execute ‘insertBefore’ on ‘Node’: The node before which the new node is to be inserted is not a child of this node.
单从 JS 代码逻辑层面来看,没有问题。但是代码的操作不符合 DOM 的规则。
DOMException 构造函数的语法如下:
// message: 可选,错误描述。
// name: 可选,错误名称。常量,具体值可以在这里找到:https://developer.mozilla.org/zh-CN/docs/Web/API/DOMException

new DOMException([message[, name]]);
DOMException 有以下三个属性:

DOMException.code:错误编号。

DOMException.message:错误描述。

DOMException.name:错误名称。

以上面那段错误代码为例,其抛出的 DOMException 各属性的值为:
code: 8
message: “Failed to execute ‘insertBefore’ on ‘Node’: The node before which the new node is to be inserted is not a child of this node.”
name: “NotFoundError”

Promise 产生的异常
在 Promise 中,如果 Promise 被 reject 了,就会抛出异常:PromiseRejectionEvent。注意,下面两种情况都会导致 Promise 被 reject:

业务代码本身调用了 Promise.reject。

Promise 中的代码出错。

PromiseRejectionEvent 的构造函数目前在浏览器中大多都不兼容,这里就不说了。
PromiseRejectionEvent 的属性有两个:

PromiseRejectionEvent.promise:被 reject 的 Promise。

PromiseRejectionEvent.reason:Promise 被 reject 的原因。会传递给 reject。Promsie 的 catch 中的参数。

加载资源出错
由于网络,安全等原因,网页加载资源失败,请求接口出错等,也是一种常见的错误。
关于错误的小结
一个网页在运行过程中,可能发生四种错误:

JavaScript 在运行过程,语言自身抛出的异常。
JavaScript 在运行过程中,调用 Web Api 时发生异常。
Promise 中的拒绝。
网页加载资源,调用接口时发生异常。

我认为,对于前两种错误,我们在平时的开发过程中,不用特别去区分,可以统一成:【代码出错】。
捕获错误
网页发生错误,开发者如何捕获这些错误呢 ? 常见的有以下方法。
try…catch…
try…catch…大家都不陌生了。一般用来在具体的代码逻辑中捕获错误。
try {
throw new Error(“oops”);
}
catch (ex) {
console.log(“error”, ex.message); // error oops
}
当 try-block 中的代码发生异常时,可以在 catck-block 中将异常接住,浏览器便不会抛出错误。但是,这种方式并不能捕获异步代码中的错误,如:
try {
setTimeout(function(){
throw new Error(‘lala’);
},0);
} catch(e) {
console.log(‘error’, e.message);
}
这个时候,浏览器依然会抛出错误:Uncaught Error: lala。
试想以下,如果我们将所有的代码合理的划分,然后都用 try catch 包起来,是不是就可以捕获到所有的错误了呢?可以通过编译工具来实现这个功能。不过,try catch 是比较耗费性能的。
window.onerror
window.onerror = function(message, source, lineno, colno, error) {…}
函数参数:

message:错误信息(字符串)

source:发生错误的脚本 URL(字符串)

lineno:发生错误的行号(数字)

colno:发生错误的列号(数字)

error:Error 对象(对象)

注意,如果这个函数返回 true,那么将会阻止执行浏览器默认的错误处理函数。
window.addEventListener(‘error’)
window.addEventListener(‘error’, function(event) {…})
我们调用 Object.prototype.toString.call(event),返回的是[object ErrorEvent]。可以看到 event 是 ErrorEvent 对象的实例。ErrorEvent 是事件对象在脚本发生错误时产生,从 Event 继承而来。由于是事件,自然可以拿到 target 属性。ErrorEvent 还包括了错误发生时的信息。

ErrorEvent.prototype.message: 字符串,包含了所发生错误的描述信息。
ErrorEvent.prototype.filename: 字符串,包含了发生错误的脚本文件的文件名。
ErrorEvent.prototype.lineno: 数字,包含了错误发生时所在的行号。
ErrorEvent.prototype.colno: 数字,包含了错误发生时所在的列号。
ErrorEvent.prototype.error: 发生错误时所抛出的 Error 对象。

注意,这里的 ErrorEvent.prototype.error 对应的 Error 对象,就是上文提到的 Error, InternalError,RangeError,EvalError,ReferenceError,SyntaxError,TypeError,URIError,DOMException 中的一种。
window.addEventListener(‘unhandledrejection’)
window.addEventListener(‘unhandledrejection’, function (event) {…});
在使用 Promise 的时候,如果没有声明 catch 代码块,Promise 的异常会被抛出。只能通过这个方法或者 window.onunhandledrejection 才能捕获到该异常。
event 就是上文提到的 PromiseRejectionEvent。我们只需要关注其 reason 就行。
window.onerror 和 window.addEventListener(‘error’)的区别

首先是事件监听器和事件处理器的区别。监听器只能声明一次,后续的声明会覆盖之前的声明。而事件处理器则可以绑定多个回调函数。
资源 (<img> 或 <script>) 加载失败时,加载资源的元素会触发一个 Event 接口的 error 事件,并执行该元素上的 onerror()处理函数。但这些 error 事件不会向上冒泡到 window。不过,这些 error 事件能被 window.addEventListener(‘error’)捕获。也就是说,面对资源加载失败的错误,只能用 window.addEventListerner(‘error’),window.onerror 无效。

关于错误捕获的小结
我认为,在开发的过程中,对于容易出错的地方,可以使用 try{}catch(){}来进行错误的捕获,做好兜底处理,避免页面挂掉。而对于全局的错误捕获,在现代浏览器中,我倾向于只使用使用 window.addEventListener(‘error’),window.addEventListener(‘unhandledrejection’)就行了。如果需要考虑兼容性,需要加上 window.onerror,三者同时使用,window.addEventListener(‘error’)专门用来捕获资源加载错误。
跨域脚本错误,Script Error
在进行错误捕获的过程中,很多时候并不能拿到完整的错误信息,得到的仅仅是一个 ”Script Error”。
产生原因
由于 12 年前这篇文章里提到的安全问题:https://blog.jeremiahgrossman…,浏览器们都对内核进行了升级:
当加载自不同域的脚本中发生语法错误时,为避免信息泄露,语法错误的细节将不会报告,而是使用简单的 ”Script error.” 代替。
一般而言,页面的 JS 文件都是放在 CDN 的,和页面自身的 URL 产生了跨域问题,所以引起了 ”Script Error”。
解决办法
服务端添加 Access-Control-Allow-Origin,页面在 script 标签中配置 crossorigin=”anonymous”。这样,便解决了因为跨域而带来的 ”Script Error” 问题。
能绕过 Script Error 么
上面介绍了 ”Script Error” 的标准解决方案。但是,并不是所有的浏览器都支持 crossorigin=”anonymous”,也不是所有的服务端都能及时配置 Access-Control-Allow-Origin,这种情况下,还有什么方法能在全局捕获到所有的错误,并拿到详细信息呢?
劫持原生方法
看一个例子:
const nativeAddEventListener = EventTarget.prototype.addEventListener; // 先将原生方法保存起来。
EventTarget.prototype.addEventListener = function (type, func, options) {// 重写原生方法。
const wrappedFunc = function (…args) {// 将回调函数包裹一层 try catch
try {
return func.apply(this, args);
} catch (e) {
const errorObj = {

error_name: e.name || ”,
error_msg: e.message || ”,
error_stack: e.stack || (e.error && e.error.stack),
error_native: e,

};
// 接下来可以将 errorObj 统一进行处理。
}
}
return nativeAddEventListener.call(this, type, wrappedFunc, options); // 调用原生的方法,保证 addEventListener 正确执行
}
我们劫持了原生的 addEventListener 代码,对 addEventListener 代码中的回调函数加了一层 try{}catch(){},这样,回调函数中抛出的错误会被 catch 住,浏览器不会对 try-catch 起来的异常进行跨域拦截,所以我们可以拿到详细的错误信息。通过上面的操作,我们可以拿到所有监听事件的回调函数中的错误啦。其他的场景怎么办呢?继续劫持原生方法。
一个前端项目中,除了事件监听,接口请求也是一个频繁出现的场景。接着上面的代码,下面我们来劫持一下 Ajax。

if (!XMLHttpRequest) {
return;
}

const nativeAjaxSend = XMLHttpRequest.prototype.send; // 首先将原生的方法保存。
const nativeAjaxOpen = XMLHttpRequest.prototype.open;

XMLHttpRequest.prototype.open = function (mothod, url, …args) {// 劫持 open 方法,是为了拿到请求的 url
const xhrInstance = this;
xhrInstance._url = url;
return nativeAjaxOpen.apply(this, [mothod, url].concat(args));
}

XMLHttpRequest.prototype.send = function (…args) {// 对于 ajax 请求的监控,主要是在 send 方法里处理。

const oldCb = this.onreadystatechange;
const oldErrorCb = this.onerror;
const xhrInstance = this;

xhrInstance.addEventListener(‘error’, function (e) {// 这里捕获到的 error 是一个 ProgressEvent。e.target 的值为 XMLHttpRequest 的实例。当网络错误 (ajax 并没有发出去) 或者发生跨域的时候,会触发 XMLHttpRequest 的 error, 此时,e.target.status 的值为:0,e.target.statusText 的值为:”

const errorObj = {

error_msg: ‘ajax filed’,
error_stack: JSON.stringify({
status: e.target.status,
statusText: e.target.statusText
}),
error_native: e,

}

/* 接下来可以对 errorObj 进行统一处理 */

});

xhrInstance.addEventListener(‘abort’, function (e) {// 主动取消 ajax 的情况需要标注,否则可能会产生误报
if (e.type === ‘abort’) {
xhrInstance._isAbort = true;
}
});

this.onreadystatechange = function (…innerArgs) {
if (xhrInstance.readyState === 4) {
if (!xhrInstance._isAbort && xhrInstance.status !== 200) {// 请求不成功时,拿到错误信息
const errorObj = {
error_msg: JSON.stringify({
code: xhrInstance.status,
msg: xhrInstance.statusText,
url: xhrInstance._url
}),
error_stack: ”,
error_native: xhrInstance
};

/* 接下来可以对 errorObj 进行统一处理 */

}

}
oldCb && oldCb.apply(this, innerArgs);
}
return nativeAjaxSend.apply(this, args);
}
}
我们引用框架时,某些框架会用 console.error 的方法抛出错误。我们可以劫持 console.error,来捕获错误。
const nativeConsoleError = window.console.error;
window.console.error = function (…args) {
args.forEach(item => {
if (typeDetect.isError(item)) {

} else {

}
});
nativeConsoleError.apply(this, args);
}
原生的方法有很多,还比如 fetch、setTimeout 等。这里不一一列举了。但是使用劫持原生方法以覆盖所有的场景是十分困难的。
前端框架是怎么捕获错误的
我们主要来看一下 React 和 Vue 是怎么解决错误捕获问题的。
React 中的错误捕获
在 Reactv16 以前,可以使用 unstable_handleError 来处理捕获的错误。Reactv16 以后,使用 componentDidCatch 来处理捕获的错误。若需全局捕获错误,可以在最外层包裹一层组件,在 componentDidCatch 中捕获错误信息。具体用法参考官方文档:https://reactjs.org/blog/2017/07/26/error-handling-in-react-16.html
在 React 中,错误会被 throw 出来。在写作本文的时候,我遇到一个问题,如果在加载 react 相关的代码前,按照上文的方法劫持 addEventListener,那么 React 将不会正常工作了,但是没有任何报错。React 有一套自己的事件系统,会不会和这个有关呢?之前没有研究过 React 源码,粗略调试了以下,没有发现问题所在。后续会仔细研究。
Vue 中的错误捕获
Vue 的源码中,在关键函数 (比如钩子函数等) 执行的时候,都加上 try{}catch(){},在 cacth 中处理捕获到的错误。看下面的源码。

// vue 源码片段
function callHook (vm, hook) {
// #7573 disable dep collection when invoking lifecycle hooks
pushTarget();
var handlers = vm.$options[hook];
if (handlers) {
for (var i = 0, j = handlers.length; i < j; i++) {
try {
handlers[i].call(vm);
} catch (e) {
handleError(e, vm, (hook + ” hook”));
}
}
}
if (vm._hasHookEvent) {
vm.$emit(‘hook:’ + hook);
}
popTarget();
}

function globalHandleError (err, vm, info) {
if (config.errorHandler) {
try {
return config.errorHandler.call(null, err, vm, info)
} catch (e) {
logError(e, null, ‘config.errorHandler’);
}
}
logError(err, vm, info);
}

function logError (err, vm, info) {
{
warn((“Error in ” + info + “: \”” + (err.toString()) + “\””), vm);
}
/* istanbul ignore else */
if ((inBrowser || inWeex) && typeof console !== ‘undefined’) {
console.error(err);
} else {
throw err
}
}
Vue 中提供了 Vue.config.errorHandler` 来处理捕获到的错误。
// err: 捕获到的错误对象。
// vm: 出错的 VueComponent.
// info: Vue 特定的错误信息,比如错误所在的生命周期钩子
Vue.config.errorHandler = function (err, vm, info) {}
如果开发者没有配置 Vue.config.errorHandler,那么捕获到的错误会以 console.error 的方式输出。
上报错误
捕获到错误后,如何上报呢?最常见、最简单的方式就是通过 <img> 了。代码简单,且没有跨域烦恼。
function logError(error){
var img = new Image();
img.onload = img.onerror = function(){
img = null;
}
img.src = `${上报地址}?${processErrorParam(error)}`;
}
当上报数据比较多时,可以使用 post 的方式进行上报。
错误的上报其实是一项复杂的工程,涉及到上报策略、上报分类等等。特别是在项目的业务比较复杂的时候,更应该关注上报的质量,避免影响到业务功能的正常运行。使用了打包工具处理的代码,往往还需要结合 sourceMap 进行代码定位。本文就不做介绍了。
写在后面
要建立一套完整、可用的前端错误监控体系是一项复杂、浩大的工程。但是,这项工程往往是必备的。本文主要介绍了你可能没关注过的 Error 的一些细节,以及如何捕获页面中的错误。关于劫持原生方法部分的代码,你可以在 https://github.com/CoyPan/Fec 找到。
符合预期。

欢迎关注我的公众号: 符合预期的 CoyPan,这里只有干货,符合你的预期。

正文完
 0