從 0 到 1 搭建前端監控平臺,面試必備的亮點項目總結

作者:海闊_天空

https://juejin.cn/post/7172072612430872584

前言

常常會苦惱,平常做的項目很普通,沒啥亮點;面試中也經常會被問到:做過哪些亮點項目嗎?

前端監控就是一個很有亮點的項目,各個大廠都有自己的內部實現,沒有監控的項目好比是在裸奔

文章分成以下六部分來介紹:

痛點

某⼀天用戶:xx 商品無法下單!
⼜⼀天運營:xx 廣告在手機端打開不了!

大家反饋的 bug,怎麼都復現不出來,尷尬的要死!😢

如何記錄項目的錯誤,並將錯誤還原出來,這是監控平臺要解決的痛點之一

錯誤還原

web-see[1] 監控提供三種錯誤還原方式:定位源碼、播放錄屏、記錄用戶行爲

定位源碼

項目出錯,要是能定位到源碼就好了,可線上的項目都是打包後的代碼,也不能把 .map 文件放到線上

監控平臺通過 source-map[2] 可以實現該功能

最終效果:

播放錄屏

多數場景下,定位到具體的源碼,就可以定位 bug,但如果是用戶做了異常操作,或者是在某些複雜操作下才出現的 bug,僅僅通過定位源碼,還是不能還原錯誤

要是能把用戶的操作都錄製下來,然後通過回放來還原錯誤就好了

監控平臺通過 rrweb[3] 可以實現該功能

最終效果:

回放的錄屏中,記錄了用戶的所有操作,紅色的線代表了鼠標的移動軌跡

前端錄屏確實是件很酷的事情,但是不能走極端,如果把用戶的所有操作都錄製下來,是沒有意義的

我們更關注的是,頁面報錯的時候用戶做了哪些操作,所以監控平臺只把報錯前 10s 的視頻保存下來(單次錄屏時長也可以自定義)

記錄用戶行爲

通過 定位源碼 + 播放錄屏 這套組合,還原錯誤應該夠用了,同時監控平臺也提供了 記錄用戶行爲 這種方式

假如用戶做了很多操作,操作的間隔超過了單次錄屏時長,錄製的視頻可能是不完整的,此時可以藉助用戶行爲來分析用戶的操作,幫助復現 bug

最終效果:

用戶行爲列表記錄了:鼠標點擊、接口調用、資源加載、頁面路由變化、代碼報錯等信息

通過 定位源碼、播放錄屏、記錄用戶行爲 這三板斧,解決了復現 bug 的痛點

自研監控的優勢

爲什麼不直接用 sentry 私有化部署,而選擇自研前端監控?

這是優先要思考的問題,sentry 作爲前端監控的行業標杆,有很多可以借鑑的地方

相比 sentry,自研監控平臺的優勢在於:

1、可以將公司的 SDK 統一成一個,包括但不限於:監控 SDK、埋點 SDK、錄屏 SDK、廣告 SDK 等

2、提供了更多的錯誤還原方式,同時錯誤信息可以和埋點信息聯動,便可拿到更細緻的用戶行爲棧,更快的排查線上錯誤

3、監控自定義的個性化指標:如 long task、memory 頁面內存、首屏加載時間等。過多的長任務會造成頁面丟幀、卡頓;過大的內存可能會造成低端機器的卡死、崩潰

4、統計資源緩存率,來判斷項目的緩存策略是否合理,提升緩存率可以減少服務器壓力,也可以提升頁面的打開速度

5、提供了 採樣對比 + 輪詢修正機制 的白屏檢測方案,用於檢測頁面是否一直處於白屏狀態,讓開發者知道頁面什麼時候白了,具體實現見 前端白屏的檢測方案,解決你的線上之憂 [4]

設計思路

一個完整的前端監控平臺包括三個部分:數據採集與上報、數據分析和存儲、數據展示

監控目的

異常分析

按照 5W1H 法則來分析前端異常,需要知道以下信息

  1. What,發⽣了什麼錯誤:JS 錯誤、異步錯誤、資源加載、接口錯誤等

  2. When,出現的時間段,如時間戳

  3. Who,影響了多少用戶,包括報錯事件數、IP

  4. Where,出現的頁面是哪些,包括頁面、對應的設備信息

  5. Why,錯誤的原因是爲什麼,包括錯誤堆棧、⾏列、SourceMap、異常錄屏

  6. How,如何定位還原問題,如何異常報警,避免類似的錯誤發生

錯誤數據採集

