前言
我一直秉持这样的观点:从某种角度来看,人类一直生存在一个充满错误的世界里面。错误的环境污染,错误的城市设计(地下排污系统,道路设计),错误的功利社会,错误的攀比之风,错误的意识,错误的态度,错误的行动......总之,是人就会犯错,这个世界错误无处不在。这是一个基本事实。
把上面的观点套用在软件编程领域也是一样的。同样,在软件编程的世界里面,充满着各种错误。有些错误是外部环境造成的,有些错误是因为自己的疏忽而造成的。有一点跟现实世界不一样的是,我们开发者并害怕在软件世界里面犯错,准确来说,是不害怕犯小错误。因为我们总是能把这样的错误在运行时转移给用户,自己眼不见心不烦。显然,这种态度和做法是不专业的。在软件工程里面,保证软件的健壮性是其中的一个主题。所以说能够妥善地处理前端错误,是前端开发者软件工程水平的体现。我记得,我的一个后端开发的朋友曾跟我说:“sam,你们前端开发的都不处理异常的吗?这你们也太不专业了吧”。是啊,相比服务端应用对错误/异常处理的重视程度,前端应用肯定还远远没有到那个份上。所以,为了体现我们作为软件工程师的专业性,我们必须把前端错误/异常处理重视起来,并妥善处理好。
延续中文翻译界的说法,我后面都会在“错误”和“异常”两种措辞中切换,而这种表述都是同一个意思。
处理好前端异常,不单能体现我们作为软件工程师的专业性,更是为了能对接前端监控系统。随着前端富应用和前后分离的软件开发模式的发展,前端代码的规模也越来越大了。为了能更好地追溯问题,定位问题和解决问题,前端监控系统应运而生。当前,前端监控系统无非承担“错误监控”和“性能监控”两大职责。“性能监控”不是本文的讨论内容,故略过不表。而要想在服务端的前端监控系统对前端错误进行收集的话,那么对前端而言,我们要面临的问题就是“前端该如何发现并上报错误呢?”。
前端应用是整个软件服务离用户最近的地方,本着“客户就是上帝”的商业准则,为用户创造良好的用户体验,是前端开发者职责之所在。当页面发生错误的时候,相比于页面点不动,在适当的时机,以一种适当的方式去提醒用户当前发生了什么,无疑是一种更友好的处理方式。
综上所述,我们有足够的理由和动机去处理好前端错误。下面,我们开始吧。
正文
错误类型
因为,web前端开发的产物就是web页面。所以,前端错误就是页面错误。那么在,日常开发中,我们的页面有可能碰到什么类型的错误呢?我归纳起来有以下几种类型:
- ECMAScript exceptions
- DOMException and DOMError
- 网络静态资源加载错误
- 跨域引用script导致的script error
- 页面崩溃
1. ECMAScript exceptions
ECMAScript异常就是javascript在执行过程中所发生的错误。每一种错误都有对应的错误类型。当错误发生的时候,就会抛出相应类型的错误对象。下面是ECMA-262定义的七种错误类型和一种非标准的InternalError类型。
-
Error
Error是其余错误类型的基类,所以所有的错误类型共享一组相同的方法和属性。Error错误很少见,其存在的主要目的是方便开发人员去自定义自己的错误类型。
-
EvalError
如果没有把eval()当做函数调用,就会抛出这种错误。比如:
new eval(); eval = foo; 复制代码
理论上说,浏览器应该抛出EvalError。但是实际上,每个浏览器的表现都不一样。再鉴于当前的ECMAScript规范中不再使用EvalError,本来这个类型的错误就很少看到,将来估计js引擎也不会抛出这种类型的错误了。
-
RangeError
这个错误类型就比较好理解了。当我们在初始化数组的时候,假如我们申请的长度过长的话,就会引发这种错误。也就是说,当数值超出相应范围的时候,就会触发这种错误。比如:
const foo = new Array(-1); const bar = new Array(Number.MAX_VALUE); // 无限递归,突破了call stack的maxmium size function foobar(){ foobar(); } foobar(); 复制代码
-
ReferenceError
这类型错误通常是访问那些未经声明或者不存在的变量时所触发的错误。比如:
console.log(a); // a并没有声明就访问了的情况下,浏览器会报ReferenceError 复制代码
当我们把变量名写错的时候,就会看到这种类型的错误。
-
SyntaxError
顾名思义,当我们javascript代码中出现语法错误的时候,就会导致这种类型的错误。比如:
eval( 1 ++ 2); // 多写了一个加号 // 关键字写错了 constt a = 1; function test(){ retrun '应该是r-e-t-u-r-n才对'; } 复制代码
-
TypeError
这是我们在运行时看到最多的错误类型了。因为javascript是动态弱类型,所有变量的数据类型都出于变动中。如果,运行时期间,在不恰当的数据类型变量上执行不恰当的操作的时候,浏览器就会报这个大名鼎鼎的TypeError。比如:
const o = new 10; // TypeError for(let key in 10){} // TypeError (10).slice() // TypeError 复制代码
-
URIError
在使用encodeURI()或者decodeURI(),当传入的字符换的URL的格式不对的时候,就会导致URIError。但是,事实上这种错误很少见,因为这两个函数的容错性非常高。
-
InternalError
这是一个非标准化的错误类型。它是指那些发生在javascript engine里面的错误。一般情况下,都是因为某些东西太多而导致的错误。比如:
- 我们写了太多的switch...case...语句;
- 正则表达式中括号太多等等。
2. DOMException and DOMError
根据MDN上的资料,DOMError不是标准规范,这里就没必要讨论它了。而DOMException就是我们在调用web API方法或者属性的时候所发生的错误,或者简单地说是在执行DOM操作的时候所抛出的错误。
在跟音频打交道的过程中,在不适当的时机调用web API方法的时候,往往会出现这类型的错误。比如:
window.onload = function () {
const video = document.getElementById('video');
video.play();
};
<video id="video" preload="none" src="http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4"></video>
复制代码
上面的代码就会报一个DOMException:
Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first.
又比如在错误地调用了DOM接口:
<head>
<script type="text/javascript">
function ThrowDOMException () {
var elem = document.createAttribute ("123");
}
</script>
</head>
<body>
<button onclick="ThrowDOMException ()">Throw a DOM exception</button>
</body>
复制代码
上面的示例,浏览器会报一个DOMException:
Uncaught DOMException: Failed to execute 'createAttribute' on 'Document': The localName provided ('123') contains an invalid character.
3. 网络静态资源加载错误
常见的网络静态资源包括有,html文件,css文件,javascript文件,图片,音频,视频,iframe等等。所有的网络静态资源都有可能遇到加载错误这个问题。加载错误的原因也不一而定,有可能是url写错了,有可能是服务器根本就没有这个资源,有可能是服务器出现内部错误,也有可能是网络忙,请求超时而导致资源加载失败。不管什么原因,凡是客户端没有加载成功的,一律视为出现了加载错误。
4. script error
当跨域引用了另外一个域下javascript资源,并且这个javascript执出错的情况下,浏览器就会抛出这个错误。
假如,我们在another-domain.com域名下有这么一个js文件:
const a = {};
console.log(a.b.c);
复制代码
在我们自己的域名 origin-domain.com中,我们去引用这个文件:
<script src="http://another-domain.com/index.js"></script>
复制代码
那么这种场景下,浏览器就会抛出一个script error类型的错误。script error类型的错误基本上是在告诉你,你跨域引用的脚本执行出错了,但是你没有知道具体错误信息的权利。一切的一切都是因为,浏览器为了信息安全,制定了所谓的同源策略(same-origin policy)。
5. page crash
当你访问一个不靠谱的web应用的时候,它可能会做出一个疯狂和不可预测的操作,这个时候,就会导致整个浏览器的崩溃。你的页面也因此遭了殃。在chrome下面,页面崩溃的样子,相信大家都见过的:
错误防范
个人觉得,对于代码错误的态度应该是这样的:能够防范的就要做好防范工作,把错误扼杀在摇篮当中。以上的五种错误类型,除了第一种,其余的几种几乎是不能人为防范的。
DOMException一般是在你对DOM API理解或者记错的情况下犯的错误。在出错前,你潜意识地就觉得自己可能是对。所以在这种情况下,是无法人为地防范的。其他的几种错误,错误源头几乎不在你的掌控范围内,所以,你也无法防范。在这一小节,我主要探讨一下我们该如何防范ECMAScript exceptions这种类型的错误,这是我们能人为是防范的。其实,在引入typescript,为我们的javascript加上一层类型的保护衣后,这里面的很多错误都能够在代码编写期得到提醒和纠正。但是,这一小节还是想来谈谈,如何通过培养一些好的javascript编程习惯来防范这类型的错误。
在ECMAScript exceptions这类型的错误中,我们主要防范的是以下三种子类型的错误:
- 隐性类型转换导致的错误
- 缺乏类型防守导致的错误
- 通信错误
隐性类型转换导致的错误
这种错误一般在对隐性类型转换不够警惕而导致的错误。隐性类型转换作为PL的一个特性提供出来,本身可能是没有错的,但是错就错在你对它后续所产生的影响没有估量好。
举个例子,你手头上有一个数字类型的id,然后从后端接口请求回一个列表。如果列表中某个item的id等于你手头这个id的话,那么你就想把这个item的的边框高亮起来。这个时候你考虑到后端同事的不靠谱,有时候返回字符串类型,有时候返回数字类型,你于是这么写:
this.list = this.list.map(item=> {
if(item.id == myID){
return {
...item,
iSelected: true
}
}
return item;
})
复制代码
是的,这里为了容错性,使用了等于号所带来的隐性类型转换而不是全等号也算是用得其所。所以说,隐性类型转换没有错,关键是你知道怎么用。但是话说回来,隐形类型转换终究是会降低了代码的可预测性,使得代码难以维护。个人观点,即使你明确知道自己在使用隐性类型转换,但是最好还是不要用。以上代码可以使用强制类型转换改造:
if(parseInt(item.id) === parseInt(myID)){
......
}
复制代码
众所周知,当使用相等(==)和不相等(!==)操作符,或者在if,for以及while等流程控制语句使用非布尔值类型的的数据的时候,实质上我们是在使用隐性类型转换。当你对隐性类型转换的警惕性稍有下降的时候,你就会犯错了。举个例子,在理财业务开发中,我们有个输入框,它主要是用于输入购买金额。在输入框的下面,我们会有一个提示语。实时地跟进用户的输入同步计算该购买金额所产生的利息:
let hintText = '请输入购买金额';
const purchaseAmount =parseFloat(document.getElementById('input').value) ;
if(purchaseAmount) {
const income = .... // 做算术四则运算;
hintText = `预计收益是$(income)元`;
}
复制代码
因为用户的有可能输入的是“0”,这个时候因为if语句的隐性类型转换为false,导致无法更新hintText。结果是,用户虽然是输入了东西,但是界面还是显示默认的提示语“请输入购买金额”。虽然不是一个致命的错误,但是很明显是一个不符合逻辑的错误。
此处,如果能够避免隐性类型转换,那么我们就不会犯下这个错误:
if(typeof purchaseAmount === 'number' && !isNaN(purchaseAmount)){
// ......
}
复制代码
虽然此处的错误不会导致js代码的运行时错误,但是保不齐下次就会把不恰当的类型漏放进去到if语句的代码块中,然后基于在此类型数据做不恰当的操作,很有可能就导致一个TypeError的错误。
所以,我们要避免使用隐性转换。怎样避免法呢?
- 使用全等号(===)和不全等号(!==)来代替等号(==)和不等号(!=);
- 在各种流程控制语句中,做条件判断的时候要确保传入的是一个布尔值(或者值为布尔值的表达式)。
缺乏类型防守导致的错误
javascript是一个动态弱类型的编程语言。所以,在使用js进行软件开发的过程中,为了保证程序的健壮性,类型防守太重要了。
举个例子,下面我们要实现一个从url上获取query string的函数:
function getQueryString(url){
const pos = url.indexOf('?');
if (pos > -1) {
return url.slice(pos + 1);
}
return '';
}
复制代码
我们理所当然地以为url是字符串类型。但是万一调用我们函数的人,一不小心传了数字类型值进来呢?显然,此处,在不恰当的数据类型上调用了不恰当的方法,浏览器会在运行时给我们一个大大的“Uncaught TypeError:url.indexOf is not a function”。只要我们提高警惕性,加上一条简单的类型防守的判断语句即可避免犯下这个错误:
function getQueryString(url){
if(typeof url === 'string') {
const pos = url.indexOf('?');
if (pos > -1) {
return url.slice(pos + 1);
}
}
return '';
}
复制代码
类型防守中,如何准备地判断数据的类型呢?我归纳为两种方式。
第一种是通过原生的操作符来判断。对于基本类型(primitive data type)而言,我们只需要使用typeof操作符来判断即可:
typeof undefined === 'undefined' // true
typeof 'str' === 'string' // true
typeof 123 === 'number' // true
typeof true === 'boolean' // true
typeof Symbol('react') === 'symbol' // true
复制代码
这里值得一提的是,null有点特殊,毕竟它是价值十亿美元的错误嘛。typeof null的值是一个“object”。所以,我们可以这样判断:
// 直接判断
const n = null;
n === null // true
又或者采用jquery里面的写法:
String(n) === 'null' // true
复制代码
而对于对象类型,再使用typeof操作符来判断就不够准确了,我们应该用“instanceof”操作符来判断:
const obj = {};
const fn = ()=> {};
const arr = [];
const date = new Date();
const reg = /^[a-z]$/;
const promise = new Promise(reslove=> reslove())
obj instanceof Object // true
fn instanceof Function // true
arr instanceof Array // true
date instanceof Date // true
reg instanceof RegExp // true
promise instanceof Promise // true
复制代码
第二种是带有点hack味道的all-purpose approach:
function typeOf(obj){
const stringResult = Object.prototype.toString.call(obj);
const matchResult = stringResult.match(/^\[(\w+)\s+(\w+)\]$/);
if(matchResult !== null){
return matchResult[2].toLowerCase();
}
throw new Error('未知类型');
}
const obj = {};
const fn = ()=> {};
const arr = [];
const date = new Date();
const reg = /^[a-z]$/;
const promise = new Promise()
typeOf(null) === 'null' // true
typeOf(undefined) === 'undefined' // true
typeOf(1) === 'number' // true
typeOf('hello world') === 'string' // true
typeOf(true) === 'boolean' // true
typeOf(Symbol('react')) === 'symbol' // true
const promise = new Promise(reslove=> reslove())
typeOf(obj) === 'object' // true
typeOf(fn) === 'function' // true
typeOf(arr) === 'array' // true
typeOf(date) === 'date' // true
typeOf(reg) === 'regexp' // true
typeOf(promise) === 'promise' // true
复制代码
可以看出,这个typeOf方法是针对所有的javascript值都能准确地判断出它的类型。 此乃“居家旅行,杀人灭口”的必备良药啊。
通信错误
在前端开发的过程总,一般接触到的通信类型包括:前端与客户端的通信,前端与服务端的通信。
在前端与客户端的通信中,一般出现得比较多的是前端传递给客户端一个未经编码的url,导致客户端解析出错。比如:
ssj://login?redir=https://frontend.com/index.html?a=b&c=d
复制代码
上面这个问题,我们使用encodeURIComponent()方法对“redir”后面的字符串进行编码一下就可以解决这个问题:
ssj://login?redir=https%3A%2F%2Ffrontend.com%2Findex.html%3Fa%3Db%26c%3Dd
复制代码
而客户端那边在解析之前也需要用匹配的方法来进行解码。
而在跟服务端通信的过程中,不少值得注意的事情。很多时候,前后端已经通过接口文档规定好数据类型和结构了,但是后端还是犯错,没有按照约定来返回数据类型和结构。
关于数据类型,后端常常犯的错有:
- 数字类型与字符串类型的混淆
- null与“null”的混淆
关于数据结构,后端也常有不按照约定返回时候。比如我们约定好返回的数据结构是这样的:
{
success: true,
msg: '',
data: {
list: []
}
}
复制代码
结果,他给我返回的是:
{
success: true,
data: {
msg: '',
data: []
}
}
复制代码
还有一种是返回的json序列字符串在语法上有问题,导致前端使用JSON.stringify()解析报错。
综上所述的种种情况,如果我们太过信任通信对方所返回的数据,而不进行相应的类型和字段防守的话,那么我们的js代码极有可能在运行时报错TypeError。
错误处理
虽然,具备良好的防范意识和借助Typescript这个工具,能够把很多低级的错误扼杀在摇篮中。但是,最最坏的打算,终究是好的。出错不要紧,要紧的是要知道怎么处理。怎么处理呢?我将其分为两个步骤:
- 第一步:捕获异常,获取错误信息;
- 第二步:视实际需求而做对应的处理;
第一步:捕获异常,获取错误信息
在页面中,捕获异常的技术手段无非有下面的几种:
- try...catch
- window.onerror
- window.addEventListener('error',()=>{})
- element.onerror
- Promise Catch与window.addEventListener("unhandledrejection",()=> {})
- iframe与iframe.onload
- 其他
下面一一进行详细讲解。
try...catch
-
动机
使用try...catch来捕获异常,我归纳起来主要有两个动机:1)是真真正正地想对可能发生错误的代码进行异常捕获;2)我想保证后面的代码继续运行。
动机一没什么好讲的,在这里,我们讲讲动机二。假如我们有以下代码:
console.log(foo); console.log('I want running') 复制代码
代码一执行,你猜怎么着?第一行语句报错了,第二行语句的log也就没打印出来。如果我们把代码改成这样:
try{ console.log(foo) }catch(e){ console.log(e) } console.log('I want running'); 复制代码
以上代码执行之后,虽然还是报了个ReferenceError错误,但是后面的log却能够被执行。
从这个示例,我们可以看出,一旦前面的(同步)代码出现了没有被开发者捕获的异常的话,那么后面的代码就不会执行了。所以,如果你希望当前可能出错的代码块后续的代码能够正常运行的话,那么你就得使用try...catch来主动捕获异常。
实际上,出错代码是如何干扰后续代码的执行,是一个值得探讨的主题。下面进行具体的探讨。因为市面上浏览器众多,对标准的实现也不太一致。所以,这里的结论仅仅是基于Chromev81.0.4044.92。探讨过程中,我们涉及到两组概念:同步代码与异步代码,代码书写期和代码运行期。
- 场景1:“同步代码 + 同步代码”的情况:
可以看到,出错的同步代码后面的同步代码不执行了。
- 场景2:“同步代码 + 异步代码 ”
跟上面的情况一下,异步代码也受到影响,也不执行了。
- 场景3:“异步代码 + 同步代码 ”:
可以看到,异步代码出错,并不会影响后面同步代码的执行。
- 场景4:“异步代码 + 异步代码 ”的情况:
出错的异步代码也不会影响后面异步代码的执行。
我们重点关注场景2和场景3,如果我们把两者结果的原因理解为:“那是因为在代码运行期,同步代码始终是先于异步代码执行的。如果先执行的同步代码没有出错的话,那么后面的代码就会正常执行,否则后面的代码就不会执行”的话,那么我们不妨看看这个例子:
按理说,在运行期,js引擎会先执行我们的同步代码“console.log(a);”。此时,我们的同步代出错了,按照我们初步得到的结论而言,执行流后面的连个异步代码应该不执行才对啊。事实上,它们两个都执行了,是吧?我们再来看一个例子:
看到了没?同样是异步代码,按理说,代码运行期,如果你是受出错的同步代码的影响的话,那你要么是两个都不执行,或者两个都执行啊?凭什么写在出错代码代码书写期前面的异步代码就能正常执行,而写在后面的就不执行呢?经过验证,在firefoxv75.0版本中也是同样的表现。
所以,到了这里,我们基本上可以得出这样的结论:运行期,一先一后的两个代码中,出错的一方代码是如何影响另外一方代码继续执行的问题中,跟异步代码没关系,只跟同步代码有关系;跟代码执行期没关系,只跟代码书写期有关系。换句话说就是,异步代码出错与否都不会影响其他代码继续执行。而出错的同步代码,如果它在代码书写期是写在其他代码之前,并且我们并没有对它进行手动地去异常捕获的话,那么它就会影响其他代码(不论它是同步还是异步代码)的继续执行。
综上所述,如果我们想要保证某块可能出错的同步代码后面的代码继续执行的话,那么我们必须对这块同步代码进行异常捕获。
-
语法
try { try_statements } [catch (exception_var_1 if condition_1) { // non-standard catch_statements_1 }] ... [catch (exception_var_2) { catch_statements_2 }] [finally { finally_statements }] 复制代码
也就是说有以下几种组合方式:
- try...catch
- try...finally
- try...catch...finally
其中,catch可以串联多个,这种语法被称为“Conditional catch-blocks”,因为它不是标准规范,并且很多浏览已经不再支持了,所以这里就不深入讨论。
使用try...catch的时候,语法上有以下几个注意点:
-
catch子句会收到一个错误对象。即使你不想使用这个错误对象,你也必须给它一个名字,否则浏览器会报一个“Uncaught SyntaxError”的错误。
-
finally子句无论如何都会执行,并且优先级比return语句还要高。比如,下面这个函数会返回0,而不是2:
function testFinally(){ try{ return 2; }catch(e){ return 1; }finally{ return 0; } } 复制代码
-
try...catch可以相互嵌套。在里面抛出的错误会被离这个错误最近的catch块捕获。这个“最近”包含两种情况。第一种是,同一层级中,同层向下离它最近的catch块(因为浏览器中只有firefox在1-59这系列版本支持,所以这种跟Conditional catch-blocks相关情况可以忽略);第二种是,不同层级中,离它最近的层级中的catch块。
try { try { throw new Error("oops"); } catch (ex) { console.error("inner", ex.message); throw ex; } finally { console.log("finally"); } } catch (ex) { console.error("outer", ex.message); } // Output: // "inner" "oops" // "finally" // "outer" "oops" 复制代码
-
处理范围
只能捕获同步代码所产生的运行时错误,对于语法错误和异步代码所产生的错误是无能为力的。
捕获不了syntax error:
捕获不了异步代码运行时所产生的错误:
不过,有一个特例是async...await。被await接管的异步代码所发生的错误是能够用try...catch来捕获的:
window.onerror
-
动机
众所周知,很多做错误监控和上报的类库就是基于这个特性来实现的,我们期待它能处理那些try...catch不能处理的错误。
-
语法
window.onerror = function(message, source, lineno, colno, error) { ... }
复制代码
历史原因,不同浏览器或者同一个浏览器的不同版本中,window.onerror事件处理函数所接收到的参数也是不同的。这里只是讨论标准规范:
- message: 错误信息的字符串(string)
- source: 出错js文件的URL(string)
- lineno: 出错代码的行号(number)
- colno: 出错代码的列号(number)
- error: 错误对象(object)
就像上面说的,因为传入onerror事件处理函数的参数除了message外,在跨浏览器上具有兼容性问题,所以,如果考虑浏览器兼容性,建议只使用message参数即可。
这里值得提一点是,如果想要window.onerror捕获错误后,取消它的默认行为(这个默认行为就是在浏览器控制台打印出一个uncaught xxxxError ),事件处理器需要返回true。这是反常识的。因为在UI事件系统中,取消默认行为一般是通过返回false来完成。
- 处理范围
下面到了我们最关注的环节。那就是window.onerror到底能够处理哪些错误呢?
MDN中如是说:
When a JavaScript runtime error (including syntax errors and exceptions thrown within handlers) occurs, an error event using interface ErrorEvent is fired at window and window.onerror() is invoked
这句话说得好像所有运行时的javascript错误,window.onerror都能捕获的那样。实际上,经过在chromevxxx和firefoxv75.0上面验证,这句话是不对的。window.onerror并不是万能的。
-
window.onerror不能捕获syntax error:
window.onerror = function(message, source, lineno, colno, error) { console.log('通过window.onerror捕获到的异常:',message); return true; } const a = 1'; // 语法错误 复制代码
看得出来,语法错误并没有捕获,还是显示在浏览器控制台上了。
-
window.onerror不能捕获异步代码的错误
但是有一点值得注意的是,window.onerror竟然能捕获同样是异步代码的setTimeout和setInterval里面的错误:
window.onerror = function(message, source, lineno, colno, error) { console.log('通过window.onerror捕获到的异常:',message); return true; } setTimeout(()=> { console.log(a); },0) setInterval(() => { console.log(b); }, 1000); 复制代码
-
window.onerror 不能捕获静态资源的加载失败。
-
正如MDN中所说的那样,window.onerror是能捕获发生在事件处理函数里面的包括语法错误在内的异常的。
``` <script> window.onerror = function(message, source, lineno, colno, error) { console.log('通过window.onerror捕获到的异常:',message); return true; } </script> <img id="img" src="./fake.png" onerror="console.log(abc)"> ``` 复制代码
控制台会打印出:
通过window.onerror捕获到的异常: Uncaught ReferenceError: abc is not defined
看来,window.onerror并不是万能的。我们还指望它完完全全地去兜住try...catch的底,结果是它也有它捕获不了的错误。
window.addEventListener('error',()=>{})和 element.onerror
-
动机
因为window.onerror不能捕获网络资源加载失败的错误,我们希望这两个技术手段能满足我们的需求。
-
语法
就像MDN所说的:
When a resource (such as an <img> or<script>) fails to load, an error event using interface Event is fired at the element that initiated the load, and the onerror() handler on the element is invoked. These error events do not bubble up to window, but (at least in Firefox) can be handled with a window.addEventListener configured with useCapture set to True.
可以看出,这两者是在同一个事件传播路径上的:一个是target phase,一个capture phase。通过验证,firefox和chrome通过设置window.addEventListener()的第二个参数为true,是能够捕获网络资源加载失败的这种类型的错误的。
-
处理范围
我们跑一个例子来看看,window.addEventListener('error')是否能够真的符合我们的期待:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>网络资源加载错误捕获</title> <script> window.addEventListener('error', (error) => { console.log('使用window.addEventListener能捕获到异常:', error); }, true) </script> <script src="./fake.js"></script> <link rel="stylesheet" href="./fake.css"> </head> <body> <img id="img" src="./fake.png"> <video id="video" src="fake.mp4"></video> <audio src="fake.mp3"></audio> <iframe id="iframe" src="./test4.html" frameborder="0" ></iframe> </body> </html> 复制代码
在这里面,所有的网络资源都是不存在的,按理说都会报错。下面我们来看看控制台打印结果:
可以看到,除了iframe的所有资源的加载错误都被window.addEventListener('error')所捕获到了。
看来iframe要区别对待来行啊。
Promise Catch与window.addEventListener("unhandledrejection",()=> {})
-
动机
目前,异步代码中,除了promise代码错误捕获没有被讲到之外,setTimeout/setInterval是能被window.onerror所捕获的,async...await是能被try...catch所捕获的。那下面,我们来看看promise代码中的错误应该如何捕获。
-
语法
const promiseObj = new Promise(executor); promiseObj .then(handleFulfilledA,handleRejectedA) .then(handleFulfilledB,handleRejectedB) .then(handleFulfilledC,handleRejectedC); // 或者 promiseObj .then(handleFulfilledA) .then(handleFulfilledB) .then(handleFulfilledC) .catch(handleRejectedAny); 复制代码
MDN更推荐后者的写法。这种写法的语法风格更接近try...catch,更易于接受。
-
处理范围
目前来看,没有任何的技术手段来捕获promise所产生的异常,promise自带的catch来得正是时候。下面我们来验证一下:
是的,是没问题的。但是,有时候无论是自己还是同事都会忘记写catch分支,这个时候在全局增加一个对 unhandledrejection 事件的监听来进行兜底,无疑是明智之举。
window.addEventListener("unhandledrejection", function(e){ console.log('捕获到的promise异常:', e); e.preventDefault(); }); new Promise((res)=>{console.log(a)}); 复制代码
这里值得一提的是,不同于DOM1用
return true/false
,DOM3级的事件机制是用event.preventDefault()
来去取消事件的默认行为的。同样,在这里的默认行为是“在浏览器控制台打印出一个uncaught xxxxError”。
iframe与iframe.onload
上面在window.addEventListener('error')一小节中说到,除了iframe以外,其他类型的网络静态资源的加载错误都能够被它所捕获。那剩下个iframe,我们该怎么办呢?
网上说,使用onerror属性简简单单地见监听iframe的error事件即可,像下面那样:
window.frames[0].onerror = function (message, source, lineno, colno, error) {
console.log('捕获到 iframe 异常:',{message, source, lineno, colno, error});
return true;
};
复制代码
无论是上面DOM1写法还是DOM0的写法,经过我的试验,好像都是不行的。苦闷之下,去问问google,只见有人向chromium团队提了个bug,这位网友反馈的问题正是我试验过程中遇到的问题。这下面这段话已经说得很清楚了:
The specification requires that all HTML elements support on onerror event. However, it does NOT require that all elements supporting network fetches raise fire a simple event called onerror. That is, elements must support allowing applications to set error handlers, but there is no (generic) requirement that the event be raised, in either HTML or the Fetch specification.
For now, this is WontFix, for Working as Intended (and as Specified)
上面的意思是说,规范只是要求我们实现对所有的HTML element的onerror事件监听的支持,但是没有要求我们真正地去触发这么一个事件。尤其在iframe元素上,我们自己觉得这里面存在网络探测(network probing)和其他的安全风险。
最后的结论是:WontFix
。最后,我们被迫采用一些比较hack的方法:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<iframe
id="iframe"
src="./test22.html"
frameborder="0"
onerror="console.log(event)"
onload="console.log(this.contentWindow.document.title)"
>
</iframe>
</body>
</html>
复制代码
下面我们来看看各个浏览器的控制台的打印情况:
chromev81.0.4044.92:
firefoxv75.0:
Microsoft Edgev44.18362.329.0:
可以看到,iframe的onerror的事件处理函数并没有执行。再者是,加载失败后,iframe所加载页面的文档title都会被设置为“Error”。最后,我们结合window.onerror所实现的方案是这样的:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<iframe
id="iframe"
src="./test22.html"
frameborder="0"
>
</iframe>
<script>
window.onerror = function(message, source, lineno, colno, error) {
console.log('通过window.onerror捕获到的异常:',message);
return true;
}
const iframe = document.getElementById('iframe');
iframe.onload = function(event){
const title = this.contentWindow.document.title.toLowerCase();
if(title === 'error'){
throw new Error('iframe加载失败');
}
}
</script>
</body>
</html>
复制代码
结果是控制台会打印出:
通过window.onerror捕获到的异常: Uncaught Error: iframe加载失败
其他
- page crash
如果想处理page crash的情况,大家可以参考这个博文。它给出的解决方案是这样的:
if(sessionStorage.getItem('good_exit') &&
sessionStorage.getItem('good_exit') !== 'true') {
/*
insert crash logging code here
*/
alert('Hey, welcome back from your crash, looks like you crashed on: ' + sessionStorage.getItem('time_before_crash'));
}
window.addEventListener('load', function () {
sessionStorage.setItem('good_exit', 'pending');
setInterval(function () {
sessionStorage.setItem('time_before_crash', new Date().toString());
}, 1000);
});
window.addEventListener('beforeunload', function () {
sessionStorage.setItem('good_exit', 'true');
});
复制代码
显然,这个处理方案跟iframe的处理方案一样。我们也只是知道页面崩溃了,至于崩溃的具体原因或者我们所期待的errorEvent对象,那是不存在的。
这个处理方案主要是依赖一个事实来展开的。那就是页面崩溃是无法触发 beforeunload事件的。所以,一旦页面崩溃了,那么记录在sessionStorage的good_exit的值只能是“pending”了。然后在页面下次进来的时候,if判断条件就为真了,我们就可以针对上一次的页面崩溃做点什么了。比如给用户一个友好的提示,或者说把上次崩溃的这个信息发给前端错误监控系统等等。
还有另外一个方案是用service worker来做,具体可以参考如何监控网页崩溃?这篇文章。
- script error
如果要处理script error这种类型的错误的话,貌似没有一个万全之策。当我们在开发中面临这样的错误的时候,我们一般有两个选择: 1) 避开同源策略的限制,从源头上去除这种错误; 2)当第三方域在我们的控制范围内,可以尝试去用hack的方式去捕获具体的第三方js的异常信息。
避开同源策略限制的方法可以有两种,具体参考Script error – What causes a Script error and how to solve them。
第一种是CORS。即在本域<script>标签加上一个crossorigin="anonymous"
属性,与之配合的是,第三方域需要对该第三方脚本请求的响应头上设置:Access-Control-Allow-Origin: */http://origin-domain.com
。
第二种是使用web代理服务器。这是处理跨域的常规做法了。
当第三方域在在我们的控制范围内,在不想采用第一处理方式的时候,我们可以用hack的方式去解决某种特性情况下的script error,具体参考解决 "Script Error" 的另类思路。有一点值得注意的是,针对这篇文章所下的结论,有为网友的评论是对的:
也就是说,这边文章所提到的方案只是解决了script error的一个特例-事件处理函数中的错误。基于浏览器不会对 try-catch 起来的异常进行跨域拦截这个事实 ,通过hack进原生的addEventListener方法来捕获第三方域脚本中【通过addEventListener来注册的】事件处理函数里面的错误,这就是这个方案的原理。但是,这个方案对于其他场景之外的错误是无法捕获的。所以,这不是一个通用方案。目前,我还没看到在不突破同源策略限制情况下,能够捕获所有script error的通用方案。
第二步:视实际需求而做对应的处理;
上面的第一步其实就是讲如何针对各种错误场景去找到其合适的处理方案。处理的目的无非就是为了知道当前出错了或者出现了什么错误。
那么第二步就要讲如何处置这些错误信息。这无非也就包括以下两种方式:
- 把错误信息上报给前端监控系统。
- 通过界面反馈,告知用户当前页面出错了。
把错误信息上报给前端监控系统
把错误信息上报给监控系统之前,往往需要对错误进行定级。如何定级,这是一个因人而异,又或者因业务而异的事情。一般情况下,我们基本上可以划分为两级:致命跟非致命的。
非致命错误,可以根据以下一个或者多个条件来确定:
- 不影响用户的主要任务
- 只影响页面的一部分
- 可以恢复
- 重复操作可以消除错误
致命错误,可以通过以下一个或多个条件来确定:
- 应用程序根本无法继续运行;
- 错误明显影响到用户的主要操作;
- 会导致其他连带的错误。
一般而言,像图片加载失败啊,因为网络原因导致API请求超时等等,这些都属于非致命的错误。基本可以这么说,非致命的错误的并不是需要关注的问题。我们在监控系统排查和定位问题的时候,我们首要关注的肯定是致命问题。
一般而言,没有主动通过try...catch来捕获,而是最终被兜底的window.onerror来捕获的错误,很有可能会导致应用程序无法继续运行或者影响用户主要操作。这类型的错误得优先排查和修复。
在某种场景下,如果必须刷新页面才能让应用程序继续正常运行的话,那就必须通知用户,同时给用户提供要给点击即可刷新页面的按钮。
确定好错误的等级,再加上拿到的错误信息,我们就可以上报给监控系统了。
无论是埋点上报还是错误上报,常用方案就是使用Image对象来发送请求。这么做,有以下几点好处:
- 浏览器兼容性好。所有浏览器都支持Image对象。
- 可以避免same-origin-policy的限制。实际开发中,通常都是一台服务器负责接收所有的服务器的错误上报。而这种情况下,就会存在跨域问题,单纯的XMLHttpRequest是解决不了这个问题。
- 在上报过程上,本身出现错误的概率比较低。如果你自己封装了一个ajax库或者使用了外部ajax库,如果这些库本身就有问题的话,你还指望它去上报的话,可想而知,结果是无法上报成功的
下面,我们简单实现一个用于上报错误的方法:
function postError(type, msg) {
const img = new Image();
img.src = `log.php?type=${encodeURICoponent(type)}&msg=${encodeURICoponent(msg)}`
}
复制代码
通过界面反馈,告知用户当前页面出错了
当页面出现错误了,尤其是出现了那些估计会导致用户点击页面没反应的错误的时候,一定要给出一些友好的提示,尊重用户的知情权。
提示语首先得友好,用户能读懂。其次是相对准确。提示的表现形式一般有轻提示toast,重提示弹窗,局部页面替换和整个页面替换(类似于常见的404错误提示)等几种。具体采取哪种形式,可以根据具体的业务场景和错误等级来决定。
一般而言,错误上报和界面反馈最好是结合起来使用,双管齐下,取得的生产效果是最佳。
总结
以一张图片来总结本文的核心内容:
参考资料
- try...catch:developer.mozilla.org/zh-CN/docs/…
- DOMException:developers.google.com/web/updates…
- DOMException:help.dottoro.com/ljushpcx.ph…
- script error: raygun.com/blog/script…
- type judgment: programmer.group/javaascript…
- window.onerror: developer.mozilla.org/en-US/docs/…
- IFrame onerror bug :bugs.chromium.org/p/chromium/…
- Logging Information on Browser Crashes:Logging Information on Browser Crashes
来源:oschina
链接:https://my.oschina.net/u/4321646/blog/4255475