【前端監控】單頁應用首屏測速

前端監控系列,SDK,服務、存儲 ,會全部總結一遍,寫文不易,希望給最後點個贊鼓勵鼓勵


之前寫過一篇首屏自動化測速,但是這篇文章不是很適用於單頁應用的測速,需要稍作調整

主要是因爲單頁應用生命週期很長,切換頁面其實還是一個頁面,事件 和 變量等數據沒有銷燬的,就會存在一些問題

  • performance 資源保存限制

  • performance 資源重複

  • 事件重複監聽

  • performance 資源響應時間的基準點不同

  • 單頁應用Dom渲染順序…

等等這些問題,後面會一一詳細說明並解決

本文分爲下面2個部分

1、監聽 spa 頁面切換

2、spa 首屏測速

3、代碼倉庫


監聽 spa 頁面切換

我們知道 spa 頁面的切換不會真的向瀏覽器發起請求,頁面也不會重新加載。

所以如果我們想對 spa 完成 pv、首屏測速,我們就只能去監聽切換事件,在事件回調裏面完成上報


1基本原理

我們監聽 spa 切換用到的事件是 onpopstate,當瀏覽器的歷史記錄發生變化的時候在 window 上觸發,如下
window.addEventListener("popstate",()=>{
  // 上報邏輯
})

更具體事件觸發時機

1、點擊瀏覽器的 後退、前進按鈕

2、 JS 中 調用 history.back()、history.forward()、history.go() 方法


2數據上報

監聽 spa 的切換

第一是爲了 pv (page view)上報,spa 雖然是單頁面,但是每次切換都是相當於獨立的頁面,所以需要 pv 上報

第二是爲了spa 首屏的測速。

pv 上報當然是上報頁面鏈接以及一些基本的數據

spa 首屏測速計算比較複雜一些,會放到下面講解


3問題一覽

監聽切換這麼容易就搞定了嗎,當然不是,我們還會面臨下面這些問題


1、是否使用 hashChange 替代 popstate 事件

並不能用 hashChange 替代 popstate 事件,比較的主要有幾點

第一,hashChange 兼容性好一些。

hashChange  支持 IE8 ,popstate 支持IE10。


第二,popstate 支持場景多。

hashChange 只有在  hash 值變化纔會觸發,而 popstate 任意同源 url 下切換都會觸發

比如 test.com/page1 和 test.com/page2 , 這樣 spa 使用 history 模式也不會有問題

第三,popstate 比 hashChange 觸發要快。

因爲我們需要在 事件回調中完成首屏測速,所以需要監聽 DOM 加載,所以事件觸發越快,越能保證DOM 加載監聽動作。

並且在我們實際React應用中,發現 hashChange 竟然比 componentDidMount 觸發還要慢,我滴乖乖,dom 都掛載完了,還監聽個鬼


2、history.pushState 和 history.replaceState 調用不會觸發 popState 

對此,我們只能進行兼容,劫持這兩個方法,並且手動觸發事件,完成和 popState 一樣的事情

具體 pushState 劫持如下

const originPushState = history.pushState;

history.pushState = function (...args) {
  let event = null;
  if (typeof Event === "function") {
    event = new Event("pushState");
  } else {
    // 兼容ie
    event = document.createEvent("HTMLEvents");
    event.initEvent("pushState"false /* 是否冒泡 */true/* 是否可以用 preventDefault() 方法取消事件 */);
  }
  window.dispatchEvent(event);
  return originPushState.apply(this, args);
};

replaceState 也是同樣的寫法。

我們主要在重寫方法裏觸發了自定義的事件 pushState 和 replaceState 

只是爲了監聽到這兩個行爲,而監聽他們也是爲了完成和 popstate 同樣的事情

所以監聽就變成

function report()// 上報邏輯 }
window.addEventListener("popstate",()=>{
  report()
})
window.addEventListener("pushState",()=>{
  report()
})
window.addEventListener("replaceState",()=>{
  report()
})

並且在 IE 中 不支持 Event ,所以需要兼容一下

spa 首屏測速

之前我寫過一篇 首屏測速的文章 首屏自動測速

但是這篇文章不太適用於 spa 的首屏測速,因爲 spa 需要額外處理一些問題,但是思路基本是一致的

不過還是會重複先說一下基本思路,和 spa 下要解決的點


1基本思路

1、監聽DOM的加載,記錄 DOM 渲染的時間,取最大的渲染時間

2、獲取首屏內 img 元素,取所有img 最大的加載時間

