從0到1搭建前端監(jiān)控平臺,面試必備的亮點項目總結(jié)
大廠技術(shù) 高級前端 Node進階
點擊上方 程序員成長指北,關(guān)注公眾號
回復1,加入高級Node交流群
原文鏈接: https://juejin.cn/post/7172072612430872584
作者:海闊_天空
前言
常常會苦惱,平常做的項目很普通,沒啥亮點;面試中也經(jīng)常會被問到:做過哪些亮點項目嗎?
前端監(jiān)控就是一個很有亮點的項目,各個大廠都有自己的內(nèi)部實現(xiàn),沒有監(jiān)控的項目好比是在裸奔
文章分成以下六部分來介紹:
自研監(jiān)控平臺解決了哪些痛點,實現(xiàn)了什么亮點功能?
相比sentry等監(jiān)控方案,自研監(jiān)控的優(yōu)勢有哪些?
前端監(jiān)控的設計方案、監(jiān)控的目的
數(shù)據(jù)的采集方式:錯誤信息、性能數(shù)據(jù)、用戶行為、加載資源、個性化指標等
設計開發(fā)一個完整的監(jiān)控SDK
監(jiān)控后臺錯誤還原演示示例
痛點
某?天用戶:xx商品無法下單!
??天運營:xx廣告在手機端打開不了!
大家反饋的bug,怎么都復現(xiàn)不出來,尷尬的要死!??
如何記錄項目的錯誤,并將錯誤還原出來,這是監(jiān)控平臺要解決的痛點之一
錯誤還原
web-see[1] 監(jiān)控提供三種錯誤還原方式:定位源碼、播放錄屏、記錄用戶行為
定位源碼
項目出錯,要是能定位到源碼就好了,可線上的項目都是打包后的代碼,也不能把 .map 文件放到線上
監(jiān)控平臺通過 source-map[2] 可以實現(xiàn)該功能
最終效果:

播放錄屏
多數(shù)場景下,定位到具體的源碼,就可以定位bug,但如果是用戶做了異常操作,或者是在某些復雜操作下才出現(xiàn)的bug,僅僅通過定位源碼,還是不能還原錯誤
要是能把用戶的操作都錄制下來,然后通過回放來還原錯誤就好了
監(jiān)控平臺通過 rrweb[3] 可以實現(xiàn)該功能
最終效果:

回放的錄屏中,記錄了用戶的所有操作,紅色的線代表了鼠標的移動軌跡
前端錄屏確實是件很酷的事情,但是不能走極端,如果把用戶的所有操作都錄制下來,是沒有意義的
我們更關(guān)注的是,頁面報錯的時候用戶做了哪些操作,所以監(jiān)控平臺只把報錯前10s的視頻保存下來(單次錄屏時長也可以自定義)
記錄用戶行為
通過 定位源碼 + 播放錄屏 這套組合,還原錯誤應該夠用了,同時監(jiān)控平臺也提供了 記錄用戶行為 這種方式
假如用戶做了很多操作,操作的間隔超過了單次錄屏時長,錄制的視頻可能是不完整的,此時可以借助用戶行為來分析用戶的操作,幫助復現(xiàn)bug
最終效果: 
用戶行為列表記錄了:鼠標點擊、接口調(diào)用、資源加載、頁面路由變化、代碼報錯等信息
通過 定位源碼、播放錄屏、記錄用戶行為 這三板斧,解決了復現(xiàn)bug的痛點
自研監(jiān)控的優(yōu)勢
為什么不直接用sentry私有化部署,而選擇自研前端監(jiān)控?
這是優(yōu)先要思考的問題,sentry作為前端監(jiān)控的行業(yè)標桿,有很多可以借鑒的地方
相比sentry,自研監(jiān)控平臺的優(yōu)勢在于:
1、可以將公司的SDK統(tǒng)一成一個,包括但不限于:監(jiān)控SDK、埋點SDK、錄屏SDK、廣告SDK等
2、提供了更多的錯誤還原方式,同時錯誤信息可以和埋點信息聯(lián)動,便可拿到更細致的用戶行為棧,更快的排查線上錯誤
3、監(jiān)控自定義的個性化指標:如 long task、memory頁面內(nèi)存、首屏加載時間等。過多的長任務會造成頁面丟幀、卡頓;過大的內(nèi)存可能會造成低端機器的卡死、崩潰
4、統(tǒng)計資源緩存率,來判斷項目的緩存策略是否合理,提升緩存率可以減少服務器壓力,也可以提升頁面的打開速度
5、提供了 采樣對比+ 輪詢修正機制 的白屏檢測方案,用于檢測頁面是否一直處于白屏狀態(tài),讓開發(fā)者知道頁面什么時候白了,具體實現(xiàn)見 前端白屏的檢測方案,解決你的線上之憂[4]
設計思路
一個完整的前端監(jiān)控平臺包括三個部分:數(shù)據(jù)采集與上報、數(shù)據(jù)分析和存儲、數(shù)據(jù)展示

