前端监控体系怎么搭建?
前端一直是距离用户最近的一层,随着产品的日益完善,我们会更加注重用户体验,而前端异常特别是外网客户异常,一直是前端开发的痛点。最近在家办公,对公司的监控系统,又做了一遍复习,特作此记录。
异常是不可控的,会影响最终的呈现结果,所以任何一个成熟的前端团队,都有充分的理由去做这样的事情:
1.成熟的工程化,前端监控系统必不可少;
2.远程定位问题,对于对外 web 页面,让客户配合找 bug 是一件及其不职业且低效的事情;
3.错误预警上报,及早发现并修复问题;
4.问题复现,尤其是移动端,机型,系统都是问题;
对于 JS
而言,我们面对的仅仅只是异常,异常的出现不会直接导致 JS
引擎崩溃,最多只会使当前执行的任务终止。
需要处理哪些异常?
对于前端来说,我们可做的异常捕获还真不少。总结一下,大概如下:
JS
语法错误、代码运行异常Http
请求异常- 静态资源加载异常
Promise
异常Iframe
异常- 跨域
Script error
- 崩溃和卡顿
下面针对每种具体情况来说明如何处理这些异常。
点兵点将
一、Try-Catch 错误捕获
try-catch
只能捕获到同步的运行时错误,对语法和异步错误却无能为力。
1.同步运行时错误:
try { let version = '3.15'; console.log(ver); } catch(e) { console.log('错误捕获:',e); }
输出:错误捕获: ReferenceError: ver is not defined
at :3:14
2.不能捕获到语法错误,我们修改一下代码,删掉一个单引号:
try { let version = '3.15; console.log(version); } catch(e) { console.log('错误捕获:',e); }
输出:Uncaught SyntaxError: Invalid or unexpected token;
值得注意的是,这并不是 try-catch 捕获到的错误,而是浏览器控制台默认打印出来的;
以上两种包括多种语法错误,在我们开发阶段基本 Eslint 就会捕获到,在线上环境出现的可能性比较小,如果是,那就是前端工程化基础不好。
3.异步错误
try { setTimeout(() => { undefined.map(v => v); }, 1000) } catch(e) { console.log('错误捕获:',e); }
输出:Uncaught TypeError: Cannot read property 'map' of undefined
at setTimeout (:3:11),和前面一样,这里 try-catch 未捕获到错误,而是浏览器控制台默认打印出来的;
二、window.onerror 信息全面,但不是万能的
当 JS
运行时错误发生时, window
会触发一个 ErrorEvent
接口的 error
事件,并执行 window.onerror()
。
/** * @param {String} message 错误信息 * @param {String} source 出错文件 * @param {Number} lineno 行号 * @param {Number} colno 列号 * @param {Object} error Error 对象(对象) */ window.onerror = (message, source, lineno, colno, error) => { console.log('error message:', message); console.log('position', lineno, colno); console.error('错误捕获:', error); return true; // 异常不继续冒泡,浏览器默认打印机制就会取消 }
1.首先试试同步运行时错误
const a = 0x01; // a s 是 number, 不是 string; const b = a.startWith('0x');
可以看到,我们捕获到了异常:
2.来试试异步运行时错误:
setTimeout(() => { const a = 0x01; // a s 是 number, 不是 string; const b = a.startWith('0x'); // undefined.map(v => v); });
控制台输出了:
3.接着,我们试试网络请求异常的情况:
/* 在 actionTest 中,加入一个 Img 标签:asd.png 是不存在的 */ <img src="http://closertb.site/asd.png" />
我们发现,不论是静态资源异常,或者接口异常,错误都无法捕获到。
特别提醒:
window.onerror
函数只有在返回true
的时候,异常才不会向上抛出,否则即使是知道异常的发生控制台还是会显示Uncaught Error: xxxxx
在实际的使用过程中, onerror
主要是来捕获预料之外的错误,而 try-catch
则是用来在可预见情况下监控特定的错误,两者结合使用更加高效。
问题又来了,捕获不到静态资源加载异常怎么办?
三、window.addEventListener 静态资源加载错误
当一项资源(如图片或脚本)加载失败,加载资源的元素会触发一个 Event
接口的 error
事件,并执行该元素上的 onerror()
处理函数。这些 error
事件不会向上冒泡到 window
,但可以被 window.addEventListener error
监听捕获。
// 仅处理资源加载错误 window.addEventListener('error', (event) => { let target = event.target || event.srcElement; let isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement; // console.log('isEl', isElementTarget); if (!isElementTarget) return false; const url = target.src || target.href; // 上报资源地址 console.log('资源加载位置', event.path); console.error('静态资源错误捕获:','resource load exception:', url); }, true);// 关于这里为什么不可以用 e.preventDefault()来阻止默认打印,是因为这个错误,我们是捕获阶段获取到的,而不是冒泡;
控制台输出:
由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕捉到才行,但是这种方式虽然可以捕捉到网络请求的异常,但是无法判断 HTTP
的状态是 404
还是其他比如 500
等等,所以还需要配合服务端日志才进行排查分析才可以。
需要注意:
- 不同浏览器下返回的
error
对象可能不同,需要注意兼容处理。 - 需要注意避免
addEventListener
重复监听。
敲黑板:以下代码,如果 img 没有被插入到 html,是不能被 addEventListener 捕获到的,
个人猜测
:其原因就是,没有被添加到 html,错误只存在内存中,并没有和 window 对象关联上
const img = new Image(); img.onload = () => { console.log('finish'); }; img.src = 'https://closertb.site/abc.jpg'; // 触发错误 // document.body.appendChild(img);
四、Promise Catch
在 promise
中使用 catch
可以非常方便的捕获到异步 error
,这个很简单。
没有写 catch
的 Promise
中抛出的错误无法被 onerror
或 try-catch
捕获到,所以我们务必要在 Promise
中不要忘记写 catch
处理抛出的异常。
解决方案: 为了防止有漏掉的 Promise
异常,建议在全局增加一个对 unhandledrejection
来全局监听 Uncaught Promise Error
:
window.addEventListener("unhandledrejection", function(e){ console.log('错误捕获:', e); e.preventDefault() });
测试列子: 见 actionTest/index.js
// 一个 post 请求 mockTest().then((data) => { console.log('succ', data); });
可以看到如下输出:
提醒一句:与 onError 采用 return true 来结束控制器的默认错误打印,unhandledrejection 如果去掉控制台的异常显示,需要加上:
e.preventDefault();
虽然可以使用增加 unhandledrejection
的监听来捕获 promise 的异常处理,但处理 fetch
或者 ajax
的异常捕获,还是不太适合,因为他只能捕获到这个错误,而无法获取错误出现的位置和错误详情;
五、Http 请求错误
在使用 ajax
或者 fetch
请求数据时, 这里主要说 Fetch, 以上说过 unhandledrejection 能捕获到请求的异常,但没法获取到请求的详情,哪个 url 发起,传参是什么,一无所知。所有这里最好的方式就是重写 fetch,具体操作:
const originFetch = window.fetch; window.fetch = (...args) => { return originFetch.apply(this, args).then(res => { // 没有 res.ok 状态,那 catch 仅能捕获到网络的错误, 请求错误就捕获不到; if(!res.ok) { throw new Error('request faild'); } return res; }).catch((error) => { console.log('request 错误捕获:', error, { ...args, message: 'request faild' }); // 上报错误 return { message: 'request faild' } }); }
还是上面的测试列子: 见 actionTest/index.js
// 一个 post 请求 mockTest().then((data) => { console.log('succ', data); });
可以看到如下输出:
提醒一句:基本上成熟的前端团队,都会封装自己的 http 请求库,所以最好的方式是监控库和 http 请求库协作的方式来实现;
六、React 框架异常捕获
在日常开发中,web 开发基本都是基于 React 和 Vue 这种成熟的 UI 框架来做,因为工作里只用到了 React
,所以这里不涉及 Vue
。 16 提供了两个钩子 componentDidCatch
与 getDerivedStateFromError
,详情见 官方文档 , 使用他们可以非常简单的获取到 react
下的错误信息;
详细见代码 src/index.js
class Root extends React.PureComponent { state = { hasError: false } // static getDerivedStateFromError(error) { // // 更新 state 使下一次渲染能够显示降级后的 UI // return { hasError: true }; // } // 可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI // 两者都可以做错误边界,但同时存在时只响应其中一个,优先响应 getDerivedStateFromError。 componentDidCatch(error) { this.setState({ hasError: true }); console.log('errorcatch', error); // 上报 Error } render() { const { hasError } = this.state; if (hasError) { return <div>有错误</div> } return ( <Provider store={store}> <Router> <Route path="/" component={Layout} /> </Router> </Provider> ); } }
需要注意的是: Error boundaries
并不会捕捉下面这些错误。
1.事件处理器
2.异步代码
3.服务端的渲染代码
4.在 error boundaries
区域内的错误
实际使用中,我们只在根组件去可以定义一个 error boundary
组件,然后整个 UI 的错误都通过这里上报!像 Dva 这种框架,也在最外层提供了上报入口。
七、Script error
一般情况,如果出现 Script error
这样的错误,基本上可以确定是出现了跨域问题。这时候,是不会有其他太多辅助信息的,但是解决思路无非如下:
跨源资源共享机制( CORS
):我们为 script
标签添加 crossOrigin
属性。
// <script src="http://closertb.site/index.js" crossorigin></script> // 或者动态去添加 `js` 脚本: const script = document.createElement('script'); script.crossOrigin = 'anonymous'; script.src = url; document.body.appendChild(script);
特别注意,服务器端需要设置:Access-Control-Allow-Origin
此外,我们也可以试试这个- 解决 Script Error 的另类思路 :
const originAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { const wrappedListener = function (...args) { try { return listener.apply(this, args); } catch (err) { throw err; } } return originAddEventListener.call(this, type, wrappedListener, options); }
简单解释一下:
- 改写了
EventTarget
的addEventListener
方法; - 对传入的
listener
进行包装,返回包装过的listener
,对其执行进行try-catch
; - 浏览器不会对
try-catch
起来的异常进行跨域拦截,所以catch
到的时候,是有堆栈信息的; - 重新
throw
出来异常的时候,执行的是同域代码,所以window.onerror
捕获的时候不会丢失堆栈信息;
利用包装 addEventListener
,我们还可以达到「扩展堆栈」的效果:
(() => { const originAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { + // 捕获添加事件时的堆栈 + const addStack = new Error(\`Event (${type})\`).stack; const wrappedListener = function (...args) { try { return listener.apply(this, args); } catch (err) { + // 异常发生时,扩展堆栈 + err.stack += '\\n' + addStack; throw err; } } return originAddEventListener.call(this, type, wrappedListener, options); } })();
八、iframe 异常
对于 iframe
的异常捕获,可以通过 window.onerror
来实现,一个简单的例子可能如下:
<iframe src="./iframe.html" frameborder="0"></iframe> <script> window.frames[0].onerror = function (message, source, lineno, colno, error) { console.log('捕获到 iframe 异常:',{message, source, lineno, colno, error}); return true; }; </script>
现在 iframe 在前端中应用比较少,这里不再展开
九、崩溃和卡顿
卡顿也就是网页暂时响应比较慢,通常我们说的 60fps, 就是描述这个的,卡顿的现象就是造成 JS
无法及时执行。但崩溃就不一样了,崩溃直接造成 JS
不运行了,JS 执行进程卡死,相比网页崩溃上报更难?崩溃和卡顿都是不可忽视的,都会导致用户体验不好,而加剧用户流失。
1、卡顿的实现相对比较简单,我们可以通过 requestAnimationFrame 采集样本,来判断页面是否长期(几秒内)低于 30fps 或其他阈值。
看下面具体实现:
const rAF = (() => { const SIXTY_TIMES = 60; const requestAnimationFrame = window.requestAnimationFrame; if (requestAnimationFrame) { return (cb) => { const timer = requestAnimationFrame(() => { cb(); window.cancelAnimationFrame(timer); }); }; // requestAnimationFrame 兼容实现 })(); function stuck() { const stucks = []; const startTime = Date.now(); const loop = (startCountTime = Date.now(), lastFrameCount = 0) => { const now = Date.now(); // 每一帧进来,计数一次 const nowFrameCount = lastFrameCount + 1; // 大于等于一秒钟为一个周期;比如如果是正常的 fps: 那当第 61 次时,即 1017 毫秒,这里就满足 if (now > ONE_SECOND + startCountTime) { // 计算一秒钟的 fps: 当前计数总次数 / 经过的时长; const timeInterval = (now - startCountTime) / ONE_SECOND; const fps = Math.round(nowFrameCount / timeInterval); if (fps > 30) { // fps 小于 30 判断为卡顿 stucks.pop(); } else { stucks.push(fps); } // 连续三次小于 30 上报卡顿(还有一种特殊情况,前面 2 次卡顿,第三次不卡,接着再连续两次卡顿,也满足) if (stucks.length === 3) { console.log(new Error(`Page Stuck captured: ${location.href} ${stucks.join(',')} ${now - startTime}ms`)); // 清空采集到的卡顿数据 stucks.length = 0; } // 即休息一个周期(我这里定义的是一分钟),重新开启采样 const timer = setTimeout(() => { loop(); clearTimeout(timer); }, 60 * 1000); return; } rAF(() => loop(startCountTime, nowFrameCount)); }; loop(); };
2、崩溃的监控相对于稍显复杂来说,在这篇文章讲的很清楚: 网页崩溃的监控 :
A 方案: 采用 load 和 beforeLoad 监听和 sessionStorage 来实现, 看代码:
window.addEventListener('load', function () { // 进页面,首先检测上次是否崩溃 if(sessionStorage.getItem('good_exit') && sessionStorage.getItem('good_exit') !== 'true') { /* insert crash logging code here */ console.log('Hey, welcome back from your crash, looks like you crashed on: ' + sessionStorage.getItem('time_before_crash')); } 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'); });
这个方案有两个问题:
- 采用 sessionStorage 存储状态,但通常网页崩溃/卡死后,用户会强制关闭网页或者索性重新打开浏览器,sessionStorage 存储但状态将不复存在;
- 而如果将状态存储在 localStorage 甚至 Cookie 中,如果用户先后打开多个网页,但不关闭,good_exit 存储的一直都是 pending,完了,每有一次网页打开,就会有一个 crash 上报。
B 方案,采用 Service Worker
Service Worker
有自己独立的工作线程,与网页区分开,网页崩溃了,Service Worker
一般情况下不会崩溃;Service Worker
生命周期一般要比网页还要长,可以用来监控网页的状态;- 网页可以通过
navigator.serviceWorker.controller.postMessage API
向掌管自己的SW
发送消息。 - 原理就是,一个页面打开。就将这个页面在
SW 中注册
;并每隔 1s 或几秒向 SW 汇报
一下,SW 收到消息后更新这个页面的最后更新时间;SW 自己,每隔几秒(大于前面的时间)扫描
自己注册页面的更新时间,如果某个页面最后更新时间是大于 N 秒,则可以判断为崩溃;
JS 实现:
// 页面 JavaScript 代码 if (navigator.serviceWorker.controller !== null) { let HEARTBEAT_INTERVAL = 5 * 1000; // 每五秒发一次心跳 let sessionId = `${location.href}-${uuid()}`; let heartbeat = function () { navigator.serviceWorker.controller.postMessage({ type: 'heartbeat', id: sessionId, data: {} // 附加信息,如果页面 crash,上报的附加数据 }); } window.addEventListener("beforeunload", function() { navigator.serviceWorker.controller.postMessage({ type: 'unload', id: sessionId }); }); setInterval(heartbeat, HEARTBEAT_INTERVAL); heartbeat(); } // serviceWOker.js const CHECK_CRASH_INTERVAL = 10 * 1000; // 每 10s 检查一次 const CRASH_THRESHOLD = 15 * 1000; // 15s 超过 15s 没有心跳则认为已经 crash const pages = {} let timer function checkCrash() { const now = Date.now() for (var id in pages) { let page = pages[id] if ((now - page.t) > CRASH_THRESHOLD) { // 上报 crash delete pages[id] } } if (Object.keys(pages).length == 0) { clearInterval(timer) timer = null } } worker.addEventListener('message', (e) => { const data = e.data; if (data.type === 'heartbeat') { pages[data.id] = { t: Date.now() } if (!timer) { timer = setInterval(function () { checkCrash() }, CHECK_CRASH_INTERVAL) } } else if (data.type === 'unload') { delete pages[data.id] } })
总结
以上基本涵盖了监控系统中 90%以上的错误捕获案例,但这只是监控系统的开端,只能算是 Demo 级别的代码。市面上有很多成熟的监控库可参考,比如 FunderBug,Raven-Js 等,我们团队的监控库就是是在 Raven 上做了一层扩展,然后结合 IndexDb 和压缩库(pako),以及服务端日志收集采用 Koa 来实现,知识点很多,但前面这些非常重要。
参考
前端代码异常监控实战
如何优雅处理前端异常?
Error Boundaries
前端监控知识点
Capture and report JavaScript errors with window.onerror
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论