zl程序教程

您现在的位置是:首页 >  后端

当前栏目

JS高级调试技巧:捕获和分析JavaScriptError详解

JS调试 详解 技巧 分析 高级 捕获
2023-06-13 09:15:19 时间

反正只要JavaScript出错后刷新不复现,那用户就可以通过刷新解决问题,浏览器不会崩溃,当没有发生过好了。这种假设在SinglePageApp流行之前还是成立的。现在的SinglePageApp运行一段时间后状态复杂无比,用户可能进行了若干输入操作才来到这里的,说刷新就刷新啊?之前的操作岂不要完全重做?所以我们还是有必要捕获和分析这些异常信息的,然后我们就可以修改代码避免影响用户体验。

捕获异常的方式

我们自己写的thrownewError()想要捕获当然可以捕获,因为我们很清楚throw写在哪里了。但是调用浏览器API时发生的异常就不一定那么容易捕获了,有些API在标准里就写着会抛出异常,有些API只有个别浏览器因为实现差异或者有缺陷而抛出异常。对于前者我们还能通过try-catch捕获,对于后者我们必须监听全局的异常然后捕获。

try-catch

如果有些浏览器API是已知会抛出异常的,那我们就需要把调用放到try-catch里面,避免因为出错而导致整个程序进入非法状态。例如说window.localStorage就是这样的一个API,在写入数据超过容量限制后就会抛出异常,在Safari的隐私浏览模式下也会如此。

try{
localStorage.setItem("date",Date.now());
}catch(error){
reportError(error);
}

另一个常见的try-catch适用场景是回调。因为回调函数的代码是我们不可控的,代码质量如何,会不会调用其它会抛出异常的API,我们一概不知道。为了不要因为回调出错而导致调用回调后的其它代码无法执行,所以把调用回到放到try-catch里面是必须的。

listeners.forEach(function(listener){
try{
listener();
}catch(error){
reportError(error);
}
});

window.onerror

对于try-catch覆盖不到的地方,如果出现异常就只能通过window.onerror来捕获了。

window.onerror=
function(errorMessage,scriptURI,lineNumber){
reportError({
message:errorMessage,
script:scriptURI,
line:lineNumber
});
}

注意不要耍小聪明使用window.addEventListenerwindow.attachEvent的形式去监听window.onerror。很多浏览器只实现了window.onerror,或者是只有window.onerror的实现是标准的。考虑到标准草案定义的也是window.onerror,我们使用window.onerror就好了。

属性丢失

假设我们有一个reportError函数用来收集捕获到的异常,然后批量发送到服务器端存储以便查询分析,那么我们会想要收集哪些信息呢?比较有用的信息包括:错误类型(name)、错误消息(message)、脚本文件地址(script)、行号(line)、列号(column)、堆栈跟踪(stack)。如果一个异常是通过try-catch捕获到的,这些信息都在Error对象上(主流浏览器都支持),所以reportError也能收集到这些信息。但如果是通过window.onerror捕获到的,我们都知道这个事件函数只有3个参数,所以这3个参数意外的信息就丢失了。

序列化消息

如果Error对象是我们自己创建的话,那么error.message就是由我们控制的。基本上我们把什么放进error.message里面,window.onerror的第一个参数(message)就会是什么。(浏览器其实会略作修改,例如加上"UncaughtError:"前缀。)因此我们可以把我们关注的属性序列化(例如JSON.Stringify)后存放到error.message里面,然后在window.onerror读取出来反序列化就可以了。当然,这仅限于我们自己创建的Error对象。

第五个参数

浏览器厂商也知道大家在使用window.onerror时受到的限制,所以开始往window.onerror上面添加新的参数。考虑到只有行号没有列号好像不是很对称的样子,IE首先把列号加上了,放在第四个参数。然而大家更关心的是能否拿到完整的堆栈,于是Firefox说不如把堆栈放在第五个参数吧。但Chrome说那还不如把整个Error对象放在第五个参数,大家想读取什么属性都可以了,包括自定义属性。结果由于Chrome动作比较快,在Chrome30实现了新的window.onerror签名,导致标准草案也就跟着这样写了。