錯誤信息是最基礎也是最重要的數據,錯誤信息主要分爲下面幾類:

錯誤捕獲方式

1)try/catch

只能捕獲代碼常規的運行錯誤,語法錯誤和異步錯誤不能捕獲到

示例:

// 示例1:常規運行時錯誤,可以捕獲 ✅
 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 可以捕獲常規錯誤、異步錯誤,但不能捕獲資源錯誤

/**
* @param { string } message 錯誤信息
* @param { string } source 發生錯誤的腳本URL
* @param { number } lineno 發生錯誤的行號
* @param { number } colno 發生錯誤的列號
* @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:常規運行時錯誤,可以捕獲 ✅
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

當靜態資源加載失敗時,會觸發 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 事件不能捕獲到常規的代碼錯誤

異常代碼:

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) {
  // 獲取全局配置,判斷是否設置處理函數,默認undefined
  // 配置config.errorHandler方法
  if (config.errorHandler) {
    try {
      // 執行 errorHandler
      return config.errorHandler.call(null, err, vm, info)
    } catch (e) {
      // 如果開發者在errorHandler函數中,手動拋出同樣錯誤信息throw err,判斷err信息是否相等,避免log兩次
      if (e !== err) {
        logError(e, null, 'config.errorHandler')
      }
    }
  }
  // 沒有配置,常規輸出
  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 來捕獲常規代碼的報錯,被捕獲的錯誤會通過 console.error 輸出而避免應用崩潰

可以在 Vue.config.errorHandler 中將捕獲的錯誤上報

Vue.config.errorHandler = function (err, vm, info) { 
  // handleError方法用來處理錯誤並上報
  handleError(err);
}

React 錯誤

從 react16 開始,官方提供了 ErrorBoundary 錯誤邊界的功能,被該組件包裹的子組件,render 函數報錯時會觸發離當前組件最近父組件的 ErrorBoundary

生產環境,一旦被 ErrorBoundary 捕獲的錯誤,也不會觸發全局的 window.onerror 和 error 事件

父組件代碼:

import React from 'react';
import Child from './Child.js';

// window.onerror 不能捕獲render函數的錯誤 ❌
window.onerror = function (err, msg, c, l) {
  console.log('err', err, msg);
};

// error 不能render函數的錯誤 ❌
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函數的錯誤 
    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 資源,如果資源出現錯誤,error 事件只會監測到一個 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 {
    // 調用https://www.test.com/index.js中定義的fn方法
    fn(); 
  } catch (e) {
    throw e;
  }
  </script>
</body>
</html>

接口錯誤

接口監控的實現原理:針對瀏覽器內置的 XMLHttpRequest、fetch 對象,利用 AOP 切片編程重寫該方法,實現對請求的接口攔截,從而獲取接口報錯的情況並上報

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"
      };
      // 執行原始的open方法
      originalOpen.apply(this, args);
    };
  });
  // 重寫XMLHttpRequest 原型上的send方法
  replaceAop(originalXhrProto, "send"originalSend ={
    return function(...args) {
      // 當請求結束時觸發,無論請求成功還是失敗都會觸發
      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接口數據
        reportData(this._xhr);
      });
      // 執行原始的send方法
      originalSend.apply(this, args);
    };
  });
}

/**
 * 重寫指定的方法
 * @param { object } source 重寫的對象
 * @param { string } name 重寫的屬性
 * @param { function } fn 攔截的函數
 */
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接口數據
            reportData(handlerData);
          });

          // 返回原始的結果,外部繼續使用then接收
          return res;
        },
        err => {
          const eTime = new Date().getTime();
          handlerData = {
            ...handlerData,
            elapsedTime: eTime - sTime,
            status: 0
          };
          // 上報fetch接口數據
          reportData(handlerData);
          throw err;
        }
      );
    };
  });
}

性能數據採集

談到性能數據採集,就會提及加載過程模型圖:

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

包括 dns 查詢、建立 tcp 連接、發送 http 請求、返回 html 文檔、html 文檔解析等階段

最初,可以通過 window.performance.timing 來獲取加載過程模型中各個階段的耗時數據