監(jiān)控目的

異常分析
按照 5W1H 法則來分析前端異常,需要知道以下信息
What,發(fā)?了什么錯誤:JS錯誤、異步錯誤、資源加載、接口錯誤等 When,出現(xiàn)的時間段,如時間戳 Who,影響了多少用戶,包括報錯事件數(shù)、IP Where,出現(xiàn)的頁面是哪些,包括頁面、對應的設備信息 Why,錯誤的原因是為什么,包括錯誤堆棧、?列、SourceMap、異常錄屏 How,如何定位還原問題,如何異常報警,避免類似的錯誤發(fā)生
錯誤數(shù)據(jù)采集
錯誤信息是最基礎也是最重要的數(shù)據(jù),錯誤信息主要分為下面幾類:
JS 代碼運行錯誤、語法錯誤等 異步錯誤等 靜態(tài)資源加載錯誤 接口請求報錯
錯誤捕獲方式
1)try/catch
只能捕獲代碼常規(guī)的運行錯誤,語法錯誤和異步錯誤不能捕獲到
示例:
// 示例1:常規(guī)運行時錯誤,可以捕獲 ?
try {
let a = undefined;
if (a.length) {
console.log('111');
}
} catch (e) {
console.log('捕獲到異常:', e);
}
// 示例2:語法錯誤,不能捕獲 ?
try {
const notdefined,
} catch(e) {
console.log('捕獲不到異常:', 'Uncaught SyntaxError');
}
// 示例3:異步錯誤,不能捕獲 ?
try {
setTimeout(() => {
console.log(notdefined);
}, 0)
} catch(e) {
console.log('捕獲不到異常:', 'Uncaught ReferenceError');
}
2) window.onerror
window.onerror 可以捕獲常規(guī)錯誤、異步錯誤,但不能捕獲資源錯誤
/**
* @param { string } message 錯誤信息
* @param { string } source 發(fā)生錯誤的腳本URL
* @param { number } lineno 發(fā)生錯誤的行號
* @param { number } colno 發(fā)生錯誤的列號
* @param { object } error Error對象
*/
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕獲到的錯誤信息是:', message, source, lineno, colno, error )
}
示例:
window.onerror = function(message, source, lineno, colno, error) {
console.log("捕獲到的錯誤信息是:", message, source, lineno, colno, error);
};
// 示例1:常規(guī)運行時錯誤,可以捕獲 ?
console.log(notdefined);
// 示例2:語法錯誤,不能捕獲 ?
const notdefined;
// 示例3:異步錯誤,可以捕獲 ?
setTimeout(() => {
console.log(notdefined);
}, 0);
// 示例4:資源錯誤,不能捕獲 ?
let script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.test.com/index.js";
document.body.appendChild(script);
3) window.addEventListener
當靜態(tài)資源加載失敗時,會觸發(fā) error 事件, 此時 window.onerror 不能捕獲到
示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<script>
window.addEventListener('error', (error) => {
console.log('捕獲到異常:', error);
}, true)
</script>
<!-- 圖片、script、css加載錯誤,都能被捕獲 ? -->
<img src="https://test.cn/×××.png">
<script src="https://test.cn/×××.js"></script>
<link href="https://test.cn/×××.css" rel="stylesheet" />
<script>
// new Image錯誤,不能捕獲 ?
// new Image運用的比較少,可以自己單獨處理
new Image().src = 'https://test.cn/×××.png'
</script>
</html>
4)Promise錯誤
Promise中拋出的錯誤,無法被 window.onerror、try/catch、 error 事件捕獲到,可通過 unhandledrejection 事件來處理
示例:
try {
new Promise((resolve, reject) => {
JSON.parse("");
resolve();
});
} catch (err) {
// try/catch 不能捕獲Promise中錯誤 ?
console.error("in try catch", err);
}
// error事件 不能捕獲Promise中錯誤 ?
window.addEventListener(
"error",
error => {
console.log("捕獲到異常:", error);
},
true
);
// window.onerror 不能捕獲Promise中錯誤 ?
window.onerror = function(message, source, lineno, colno, error) {
console.log("捕獲到異常:", { message, source, lineno, colno, error });
};
// unhandledrejection 可以捕獲Promise中的錯誤 ?
window.addEventListener("unhandledrejection", function(e) {
console.log("捕獲到異常", e);
// preventDefault阻止傳播,不會在控制臺打印
e.preventDefault();
});
Vue 錯誤
Vue項目中,window.onerror 和 error 事件不能捕獲到常規(guī)的代碼錯誤
異常代碼:
export default {
created() {
let a = null;
if(a.length > 1) {
// ...
}
}
};
main.js中添加捕獲代碼:
window.addEventListener('error', (error) => {
console.log('error', error);
});
window.onerror = function (msg, url, line, col, error) {
console.log('onerror', msg, url, line, col, error);
};
控制臺會報錯,但是 window.onerror 和 error 不能捕獲到

