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

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

回放的錄屏中,記錄了用戶的所有操作,紅色的線代表了鼠標(biāo)的移動(dòng)軌跡
前端錄屏確實(shí)是件很酷的事情,但是不能走極端,如果把用戶的所有操作都錄制下來,是沒有意義的
我們更關(guān)注的是,頁面報(bào)錯(cuò)的時(shí)候用戶做了哪些操作,所以監(jiān)控平臺(tái)只把報(bào)錯(cuò)前10s的視頻保存下來(單次錄屏?xí)r長(zhǎng)也可以自定義)
記錄用戶行為
通過 定位源碼 + 播放錄屏 這套組合,還原錯(cuò)誤應(yīng)該夠用了,同時(shí)監(jiān)控平臺(tái)也提供了 記錄用戶行為 這種方式
假如用戶做了很多操作,操作的間隔超過了單次錄屏?xí)r長(zhǎng),錄制的視頻可能是不完整的,此時(shí)可以借助用戶行為來分析用戶的操作,幫助復(fù)現(xiàn)bug
最終效果:

用戶行為列表記錄了:鼠標(biāo)點(diǎn)擊、接口調(diào)用、資源加載、頁面路由變化、代碼報(bào)錯(cuò)等信息
通過?定位源碼、播放錄屏、記錄用戶行為
?這三板斧,解決了復(fù)現(xiàn)bug的痛點(diǎn)
自研監(jiān)控的優(yōu)勢(shì)
為什么不直接用sentry私有化部署,而選擇自研前端監(jiān)控?
這是優(yōu)先要思考的問題,sentry作為前端監(jiān)控的行業(yè)標(biāo)桿,有很多可以借鑒的地方
相比sentry,自研監(jiān)控平臺(tái)的優(yōu)勢(shì)在于:
1、可以將公司的SDK統(tǒng)一成一個(gè),包括但不限于:監(jiān)控SDK、埋點(diǎn)SDK、錄屏SDK、廣告SDK等
2、提供了更多的錯(cuò)誤還原方式,同時(shí)錯(cuò)誤信息可以和埋點(diǎn)信息聯(lián)動(dòng),便可拿到更細(xì)致的用戶行為棧,更快的排查線上錯(cuò)誤
3、監(jiān)控自定義的個(gè)性化指標(biāo):如 long task、memory頁面內(nèi)存、首屏加載時(shí)間等。過多的長(zhǎng)任務(wù)會(huì)造成頁面丟幀、卡頓;過大的內(nèi)存可能會(huì)造成低端機(jī)器的卡死、崩潰
4、統(tǒng)計(jì)資源緩存率,來判斷項(xiàng)目的緩存策略是否合理,提升緩存率可以減少服務(wù)器壓力,也可以提升頁面的打開速度
5、提供了?采樣對(duì)比+ 輪詢修正機(jī)制?的白屏檢測(cè)方案,用于檢測(cè)頁面是否一直處于白屏狀態(tài),讓開發(fā)者知道頁面什么時(shí)候白了,具體實(shí)現(xiàn)見?前端白屏的檢測(cè)方案,解決你的線上之憂[4]
設(shè)計(jì)思路
一個(gè)完整的前端監(jiān)控平臺(tái)包括三個(gè)部分:數(shù)據(jù)采集與上報(bào)、數(shù)據(jù)分析和存儲(chǔ)、數(shù)據(jù)展示

監(jiān)控目的

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

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