// window.performance.timing 各字段說明
{
    navigationStart,  // 同一個瀏覽器上下文中,上一個文檔結束時的時間戳。如果沒有上一個文檔,這個值會和 fetchStart 相同。
    unloadEventStart,  // 上一個文檔 unload 事件觸發時的時間戳。如果沒有上一個文檔,爲 0。
    unloadEventEnd, // 上一個文檔 unload 事件結束時的時間戳。如果沒有上一個文檔,爲 0。
    redirectStart, // 表示第一個 http 重定向開始時的時間戳。如果沒有重定向或者有一個非同源的重定向,爲 0。
    redirectEnd, // 表示最後一個 http 重定向結束時的時間戳。如果沒有重定向或者有一個非同源的重定向,爲 0。
    fetchStart, // 表示瀏覽器準備好使用 http 請求來獲取文檔的時間戳。這個時間點會在檢查任何緩存之前。
    domainLookupStart, // 域名查詢開始的時間戳。如果使用了持久連接或者本地有緩存,這個值會和 fetchStart 相同。
    domainLookupEnd, // 域名查詢結束的時間戳。如果使用了持久連接或者本地有緩存,這個值會和 fetchStart 相同。
    connectStart, // http 請求向服務器發送連接請求時的時間戳。如果使用了持久連接,這個值會和 fetchStart 相同。
    connectEnd, // 瀏覽器和服務器之前建立連接的時間戳,所有握手和認證過程全部結束。如果使用了持久連接,這個值會和 fetchStart 相同。
    secureConnectionStart, // 瀏覽器與服務器開始安全鏈接的握手時的時間戳。如果當前網頁不要求安全連接,返回 0。
    requestStart, // 瀏覽器向服務器發起 http 請求(或者讀取本地緩存)時的時間戳,即獲取 html 文檔。
    responseStart, // 瀏覽器從服務器接收到第一個字節時的時間戳。
    responseEnd, // 瀏覽器從服務器接受到最後一個字節時的時間戳。
    domLoading, // dom 結構開始解析的時間戳,document.readyState 的值爲 loading。
    domInteractive, // dom 結構解析結束,開始加載內嵌資源的時間戳,document.readyState 的狀態爲 interactive。
    domContentLoadedEventStart, // DOMContentLoaded 事件觸發時的時間戳,所有需要執行的腳本執行完畢。
    domContentLoadedEventEnd,  // DOMContentLoaded 事件結束時的時間戳
    domComplete, // dom 文檔完成解析的時間戳, document.readyState 的值爲 complete。
    loadEventStart, // load 事件觸發的時間。
    loadEventEnd // load 時間結束時的時間。
}

後來 window.performance.timing 被廢棄,通過 PerformanceObserver[5] 來獲取。舊的 api,返回的是一個 UNIX 類型的絕對時間,和用戶的系統時間相關,分析的時候需要再次計算。而新的 api,返回的是一個相對時間,可以直接用來分析

現在 chrome 開發團隊提供了 web-vitals[6] 庫,方便來計算各性能數據(注意:web-vitals 不支持 safari 瀏覽器)

用戶行爲數據採集

用戶行爲包括:頁面路由變化、鼠標點擊、資源加載、接口調用、代碼報錯等行爲

設計思路

1、通過 Breadcrumb 類來創建用戶行爲的對象,來存儲和管理所有的用戶行爲

2、通過重寫或添加相應的事件,完成用戶行爲數據的採集

用戶行爲代碼示例:

// 創建用戶行爲類
class Breadcrumb {
  // maxBreadcrumbs控制上報用戶行爲的最大條數
  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();

// 添加一條頁面跳轉的行爲,從home頁面跳轉到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"
});

// 添加一條調用接口行爲
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()
});

頁面跳轉

通過監聽路由的變化來判斷頁面跳轉,路由有history、hash兩種模式,history 模式可以監聽popstate事件,hash 模式通過重寫 pushState和 replaceState事件

vue 項目中不能通過 hashchange 事件來監聽路由變化,vue-router 底層調用的是 history.pushStatehistory.replaceState,不會觸發 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時觸發
function pushHash (path) { 
  if (supportsPushState) {
    pushState(getUrl(path));
  } else {
    window.location.hash = path;
  }
}

通過重寫 pushState、replaceState 事件來監聽路由變化

// 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 是什麼、加載了多久、是否來自緩存等,最終生成 資源加載瀑布圖 [7]

瀑布圖展現了瀏覽器爲渲染網頁而加載的所有的資源,包括加載的順序和每個資源的加載時間

分析這些資源是如何加載的, 可以幫助我們瞭解究竟是什麼原因拖慢了網頁,從而採取對應的措施來提升網頁速度

可以通過 performance.getEntriesByType('resource') 獲取頁面加載的資源列表,同時可以結合 initiatorType 字段來判斷資源類型,對資源進行過濾