vue 通過 Vue.config.errorHander 來捕獲異常:
Vue.config.errorHandler = (err, vm, info) => {
console.log('進來啦~', err);
}
控制臺打印:

errorHandler源碼分析
在src/core/util目錄下,有一個error.js文件
function globalHandleError (err, vm, info) {
// 獲取全局配置,判斷是否設置處理函數(shù),默認undefined
// 配置config.errorHandler方法
if (config.errorHandler) {
try {
// 執(zhí)行 errorHandler
return config.errorHandler.call(null, err, vm, info)
} catch (e) {
// 如果開發(fā)者在errorHandler函數(shù)中,手動拋出同樣錯誤信息throw err,判斷err信息是否相等,避免log兩次
if (e !== err) {
logError(e, null, 'config.errorHandler')
}
}
}
// 沒有配置,常規(guī)輸出
logError(err, vm, info)
}
function logError (err, vm, info) {
if (process.env.NODE_ENV !== 'production') {
warn(`Error in ${info}: "${err.toString()}"`, vm)
}
/* istanbul ignore else */
if ((inBrowser || inWeex) && typeof console !== 'undefined') {
console.error(err)
} else {
throw err
}
}
通過源碼明白了,vue 使用 try/catch 來捕獲常規(guī)代碼的報錯,被捕獲的錯誤會通過 console.error 輸出而避免應用崩潰
可以在 Vue.config.errorHandler 中將捕獲的錯誤上報
Vue.config.errorHandler = function (err, vm, info) {
// handleError方法用來處理錯誤并上報
handleError(err);
}
React 錯誤
從 react16 開始,官方提供了 ErrorBoundary 錯誤邊界的功能,被該組件包裹的子組件,render 函數(shù)報錯時會觸發(fā)離當前組件最近父組件的ErrorBoundary
生產(chǎn)環(huán)境,一旦被 ErrorBoundary 捕獲的錯誤,也不會觸發(fā)全局的 window.onerror 和 error 事件
父組件代碼:
import React from 'react';
import Child from './Child.js';
// window.onerror 不能捕獲render函數(shù)的錯誤 ?
window.onerror = function (err, msg, c, l) {
console.log('err', err, msg);
};
// error 不能render函數(shù)的錯誤 ?
window.addEventListener( 'error', (error) => {
console.log('捕獲到異常:', error);
},true
);
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能夠顯示降級后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// componentDidCatch 可以捕獲render函數(shù)的錯誤
console.log(error, errorInfo)
// 同樣可以將錯誤日志上報給服務器
reportError(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 自定義降級后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
function Parent() {
return (
<div>
父組件
<ErrorBoundary>
<Child />
</ErrorBoundary>
</div>
);
}
export default Parent;
子組件代碼:
// 子組件 渲染出錯
function Child() {
let list = {};
return (
<div>
子組件
{list.map((item, key) => (
<span key={key}>{item}</span>
))}
</div>
);
}
export default Child;
同vue項目的處理類似,react項目中,可以在 componentDidCatch 中將捕獲的錯誤上報
componentDidCatch(error, errorInfo) {
// handleError方法用來處理錯誤并上報
handleError(err);
}
跨域問題
如果當前頁面中,引入了其他域名的JS資源,如果資源出現(xiàn)錯誤,error 事件只會監(jiān)測到一個 script error 的異常。
示例:
window.addEventListener("error", error => {
console.log("捕獲到異常:", error);
}, true );
// 當前頁面加載其他域的資源,如https://www.test.com/index.js
<script src="https://www.test.com/index.js"></script>
// 加載的https://www.test.com/index.js的代碼
function fn() {
JSON.parse("");
}
fn();
報錯信息:

只能捕獲到 script error 的原因:
是由于瀏覽器基于安全考慮,故意隱藏了其它域JS文件拋出的具體錯誤信息,這樣可以有效避免敏感信息無意中被第三方(不受控制的)腳本捕獲到,因此,瀏覽器只允許同域下的腳本捕獲具體的錯誤信息
解決方法:
前端script加crossorigin,后端配置 Access-Control-Allow-Origin
<script src="https://www.test.com/index.js" crossorigin></script>
添加 crossorigin 后可以捕獲到完整的報錯信息:

如果不能修改服務端的請求頭,可以考慮通過使用 try/catch 繞過,將錯誤拋出
<!doctype html>
<html>
<body>
<script src="https://www.test.com/index.js"></script>
<script>
window.addEventListener("error", error => {
console.log("捕獲到異常:", error);
}, true );
try {
// 調(diào)用https://www.test.com/index.js中定義的fn方法
fn();
} catch (e) {
throw e;
}
</script>
</body>
</html>
接口錯誤
接口監(jiān)控的實現(xiàn)原理:針對瀏覽器內(nèi)置的 XMLHttpRequest、fetch 對象,利用 AOP 切片編程重寫該方法,實現(xiàn)對請求的接口攔截,從而獲取接口報錯的情況并上報
1)攔截XMLHttpRequest請求示例:
function xhrReplace() {
if (!("XMLHttpRequest" in window)) {
return;
}
const originalXhrProto = XMLHttpRequest.prototype;
// 重寫XMLHttpRequest 原型上的open方法
replaceAop(originalXhrProto, "open", originalOpen => {
return function(...args) {
// 獲取請求的信息
this._xhr = {
method: typeof args[0] === "string" ? args[0].toUpperCase() : args[0],
url: args[1],
startTime: new Date().getTime(),
type: "xhr"
};
// 執(zhí)行原始的open方法
originalOpen.apply(this, args);
};
});
// 重寫XMLHttpRequest 原型上的send方法
replaceAop(originalXhrProto, "send", originalSend => {
return function(...args) {
// 當請求結(jié)束時觸發(fā),無論請求成功還是失敗都會觸發(fā)
this.addEventListener("loadend", () => {
const { responseType, response, status } = this;
const endTime = new Date().getTime();
this._xhr.reqData = args[0];
this._xhr.status = status;
if (["", "json", "text"].indexOf(responseType) !== -1) {
this._xhr.responseText =
typeof response === "object" ? JSON.stringify(response) : response;
}
// 獲取接口的請求時長
this._xhr.elapsedTime = endTime - this._xhr.startTime;
// 上報xhr接口數(shù)據(jù)
reportData(this._xhr);
});
// 執(zhí)行原始的send方法
originalSend.apply(this, args);
};
});
}
/**
* 重寫指定的方法
* @param { object } source 重寫的對象
* @param { string } name 重寫的屬性
* @param { function } fn 攔截的函數(shù)
*/
function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === "function") {
source[name] = wrapped;
}
}
}
2)攔截fetch請求示例:
function fetchReplace() {
if (!("fetch" in window)) {
return;
}
// 重寫fetch方法
replaceAop(window, "fetch", originalFetch => {
return function(url, config) {
const sTime = new Date().getTime();
const method = (config && config.method) || "GET";
let handlerData = {
type: "fetch",
method,
reqData: config && config.body,
url
};
return originalFetch.apply(window, [url, config]).then(
res => {
// res.clone克隆,防止被標記已消費
const tempRes = res.clone();
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: tempRes.status
};
tempRes.text().then(data => {
handlerData.responseText = data;
// 上報fetch接口數(shù)據(jù)
reportData(handlerData);
});
// 返回原始的結(jié)果,外部繼續(xù)使用then接收
return res;
},
err => {
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: 0
};
// 上報fetch接口數(shù)據(jù)
reportData(handlerData);
throw err;
}
);
};
});
}
性能數(shù)據(jù)采集
談到性能數(shù)據(jù)采集,就會提及加載過程模型圖:

