如何优雅的处理异常呢?
- 可疑区域增加 Try-Catch
- 全局监控 JS 异常 window.onerror
- 全局监控静态资源异常 window.addEventListener
- 捕获没有 Catch 的 Promise 异常:unhandledrejection
- VUE errorHandler 和 React componentDidCatch
- 监控网页崩溃:window 对象的 load 和 beforeunload
- 跨域 crossOrigin 解决
其实很简单,正如上文所说:采用组合方案,分类型的去捕获异常,这样基本 80%-90% 的问题都化于无形。
- 前端代码异常监控实战
- 前端监控知识点
- Logging Information on Browser Crashes
- Error Boundaries
- Capture and report JavaScript errors with window.onerror
原文地址: https://juejin.im/post/5b5dcfb46fb9a04f8f37afbb
概述
对于后台开发来说,记录日志是一种非常常见的开发习惯,通常我们会使用try...catch
代码块来主动捕获错误、对于每次接口调用,也会记录下每次接口调用的时间消耗,以便我们监控服务器接口性能,进行问题排查。
刚进公司时,在进行Node.js
的接口开发时,我不太习惯每次排查问题都要通过跳板机登上服务器看日志,后来慢慢习惯了这种方式。
举个例子:
1 | /** |
以下代码经常会出现在用Node.js
的接口中,在接口中会统计查询DB
所耗时间、亦或是统计RPC
服务调用所耗时间,以便监测性能瓶颈,对性能做优化;又或是对异常使用try ... catch
主动捕获,以便随时对问题进行回溯、还原问题的场景,进行bug
的修复。
而对于前端来说呢?可以看以下的场景。
最近在进行一个需求开发时,偶尔发现webgl
渲染影像失败的情况,或者说影像会出现解析失败的情况,我们可能根本不知道哪张影像会解析或渲染失败;又或如最近开发的另外一个需求,我们会做一个关于webgl
渲染时间的优化和影像预加载的需求,如果缺乏性能监控,该如何统计所做的渲染优化和影像预加载优化的优化比例,如何证明自己所做的事情具有价值呢?可能是通过测试同学的黑盒测试,对优化前后的时间进行录屏,分析从进入页面到影像渲染完成到底经过了多少帧图像。这样的数据,可能既不准确、又较为片面,设想测试同学并不是真正的用户,也无法还原真实的用户他们所处的网络环境。回过头来发现,我们的项目,虽然在服务端层面做好了日志和性能统计,但在前端对异常的监控和性能的统计。对于前端的性能与异常上报的可行性探索是有必要的。
异常捕获
对于前端来说,我们需要的异常捕获无非为以下两种:
- 接口调用情况;
- 页面逻辑是否错误,例如,用户进入页面后页面显示白屏;
对于接口调用情况,在前端通常需要上报客户端相关参数,例如:用户OS与浏览器版本、请求参数(如页面ID);而对于页面逻辑是否错误问题,通常除了用户OS与浏览器版本外,需要的是报错的堆栈信息及具体报错位置。
异常捕获方法
全局捕获
可以通过全局监听异常来捕获,通过window.onerror
或者addEventListener
,看以下例子:
1 | window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) { |
通过window.onerror
事件,可以得到具体的异常信息、异常文件的URL、异常的行号与列号及异常的堆栈信息,再捕获异常后,统一上报至我们的日志服务器。
亦或是,通过window.addEventListener
方法来进行异常上报,道理同理:
1 | window.addEventListener('error', function() { |
try… catch
使用try... catch
虽然能够较好地进行异常捕获,不至于使得页面由于一处错误挂掉,但try ... catch
捕获方式显得过于臃肿,大多代码使用try ... catch
包裹,影响代码可读性。
常见问题
跨域脚本无法准确捕获异常
通常情况下,我们会把静态资源,如JavaScript
脚本放到专门的静态资源服务器,亦或者CDN
,看以下例子:
1 | <!DOCTYPE html> |
1 | // error.js |
结果显示,跨域之后window.onerror
根本捕获不到正确的异常信息,而是统一返回一个Script error
,
解决方案:对script
标签增加一个crossorigin=”anonymous”
,并且服务器添加Access-Control-Allow-Origin
。
1 | <script src="http://cdn.xxx.com/index.js" crossorigin="anonymous"></script> |
sourceMap
通常在生产环境下的代码是经过webpack
打包后压缩混淆的代码,所以我们可能会遇到这样的问题,如图所示:
我们发现所有的报错的代码行数都在第一行了,为什么呢?这是因为在生产环境下,我们的代码被压缩成了一行:
1 | !function(e){var n={};function r(o){if(n[o])return n[o].exports;var t=n[o]={i:o,l:!1,exports:{}};return e[o].call(t.exports,t,t.exports,r),t.l=!0,t.exports}r.m=e,r.c=n,r.d=function(e,n,o){r.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:o})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,n){if(1&n&&(e=r(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(r.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var t in e)r.d(o,t,function(n){return e[n]}.bind(null,t));return o},r.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(n,"a",n),n},r.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},r.p="",r(r.s=0)}([function(e,n){throw window.onerror=function(e,n,r,o,t){console.log("errorMessage: "+e),console.log("scriptURI: "+n),console.log("lineNo: "+r),console.log("columnNo: "+o),console.log("error: "+t);var l={errorMessage:e||null,scriptURI:n||null,lineNo:r||null,columnNo:o||null,stack:t&&t.stack?t.stack:null};if(XMLHttpRequest){var u=new XMLHttpRequest;u.open("post","/middleware/errorMsg",!0),u.setRequestHeader("Content-Type","application/json"),u.send(JSON.stringify(l))}},new Error("这是一个错误")}]); |
在我的开发过程中也遇到过这个问题,我在开发一个功能组件库的时候,使用npm link
了我的组件库,但是由于组件库被npm link
后是打包后的生产环境下的代码,所有的报错都定位到了第一行。
解决办法是开启webpack
的source-map
,我们利用webpack
打包后的生成的一份.map
的脚本文件就可以让浏览器对错误位置进行追踪了。此处可以参考webpack document。
其实就是webpack.config.js
中加上一行devtool: 'source-map'
,如下所示,为示例的webpack.config.js
:
1 | var path = require('path'); |
在webpack
打包后生成对应的source-map
,这样浏览器就能够定位到具体错误的位置:
开启source-map
的缺陷是兼容性,目前只有Chrome
浏览器和Firefox
浏览器才对source-map
支持。不过我们对这一类情况也有解决办法。可以使用引入npm
库来支持source-map
,可以参考mozilla/source-map。这个npm
库既可以运行在客户端也可以运行在服务端,不过更为推荐的是在服务端使用Node.js
对接收到的日志信息时使用source-map
解析,以避免源代码的泄露造成风险,如下代码所示:
1 | const express = require('express'); |
如下图所示,我们已经可以看到,在服务端已经成功解析出了具体错误的行号、列号,我们可以通过日志的方式进行记录,达到了前端异常监控的目的。
Vue捕获异常
在我的项目中就遇到这样的问题,使用了js-tracker
这样的插件来统一进行全局的异常捕获和日志上报,结果发现我们根本捕获不到Vue
组件的异常,查阅资料得知,在Vue
中,异常可能被Vue
自身给try ... catch
了,不会传到window.onerror
事件触发,那么我们如何把Vue
组件中的异常作统一捕获呢?
使用Vue.config.errorHandler这样的Vue
全局配置,可以在Vue
指定组件的渲染和观察期间未捕获错误的处理函数。这个处理函数被调用时,可获取错误信息和Vue
实例。
1 | Vue.config.errorHandler = function (err, vm, info) { |
在
React
中,可以使用ErrorBoundary
组件包括业务组件的方式进行异常捕获,配合React 16.0+
新出的componentDidCatch API
,可以实现统一的异常捕获和日志上报。
1 | class ErrorBoundary extends React.Component { |
使用方式如下:
1 | <ErrorBoundary> |
性能监控
最简单的性能监控
最常见的性能监控需求则是需要我们统计用户从开始请求页面到所有DOM
元素渲染完成的时间,也就是俗称的首屏加载时间,DOM
提供了这一接口,监听document
的DOMContentLoaded
事件与window
的load
事件可统计页面首屏加载时间即所有DOM
渲染时间:
1 |
|
对于使用框架,如Vue
或者说React
,组件是异步渲染然后挂载到DOM
的,在页面初始化时并没有太多的DOM
节点,可以参考下文关于首屏时间采集自动化的解决方案来对渲染时间进行打点。
performance
但是以上时间的监控过于粗略,例如我们想统计文档的网络加载耗时、解析DOM
的耗时与渲染DOM
的耗时,就不太好办到了,所幸的是浏览器提供了window.performance
接口,具体可见MDN文档
几乎所有浏览器都支持window.performance
接口,下面来看看在控制台打印window.performance
可以得到些什么:
可以看到,window,performance
主要包括有memory
、navigation
、timing
以及timeOrigin
及onresourcetimingbufferfull
方法。
navigation
对象提供了在指定的时间段里发生的操作相关信息,包括页面是加载还是刷新、发生了多少次重定向等等。timing
对象包含延迟相关的性能信息。这是我们页面加载性能优化需求中主要上报的相关信息。memory
为Chrome
添加的一个非标准扩展,这个属性提供了一个可以获取到基本内存使用情况的对象。在其它浏览器应该考虑到这个API
的兼容处理。timeOrigin
则返回性能测量开始时的时间的高精度时间戳。如图所示,精确到了小数点后四位。onresourcetimingbufferfull
方法,它是一个在resourcetimingbufferfull
事件触发时会被调用的event handler
。这个事件当浏览器的资源时间性能缓冲区已满时会触发。可以通过监听这一事件触发来预估页面crash
,统计页面crash
概率,以便后期的性能优化,如下示例所示:
1 | function buffer_full(event) { |
1 | <body onload="init()"> |
计算网站性能
使用performance
的timing
属性,可以拿到页面性能相关的数据,这里在很多文章都有提到关于利用window.performance.timing
记录页面性能的文章,例如alloyteam
团队写的初探 performance – 监控网页与程序性能,对于timing
的各项属性含义,可以借助摘自此文的下图理解,以下代码摘自此文作为计算网站性能的工具函数参考:
1 | // 获取 performance 数据 |
1 | // 计算加载时间 |
日志上报
单独的日志域名
对于日志上报使用单独的日志域名的目的是避免对业务造成影响。其一,对于服务器来说,我们肯定不希望占用业务服务器的计算资源,也不希望过多的日志在业务服务器堆积,造成业务服务器的存储空间不够的情况。其二,我们知道在页面初始化的过程中,会对页面加载时间、PV、UV等数据进行上报,这些上报请求会和加载业务数据几乎是同时刻发出,而浏览器一般会对同一个域名的请求量有并发数的限制,如Chrome
会有对并发数为6
个的限制。因此需要对日志系统单独设定域名,最小化对页面加载性能造成的影响。
跨域的问题
对于单独的日志域名,肯定会涉及到跨域的问题,采取的解决方案一般有以下两种:
- 一种是构造空的
Image
对象的方式,其原因是请求图片并不涉及到跨域的问题;
1 | var url = 'xxx'; |
- 利用
Ajax
上报日志,必须对日志服务器接口开启跨域请求头部Access-Control-Allow-Origin:*
,这里Ajax
就并不强制使用GET
请求了,即可克服URL
长度限制的问题。
1 | if (XMLHttpRequest) { |
在我的项目中使用的是第一种的方式,也就是构造空的Image
对象,但是我们知道对于GET
请求会有长度的限制,需要确保的是请求的长度不会超过阈值。
省去响应主体
对于我们上报日志,其实对于客户端来说,并不需要考虑上报的结果,甚至对于上报失败,我们也不需要在前端做任何交互,所以上报来说,其实使用HEAD
请求就够了,接口返回空的结果,最大地减少上报日志造成的资源浪费。
合并上报
类似于雪碧图的思想,如果我们的应用需要上报的日志数量很多,那么有必要合并日志进行统一的上报。
解决方案可以是尝试在用户离开页面或者组件销毁时发送一个异步的POST
请求来进行上报,但是尝试在卸载(unload
)文档之前向web
服务器发送数据。保证在文档卸载期间发送数据一直是一个困难。因为用户代理通常会忽略在卸载事件处理器中产生的异步XMLHttpRequest
,因为此时已经会跳转到下一个页面。所以这里是必须设置为同步的XMLHttpRequest
请求吗?
1 | window.addEventListener('unload', logData, false); |
使用同步的方式势必会对用户体验造成影响,甚至会让用户感受到浏览器卡死感觉,对于产品而言,体验非常不好,通过查阅MDN文档,可以使用sendBeacon()
方法,将会使用户代理在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能。这就解决了提交分析数据时的所有的问题:使它可靠,异步并且不会影响下一页面的加载。此外,代码实际上还要比其他技术简单!
下面的例子展示了一个理论上的统计代码模式——通过使用sendBeacon()
方法向服务器发送数据。
1 | window.addEventListener('unload', logData, false); |
小结
作为前端开发者而言,要对产品保持敬畏之心,时刻保持对性能追求极致,对异常不可容忍的态度。前端的性能监控与异常上报显得尤为重要。
代码难免有问题,对于异常可以使用window.onerror
或者addEventListener
的方式添加全局的异常捕获侦听函数,但可能使用这种方式无法正确捕获到错误:对于跨域的脚本,需要对script
标签增加一个crossorigin=”anonymous”
;对于生产环境打包的代码,无法正确定位到异常产生的行数,可以使用source-map
来解决;而对于使用框架的情况,需要在框架统一的异常捕获处埋点。
而对于性能的监控,所幸的是浏览器提供了window.performance API
,通过这个API
,很便捷地获取到当前页面性能相关的数据。
而这些异常和性能数据如何上报呢?一般说来,为了避免对业务产生的影响,会单独建立日志服务器和日志域名,但对于不同的域名,又会产生跨域的问题。我们可以通过构造空的Image
对象来解决,亦或是通过设定跨域请求头部Access-Control-Allow-Origin:*
来解决。此外,如果上报的性能和日志数据高频触发,则可以在页面unload
时统一上报,而unload
时的异步请求又可能会被浏览器所忽略,且不能改为同步请求。此时navigator.sendBeacon API
可算帮了我们大忙,它可用于通过HTTP
将少量数据异步传输到Web
服务器。而忽略页面unload
时的影响。