本文深入解析 JavaScript 异常处理机制,涵盖 try-catch 局部捕获与 window.onerror 全局监听的应用场景。重点探讨跨域脚本导致的 'Script error.' 问题,详解通过 CORS 属性、动态注入及行号反查技术实现完整错误信息收集的方案,帮助前端工程师构建健壮的线上错误监控体系。
JavaScript 异常处理概述
前端开发者普遍了解 JavaScript 具备基础的异常处理机制,例如通过 throw new Error() 主动抛出错误,或在调用浏览器 API 失败时由系统自动抛出异常。然而,多数开发者往往忽略了系统化收集和分析这些异常数据的重要性。
在单页应用(SPA)普及之前,假设用户遇到错误后刷新页面即可恢复,且浏览器不会崩溃,这种观点尚能成立。但在现代 SPA 中,应用运行久后状态复杂,用户可能已执行多次操作,强制刷新将导致前序操作全部丢失。因此,捕获并分析异常以优化代码、保障用户体验显得尤为必要。
异常捕获机制:try-catch 与 window.onerror
异常捕获主要分为两种途径。
对于开发者自行编写的 throw new Error(),由于代码位置明确,捕获相对容易。然而,调用浏览器 API 时产生的异常则较为复杂:部分标准 API 明确定义会抛出异常,部分则因浏览器实现差异或缺陷而抛出。前者可通过 try-catch 捕获,后者则必须依赖全局异常监听机制。
try-catch 机制。
当已知某些浏览器 API 会抛出异常时,应将其调用包裹在 try-catch 中,以防止程序进入非法状态。例如,window.localStorage 在写入数据超出容量限制或处于 Safari 隐私浏览模式时,会抛出异常。
示例代码如下:
try {
localStorage.setItem('date', Date.now());
} catch (error) {
reportError(error);
}另一个典型场景是回调函数。由于回调代码的质量及内部是否调用其他可能抛出异常的 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.addEventListener 或 window.attachEvent 监听 window.onerror。多数浏览器仅实现 window.onerror,且其为标准草案定义的方式,故应直接使用 window.onerror。
Error 对象属性与序列化策略
属性丢失问题。
若定义 reportError 函数用于收集异常并批量发送至服务器存储以便分析,需关注错误类型(name)、消息(message)、脚本地址(script)、行号(line)、列号(column)及堆栈跟踪(stack)。通过 try-catch 捕获的异常,其信息均存在于 Error 对象中(主流浏览器支持),reportError 可完整获取。但若通过 window.onerror 捕获,由于该事件函数仅提供 3 个参数,其余信息将丢失。
序列化消息策略。
若 Error 对象由开发者创建,error.message 的内容可控。window.onerror 的第一个参数(message)将接收该值(浏览器可能添加 'Uncaught Error: ' 前缀)。因此,可将关注的属性序列化(如 JSON.stringify)后存入 error.message,并在 window.onerror 中反序列化读取。此方法仅适用于开发者自行创建的 Error 对象。
window.onerror 参数演进与属性正规化
第五个参数演进。
浏览器厂商意识到 window.onerror 的限制,逐步增加新参数。IE 首先增加列号作为第四个参数。Firefox 提议将堆栈信息作为第五个参数。Chrome 则更进一步,直接将整个 Error 对象作为第五个参数,允许读取包括自定义属性在内的所有信息。由于 Chrome 30 率先实现此签名,标准草案也随之调整。
示例代码如下:
window.onerror = function(
errorMessage,
scriptURI,
lineNumber,
columnNumber,
error
) {
if (error) {
reportError(error);
} else {
reportError({
message: errorMessage,
script: scriptURI,
line: lineNumber,
column: columnNumber
});
}
}属性正规化
此前讨论的 Error 对象属性命名基于 Chrome,但各浏览器命名各异,如脚本地址在 Chrome 中为 script,在 Firefox 中为 filename。因此,需编写专用函数对 Error 对象进行正规化,将不同属性映射至统一名称。具体做法可参考相关文档,维护映射表并不复杂。
类似地,堆栈跟踪(stack)格式因浏览器文本格式不同而需手动维护正则表达式,以提取每帧的函数名(identifier)、文件(script)、行号(line)和列号(column)。
跨域脚本错误与 CORS 解决方案
安全限制。
若遇到 'Script error.' 消息,即源于浏览器对不同源(origin)脚本文件的安全限制。其逻辑为:若网银登录后返回的 HTML 与匿名用户不同,第三方网站可将网银 URI 置于 script.src 中。浏览器虽不解析 HTML 为 JS,但会抛出异常,第三方网站可通过解析异常位置判断用户登录状态。因此,浏览器过滤不同源脚本抛出的异常,仅保留 'Script error.' 消息,其他属性消失。
对于规模较大的网站,脚本常托管于 CDN,不同源属常态。即便小型网站,也常引用 jQuery 或 Backbone 等公共 CDN 版本以加速下载。此安全限制导致从 Chrome 和 Firefox 收集的异常信息均为无效的 'Script error.'。
CORS 解决方案。
绕过限制需保证脚本与页面同源。将脚本置于非 CDN 服务器会降低下载速度。一种方案是:脚本仍存于 CDN,利用 XMLHttpRequest 通过 CORS 下载内容,再创建 <script> 标签注入页面。页面内嵌代码即为同源。
此方案看似简单,但实现细节复杂。举例说明:
示例代码如下:
<script src="http://cdn.com/step1.js"></script>
<script>
(function step2() {})();
</script>
<script src="http://cdn.com/step3.js"></script>若 step1、step2、step3 存在依赖关系,必须严格按顺序执行,否则出错。浏览器可并行请求 step1 和 step3,但执行顺序保证。若通过 XMLHttpRequest 获取 step1 和 step3 内容,需自行保证顺序。此外,step2 在 step1 非阻塞下载时可执行,故需干预 step2 使其等待 step1 完成。
若已有生成页面 <script> 标签的工具,需调整工具以修改 <script> 标签:
示例代码如下:
<script>
scheduleRemoteScript('http://cdn.com/step1.js');
</script>
<script>
scheduleInlineScript(function code() {
(function step2() {})();
});
</script>
<script>
scheduleRemoteScript('http://cdn.com/step3.js');
</script>需实现 scheduleRemoteScript 和 scheduleInlineScript 函数,并确保其在首个引用外部脚本的 <script> 标签前定义。其余 <script> 标签将改写为上述形式。注意,原立即执行的 step2 函数被包裹在更大的 code 函数中。code 函数不执行,仅作为容器,使 step2 代码无需转义即可保留且不立即执行。
随后需实现完整机制,确保 scheduleRemoteScript 下载的文件内容与 scheduleInlineScript 获取的代码按正确顺序依次执行。详细代码此处省略,有兴趣者可自行实现。
行号冲突与反查机制
行号反查机制。
通过 CORS 获取内容并注入页面可突破安全限制,但引入行号冲突新问题。原通过 error.script 定位唯一脚本,再通过 error.line 定位行号。现因均为页面内嵌代码,多个 <script> 标签无法通过 error.script 区分,且每个 <script> 内部行号从 1 起算,导致无法利用异常信息定位源代码位置。
为避免行号冲突,可浪费部分行号,使每个 <script> 标签中实际代码使用的行号区间互不重叠。例如,假设每个 <script> 标签代码不超过 1000 行,则令第一个 <script> 占用第 1–1000 行,第二个占用第 1001–2000 行(前插 1000 行空行),第三个占用第 2001–3000 行(前插 2000 行空行),依此类推。使用 data-* 属性记录这些信息以便反查。
示例代码如下:
<script
data-src="http://cdn.com/step1.js"
data-line-start="1"
>
// code for step 1
</script>
<script data-line-start="1001">
// '\n' * 1000
// code for step 2
</script>
<script
data-src="http://cdn.com/step3.js"
data-line-start="2001"
>
// '\n' * 2000
// code for step 3
</script>处理后,若错误 error.line 为 3005,则实际 error.script 应为 'http://cdn.com/step3.js',实际 error.line 为 5。此反查工作可在前文提到的 reportError 函数中完成。
由于无法保证每个脚本文件仅 1000 行,部分文件可能远小于此值,故无需固定分配 1000 行区间。可根据实际脚本行数动态分配区间,只要保证各 <script> 标签所用区间互不重叠即可。
crossorigin 属性应用与总结
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 所需的 HTTP Header,Safari 无法利用此特性解决上述问题。
总结。
JavaScript 异常处理看似简单,与其他语言无异,但若要完整捕获异常并分析属性,实则不易。尽管已有第三方服务提供类似 Google Analytics 的 JavaScript 异常捕获功能,但理解其细节与原理仍需亲身实践。