以Spa頁面來說,頁面的加載過程大致是這樣的:

包括dns查詢、建立tcp連接、發(fā)送http請求、返回html文檔、html文檔解析等階段
最初,可以通過 window.performance.timing 來獲取加載過程模型中各個階段的耗時數(shù)據(jù)
// window.performance.timing 各字段說明
{
navigationStart, // 同一個瀏覽器上下文中,上一個文檔結(jié)束時的時間戳。如果沒有上一個文檔,這個值會和 fetchStart 相同。
unloadEventStart, // 上一個文檔 unload 事件觸發(fā)時的時間戳。如果沒有上一個文檔,為 0。
unloadEventEnd, // 上一個文檔 unload 事件結(jié)束時的時間戳。如果沒有上一個文檔,為 0。
redirectStart, // 表示第一個 http 重定向開始時的時間戳。如果沒有重定向或者有一個非同源的重定向,為 0。
redirectEnd, // 表示最后一個 http 重定向結(jié)束時的時間戳。如果沒有重定向或者有一個非同源的重定向,為 0。
fetchStart, // 表示瀏覽器準備好使用 http 請求來獲取文檔的時間戳。這個時間點會在檢查任何緩存之前。
domainLookupStart, // 域名查詢開始的時間戳。如果使用了持久連接或者本地有緩存,這個值會和 fetchStart 相同。
domainLookupEnd, // 域名查詢結(jié)束的時間戳。如果使用了持久連接或者本地有緩存,這個值會和 fetchStart 相同。
connectStart, // http 請求向服務器發(fā)送連接請求時的時間戳。如果使用了持久連接,這個值會和 fetchStart 相同。
connectEnd, // 瀏覽器和服務器之前建立連接的時間戳,所有握手和認證過程全部結(jié)束。如果使用了持久連接,這個值會和 fetchStart 相同。
secureConnectionStart, // 瀏覽器與服務器開始安全鏈接的握手時的時間戳。如果當前網(wǎng)頁不要求安全連接,返回 0。
requestStart, // 瀏覽器向服務器發(fā)起 http 請求(或者讀取本地緩存)時的時間戳,即獲取 html 文檔。
responseStart, // 瀏覽器從服務器接收到第一個字節(jié)時的時間戳。
responseEnd, // 瀏覽器從服務器接受到最后一個字節(jié)時的時間戳。
domLoading, // dom 結(jié)構(gòu)開始解析的時間戳,document.readyState 的值為 loading。
domInteractive, // dom 結(jié)構(gòu)解析結(jié)束,開始加載內(nèi)嵌資源的時間戳,document.readyState 的狀態(tài)為 interactive。
domContentLoadedEventStart, // DOMContentLoaded 事件觸發(fā)時的時間戳,所有需要執(zhí)行的腳本執(zhí)行完畢。
domContentLoadedEventEnd, // DOMContentLoaded 事件結(jié)束時的時間戳
domComplete, // dom 文檔完成解析的時間戳, document.readyState 的值為 complete。
loadEventStart, // load 事件觸發(fā)的時間。
loadEventEnd // load 時間結(jié)束時的時間。
}
后來 window.performance.timing 被廢棄,通過 PerformanceObserver[5] 來獲取。舊的 api,返回的是一個 UNIX 類型的絕對時間,和用戶的系統(tǒng)時間相關(guān),分析的時候需要再次計算。而新的 api,返回的是一個相對時間,可以直接用來分析
現(xiàn)在 chrome 開發(fā)團隊提供了 web-vitals[6] 庫,方便來計算各性能數(shù)據(jù)(注意:web-vitals 不支持safari瀏覽器)
關(guān)于 FP、FCP、LCP、CLS、TTFB、FID 等性能指標的含義和計算方式,我在 「歷時8個月」10萬字前端知識體系總結(jié)(工程化篇)??[7] 中有詳細的講解,這里不再贅述
用戶行為數(shù)據(jù)采集
用戶行為包括:頁面路由變化、鼠標點擊、資源加載、接口調(diào)用、代碼報錯等行為
設計思路
1、通過Breadcrumb類來創(chuàng)建用戶行為的對象,來存儲和管理所有的用戶行為
2、通過重寫或添加相應的事件,完成用戶行為數(shù)據(jù)的采集
用戶行為代碼示例:
// 創(chuàng)建用戶行為類
class Breadcrumb {
// maxBreadcrumbs控制上報用戶行為的最大條數(shù)
maxBreadcrumbs = 20;
// stack 存儲用戶行為
stack = [];
constructor() {}
// 添加用戶行為棧
push(data) {
if (this.stack.length >= this.maxBreadcrumbs) {
// 超出則刪除第一條
this.stack.shift();
}
this.stack.push(data);
// 按照時間排序
this.stack.sort((a, b) => a.time - b.time);
}
}
let breadcrumb = new Breadcrumb();
// 添加一條頁面跳轉(zhuǎn)的行為,從home頁面跳轉(zhuǎn)到about頁面
breadcrumb.push({
type: "Route",
form: '/home',
to: '/about'
url: "http://localhost:3000/index.html",
time: "1668759320435"
});
// 添加一條用戶點擊行為
breadcrumb.push({
type: "Click",
dom: "<button id='btn'>按鈕</button>",
time: "1668759620485"
});
// 添加一條調(diào)用接口行為
breadcrumb.push({
type: "Xhr",
url: "http://10.105.10.12/monitor/open/pushData",
time: "1668760485550"
});
// 上報用戶行為
reportData({
uuid: "a6481683-6d2e-4bd8-bba1-64819d8cce8c",
stack: breadcrumb.getStack()
});
頁面跳轉(zhuǎn)
通過監(jiān)聽路由的變化來判斷頁面跳轉(zhuǎn),路由有history、hash兩種模式,history模式可以監(jiān)聽popstate事件,hash模式通過重寫 pushState和 replaceState事件
vue項目中不能通過 hashchange 事件來監(jiān)聽路由變化,vue-router 底層調(diào)用的是 history.pushState 和 history.replaceState,不會觸發(fā) hashchange
vue-router源碼:
function pushState (url, replace) {
saveScrollPosition();
var history = window.history;
try {
if (replace) {
history.replaceState({ key: _key }, '', url);
} else {
_key = genKey();
history.pushState({ key: _key }, '', url);
}
} catch (e) {
window.location[replace ? 'replace' : 'assign'](url);
}
}
...
// this.$router.push時觸發(fā)
function pushHash (path) {
if (supportsPushState) {
pushState(getUrl(path));
} else {
window.location.hash = path;
}
}
通過重寫 pushState、replaceState 事件來監(jiān)聽路由變化
// lastHref 前一個頁面的路由
let lastHref = document.location.href;
function historyReplace() {
function historyReplaceFn(originalHistoryFn) {
return function(...args) {
const url = args.length > 2 ? args[2] : undefined;
if (url) {
const from = lastHref;
const to = String(url);
lastHref = to;
// 上報路由變化
reportData("routeChange", {
from,
to
});
}
return originalHistoryFn.apply(this, args);
};
}
// 重寫pushState事件
replaceAop(window.history, "pushState", historyReplaceFn);
// 重寫replaceState事件
replaceAop(window.history, "replaceState", historyReplaceFn);
}
function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === "function") {
source[name] = wrapped;
}
}
}
用戶點擊
給 document 對象添加click事件,并上報
function domReplace() {
document.addEventListener("click",({ target }) => {
const tagName = target.tagName.toLowerCase();
if (tagName === "body") {
return null;
}
let classNames = target.classList.value;
classNames = classNames !== "" ? ` class="${classNames}"` : "";
const id = target.id ? ` id="${target.id}"` : "";
const innerText = target.innerText;
// 獲取包含id、class、innerTextde字符串的標簽
let dom = `<${tagName}${id}${
classNames !== "" ? classNames : ""
}>${innerText}</${tagName}>`;
// 上報
reportData({
type: 'Click',
dom
});
},
true
);
}
資源加載
獲取頁面中加載的資源信息,比如它們的 url 是什么、加載了多久、是否來自緩存等,最終生成 資源加載瀑布圖[8]