errorHandler源碼分析
在src/core/util
目錄下,有一個(gè)error.js
文件
function?globalHandleError?(err,?vm,?info)?{
??//?獲取全局配置,判斷是否設(shè)置處理函數(shù),默認(rèn)undefined
??//?配置config.errorHandler方法
??if?(config.errorHandler)?{
????try?{
??????//?執(zhí)行?errorHandler
??????return?config.errorHandler.call(null,?err,?vm,?info)
????}?catch?(e)?{
??????//?如果開發(fā)者在errorHandler函數(shù)中,手動(dòng)拋出同樣錯(cuò)誤信息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ī)代碼的報(bào)錯(cuò),被捕獲的錯(cuò)誤會(huì)通過 console.error 輸出而避免應(yīng)用崩潰
可以在 Vue.config.errorHandler 中將捕獲的錯(cuò)誤上報(bào)
Vue.config.errorHandler = function (err, vm, info) {
?// handleError方法用來處理錯(cuò)誤并上報(bào)
?handleError(err);
}
React 錯(cuò)誤
從 react16 開始,官方提供了 ErrorBoundary 錯(cuò)誤邊界的功能,被該組件包裹的子組件,render 函數(shù)報(bào)錯(cuò)時(shí)會(huì)觸發(fā)離當(dāng)前組件最近父組件的ErrorBoundary
生產(chǎn)環(huán)境,一旦被 ErrorBoundary 捕獲的錯(cuò)誤,也不會(huì)觸發(fā)全局的 window.onerror 和 error 事件
父組件代碼:
import?React?from?'react';
import?Child?from?'./Child.js';
//?window.onerror?不能捕獲render函數(shù)的錯(cuò)誤??
window.onerror?=?function?(err,?msg,?c,?l)?{
??console.log('err',?err,?msg);
};
//?error?不能render函數(shù)的錯(cuò)誤??
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?使下一次渲染能夠顯示降級(jí)后的?UI
????return?{?hasError:?true?};
??}
??componentDidCatch(error,?errorInfo)?{
????//?componentDidCatch?可以捕獲render函數(shù)的錯(cuò)誤?
????console.log(error,?errorInfo)
????
????//?同樣可以將錯(cuò)誤日志上報(bào)給服務(wù)器
????reportError(error,?errorInfo);
??}
??render()?{
????if?(this.state.hasError)?{
??????//?自定義降級(jí)后的?UI?并渲染
??????return?<h1>Something?went?wrong.</h1>;
????}
????return?this.props.children;
??}
}
function?Parent()?{
??return?(
????<div>
??????父組件
??????<ErrorBoundary>
????????<Child?/>
??????</ErrorBoundary>
????</div>
??);
}
export?default?Parent;
子組件代碼:
//?子組件?渲染出錯(cuò)
function?Child()?{
??let?list?=?{};
??return?(
????<div>
??????子組件
??????{list.map((item,?key)?=>?(
????????<span?key={key}>{item}</span>
??????))}
????</div>
??);
}
export?default?Child;
同vue項(xiàng)目的處理類似,react項(xiàng)目中,可以在 componentDidCatch 中將捕獲的錯(cuò)誤上報(bào)
componentDidCatch(error, errorInfo) {
?// handleError方法用來處理錯(cuò)誤并上報(bào)
?handleError(err);
}
跨域問題
如果當(dāng)前頁面中,引入了其他域名的JS資源,如果資源出現(xiàn)錯(cuò)誤,error 事件只會(huì)監(jiān)測(cè)到一個(gè)?script error
?的異常。
示例:
window.addEventListener("error",?error?=>?{?
??console.log("捕獲到異常:",?error);
},?true?);
//?當(dāng)前頁面加載其他域的資源,如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();
報(bào)錯(cuò)信息:

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

如果不能修改服務(wù)端的請(qǐng)求頭,可以考慮通過使用 try/catch 繞過,將錯(cuò)誤拋出
<!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>
接口錯(cuò)誤
接口監(jiān)控的實(shí)現(xiàn)原理:針對(duì)瀏覽器內(nèi)置的 XMLHttpRequest、fetch 對(duì)象,利用 AOP 切片編程重寫該方法,實(shí)現(xiàn)對(duì)請(qǐng)求的接口攔截,從而獲取接口報(bào)錯(cuò)的情況并上報(bào)
1)攔截XMLHttpRequest請(qǐng)求示例:
function?xhrReplace()?{
??if?(!("XMLHttpRequest"?in?window))?{
????return;
??}
??const?originalXhrProto?=?XMLHttpRequest.prototype;
??//?重寫XMLHttpRequest?原型上的open方法
??replaceAop(originalXhrProto,?"open",?originalOpen?=>?{
????return?function(...args)?{
??????//?獲取請(qǐng)求的信息
??????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)?{
??????//?當(dāng)請(qǐng)求結(jié)束時(shí)觸發(fā),無論請(qǐng)求成功還是失敗都會(huì)觸發(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;
????????}
????????//?獲取接口的請(qǐng)求時(shí)長(zhǎng)
????????this._xhr.elapsedTime?=?endTime?-?this._xhr.startTime;
????????//?上報(bào)xhr接口數(shù)據(jù)
????????reportData(this._xhr);
??????});
??????//?執(zhí)行原始的send方法
??????originalSend.apply(this,?args);
????};
??});
}
/**
?*?重寫指定的方法
?*?@param?{?object?}?source?重寫的對(duì)象
?*?@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請(qǐng)求示例:
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克隆,防止被標(biāo)記已消費(fèi)
? ? ? ? ?const tempRes = res.clone();
? ? ? ? ?const eTime = new Date().getTime();
? ? ? ? ?handlerData = {
? ? ? ? ? ?...handlerData,
? ? ? ? ? ?elapsedTime: eTime - sTime,
? ? ? ? ? ?status: tempRes.status
? ? ? ? ?};
? ? ? ? ?tempRes.text().then(data => {
? ? ? ? ? ?handlerData.responseText = data;
? ? ? ? ? ?// 上報(bào)fetch接口數(shù)據(jù)
? ? ? ? ? ?reportData(handlerData);
? ? ? ? ?});
? ? ? ? ?// 返回原始的結(jié)果,外部繼續(xù)使用then接收
? ? ? ? ?return res;
? ? ? ?},
? ? ? ?err => {
? ? ? ? ?const eTime = new Date().getTime();
? ? ? ? ?handlerData = {
? ? ? ? ? ?...handlerData,
? ? ? ? ? ?elapsedTime: eTime - sTime,
? ? ? ? ? ?status: 0
? ? ? ? ?};
? ? ? ? ?// 上報(bào)fetch接口數(shù)據(jù)
? ? ? ? ?reportData(handlerData);
? ? ? ? ?throw err;
? ? ? ?}
? ? ?);
? ?};
?});
}
性能數(shù)據(jù)采集
談到性能數(shù)據(jù)采集,就會(huì)提及加載過程模型圖:

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

包括dns查詢、建立tcp連接、發(fā)送http請(qǐng)求、返回html文檔、html文檔解析等階段
最初,可以通過?window.performance.timing
?來獲取加載過程模型中各個(gè)階段的耗時(shí)數(shù)據(jù)
// window.performance.timing 各字段說明
{
? ?navigationStart, ?// 同一個(gè)瀏覽器上下文中,上一個(gè)文檔結(jié)束時(shí)的時(shí)間戳。如果沒有上一個(gè)文檔,這個(gè)值會(huì)和 fetchStart 相同。
? ?unloadEventStart, ?// 上一個(gè)文檔 unload 事件觸發(fā)時(shí)的時(shí)間戳。如果沒有上一個(gè)文檔,為 0。
? ?unloadEventEnd, // 上一個(gè)文檔 unload 事件結(jié)束時(shí)的時(shí)間戳。如果沒有上一個(gè)文檔,為 0。
? ?redirectStart, // 表示第一個(gè) http 重定向開始時(shí)的時(shí)間戳。如果沒有重定向或者有一個(gè)非同源的重定向,為 0。
? ?redirectEnd, // 表示最后一個(gè) http 重定向結(jié)束時(shí)的時(shí)間戳。如果沒有重定向或者有一個(gè)非同源的重定向,為 0。
? ?fetchStart, // 表示瀏覽器準(zhǔn)備好使用 http 請(qǐng)求來獲取文檔的時(shí)間戳。這個(gè)時(shí)間點(diǎn)會(huì)在檢查任何緩存之前。
? ?domainLookupStart, // 域名查詢開始的時(shí)間戳。如果使用了持久連接或者本地有緩存,這個(gè)值會(huì)和 fetchStart 相同。
? ?domainLookupEnd, // 域名查詢結(jié)束的時(shí)間戳。如果使用了持久連接或者本地有緩存,這個(gè)值會(huì)和 fetchStart 相同。
? ?connectStart, // http 請(qǐng)求向服務(wù)器發(fā)送連接請(qǐng)求時(shí)的時(shí)間戳。如果使用了持久連接,這個(gè)值會(huì)和 fetchStart 相同。
? ?connectEnd, // 瀏覽器和服務(wù)器之前建立連接的時(shí)間戳,所有握手和認(rèn)證過程全部結(jié)束。如果使用了持久連接,這個(gè)值會(huì)和 fetchStart 相同。
? ?secureConnectionStart, // 瀏覽器與服務(wù)器開始安全鏈接的握手時(shí)的時(shí)間戳。如果當(dāng)前網(wǎng)頁不要求安全連接,返回 0。
? ?requestStart, // 瀏覽器向服務(wù)器發(fā)起 http 請(qǐng)求(或者讀取本地緩存)時(shí)的時(shí)間戳,即獲取 html 文檔。
? ?responseStart, // 瀏覽器從服務(wù)器接收到第一個(gè)字節(jié)時(shí)的時(shí)間戳。
? ?responseEnd, // 瀏覽器從服務(wù)器接受到最后一個(gè)字節(jié)時(shí)的時(shí)間戳。
? ?domLoading, // dom 結(jié)構(gòu)開始解析的時(shí)間戳,document.readyState 的值為 loading。
? ?domInteractive, // dom 結(jié)構(gòu)解析結(jié)束,開始加載內(nèi)嵌資源的時(shí)間戳,document.readyState 的狀態(tài)為 interactive。
? ?domContentLoadedEventStart, // DOMContentLoaded 事件觸發(fā)時(shí)的時(shí)間戳,所有需要執(zhí)行的腳本執(zhí)行完畢。
? ?domContentLoadedEventEnd, ?// DOMContentLoaded 事件結(jié)束時(shí)的時(shí)間戳
? ?domComplete, // dom 文檔完成解析的時(shí)間戳, document.readyState 的值為 complete。
? ?loadEventStart, // load 事件觸發(fā)的時(shí)間。
? ?loadEventEnd // load 時(shí)間結(jié)束時(shí)的時(shí)間。
}
后來 window.performance.timing 被廢棄,通過?PerformanceObserver[5]?來獲取。舊的 api,返回的是一個(gè)?UNIX
?類型的絕對(duì)時(shí)間,和用戶的系統(tǒng)時(shí)間相關(guān),分析的時(shí)候需要再次計(jì)算。而新的 api,返回的是一個(gè)相對(duì)時(shí)間,可以直接用來分析
現(xiàn)在 chrome 開發(fā)團(tuán)隊(duì)提供了?web-vitals[6]?庫(kù),方便來計(jì)算各性能數(shù)據(jù)(注意:web-vitals 不支持safari瀏覽器)
用戶行為數(shù)據(jù)采集
用戶行為包括:頁面路由變化、鼠標(biāo)點(diǎn)擊、資源加載、接口調(diào)用、代碼報(bào)錯(cuò)等行為
設(shè)計(jì)思路
1、通過Breadcrumb類來創(chuàng)建用戶行為的對(duì)象,來存儲(chǔ)和管理所有的用戶行為
2、通過重寫或添加相應(yīng)的事件,完成用戶行為數(shù)據(jù)的采集
用戶行為代碼示例:
//?創(chuàng)建用戶行為類
class?Breadcrumb?{
??//?maxBreadcrumbs控制上報(bào)用戶行為的最大條數(shù)
??maxBreadcrumbs?=?20;
??//?stack?存儲(chǔ)用戶行為
??stack?=?[];
??constructor()?{}
??//?添加用戶行為棧
??push(data)?{
????if?(this.stack.length?>=?this.maxBreadcrumbs)?{
??????//?超出則刪除第一條
??????this.stack.shift();
????}
????this.stack.push(data);
????//?按照時(shí)間排序
????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"
});
//?添加一條用戶點(diǎn)擊行為
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"
});
//?上報(bào)用戶行為
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項(xiàng)目中不能通過?hashchange
?事件來監(jiān)聽路由變化,vue-router
?底層調(diào)用的是?history.pushState
?和?history.replaceState
,不會(huì)觸發(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時(shí)觸發(fā)
function pushHash (path) {
?if (supportsPushState) {
? ?pushState(getUrl(path));
?} else {
? ?window.location.hash = path;
?}
}
通過重寫 pushState、replaceState 事件來監(jiān)聽路由變化
//?lastHref?前一個(gè)頁面的路由
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;
????????//?上報(bào)路由變化
????????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;
????}
??}
}
用戶點(diǎn)擊
給 document 對(duì)象添加click事件,并上報(bào)
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字符串的標(biāo)簽
??????let?dom?=?`<${tagName}${id}${
????????classNames?!==?""???classNames?:?""
??????}>${innerText}</${tagName}>`;
??????//?上報(bào)
??????reportData({
????????type:?'Click',
????????dom
??????});
????},
????true
??);
}
資源加載
獲取頁面中加載的資源信息,比如它們的 url 是什么、加載了多久、是否來自緩存等,最終生成?資源加載瀑布圖[7]

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

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

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

用戶行為收集
core/breadcrumb.js
創(chuàng)建用戶行為類,stack用來存儲(chǔ)用戶行為,當(dāng)長(zhǎng)度超過限制時(shí),最早的一條數(shù)據(jù)會(huì)被覆蓋掉,在上報(bào)錯(cuò)誤時(shí),對(duì)應(yīng)的用戶行為會(huì)添加到該錯(cuò)誤信息中

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

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

監(jiān)控SDK,參考了 sentry、 monitor、 mitojs
項(xiàng)目后臺(tái)demo
主要用來演示錯(cuò)誤還原功能,方式包括:定位源碼、播放錄屏、記錄用戶行為

后臺(tái)demo功能介紹:
1、使用 express 開啟靜態(tài)服務(wù)器,模擬線上環(huán)境,用于實(shí)現(xiàn)定位源碼的功能
2、server.js 中實(shí)現(xiàn)了 reportData(錯(cuò)誤上報(bào))、getmap(獲取 map 文件)、getRecordScreenId(獲取錄屏信息)、 getErrorList(獲取錯(cuò)誤列表)的接口
3、用戶可點(diǎn)擊 'js 報(bào)錯(cuò)'、'異步報(bào)錯(cuò)'、'promise 錯(cuò)誤' 按鈕,上報(bào)對(duì)應(yīng)的代碼錯(cuò)誤,后臺(tái)實(shí)現(xiàn)錯(cuò)誤還原功能
4、點(diǎn)擊 'xhr 請(qǐng)求報(bào)錯(cuò)'、'fetch 請(qǐng)求報(bào)錯(cuò)' 按鈕,上報(bào)接口報(bào)錯(cuò)信息
5、點(diǎn)擊 '加載資源報(bào)錯(cuò)' 按鈕,上報(bào)對(duì)應(yīng)的資源報(bào)錯(cuò)信息
通過這些異步的捕獲,了解監(jiān)控平臺(tái)的整體流程
安裝與使用
npm官網(wǎng)搜索?web-see[11]

倉(cāng)庫(kù)地址
監(jiān)控SDK:?web-see[12]
監(jiān)控后臺(tái):?web-see-demo[13]
總結(jié)
目前市面上的前端監(jiān)控方案可謂是百花齊放,但底層原理都是相通的。從基礎(chǔ)的理論知識(shí)到實(shí)現(xiàn)一個(gè)可用的監(jiān)控平臺(tái),收獲還是挺多的
有興趣的小伙伴可以結(jié)合git倉(cāng)庫(kù)的源碼玩一玩,再結(jié)合本文一起閱讀,幫助加深理解