3、取 DOM 渲染 和 img 加載 的 最大一方


如下圖


其中使用到的 API 就是

1、使用 MutationObserver 監聽 DOM 加載

2、從 performance.getEntriesByType 獲取到 img 加載完成的時間

基本思路和 之前的 文章是一致的,這裏也是在其基礎上做了優化 和 兼容一下,具體可以先看那篇文章  首屏自動測速


2問題一覽

現在開始說一下針對 spa 做的兼容以及新的優化點

針對spa做的兼容主要有 6點

1、更新資源以及計算的時間基點

2、spa 無法監聽細緻的 DOM 掛載

3、避免多個 mutation 監聽工作

4、過濾重複資源

5、資源存儲有限,可能會被清除

6、spa 公共的加載時間取捨

另外針對 首屏時間計算的一個優化點

1、網絡原因導致 img 加載超過既定3s,從而首屏時間不準確


1、更新資源以及計算的時間基點

在我們記錄DOM 渲染時間的時候,使用 performance.now ,而 它返回的是從頁面開始加載到 當前調用的時間。

如果是獨立的多頁面,計算使用它是沒有問題的。如果是單頁,頁面切換沒有刷新,所有時間都基於頁面開始,那這個時間可就大了去了

比如 從 performance.getEntries 獲取的資源,因爲從頁面加載開始算,所以時間非常大


雖然取 duration 是正常的,但是把 開始加載前那一段時間算進去纔是正確的

在監聽spa 切換的時候,獲取當前時間基點所以我們需要更新時間基點,但是我們不需要對每個資源都減去新的時間基點

只要在最後拿到首屏時間的時候 減去新的時間基點就好了

像這樣

window.addEventListener("popstate", () => {
    const baseTime = performance.now();
    getFirstScreenTime().then((time) => {
        console.log("getFirstScreenTime", time - baseTime);
    });
});


2、spa 無法監聽細緻的 DOM 掛載

如果是服務端渲染的頁面,返回的是完整的 html。瀏覽器爲了儘快渲染頁面,會一邊接收html 信息,一邊解析內容並渲染。不會等到整個 HTML 文檔解析完畢,而是一個漸進的過程。

所以這樣情況下去監聽DOM 掛載,通常我們可以監聽到每個DOM的掛載,所以從這一步就可以拿到 img 元素

但是 spa 的渲染則不同,因爲 spa 爲了性能考慮,都是把所有 dom 構造完畢之後,統一掛載的,所以導致我們無法獲取到具體的每個dom 掛載信息,只能拿到零星幾個包裹元素

這樣就無法獲取到img 元素,所以我們直接從監聽到的dom 中直接查找 img 元素

具體就是通過 getElementsByTags("img")


3、避免多個 mutation 監聽工作

在 spa 切換的時候,我們會開啓 MutationObserver 監聽DOM 的掛載,但是因爲我們會有一個 等待時間,3s 或者5s

如果這個定時器沒有結束的時候,用戶就切換頁面,就會產生一個新的頁面的 MutationObserver 監聽,並且舊的 MutationObserver 還在工作,最後還會進行首屏時間計算上報,但是這個數據是不準確的

所以我們需要在保證 MutationObserver 監聽單例,在 spa 切換的時候,重置 MutationObserver ,結束上一個監聽

具體處理可以看後面貼出的代碼倉庫 Demo


4、過濾資源重複

performance.getEntries 記錄的資源,是從頁面開始的,所以當你一個頁面切走又切回來。

這個頁面的 資源重新加載,會在 performance 中存在兩份相同的資源

所以需要過濾舊的資源

1、從結尾開始找。因爲資源是按加載順序排列的,所以最新的資源在後面,我們可以從結尾開始查找

2、判斷是否 切換後才加載的資源

有兩個過濾條件

  1. responseEnd 小於 popstate 觸發時間

  2. duration < reponseEnd-popstate觸發時間

具體處理可以看後面貼出的代碼倉庫 Demo


5、資源存儲有限,可能會被清除

因爲spa 切換不會刷新,就算切了幾十個頁面, performance 還是存儲了一開始到現在的資源。瀏覽器通常存儲有限,比如 Chrome 只會存儲250個,超過後的新資源可能無法被記錄!

這樣就會導致我們無法獲取到新的 img 加載時間。

所以我們需要監聽資源緩衝區是否滿了,如果滿了,就要清除一下,另外需要自己保存一份