其中 PerformanceResourceTiming[8] 來分析資源加載的詳細數據

// PerformanceResourceTiming 各字段說明
{
  connectEnd, // 表示瀏覽器完成建立與服務器的連接以檢索資源之後的時間
  connectStart, // 表示瀏覽器開始建立與服務器的連接以檢索資源之前的時間
  decodedBodySize, // 表示在刪除任何應用的內容編碼之後,從*消息主體*的請求(HTTP 或緩存)中接收到的大小(以八位字節爲單位)
  domainLookupEnd, // 表示瀏覽器完成資源的域名查找之後的時間
  domainLookupStart, // 表示在瀏覽器立即開始資源的域名查找之前的時間
  duration, // 返回一個timestamp,即 responseEnd 和 startTime 屬性的差值
  encodedBodySize, // 表示在刪除任何應用的內容編碼之前,從*有效內容主體*的請求(HTTP 或緩存)中接收到的大小(以八位字節爲單位)
  entryType, // 返回 "resource"
  fetchStart, // 表示瀏覽器即將開始獲取資源之前的時間
  initiatorType, // 代表啓動性能條目的資源的類型,如 PerformanceResourceTiming.initiatorType 中所指定
  name, // 返回資源 URL
  nextHopProtocol, // 代表用於獲取資源的網絡協議
  redirectEnd, // 表示收到上一次重定向響應的發送最後一個字節時的時間
  redirectStart, // 表示上一次重定向開始的時間
  requestStart, // 表示瀏覽器開始向服務器請求資源之前的時間
  responseEnd, // 表示在瀏覽器接收到資源的最後一個字節之後或在傳輸連接關閉之前(以先到者爲準)的時間
  responseStart, // 表示瀏覽器從服務器接收到響應的第一個字節後的時間
  secureConnectionStart, // 表示瀏覽器即將開始握手過程以保護當前連接之前的時間
  serverTiming, // 一個 PerformanceServerTiming 數組,包含服務器計時指標的PerformanceServerTiming 條目
  startTime, // 表示資源獲取開始的時間。該值等效於 PerformanceEntry.fetchStart
  transferSize, // 代表所獲取資源的大小(以八位字節爲單位)。該大小包括響應標頭字段以及響應有效內容主體
  workerStart // 如果服務 Worker 線程已經在運行,則返回在分派 FetchEvent 之前的時間戳,如果尚未運行,則返回在啓動 Service Worker 線程之前的時間戳。如果服務 Worker 未攔截該資源,則該屬性將始終返回 0。
}

獲取資源加載時長爲 duration 字段,即 responseEnd 與 startTime 的差值

獲取加載資源列表:

function getResource() {
  if (performance.getEntriesByType) {
    const entries = performance.getEntriesByType('resource');
    // 過濾掉非靜態資源的 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,說明它走的是協商緩存(`encodedBodySize 表示請求響應數據 body 的大小`function isCache(entry) {
  return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0);
}

一個真實的頁面中,資源加載大多數是逐步進行的,有些資源本身就做了延遲加載,有些是需要用戶發生交互後纔會去請求一些資源

如果我們只關注首頁資源,可以在 window.onload 事件中去收集

如果要收集所有的資源,需要通過定時器反覆地去收集,並且在一輪收集結束後,通過調用 clearResourceTimings[9] 將 performance entries 裏的信息清空,避免在下一輪收集時取到重複的資源

個性化指標

long task

執行時間超過 50ms 的任務,被稱爲 long task[10] 長任務

獲取頁面的長任務列表:

const entryHandler = list => {
  for (const long of list.getEntries()) {
    // 獲取長任務詳情
    console.log(long);
  }
};

let observer = new PerformanceObserver(entryHandler);
observer.observe({ entryTypes: ["longtask"] });

memory 頁面內存

performance.memory 可以顯示此刻內存佔用情況,它是一個動態值,其中:

通常,usedJSHeapSize 不能大於 totalJSHeapSize,如果大於,有可能出現了內存泄漏

// load事件中獲取此時頁面的內存大小
window.addEventListener("load"() ={
  console.log("memory", performance.memory);
});

首屏加載時間

首屏加載時間和首頁加載時間不一樣,首屏指的是屏幕內的 dom 渲染完成的時間

比如首頁很長需要好幾屏展示,這種情況下屏幕以外的元素不考慮在內

計算首屏加載時間流程

1)利用MutationObserver監聽document對象,每當 dom 變化時觸發該事件

