页面奔溃包含两种场景,第一种是浏览器在加载网页时遇到问题导致的奔溃,另一种是因为脚本渲染出错导致页面空白无内容的奔溃。
  前段时间运营抱怨有张活动页出现了空白(第二种奔溃场景),导致用户无法访问,希望我们能主动监控到这种情况,而不是通过用户的上报。
  后面和运维沟通,他那边目前只能监控接口的访问情况,无法监控静态资源,若要监控得自己想办法实现。
  首先想到的自然是利用现有的监控系统来了解页面空白情况,例如某个项目5分钟内没有监控日志,那就认为出现了页面奔溃。
  急匆匆的写了段定时任务,放到线上运行,发现这样监控会有一个很大漏洞。因为某些项目的访问量本来就不高,5分钟内没有日志是属于正常情况,所以只得作罢。
  2023-01-16 经过 TypeScript 整理重写后,正式将监控系统的脚本开源,命名为 [shin-monitor](https://github.com/pwstrick/shin-monitor)。
## 一、页面奔溃
  首先来解决第一种奔溃场景,在网上搜了些关键字,发现了些有用的资料,例如[如何监控网页崩溃](https://zhuanlan.zhihu.com/p/40273861),[前端崩溃监控优化历程](https://www.jackpu.com/web-qian-duan-crash-jian-kong-you-hua-li-cheng/)等。
  这些资料提供了一个全新的思路来监控页面奔溃,基于Service Worker的崩溃统计方案。
  简单地说就是一种心跳检测机制,在页面的脚本中创建Service Worker工作线程,然后定时地向该线程发送消息,即使网页奔溃了,线程还能存活。
  在线程中接收消息并比对时间,当间隔时间大于15秒时,就认为超时没有心跳了,页面处于奔溃阶段,向监控系统上报相关信息。
  在我操刀实现的时候,Service Worker没有运行成功,后面就改成了[Web Worker](https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API)。
  工作线程的代码保存在sw.js(如下所示),在参考一篇[Web Workers](https://www.html5rocks.com/zh/tutorials/workers/basics/%20)的文章时,他提到在线程中可以navigator对象,该对象正好有个sendBeacon()方法,可用于跨域请求。
  但是没想到线程中用的[WorkerNavigator](https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator),并没有该方法,后面无奈改成了[fetch()](https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API/Using_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元素,内容都会渲染到该元素中。
~~~html
<div id="root"></div>
~~~
  自定义一个关键DOM的判断条件,如下所示,在定时器中循环执行。
~~~
shin.setParam({
validateCrash: () => {
//当root标签中的内容为空时,可认为页面已奔溃
return {
success: document.getElementById("root").innerHTML.length > 0,
prompt: "页面出现空白"
};
}
});
~~~
  此处还有个小坑,就是定时器的运行时机,不能太早,太早判断的话,div元素中肯定没有内容,后面就将判断时机移到了[DOMContentLoaded](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/DOMContentLoaded_event)事件中。
  下面是监控白屏的主要逻辑,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);
// worker = null;
}
} 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](https://developer.mozilla.org/en-US/docs/Web/API/Element/clientHeight)不会计算行内元素(例如 span、a 等)的高度,[getBoundingClientRect()](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)会计算。
  并且 getBoundingClientRect() 还会将诸如[transform: scale(0.5)](https://stackoverflow.com/questions/32438642/clientwidth-and-clientheight-report-zero-while-getboundingclientrect-is-correct)变换元素尺寸后,得到最终计算后的值。
~~~
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)isCrash**
  2022-12-07 一开始 isCrash 默认标记为 false,也就是关闭监控的,后面默认打开后,线上出现白屏的页面一下子增加了四五百左右。
  接下来就是验证上报的白屏是否准确,下面是上报的一条记录,它有一串字符身份信息,例如 syqgpsyz4s。
~~~
{
"type": "crash",
"desc": {
"prompt": "页面没有高度",
"url": "https://www.xxx.com/chat.html?matchId=100",
"html": ""
}
}
~~~
  根据身份信息,再去日志明细中查找他的前后动作,发现只有一条记录,也就是既没有脚本错误,也没有接口请求。
:-: ![](https://img.kancloud.cn/15/93/15936c35499d176010988afa6deb3096_2968x1456.png =800x)
  再根据此身份去查询性能监控的记录 ID,找出当时静态资源的瀑布图,在此图中,并没有发现资源异常。
:-: ![](https://img.kancloud.cn/d4/50/d4504aa4153751f52f81c4bb242b2a33_1946x1190.png =800x)
  但是当我直接请求 url 地址时,却发现有 3 个资源的请求是 404,与正常页面中的 3 个资源做比对,发现两者的随机后缀是不同的。
:-: ![](https://img.kancloud.cn/41/b5/41b588d0b22308328beef3d19ca566b3_1718x260.png =800x)
  现在恍然大悟,是 CDN 缓存刷新失败导致的问题,问题马上就定位到了。
  还发现另一个问题是因为参数的值导致的白屏,首次使用下来,准确率还是蛮高的。
  2022-12-19 还有一类不是 CDN 引起的资源报错,那就是客户端的缓存。客户端会缓存 HTML 页面,当访问缓存页面时,其中的资源必定已经不存在。
  要破除缓存,就要给 URL 地址增加一个时间戳参数,好在客户端中的活动页面都是通过自研的[短链](https://www.cnblogs.com/strick/p/14299313.html)跳转的,可以在短链映射真实地址时,自动增加时间戳参数。
  关于资源瀑布图,还有优化空间,可以将 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 //捕获
);
~~~
  2022-12-09 在优化白屏后的几天,发现有误报的情况发生,因为 html 属性值中有内容。
  打开这些页面分析,发现有些内容的样式是绝对定位或固定定位,也就是说这些内容并不会撑起 body 的高度。
  那么要有高度,就需要等待其他元素渲染,若在上报白屏时,还没渲染成功,那么就有可能误报。
  为了验证自己的猜想,去查询了下某条性能记录的资源瀑布图,发现在触发 DOMContentLoaded 时,那些能撑起高度的资源还没加载完成。
  经测试发现,当因为脚本错误出现白屏时,两个事件的触发时机会很接近,而如果是正常情况,那么两者会有些时间的间隔。
  所以发生白屏时,也能减少因用户快速关闭页面而发生漏报的情况,因此最后决定将上报迁移到 load 事件中。
  2022-12-13 在监控白屏时,发现有一类的白屏是由标签栏切换引起的,因为在切换后会先将之前的列表清空,再去请求接口。
  在等待数据时就会有那么一段白屏时间差,为了体验好点,其实可以加一些过渡效果,例如加个 loading 等待。
*****
> 原文出处:
[博客园-从零开始搞系列](https://www.cnblogs.com/strick/category/1928903.html)
已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎阅读。
![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200)
推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、扩展运算符和剩余参数
- 3、解构
- 4、模板字面量
- 5、对象字面量的扩展
- 6、Symbol
- 7、代码模块化
- 8、数字
- 9、字符串
- 10、正则表达式
- 11、对象
- 12、数组
- 13、类型化数组
- 14、函数
- 15、箭头函数和尾调用优化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、类
- 21、类的继承
- 22、Promise
- 23、Promise的静态方法和应用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基础实践
- 3、WebRTC视频通话
- 4、Web音视频基础
- CSS进阶
- 1、CSS基础拾遗
- 2、伪类和伪元素
- 3、CSS属性拾遗
- 4、浮动形状
- 5、渐变
- 6、滤镜
- 7、合成
- 8、裁剪和遮罩
- 9、网格布局
- 10、CSS方法论
- 11、管理后台响应式改造
- React
- 1、函数式编程
- 2、JSX
- 3、组件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表单
- 8、样式
- 9、组件通信
- 10、高阶组件
- 11、Redux基础
- 12、Redux中间件
- 13、React Router
- 14、测试框架
- 15、React Hooks
- 16、React源码分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基础
- 4、webpack进阶
- 5、Git
- 6、Fiddler
- 7、自制脚手架
- 8、VSCode插件研发
- 9、WebView中的页面调试方法
- Vue.js
- 1、数据绑定
- 2、指令
- 3、样式和表单
- 4、组件
- 5、组件通信
- 6、内容分发
- 7、渲染函数和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、数据类型
- 2、接口
- 3、类
- 4、泛型
- 5、类型兼容性
- 6、高级类型
- 7、命名空间
- 8、装饰器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系统和网络
- 3、命令行工具
- 4、自建前端监控系统
- 5、定时任务的调试
- 6、自制短链系统
- 7、定时任务的进化史
- 8、通用接口
- 9、微前端实践
- 10、接口日志查询
- 11、E2E测试
- 12、BFF
- 13、MySQL归档
- 14、压力测试
- 15、活动规则引擎
- 16、活动配置化
- 17、UmiJS版本升级
- 18、半吊子的可视化搭建系统
- 19、KOA源码分析(上)
- 20、KOA源码分析(下)
- 21、花10分钟入门Node.js
- 22、Node环境升级日志
- 23、Worker threads
- 24、低代码
- 25、Web自动化测试
- 26、接口拦截和页面回放实验
- 27、接口管理
- 28、Cypress自动化测试实践
- 29、基于Electron的开播助手
- Node.js精进
- 1、模块化
- 2、异步编程
- 3、流
- 4、事件触发器
- 5、HTTP
- 6、文件
- 7、日志
- 8、错误处理
- 9、性能监控(上)
- 10、性能监控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 监控系统
- 1、SDK
- 2、存储和分析
- 3、性能监控
- 4、内存泄漏
- 5、小程序
- 6、较长的白屏时间
- 7、页面奔溃
- 8、shin-monitor源码分析
- 前端性能精进
- 1、优化方法论之测量
- 2、优化方法论之分析
- 3、浏览器之图像
- 4、浏览器之呈现
- 5、浏览器之JavaScript
- 6、网络
- 7、构建
- 前端体验优化
- 1、概述
- 2、基建
- 3、后端
- 4、数据
- 5、后台
- Web优化
- 1、CSS优化
- 2、JavaScript优化
- 3、图像和网络
- 4、用户体验和工具
- 5、网站优化
- 6、优化闭环实践
- 数据结构与算法
- 1、链表
- 2、栈、队列、散列表和位运算
- 3、二叉树
- 4、二分查找
- 5、回溯算法
- 6、贪心算法
- 7、分治算法
- 8、动态规划
- 程序员之路
- 大学
- 2011年
- 2012年
- 2013年
- 2014年
- 项目反思
- 前端基础学习分享
- 2015年
- 再一次项目反思
- 然并卵
- PC网站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端学习之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 日志
- 2020