window.onerror=function(
errorMessage,
scriptURI,
lineNumber,
columnNumber,
error
){
if(error){
reportError(error);
}else{
reportError({
message:errorMessage,
script:scriptURI,
line:lineNumber,
column:columnNumber
});
}
}
属性正规化

我们之前讨论到的Error对象属性,其名称都是基于Chrome命名方式的,然而不同浏览器对Error对象属性的命名方式各不相同,例如脚本文件地址在Chrome叫做script但在Firefox叫做filename。因此,我们还需要一个专门的函数来对Error对象进行正规化处理,也就是把不同的属性名称都映射到统一的属性名称上。具体做法可以参考这篇文章。尽管浏览器实现会更新,但人手维护一份这样的映射表并不会太难。

类似的是堆栈跟踪(stack)的格式。这个属性以纯文本的形式保存一份异常在发生时的堆栈信息,由于各个浏览器使用的文本格式不一样,所以也需要人手维护一份正则表达,用于从纯文本中提取每一帧的函数名(identifier)、文件(script)、行号(line)和列号(column)。

安全限制

如果你也遇到过消息为"Scripterror."的错误,你会明白我在说什么的,这其实是浏览器针对不同源(origin)脚本文件的限制。这个安全限制的理由是这样的:假设一家网银在用户登录后返回的HTML跟匿名用户看到的HTML不一样,一个第三方网站就能把这家网银的URI放到script.src属性里面。HTML当然不可能被当做JS解析啦,所以浏览器会抛出异常,而这个第三方网站就能通过解析异常的位置来判断用户是否有登录。为此浏览器对于不同源脚本文件抛出的异常一律进行过滤,过滤得只剩下"Scripterror."这样一条不变的消息,其它属性统统消失。

对于有一定规模的网站来说,脚本文件放在CDN上,不同源是很正常的。现在就算是自己做个小网站,常见框架如jQuery和Backbone都能直接引用公共CDN上的版本,加速用户下载。所以这个安全限制确实造成了一些麻烦,导致我们从Chrome和Firefox收集到的异常信息都是无用的"Scripterror."

CORS

想要绕过这个限制,只要保证脚本文件和页面本身同源即可。但把脚本文件放在不经CDN加速的服务器上,岂不降低用户下载速度?一个解决方案是,脚本文件继续放在CDN上,利用XMLHttpRequest通过CORS把内容下载回来,再创建<script>标签注入到页面当中。在页面当中内嵌的代码当然是同源的啦。

这说起来很简单,但实现起来却有很多细节问题。用一个简单的例子来说:

<scriptsrc="http://cdn.com/step1.js"></script>
<script>
(functionstep2(){})();
</script>
<scriptsrc="http://cdn.com/step3.js"></script>

我们都知道这个step1、step2、step3如果存在依赖关系的话,则必须严格按照这个顺序执行,否则就可能出错。浏览器可以并行请求step1和step3的文件,但在执行时顺序是保证的。如果我们自己通过XMLHttpRequest获取step1和step3的文件内容,我们就需要自行保证其顺序正确性。此外不要忘记了step2,在step1以非阻塞形式下载的时候step2就可以被执行了,所以我们还必须人为干预step2让它等待step1完成后再执行。

如果我们已经有一整套工具来生成网站上不同页面的<script>标签的话,我们就需要调整一下这套工具让它对<script>标签做出改动:

<script>
scheduleRemoteScript("http://cdn.com/step1.js");
</script>
<script>
scheduleInlineScript(functioncode(){
(functionstep2(){})();
});
</script>
<script>
scheduleRemoteScript("http://cdn.com/step3.js");
</script>

我们需要实现scheduleRemoteScriptscheduleInlineScript这两个函数,并且保证它们在第一个引用外部脚本文件的<script>标签之前就被定义好,然后余下的<script>标签都会被改写成上面这种形式。注意原本立即执行的step2函数被放到了一个更大的code函数里面了。code函数并不会被执行,它只是一个容器而已,这样使得原本step2的代码不需要转义就能保留下来,但又不会被立即执行。

