本文首发于公众号: 符合预期的 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 是比较耗费性能的.
- Windows.onerror
- Windows.onerror = function(message, source, lineno, colno, error) {
- ...
- }
函数参数:
message: 错误信息(字符串)
source: 发生错误的脚本 URL(字符串)
lineno: 发生错误的行号(数字)
colno: 发生错误的列号(数字)
error:Error 对象(对象)
注意, 如果这个函数返回 true, 那么将会阻止执行浏览器默认的错误处理函数.
- Windows.addEventListener('error')
- Windows.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 中的一种.
- Windows.addEventListener('unhandledrejection')
- Windows.addEventListener('unhandledrejection', function (event) {
- ...
- });
在使用 Promise 的时候, 如果没有声明 catch 代码块, Promise 的异常会被抛出. 只能通过这个方法或者 Windows.onunhandledrejection 才能捕获到该异常.
event 就是上文提到的 PromiseRejectionEvent. 我们只需要关注其 reason 就行.
Windows.onerror 和 Windows.addEventListener('error')的区别
首先是事件监听器和事件处理器的区别. 监听器只能声明一次, 后续的声明会覆盖之前的声明. 而事件处理器则可以绑定多个回调函数.
资源 ( img 或 script ) 加载失败时, 加载资源的元素会触发一个 Event 接口的 error 事件, 并执行该元素上的 onerror()处理函数. 但这些 error 事件不会向上冒泡到 Windows. 不过, 这些 error 事件能被
Windows.addEventListener('error')
捕获. 也就是说,** 面对资源加载失败的错误, 只能用
Windows.addEventListerner('error')
,Windows.onerror** 无效.
关于错误捕获的小结
我认为, 在开发的过程中, 对于容易出错的地方, 可以使用 try{}catch(){}来进行错误的捕获, 做好兜底处理, 避免页面挂掉. 而对于全局的错误捕获, 在现代浏览器中, 我倾向于只使用使用 Windows.addEventListener('error'),Windows.addEventListener('unhandledrejection')就行了. 如果需要考虑兼容性, 需要加上 Windows.onerror, 三者同时使用, Windows.addEventListener('error')专门用来捕获资源加载错误.
跨域脚本错误, Script Error
在进行错误捕获的过程中, 很多时候并不能拿到完整的错误信息, 得到的仅仅是一个 "Script Error".
产生原因
由于 12 年前这篇文章里提到的安全问题:
当加载自不同域的脚本中发生语法错误时, 为避免信息泄露, 语法错误的细节将不会报告, 而是使用简单的 "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 = Windows.console.error;
- Windows.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 中捕获错误信息. 具体用法参考官方文档:
在 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 找到.
符合预期.
来源: https://juejin.im/post/5c2d60616fb9a049dc025c39