页面奔溃包含两种场景,第一种是浏览器在加载网页时遇到问题导致的奔溃,另一种是因为脚本渲染出错导致页面空白无内容的奔溃。
前段时间运营抱怨有张活动页出现了空白(第二种奔溃场景),导致用户无法访问,希望我们能主动监控到这种情况,而不是通过用户的上报。
后面和运维沟通,他那边目前只能监控接口的访问情况,无法监控静态资源,若要监控得自己想办法实现。
首先想到的自然是利用现有的监控系统来了解页面空白情况,例如某个项目5分钟内没有监控日志,那就认为出现了页面奔溃。
急匆匆的写了段定时任务,放到线上运行,发现这样监控会有一个很大漏洞。因为某些项目的访问量本来就不高,5分钟内没有日志是属于正常情况,所以只得作罢。
2023-01-16 经过 TypeScript 整理重写后,正式将监控系统的脚本开源,命名为 shin-monitor。
一、页面奔溃
首先来解决第一种奔溃场景,在网上搜了些关键字,发现了些有用的资料,例如如何监控网页崩溃,前端崩溃监控优化历程等。
这些资料提供了一个全新的思路来监控页面奔溃,基于Service Worker的崩溃统计方案。
简单地说就是一种心跳检测机制,在页面的脚本中创建Service Worker工作线程,然后定时地向该线程发送消息,即使网页奔溃了,线程还能存活。
在线程中接收消息并比对时间,当间隔时间大于15秒时,就认为超时没有心跳了,页面处于奔溃阶段,向监控系统上报相关信息。
在我操刀实现的时候,Service Worker没有运行成功,后面就改成了Web Worker。
工作线程的代码保存在sw.js(如下所示),在参考一篇Web Workers的文章时,他提到在线程中可以navigator对象,该对象正好有个sendBeacon()方法,可用于跨域请求。
但是没想到线程中用的WorkerNavigator,并没有该方法,后面无奈改成了fetch()。
但是有跨域问题,要么在响应时加上跨域头,要么就无视直接发送,因为浏览器只会拦截响应不会拦截请求。
var CHECK_CRASH_INTERVAL = 10 * 1000; // 每 10s 检查一次 var CRASH_THRESHOLD = 15 * 1000; // 15s 超过15s没有心跳则认为已经 crash var pages = {}, timer; function send(param) { fetch(param.src); }; function checkCrash() { var now = Date.now() for (var id in pages) { var page = pages[id]; if ((now - page.t) > CRASH_THRESHOLD) { // 上报 crash delete pages[id]; send(page.data); } } if (Object.keys(pages).length == 0) { clearInterval(timer) timer = null } } self.addEventListener('message', (e) => { var data = e.data; if (data.type === 'heartbeat') { // console.log('heartbeat'); pages[data.id] = { t: Date.now(), data: data.data } if (!timer) { timer = setInterval(function () { checkCrash() }, CHECK_CRASH_INTERVAL) } } else if (data.type === 'unload') { delete pages[data.id] } })
在网页中加的代码如下,由于Worker加载的脚本有同源策略的限制,所以脚本和页面需要在相同的域名中。
function monitorCrash(param) { var isCrash = param.isCrash; if (!isCrash || !window.Worker) return; var worker = new Worker("/sw.js"); var HEARTBEAT_INTERVAL = 5 * 1000; // 每五秒发一次心跳 var sessionId = getIdentity(); var heartbeat = function () { worker.postMessage({ type: "heartbeat", id: sessionId, data: { //在页面奔溃时,上报数据,需要将上报地址一起传递 src: param.src } }); }; window.addEventListener("beforeunload", function () { worker.postMessage({ type: "unload", id: sessionId }); }); var timer = setInterval(heartbeat, HEARTBEAT_INTERVAL); heartbeat(); }
上线后先在管理后台做测试,管理后台使用的是PC浏览器,马上就发现了比较严重的误报问题。
分析下来有可能是网页在标签栏不活动的时候,影响了定时器的执行,再次活动计算两个时间段的间隔,很有可能超出了15秒,而上报奔溃日志。
鉴于此,在没有完美解决方案之前,暂时将此功能下架。
二、页面空白
再来解决第二种奔溃场景,现在开发都会依托React或Vue等库或框架,而这些都是用脚本来渲染出DOM结构的。
一旦在渲染时出现脚本错误(例如未定义的变量、浏览器不支持的语法等)就会中断渲染,从而就会出现页面无内容的情况。
这类监控并不需要使用Web Worker,只要我的监控SDK在业务脚本之前引入,就能保证监控代码正常运行。
1)自定义白屏方法
监控原理就是加个定时器,查看渲染容器中是否是空白,若是空白就上报并关闭定时器,否则循环监控。
例如后台管理系统采用的是React,在HTML中会声明一个div元素,内容都会渲染到该元素中。
<div id="root"></div>
自定义一个关键DOM的判断条件,如下所示,在定时器中循环执行。
shin.setParam({ validateCrash: () => { //当root标签中的内容为空时,可认为页面已奔溃 return { success: document.getElementById("root").innerHTML.length > 0, prompt: "页面出现空白" }; } });
此处还有个小坑,就是定时器的运行时机,不能太早,太早判断的话,div元素中肯定没有内容,后面就将判断时机移到了DOMContentLoaded事件中。
下面是监控白屏的主要逻辑,isCrash 是一个监控开关,document.body.clientHeight 是指内容高度与上下内边距的和。
在我们这边的页面中, body 都不会有内边距,所以该判断适用,当然,具体可根据业务场景做自定义的兜底处理。
2022-12-26 注意,若自定义了 validateCrash() 方法,那么就不能走默认的白屏判断条件了。
function monitorCrash(param) { var isCrash = param.isCrash; var validateCrash = param.validateCrash; if (!isCrash || !window.Worker) { return; } var HEARTBEAT_INTERVAL = 5 * 1000; // 每五秒发一次心跳 var crashHeartbeat = function () { // 是否自定义了规则 if (validateCrash) { var result = validateCrash(); // 符合自定义的奔溃规则 if (result && !result.success) { handleError({ type: ERROR_CRASH, desc: { prompt: result.prompt, url: location.href } }); // 关闭定时器 clearInterval(timer); } } else if (_isWhiteScreen()) { // 兜底白屏算法,可根据自身业务定义 // 查询第一个div var currentDiv = document.querySelector("div"); // 增加 html 字段是为了验证是否出现了误报 handleError({ type: ERROR_CRASH, desc: { prompt: "页面没有高度", url: location.href, html: currentDiv ? currentDiv.innerHTML : "" } }); clearInterval(timer); } }; var timer = setInterval(crashHeartbeat, HEARTBEAT_INTERVAL); crashHeartbeat(); // 立即执行一次 // 5分钟后自动取消定时器 setTimeout(function () { // 关闭定时器 clearInterval(timer); }, 1000 * 300); }
2)_isWhiteScreen()
2022-12-26 _isWhiteScreen() 是一个兜底的白屏算法,可根据自身业务定义。
最初的判断条件是 document.body.clientHeight 是否大于 0,但是如果 body 的所有子元素都是绝对定位时,那么它的高度同样也会变成 0。
由此就给出了优化后的白屏算法,判断 body 元素的子元素的高度是否都是 0,若都是 0,那么就是白屏。
function _isWhiteScreen() { // 罗列 body 的子元素 var children = [].slice.call(document.body.children); // 过滤出高度不为 0 的子元素 var visibles = children.filter(function (element) { return element.clientHeight > 0; }); return visibles.length === 0; }
但是上线后,出现了大量的误报,分析网页代码后,发现页面有个比较差的交互,那就是在进入时会有极短的时间白屏,在等待从客户端中拿用户信息。
两个方案,第一个是在那段时间增加 loading 特效,满足判断条件;第二个是为白屏监控增加延迟时间,例如延迟 1 秒后再判断是否真的白屏。
注意,现在的页面以 CSR(客户端渲染)为主,预留一个空 div 元素在页面中。大部分情况下,只有在拿到接口数据后,才会对页面进行渲染。
如果这个接口通信持续了一秒以上,那么就会触发白屏检测,此时就会上报为白屏。虽然这是个误报,但是这么重要的接口居然超过 1 秒,那还是有必要优化的。
2024-10-09 正巧发现一个接口返回比较慢,分析后发现是因为响应内容比较大(2M),遇到网络比较差的时候,通信时间就会拉长,原来里面有张图被内嵌为 base64,只需将其改成 url 访问即可。
setTimeout(function () { monitorCrash(shin.param); }, 1000);
在翻看白屏记录时,又发现了 _isWhiteScreen() 函数的漏洞。
那就是如果 body 只有一个子元素,但是子元素中的元素恰好都是绝对定位,那么此时就会误判,body 子元素的高度确实是 0。
再度优化后,会对 body 的子元素做深度优先搜索,若已找到一个有高度的元素、或若元素隐藏、或元素有高度并且不是 body 元素,则结束搜索。
2022-12-29 将 node.clientHeihgt 改成 node.getBoundingClientRect().height,前者会将内容高度和上下内边距相加,后者还会加上边框。
但是 clientHeihgt 不会计算行内元素(例如 span、a 等)的高度,getBoundingClientRect() 会计算。
并且 getBoundingClientRect() 还会将诸如 transform: scale(0.5) 变换元素尺寸后,得到最终计算后的值。
function _isWhiteScreen() { var visibles = []; var nodes = []; //遍历到的节点的关键信息,用于查明白屏原因 // 深度优先遍历子元素 var dfs = (node) => { var tagName = node.tagName.toLowerCase(); var rect = node.getBoundingClientRect(); // 选取节点的属性作记录 var attrs = { id: node.id, tag: tagName, className: node.className, display: node.style.display, height: rect.height }; if (node.src) { attrs.src = node.src; // 记录图像的地址 } if (node.href) { attrs.href = node.href; // 记录链接的地址 } nodes.push(attrs); // 若已找到一个有高度的元素,则结束搜索 if (visibles.length > 0) return; // 若元素隐藏,则结束搜索 if (node.style.display === "none") return; // 若元素有高度并且不是 body 元素,则结束搜索 if (rect.height > 0 && tagName !== "body") { visibles.push(node); return; } node.children && [].slice.call(node.children).forEach((child) => { var tagName = child.tagName.toLowerCase(); // 过滤脚本和样式元素 if (tagName === "script" || tagName === "link") return; dfs(child); }); }; dfs(document.body); return { visibles: visibles, nodes: nodes }; }
2022-12-28 最近遇到一个白屏误报的问题,翻看了好几遍代码,也没看出有什么问题,于是将遍历的节点的关键信息,也一并上报,帮助排查。
通过这些关键信息,可以识别出节点在 HTML 结构中所处的位置。
2022-12-29 今天终于破解了昨日百思不得其解的问题,虽然得到的所有子元素的高度都为 0,但是回放又能看到元素内容。
我一度怀疑是白屏判断的触发时机问题,特地记录的时间戳,但的确是在指定时间运行。通过查看记录的 UA 信息,可以判断是在 PC 的浏览器中上报的。
进一步缩小范围可知,和一个 iframe 中的网页有关,当包含 iframe 的弹框关闭时,弹框会被隐藏(display:none)。
由于有一个定时器在轮询判断是否白屏,此时,在 iframe 内,因为被隐藏的缘故,因此所有的元素高度都将是 0。
这种情况比较特殊,目前的做法是将弹框关闭时,其内容直接销毁而不再是隐藏。
注意,在 monitorCrash() 函数中,需要对 else 分支内的 _isWhiteScreen() 做相应的处理。
// 兜底白屏算法,可根据自身业务定义 var whiteObj = _isWhiteScreen(); if (whiteObj.visibles.length > 0) { return; } // 查询第一个div var currentDiv = document.querySelector("div"); // 增加 html 字段是为了验证是否出现了误报 handleError({ type: ERROR_CRASH, desc: { prompt: "页面没有高度", url: location.href, html: currentDiv ? currentDiv.innerHTML : "", timestamp: _calcCurrentTime(), fontSize: document.documentElement.style.fontSize, // 根节点的字体大小 nodes: whiteObj.nodes } }); clearInterval(timer);
这个算法还有优化的空间,假如碰到一种极端情况,body 只有一个 div 子元素,没有内容,但是声明了高度或内边距,那么就会认为当前不是白屏。
不过目前,公司的页面开发暂时不会涉及此类情况,所以先不考虑了。应该还有很多其他的极端情况,待到搜集到上报,再一并做优化。
3)案例一(CDN刷新失败)
2022-12-07 一开始 isCrash 默认标记为 false,也就是关闭监控的,后面默认打开后,线上出现白屏的页面一下子增加了四五百左右。
接下来就是验证上报的白屏是否准确,下面是上报的一条记录,它有一串字符身份信息,例如 syqgpsyz4s。
{ "type": "crash", "desc": { "prompt": "页面没有高度", "url": "https://www.xxx.com/chat.html?matchId=100", "html": "" } }
根据身份信息,再去日志明细中查找他的前后动作,发现只有一条记录,也就是既没有脚本错误,也没有接口请求。

再根据此身份去查询性能监控的记录 ID,找出当时静态资源的瀑布图,在此图中,并没有发现资源异常。

但是当我直接请求 url 地址时,却发现有 3 个资源的请求是 404,与正常页面中的 3 个资源做比对,发现两者的随机后缀是不同的。

现在恍然大悟,是 CDN 缓存刷新失败导致的问题,问题马上就定位到了。
还发现另一个问题是因为参数的值导致的白屏,首次使用下来,准确率还是蛮高的。
4)案例二(客户端缓存)
2022-12-19 还有一类不是 CDN 引起的资源报错,那就是客户端的缓存。客户端会缓存 HTML 页面,当访问缓存页面时,其中的资源必定已经不存在。
要破除缓存,就要给 URL 地址增加一个时间戳参数,好在客户端中的活动页面都是通过自研的短链跳转的,可以在短链映射真实地址时,自动增加时间戳参数。
关于资源瀑布图,还有优化空间,可以将 404 资源标红。同时也发现了静态资源请求错误没有记录的问题。
去掉下面 if 语句中对 event.filename 的判断,因为资源错误是没有 filename 属性的,这样就能将此类资源错误记录在案了。
window.addEventListener( "error", function (event) { var errorTarget = event.target; // 过滤掉与业务无关的错误 if (event.message === "Script error." || !event.filename) { return; } if ( errorTarget !== window && errorTarget.nodeName && LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()] ) { handleError(formatLoadError(errorTarget)); } else { handleError( formatRuntimerError( event.message, event.filename, event.lineno, event.colno, event.error ) ); } }, true //捕获 );
5)案例三(触发时机的误报)
2022-12-09 在优化白屏后的几天,发现有误报的情况发生,因为 html 属性值中有内容。
打开这些页面分析,发现有些内容的样式是绝对定位或固定定位,也就是说这些内容并不会撑起 body 的高度。
那么要有高度,就需要等待其他元素渲染,若在上报白屏时,还没渲染成功,那么就有可能误报。
为了验证自己的猜想,去查询了下某条性能记录的资源瀑布图,发现在触发 DOMContentLoaded 时,那些能撑起高度的资源还没加载完成。
经测试发现,当因为脚本错误出现白屏时,两个事件的触发时机会很接近,而如果是正常情况,那么两者会有些时间的间隔。
所以发生白屏时,也能减少因用户快速关闭页面而发生漏报的情况,因此最后决定将上报迁移到 load 事件中。
6)案例四(标签切换的误报)
2022-12-13 在监控白屏时,发现有一类的白屏是由标签栏切换引起的,因为在切换后会先将之前的列表清空,再去请求接口。
在等待数据时就会有那么一段白屏时间差,为了体验好点,其实可以加一些过渡效果,例如加个 loading 等待。
7)案例五(内置刷新按钮)
2025-08-05 公司有张活动页,访问量比较大,每日 PV 在 10W 左右。
有一天上报了大量的白屏告警,1000多条。QA 将白屏 BUG 从中等级提升到了高等级。
我怀疑是 CDN 异常的问题,因为监控到了 Script 的错误上报,但是去查 CDN 的日志,又没错误。
不知道是不是上报量太小,还是什么原因,第二天又恢复了往常的上报量。
鉴于此,现有的日志无法给到更为明确的线索,于是在页面中内联了一段代码。
当出现白屏时,自动弹出一个框,提示页面资源加载失败,并且放了两个按钮,重新加载和关闭。

经过一段时间的观察,白屏数量和反馈给客服的投诉量都少了,从 80 多降低到了个位数。
但查看用户行为轨迹发现,那些点了关闭按钮的,还是会保持白屏。

于是我做了个大胆的改造,移除了关闭按钮。
只保留重新加载,如果要关闭页面,就要动动手指移动到左上角关闭 icon。
这么一改,我相信很多人,都会愿意一试,页面可能就能恢复。
我的理解是,没有正确请求到脚本资源,可能请求了 WebView 中的缓存文件导致的错误。
posted on
浙公网安备 33010602011771号