2)判斷監聽的 dom 是否在首屏內,如果在首屏內,將該 dom 放到指定的數組中,記錄下當前 dom 變化的時間點

3)在 MutationObserver 的 callback 函數中,通過防抖函數,監聽document.readyState狀態的變化

4)當document.readyState === 'complete',停止定時器和 取消對 document 的監聽

5)遍歷存放 dom 的數組,找出最後變化節點的時間,用該時間點減去performance.timing.navigationStart 得出首屏的加載時間

監控 SDK

監控 SDK 的作用:數據採集與上報

整體架構

整體架構使用 發佈 - 訂閱 設計模式,這樣設計的好處是便於後續擴展與維護,如果想添加新的hook或事件,在該回調中添加對應的函數即可

SDK 入口

src/index.js

對外導出 init 事件,配置了 vue、react 項目的不同引入方式

vue 項目在 Vue.config.errorHandler 中上報錯誤,react 項目在 ErrorBoundary 中上報錯誤

事件發佈與訂閱

通過添加監聽事件來捕獲錯誤,利用 AOP 切片編程,重寫接口請求、路由監聽等功能,從而獲取對應的數據

src/load.js

用戶行爲收集

core/breadcrumb.js

創建用戶行爲類,stack 用來存儲用戶行爲,當長度超過限制時,最早的一條數據會被覆蓋掉,在上報錯誤時,對應的用戶行爲會添加到該錯誤信息中

數據上報方式

支持圖片打點上報和 fetch 請求上報兩種方式

圖片打點上報的優勢:
1)支持跨域,一般而言,上報域名都不是當前域名,上報的接口請求會構成跨域
2)體積小且不需要插入 dom 中
3)不需要等待服務器返回數據

圖片打點缺點是:url 受瀏覽器長度限制

core/transportData.js

數據上報時機

優先使用 requestIdleCallback,利用瀏覽器空閒時間上報,其次使用微任務上報

監控 SDK,參考了 sentry、 monitor、 mitojs

項目後臺 demo

主要用來演示錯誤還原功能,方式包括:定位源碼、播放錄屏、記錄用戶行爲

後臺 demo 功能介紹:

1、使用 express 開啓靜態服務器,模擬線上環境,用於實現定位源碼的功能

2、server.js 中實現了 reportData(錯誤上報)、getmap(獲取 map 文件)、getRecordScreenId(獲取錄屏信息)、 getErrorList(獲取錯誤列表)的接口

3、用戶可點擊'js 報錯'、'異步報錯'、'promise 錯誤' 按鈕,上報對應的代碼錯誤,後臺實現錯誤還原功能

4、點擊'xhr 請求報錯'、'fetch 請求報錯' 按鈕,上報接口報錯信息

5、點擊 '加載資源報錯' 按鈕,上報對應的資源報錯信息

通過這些異步的捕獲,瞭解監控平臺的整體流程

安裝與使用

npm 官網搜索 web-see[11]

倉庫地址

監控 SDK: web-see[12]

監控後臺: web-see-demo[13]

總結

目前市面上的前端監控方案可謂是百花齊放,但底層原理都是相通的。從基礎的理論知識到實現一個可用的監控平臺,收穫還是挺多的

有興趣的小夥伴可以結合 git 倉庫的源碼玩一玩,再結合本文一起閱讀,幫助加深理解

參考資料

[1]

web-see: https://github.com/xy-sea/web-see

[2]

source-map: https://github.com/mozilla/source-map

[3]

rrweb: https://github.com/rrweb-io/rrweb

[4]

前端白屏的檢測方案,解決你的線上之憂: https://juejin.cn/post/7176206226903007292

[5]

PerformanceObserver: https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceObserver

[6]

web-vitals: https://www.npmjs.com/package/web-vitals

[7]

資源加載瀑布圖: https://blog.csdn.net/csdn_girl/article/details/54911632

[8]

PerformanceResourceTiming: https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType

[9]

clearResourceTimings: https://link.zhihu.com/?target=https%3A//developer.mozilla.org/zh-CN/docs/Web/API/Performance/clearResourceTimings

[10]

long task: https://developer.mozilla.org/zh-CN/docs/Web/API/Long_Tasks_API

[11]

web-see: https://www.npmjs.com/package/web-see

[12]

web-see: https://github.com/xy-sea/web-see

[13]

web-see-demo: https://github.com/xy-sea/web-see-demo

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-JPKDaZTmJQUxtfFs5jsZQ