接下来我们还需要实现一套完整的机制,保证这些由scheduleRemoteScript根据地址下载回来的文件内容和由scheduleInlineScript直接获取到的代码能够按照正确的顺序一个接一个地执行。详细的代码我就不在这里给出了,大家有兴趣可以自己去实现。

行号反查

通过CORS获取内容再把代码注入页面能够突破安全限制,但会引入一个新的问题,那就是行号冲突。原本通过error.script可以定位到唯一的脚本文件,再通过error.line可以定位到唯一的行号。现在由于都是页面内嵌的代码,多个<script>标签并不能通过error.script来区分,然而每一个<script>标签内部的行号都是从1算起的,结果就导致我们无法利用异常信息定位错误所在的源代码位置。

为了避免行号冲突,我们可以浪费一些行号,使得每一个<script>标签中有实际代码所使用的行号区间互相不重叠。举个例子来说,假设每个<script>标签中的实际代码都不超过1000行,那么我可以让第一个<script>标签中的代码占用第1?1000行,让第二个<script>标签中的代码占用第1001?2000行(前面插入1000行空行),第三个<script>标签种的代码占用第2001?3000行(前面插入2000行空行),以此类推。然后我们使用data-*属性记录这些信息,便于反查。

<script
data-src="http://cdn.com/step1.js"
data-line-start="1"
>
//codeforstep1
</script>
<scriptdata-line-start="1001">
//"\n"*1000
//codeforstep2
</script>
<script
data-src="http://cdn.com/step3.js"
data-line-start="2001"
>
//"\n"*2000
//codeforstep3
</script>

经过这样处理后,如果一个错误的error.line3005的话,那意味着实际的error.script应该是"http://cdn.com/step3.js",而实际的error.line则应该是5。我们可以在之前提到的reportError函数里面完成这项行号反查工作。

当然,由于我们没办法保证每一个脚本文件只有1000行,也有可能有些脚本文件明显小于1000行,所以其实不需要固定分配1000行的区间给每一个<script>标签。我们可以根据实际脚本行数来分配区间,只要保证每一个<script>标签所使用的区间互不重叠就可以了。

crossorigin属性

浏览器对于不同源的内容进行的安全限制当然不仅限于<script>标签。既然XMLHttpRequest可以通过CORS来突破这个限制,为什么直接通过标签引用的资源就不可以呢?这当然是可以的。

针对<script>标签引用不同源脚本文件的限制同样作用于<img>标签引用不同源图片文件。如果一个<img>标签是不同源的话,一旦在<canvas>绘图时用到了,该<canvas>将变为只写状态,保证网站不能通过JavaScript窃取未授权的不同源图片数据。后来<img>标签通过引入crossorigin属性解决了这个问题。如果使用crossorigin="anonymous",则相当于匿名CORS;如果使用`crossorigin=“use-credentials”,则相当于带认证的CORS。

既然<img>标签能这样做,为什么<script>标签就不能这样做?于是浏览器厂商就为<script>标签加入了同样的crossorigin属性用于解决上述安全限制问题。现在Chrome和Firefox对这个属性的支持是完全没有问题的。Safari则会把crossorigin="anonymous"当做crossorigin="use-credentials"处理,结果是如果服务器只支持匿名CORS则Safari会当做认证失败。由于CDN服务器出于性能的考虑被设计为只能返回静态内容,不可能动态的根据请求返回认证CORS所需的HTTPHeader,Safari相当于不能利用此特性来解决上述问题。

总结

JavaScript异常处理看起来很简单,跟其它语言没什么区别,但真的要把异常都捕获了然后对属性做分析,其实还不是那么容易的事情。现在尽管有一些第三方服务提供捕获JavaScript异常的类GoogleAnalytics服务,但如果要弄明白其中的细节和原理还是必须自己亲手做一次。