JavaScript 异常捕获与跨域安全限制深度解析

作者:缓存笔记时间:2026-10-27 23:37:30浏览:512

本文深入解析 JavaScript 异常捕获机制,涵盖 try-catch 与 window.onerror 的使用场景及参数差异。重点探讨跨域脚本导致的错误信息丢失问题,通过 CORS 配置、动态注入脚本及行号反查技术,实现完整的远程脚本错误监控与定位。

过去认为,只要 JavaScript 报错后用户刷新页面即可恢复,浏览器不会崩溃,仿佛错误未发生。这种观点在单页应用(Single Page App)普及前尚能成立。然而现代单页应用运行久了状态极其复杂,用户可能经过多次交互才到达当前页面,此时若强制刷新,之前的操作将全部作废。因此,捕获并分析异常信息以优化代码、保障用户体验显得尤为必要。

捕获异常的方式

对于自身代码中 throw new Error() 抛出的错误,由于清楚 throw 的位置,自然可以进行捕获。但在调用浏览器 API 时发生的异常则较为复杂:部分 API 在标准中明确定义会抛出异常,另一些则是因浏览器实现差异或缺陷所致。前者可通过 try-catch 机制捕获,而后者必须依赖全局异常监听器进行捕获。

try-catch

若某些浏览器 API 已知会抛出异常,必须将相关调用包裹在 try-catch 结构中,防止程序因错误进入非法状态。例如 window.localStorage 在写入数据超出容量限制时便会抛出异常,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.addEventListener 或 window.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)。(浏览器会略作修改,如添加 'Uncaught Error: ' 前缀。)因此,可将关注的属性序列化(如 JSON.Stringify)后存入 error.message,并在 window.onerror 中读取并反序列化。当然,这仅适用于我们自行创建的 Error 对象。

第五个参数

浏览器厂商意识到开发者在使用 window.onerror 时受到的限制,开始在 window.onerror 上添加新参数。考虑到仅有行号而无列号不对称,IE 率先将列号加入作为第四个参数。然而开发者更关注获取完整堆栈,Firefox 提议将堆栈置于第五个参数。Chrome 则建议直接将整个 Error 对象放入第五个参数,以便读取任意属性,包括自定义属性。由于 Chrome 行动迅速,在 Chrome 30 实现了新的 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)。

安全限制

若你曾遇到过消息为 '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 即可执行,因此必须人为干预 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 行,第二个 <script> 标签占用第 1001–2000 行(前插 1000 行空行),第三个 <script> 标签占用第 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 行,故无需固定分配 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 所需的 HTTP Header,Safari 相当于无法利用此特性解决上述问题。

总结

JavaScript 异常处理看似简单,与其他语言无异,但真正捕获所有异常并分析属性并非易事。尽管有第三方服务提供类似 Google Analytics 的 JavaScript 异常捕获服务,但要弄清细节和原理,仍需亲手实践一次。

作者最新文章