頁面奔潰包含兩種場景,第一種是瀏覽器在加載網頁時遇到問題導致的奔潰,另一種是因為腳本渲染出錯導致頁面空白無內容的奔潰。
  前段時間運營抱怨有張活動頁出現了空白(第二種奔潰場景),導致用戶無法訪問,希望我們能主動監控到這種情況,而不是通過用戶的上報。
  后面和運維溝通,他那邊目前只能監控接口的訪問情況,無法監控靜態資源,若要監控得自己想辦法實現。
  首先想到的自然是利用現有的監控系統來了解頁面空白情況,例如某個項目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": ""
}
}
~~~
  根據身份信息,再去日志明細中查找他的前后動作,發現只有一條記錄,也就是既沒有腳本錯誤,也沒有接口請求。
:-: 
  再根據此身份去查詢性能監控的記錄 ID,找出當時靜態資源的瀑布圖,在此圖中,并沒有發現資源異常。
:-: 
  但是當我直接請求 url 地址時,卻發現有 3 個資源的請求是 404,與正常頁面中的 3 個資源做比對,發現兩者的隨機后綴是不同的。
:-: 
  現在恍然大悟,是 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),歡迎閱讀。

推薦一款前端監控腳本:[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年
- 2024年
- 日志
- 2020