瀑布圖展現(xiàn)了瀏覽器為渲染網(wǎng)頁而加載的所有的資源,包括加載的順序和每個資源的加載時間
分析這些資源是如何加載的, 可以幫助我們了解究竟是什么原因拖慢了網(wǎng)頁,從而采取對應的措施來提升網(wǎng)頁速度
可以通過 performance.getEntriesByType('resource')[9] 獲取頁面加載的資源列表,同時可以結(jié)合 initiatorType 字段來判斷資源類型,對資源進行過濾
其中 PerformanceResourceTiming[10] 來分析資源加載的詳細數(shù)據(jù)
// PerformanceResourceTiming 各字段說明
{
connectEnd, // 表示瀏覽器完成建立與服務器的連接以檢索資源之后的時間
connectStart, // 表示瀏覽器開始建立與服務器的連接以檢索資源之前的時間
decodedBodySize, // 表示在刪除任何應用的內(nèi)容編碼之后,從*消息主體*的請求(HTTP 或緩存)中接收到的大?。ㄒ园宋蛔止?jié)為單位)
domainLookupEnd, // 表示瀏覽器完成資源的域名查找之后的時間
domainLookupStart, // 表示在瀏覽器立即開始資源的域名查找之前的時間
duration, // 返回一個timestamp,即 responseEnd 和 startTime 屬性的差值
encodedBodySize, // 表示在刪除任何應用的內(nèi)容編碼之前,從*有效內(nèi)容主體*的請求(HTTP 或緩存)中接收到的大?。ㄒ园宋蛔止?jié)為單位)
entryType, // 返回 "resource"
fetchStart, // 表示瀏覽器即將開始獲取資源之前的時間
initiatorType, // 代表啟動性能條目的資源的類型,如 PerformanceResourceTiming.initiatorType 中所指定
name, // 返回資源 URL
nextHopProtocol, // 代表用于獲取資源的網(wǎng)絡協(xié)議
redirectEnd, // 表示收到上一次重定向響應的發(fā)送最后一個字節(jié)時的時間
redirectStart, // 表示上一次重定向開始的時間
requestStart, // 表示瀏覽器開始向服務器請求資源之前的時間
responseEnd, // 表示在瀏覽器接收到資源的最后一個字節(jié)之后或在傳輸連接關(guān)閉之前(以先到者為準)的時間
responseStart, // 表示瀏覽器從服務器接收到響應的第一個字節(jié)后的時間
secureConnectionStart, // 表示瀏覽器即將開始握手過程以保護當前連接之前的時間
serverTiming, // 一個 PerformanceServerTiming 數(shù)組,包含服務器計時指標的PerformanceServerTiming 條目
startTime, // 表示資源獲取開始的時間。該值等效于 PerformanceEntry.fetchStart
transferSize, // 代表所獲取資源的大?。ㄒ园宋蛔止?jié)為單位)。該大小包括響應標頭字段以及響應有效內(nèi)容主體
workerStart // 如果服務 Worker 線程已經(jīng)在運行,則返回在分派 FetchEvent 之前的時間戳,如果尚未運行,則返回在啟動 Service Worker 線程之前的時間戳。如果服務 Worker 未攔截該資源,則該屬性將始終返回 0。
}
獲取資源加載時長為 duration 字段,即 responseEnd 與 startTime 的差值
獲取加載資源列表:
function getResource() {
if (performance.getEntriesByType) {
const entries = performance.getEntriesByType('resource');
// 過濾掉非靜態(tài)資源的 fetch、 xmlhttprequest、beacon
let list = entries.filter((entry) => {
return ['fetch', 'xmlhttprequest', 'beacon'].indexOf(entry.initiatorType) === -1;
});
if (list.length) {
list = JSON.parse(JSON.stringify(list));
list.forEach((entry) => {
entry.isCache = isCache(entry);
});
}
return list;
}
}
// 判斷資料是否來自緩存
// transferSize為0,說明是從緩存中直接讀取的(強制緩存)
// transferSize不為0,但是`encodedBodySize` 字段為 0,說明它走的是協(xié)商緩存(`encodedBodySize 表示請求響應數(shù)據(jù) body 的大小`)
function isCache(entry) {
return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0);
}
一個真實的頁面中,資源加載大多數(shù)是逐步進行的,有些資源本身就做了延遲加載,有些是需要用戶發(fā)生交互后才會去請求一些資源
如果我們只關(guān)注首頁資源,可以在 window.onload 事件中去收集
如果要收集所有的資源,需要通過定時器反復地去收集,并且在一輪收集結(jié)束后,通過調(diào)用 clearResourceTimings[11] 將 performance entries 里的信息清空,避免在下一輪收集時取到重復的資源
個性化指標
long task
執(zhí)行時間超過50ms的任務,被稱為 long task[12] 長任務
獲取頁面的長任務列表:
const entryHandler = list => {
for (const long of list.getEntries()) {
// 獲取長任務詳情
console.log(long);
}
};
let observer = new PerformanceObserver(entryHandler);
observer.observe({ entryTypes: ["longtask"] });
memory頁面內(nèi)存
performance.memory 可以顯示此刻內(nèi)存占用情況,它是一個動態(tài)值,其中:
jsHeapSizeLimit 該屬性代表的含義是:內(nèi)存大小的限制。
totalJSHeapSize 表示總內(nèi)存的大小。
usedJSHeapSize 表示可使用的內(nèi)存的大小。
通常,usedJSHeapSize 不能大于 totalJSHeapSize,如果大于,有可能出現(xiàn)了內(nèi)存泄漏
// load事件中獲取此時頁面的內(nèi)存大小
window.addEventListener("load", () => {
console.log("memory", performance.memory);
});
首屏加載時間
首屏加載時間和首頁加載時間不一樣,首屏指的是屏幕內(nèi)的dom渲染完成的時間
比如首頁很長需要好幾屏展示,這種情況下屏幕以外的元素不考慮在內(nèi)
計算首屏加載時間流程
1)利用MutationObserver監(jiān)聽document對象,每當dom變化時觸發(fā)該事件
2)判斷監(jiān)聽的dom是否在首屏內(nèi),如果在首屏內(nèi),將該dom放到指定的數(shù)組中,記錄下當前dom變化的時間點
3)在MutationObserver的callback函數(shù)中,通過防抖函數(shù),監(jiān)聽document.readyState狀態(tài)的變化
4)當document.readyState === 'complete',停止定時器和 取消對document的監(jiān)聽
5)遍歷存放dom的數(shù)組,找出最后變化節(jié)點的時間,用該時間點減去performance.timing.navigationStart 得出首屏的加載時間
監(jiān)控SDK
監(jiān)控SDK的作用:數(shù)據(jù)采集與上報
整體架構(gòu)