let imgResource = [];
const origin = performance.onresourcetimingbufferfull;
performance.onresourcetimingbufferfull = (...args) => {
  const imgs = performance
    .getEntriesByType("resource")
    .filter((res) => res.initiatorType === "img");
  imgResource = [...imgResource, ...imgs];

  performance.clearResourceTimings();
  return origin.apply(this, args);
};

這裏關於 資源緩衝區的,在 靜態資源上報那一文中,我們有更詳細的說明  靜態資源上報

另外,我們還需要劫持 清除的方法,避免被其他地方清除資源,導致我們無法獲取到信息(比比如在資源上報中,就會清除)

const origin = performance.clearResourceTimings;
performance.clearResourceTimings = function (...args) {
  const imgs = performance
    .getEntriesByType("resource")
    .filter((res) => res.initiatorType === "img");

  imgResource = [...imgResource, ...imgs];

  return origin.apply(this, args);
};

這裏如果是自己清除的,可能存在重複緩存的可能,所以這裏需要用一個標誌位判斷一下

let isClearByMe = false;
performance.onresourcetimingbufferfull = (...args) => {    
  //....緩存img資源
  isClearByMe = true;
  performance.clearResourceTimings();
  isClearByMe = false;
  return origin.apply(this, args);
};

performance.clearResourceTimings = function (...args) {
  if (!isClearByMe) {  
    //....緩存img資源
  }
  return origin.apply(this, args);
};

更具體處理可以看後面貼出的代碼倉庫 Demo

6、spa 公共的加載時間取捨

一個頁面,直接訪問的首屏時間 和 spa 切換訪問的時間 是會差一部分的,差的就是公共時間,比如紅色圈起來的這部分

可以從 performance.timing 獲取到這部分數據,主要獲取 connectEnd 這部分

然後spa 計算得到的首屏時間中,加上這部分就會得到一個比較完善的 首屏時間

但是我認爲這裏並不是強制要加上的

因爲直接訪問和spa切換訪問,本來動作就不一樣,時間基點不一樣,所以沒有公共部分是合理的。

但是實際上還是仍然有這部分需求,想要首屏時間儘量在同一比較線上,所以需要支持自定義,通過一個參數去決定是否需要加上這部分


7、img 加載超過3s,導致首屏計算不準確

在之前的首屏計算中,我們會設置一個定時器,3s 或者5s,爲了保證 img 加載完畢,然後再計算首屏時間。

但是實際應用中,發現網絡差的時候,圖片真的加載很久都不出來,此時我們就獲取不到 img 加載的時間,從而使用了 dom 渲染的時間。

這樣首屏時間就是錯誤的

對此,主要有兩種想法

1、超過3s 或者5s,如果存在 img 元素,但是 img 沒有加載完畢,那麼就認爲首屏時間是 3s 或者 5s。因爲網絡原因的話,再長的時間好像都沒有什麼意義了,畢竟用戶可能早就已經切走了

2、監聽 img 的事件,所有圖片的 onload 或者 onerror 觸發,才最終結算首屏時間。

最好通過參數讓用戶決定選擇哪種實現方式

所以這裏主要說下 第二種的實現。

因爲我們監聽DOM時會額外存一份首屏內的 img 數組

所以最後我們會對 img 分類一下(通過 img 元素上的 complete 屬性)

已經加載完成的從 performance.getEntries 中獲取時間,沒有加載完成的,則監聽 load 和 error

簡單示例代碼如下,只是講解思路而已

const unloadImgs = allImgs.filter((img) => !img.complete);
const loadImgs = allImgs.filter((img) => img.complete);

unloadImgs.forEach((item) => {
  const originLoad = node.onload;
  const originError = node.onerror;
  node.onload = function (...args) {
    // ... performance.now() 拿到加載完成時間
    return originLoad?.apply?.(this, args);
  };
  node.onerror = function (...args) {
    // ... 加載失敗,默認是0
    return originError?.apply?.(this, args);
  };
});
詳細代碼可以看 後面提供的代碼 Demo



代碼倉庫

關於  spa 首屏測速的核心計算Demo,大家可以參考一下

https://gitee.com/hoholove/study-code-snippet/blob/master/LOGGER/spaFirstScreen.js




最後

鑑於本人能力有限,難免會有疏漏錯誤的地方,請大家多多包涵, 如果有任何描述不當的地方,歡迎後臺聯繫本人,領取紅包



本文分享自微信公衆號 - 神仙朱(skying-zhu)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章