整體架構(gòu)使用 發(fā)布-訂閱 設計模式,這樣設計的好處是便于后續(xù)擴展與維護,如果想添加新的hook或事件,在該回調(diào)中添加對應的函數(shù)即可
SDK 入口
src/index.js
對外導出init事件,配置了vue、react項目的不同引入方式
vue項目在Vue.config.errorHandler中上報錯誤,react項目在ErrorBoundary中上報錯誤

事件發(fā)布與訂閱
通過添加監(jiān)聽事件來捕獲錯誤,利用 AOP 切片編程,重寫接口請求、路由監(jiān)聽等功能,從而獲取對應的數(shù)據(jù)
src/load.js

用戶行為收集
core/breadcrumb.js
創(chuàng)建用戶行為類,stack用來存儲用戶行為,當長度超過限制時,最早的一條數(shù)據(jù)會被覆蓋掉,在上報錯誤時,對應的用戶行為會添加到該錯誤信息中

數(shù)據(jù)上報方式
支持圖片打點上報和fetch請求上報兩種方式
圖片打點上報的優(yōu)勢:
1)支持跨域,一般而言,上報域名都不是當前域名,上報的接口請求會構(gòu)成跨域
2)體積小且不需要插入dom中
3)不需要等待服務器返回數(shù)據(jù)
圖片打點缺點是:url受瀏覽器長度限制
core/transportData.js

數(shù)據(jù)上報時機
優(yōu)先使用 requestIdleCallback,利用瀏覽器空閑時間上報,其次使用微任務上報

監(jiān)控SDK,參考了 sentry[13]、 monitor[14]、 mitojs[15]
項目后臺demo
主要用來演示錯誤還原功能,方式包括:定位源碼、播放錄屏、記錄用戶行為
[16]web-see.gif
后臺demo功能介紹:
1、使用 express 開啟靜態(tài)服務器,模擬線上環(huán)境,用于實現(xiàn)定位源碼的功能
2、server.js 中實現(xiàn)了 reportData(錯誤上報)、getmap(獲取 map 文件)、getRecordScreenId(獲取錄屏信息)、 getErrorList(獲取錯誤列表)的接口
3、用戶可點擊 'js 報錯'、'異步報錯'、'promise 錯誤' 按鈕,上報對應的代碼錯誤,后臺實現(xiàn)錯誤還原功能
4、點擊 'xhr 請求報錯'、'fetch 請求報錯' 按鈕,上報接口報錯信息
5、點擊 '加載資源報錯' 按鈕,上報對應的資源報錯信息
通過這些異步的捕獲,了解監(jiān)控平臺的整體流程
安裝與使用
npm官網(wǎng)搜索 web-see[17] https://www.npmjs.com/package/web-see

倉庫地址
監(jiān)控SDK: web-see[18] https://github.com/xy-sea/web-see
監(jiān)控后臺: web-see-demo[19] https://github.com/xy-sea/web-see-demo
總結(jié)
目前市面上的前端監(jiān)控方案可謂是百花齊放,但底層原理都是相通的。從基礎的理論知識到實現(xiàn)一個可用的監(jiān)控平臺,收獲還是挺多的
有興趣的小伙伴可以結(jié)合git倉庫的源碼玩一玩,再結(jié)合本文一起閱讀,幫助加深理解
后續(xù)
下一篇會繼續(xù)討論前端監(jiān)控,講解具體如何實現(xiàn):定位源碼、播放錄屏等功能
感興趣的小伙伴可以點個關(guān)注,后續(xù)好文不斷!
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學習、共建。下方加 考拉 好友回復「Node」即可。
“分享、點贊、在看” 支持一波??
參考資料
https://github.com/xy-sea/web-see: https://github.com/xy-sea/web-see
[2]https://github.com/mozilla/source-map: https://github.com/mozilla/source-map
[3]https://github.com/rrweb-io/rrweb: https://github.com/rrweb-io/rrweb
[4]https://juejin.cn/post/7176206226903007292: https://juejin.cn/post/7176206226903007292
[5]https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FPerformanceObserver: https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceObserver
[6]https://www.npmjs.com/package/web-vitals: https://www.npmjs.com/package/web-vitals
[7]https://juejin.cn/post/7146976516692410376/#heading-63: https://juejin.cn/post/7146976516692410376/#heading-63
[8]https://blog.csdn.net/csdn_girl/article/details/54911632: https://blog.csdn.net/csdn_girl/article/details/54911632
[9]https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType: https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType
[10]https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType: https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType
[11]https://link.zhihu.com/?target=https%3A//developer.mozilla.org/zh-CN/docs/Web/API/Performance/clearResourceTimings: https://link.zhihu.com/?target=https%3A//developer.mozilla.org/zh-CN/docs/Web/API/Performance/clearResourceTimings
[12]https://developer.mozilla.org/zh-CN/docs/Web/API/Long_Tasks_API: https://developer.mozilla.org/zh-CN/docs/Web/API/Long_Tasks_API
[13]https://github.com/getsentry/sentry-javascript: https://github.com/getsentry/sentry-javascript
[14]https://github.com/clouDr-f2e/monitor: https://github.com/clouDr-f2e/monitor
[15]https://github.com/mitojs/mitojs: https://github.com/mitojs/mitojs
[16]https://github.com/xy-sea/web-see-demo#%E5%8A%9F%E8%83%BD: https://github.com/xy-sea/web-see-demo#%E5%8A%9F%E8%83%BD
[17]https://www.npmjs.com/package/web-see: https://www.npmjs.com/package/web-see
[18]https://github.com/xy-sea/web-see: https://github.com/xy-sea/web-see
[19]https://github.com/xy-sea/web-see-demo: https://github.com/xy-sea/web-see-demo
