前端技術分享:頁面效能優化問題覆盤

有道技術團隊發表於2022-02-16

專案背景

在 code_pc 專案中,前端需要使用 rrweb 對老師教學內容進行錄製,學員可以進行錄製回放。為減小錄製檔案體積,當前的錄製策略是先錄製一次全量快照,後續錄製增量快照,錄製階段實際就是通過 MutationObserver 監聽 DOM 元素變化,然後將一個個事件 push 到陣列中。

為了進行持久化儲存,可以將錄製資料壓縮後序列化為 JSON 檔案。老師會將 JSON 檔案放入課件包中,打成壓縮包上傳到教務系統中。學員回放時,前端會先下載壓縮包,通過 JSZip 解壓,取到 JSON 檔案後,反序列化再解壓後,得到原始的錄製資料,再傳入 rrwebPlayer 實現錄製回放。

發現問題

在專案開發階段,測試錄製都不會太長,因此錄製檔案體積不大(在幾百 kb),回放比較流暢。但隨著專案進入測試階段,模擬長時間上課場景的錄製之後,發現錄製檔案變得很大,達到 10-20 M,QA 同學反映開啟學員回放頁面的時候,頁面明顯示卡頓,卡頓時間在 20s 以上,在這段時間內,頁面互動事件沒有任何響應。

頁面效能是影響使用者體驗的主要因素,對於如此長時間的頁面卡頓,使用者顯然是無法接受的。

問題排查

經過組內溝通後得知,可能導致頁面卡頓的主要有兩方面因素:前端解壓 zip 包,和錄製回放檔案載入。同事懷疑主要是 zip 包解壓的問題,同時希望我嘗試將解壓過程放到 worker 執行緒中進行。那麼是否確實如同事所說,前端解壓 zip 包導致頁面卡頓呢?

3.1 解決 Vue 遞迴複雜物件引起的耗時問題

對於頁面卡頓問題,首先想到肯定是執行緒阻塞引起的,這就需要排查哪裡出現長任務。

所謂長任務是指執行耗時在 50ms 以上的任務,大家知道 Chrome 瀏覽器頁面渲染和 V8 引擎用的是一個執行緒,如果 JS 指令碼執行耗時太長,就會阻塞渲染執行緒,進而導致頁面卡頓。

對於 JS 執行耗時分析,這塊大家應該都知道使用 performance 皮膚。在 performance 皮膚中,通過看火焰圖分析 call stack 和執行耗時。火焰圖中每一個方塊的寬度代表執行耗時,方塊疊加的高度代表呼叫棧的深度。

按照這個思路,我們來看下分析的結果:
在這裡插入圖片描述
可以看到,replayRRweb 顯然是一個長任務,耗時接近 18s ,嚴重阻塞了主執行緒。

而 replayRRweb 耗時過長又是因為內部兩個呼叫引起的,分別是左邊淺綠色部分和右邊深綠色部分。我們來看下呼叫棧,看看哪裡哪裡耗時比較嚴重:
在這裡插入圖片描述
熟悉 Vue 原始碼的同學可能已經看出來了,上面這些耗時比較嚴重的方法,都是 Vue 內部遞迴響應式的方法(右邊顯示這些方法來自 vue.runtime.esm.js)。

為什麼這些方法會長時間佔用主執行緒呢?在 Vue 效能優化中有一條:不要將複雜物件丟到 data 裡面,否則會 Vue 會深度遍歷物件中的屬性新增 getter、setter(即使這些資料不需要用於檢視渲染),進而導致效能問題。

那麼在業務程式碼中是否有這樣的問題呢?我們找到了一段非常可疑的程式碼

export default {
  data() {
    return {
      rrWebplayer: null
    }
  },
  mounted() {
    bus.$on("setRrwebEvents", (eventPromise) => {
      eventPromise.then((res) => {
        this.replayRRweb(JSON.parse(res));
      })
    })
  },
  methods: {
    replayRRweb(eventsRes) {
      this.rrWebplayer = new rrwebPlayer({
        target: document.getElementById('replayer'),
        props: {
          events: eventsRes,
          unpackFn: unpack,
          // ...
        }
      })
    }
  }
}

在上面的程式碼中,建立了一個 rrwebPlayer 例項,並賦值給 rrWebplayer 的響應式資料。在建立例項的時候,還接受了一個 eventsRes 陣列,這個陣列非常大,包含幾萬條資料。

這種情況下,如果 Vue 對 rrWebplayer 進行遞迴響應式,想必非常耗時。因此,我們需要將 rrWebplayer 變為 Non-reactive data(避免 Vue 遞迴響應式)。

轉為 Non-reactive data,主要有三種方法

資料沒有預先定義在 data 選項中,而是在元件例項 created 之後再動態定義 this.rrwebPlayer (沒有事先進行依賴收集,不會遞迴響應式);

資料預先定義在 data 選項中,但是後續修改狀態的時候,物件經過 Object.freeze 處理(讓 Vue 忽略該物件的響應式處理);

資料定義在元件例項之外,以模組私有變數形式定義(這種方式要注意記憶體洩漏問題,Vue 不會在元件解除安裝的時候銷燬狀態);

這裡我們使用第三種方法,將 rrWebplayer 改成 Non-reactive data 試一下:

let rrWebplayer = null;export default {
  //...
  methods: {
    replayRRweb(eventsRes) {
      rrWebplayer = new rrwebPlayer({
        target: document.getElementById('replayer'),
        props: {
          events: eventsRes,
          unpackFn: unpack,
          // ...
        }
      })
    }
  }
}

重新載入頁面,可以看到這時候頁面雖然還卡頓,但是卡頓時間明顯縮短到5秒內了。觀察火焰圖可知,replayRRweb 呼叫棧下,遞迴響應式的呼叫棧已經消失不見了:
在這裡插入圖片描述

3.2 使用時間分片解決回放檔案載入耗時問題

但是對於使用者來說,這樣仍然是不可接受的,我們繼續看一下哪裡耗時嚴重:
圖片
可以看到問題還是出在 replayRRweb 這個函式裡面,到底是哪一步呢:
在這裡插入圖片描述
那麼 unpack 耗時的問題怎麼解決呢?

由於 rrweb 錄製回放 需要進行 dom 操作,必須在主執行緒執行,不能使用 worker 執行緒(獲取不到 dom API)。對於主執行緒中的長任務,很容易想到的就是通過 時間分片,將長任務分割成一個個小任務,通過事件迴圈進行任務排程,在主執行緒空閒且當前幀有空閒時間的時候,執行任務,否則就渲染下一幀。方案確定了,下面就是選擇哪個 API 和怎麼分割任務的問題。

這裡有同學可能會提出疑問,為什麼 unpack 過程不能放到 worker 執行緒執行,worker
執行緒中對資料解壓之後返回給主執行緒載入並回放,這樣不就可以實現非阻塞了嗎?

如果仔細想一想,當 worker 執行緒中進行 unpack,主執行緒必須等待,直到資料解壓完成才能進行回放,這跟直接在主執行緒中 unpack
沒有本質區別。worker 執行緒只有在有若干並行任務需要執行的時候,才具有效能優勢。

提到時間分片,很多同學可能都會想到 requestIdleCallback 這個 API。requestIdleCallback 可以在瀏覽器渲染一幀的空閒時間執行任務,從而不阻塞頁面渲染、UI 互動事件等。目的是為了解決當任務需要長時間佔用主程式,導致更高優先順序任務(如動畫或事件任務),無法及時響應,而帶來的頁面丟幀(卡死)情況。因此,requestIdleCallback 的定位是處理不重要且不緊急的任務。

requestIdleCallback 不是每一幀結束都會執行,只有在一幀的 16.6ms
中渲染任務結束且還有剩餘時間,才會執行。這種情況下,下一幀需要在 requestIdleCallback 執行結束才能繼續渲染,所以
requestIdleCallback 每個 Tick 執行不要超過
30ms,如果長時間不將控制權交還給瀏覽器,會影響下一幀的渲染,導致頁面出現卡頓和事件響應不及時。

requestIdleCallback 引數說明:

// 接受回撥任務
type RequestIdleCallback = (cb: (deadline: Deadline) => void, options?: Options) => number
// 回撥函式接受的引數
type Deadline = {
 timeRemaining: () => number // 當前剩餘的可用時間。即該幀剩餘時間。
 didTimeout: boolean // 是否超時。
}

我們可以用 requestIdleCallback 寫個簡單的 demo:

// 一萬個任務,這裡使用 ES2021 數值分隔符
const unit = 10_000;
// 單個任務需要處理如下
const onOneUnit = () => {
    for (let i = 0; i <= 500_000; i++) {}
}
// 每個任務預留執行時間
1msconst FREE_TIME = 1;
// 執行到第幾個任務
let _u = 0;

function cb(deadline) {
// 當任務還沒有被處理完 & 一幀還有的空閒時間 > 1ms
    while (_u < unit && deadline.timeRemaining() >FREE_TIME) {
        onOneUnit();
        _u ++;
    }
    // 任務幹完
    if (_u >= unit) return;
    // 任務沒完成, 繼續等空閒執行
    window.requestIdleCallback(cb)
}

window.requestIdleCallback(cb)

這樣看來 requestIdleCallback 似乎很完美,能否直接用在實際業務場景中呢?答案是不行。我們查閱 MDN 文件就可以發現,requestIdleCallback 還只是一個實驗性 API,瀏覽器相容性一般:
在這裡插入圖片描述
查閱 caniuse 也得到類似的結論,所有 IE 瀏覽器不支援,safari 預設情況下不啟用:
在這裡插入圖片描述
而且還有一個問題,requestIdleCallback 觸發頻率不穩定,受很多因素影響。經過實際測試,FPS 只有 20ms 左右,正常情況下渲染一幀時長控制在16.67ms 。

為了解決上述問題,在 React Fiber 架構中,內部自行實現了一套 requestIdleCallback 機制:

  • 使用 requestAnimationFrame 獲取渲染某一幀的開始時間,進而計算出當前幀到期時間點;
  • 使用 performance.now() 實現微秒級高精度時間戳,用於計算當前幀剩餘時間;
  • 使用 MessageChannel 零延遲巨集任務實現任務排程,如使用 setTimeout() 則有一個最小的時間閾值,一般是 4ms;

按照上述思路,我們可以簡單實現一個 requestIdleCallback 如下:

// 當前幀到期時間點
let deadlineTime;
// 回撥任務
let callback;
// 使用巨集任務進行任務排程
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;
// 接收並執行巨集任務
port2.onmessage = () => {
  // 判斷當前幀是否還有空閒,即返回的是剩下的時間
  const timeRemaining = () => deadlineTime - performance.now();
  const _timeRemain = timeRemaining();
  // 有空閒時間 且 有回撥任務
  if (_timeRemain > 0 && callback) {
    const deadline = {
      timeRemaining,
      didTimeout: _timeRemain < 0,
    };
    // 執行回撥
    callback(deadline);
  }
};
window.requestIdleCallback = function (cb) {
  requestAnimationFrame((rafTime) => {
    // 結束時間點 = 開始時間點 + 一幀用時16.667ms
    deadlineTime = rafTime + 16.667;
    // 儲存任務
    callback = cb;
    // 傳送個巨集任務
    port1.postMessage(null);
  });
};

在專案中,考慮到 api fallback 方案、以及支援取消任務功能(上面的程式碼比較簡單,僅僅只有新增任務功能,無法取消任務),最終選用 React 官方原始碼實現。

那麼 API 的問題解決了,剩下就是怎麼分割任務的問題。

查閱 rrweb 文件得知,rrWebplayer 例項上提供一個 addEvent 方法,用於動態新增回放資料,可用於實時直播等場景。按照這個思路,我們可以將錄製回放資料進行分片,分多次呼叫 addEvent 新增。

import {
  requestHostCallback, cancelHostCallback,
}
 from "@/utils/SchedulerHostConfig";
export default {
  // ...
  methods: {
    replayRRweb(eventsRes = []) {
      const PACKAGE_SIZE = 100;
      // 分片大小
      const LEN = eventsRes.length;
      // 錄製回放資料總條數
      const SLICE_NUM = Math.ceil(LEN / PACKAGE_SIZE);
      // 分片數量
      rrWebplayer = new rrwebPlayer({
        target: document.getElementById("replayer"),
        props: {
          // 預載入分片
          events: eventsRes.slice(0, PACKAGE_SIZE),
          unpackFn: unpack,
        },
      });
      // 如有任務先取消之前的任務
      cancelHostCallback();
      const cb = () => {
        // 執行到第幾個任務
        let _u = 1;
        return () => {
          // 每一次執行的任務
          // 注意陣列的 forEach 沒辦法從中間某個位置開始遍歷
          for (let j = _u * PACKAGE_SIZE; j < (_u + 1) * PACKAGE_SIZE; j++) {
            if (j >= LEN) break;
            rrWebplayer.addEvent(eventsRes[j]);
          }
          _u++;
          // 返回任務是否完成
          return _u < SLICE_NUM;
        };
      };
      requestHostCallback(cb(), () => {
        // 載入完畢回撥
      });
    },
  },
};

注意最後載入完畢回撥,原始碼中不提供這個功能,是本人自行修改原始碼加上的。

按照上面的方案,我們重新載入學員回放頁面看看,現在已經基本察覺不到卡頓了。我們找一個 20M 大檔案載入,觀察下火焰圖可知,錄製檔案載入任務已經被分割為一條條很細的小任務,每個任務執行的時間在 10-20ms 左右,已經不會明顯阻塞主執行緒了:
在這裡插入圖片描述
優化後,頁面仍有卡頓,這是因為我們拆分任務的粒度是 100 條,這種情況下載入錄製回放仍有壓力,我們觀察 fps 只有十幾,會有卡頓感。我們繼續將粒度調整到 10 條,這時候頁面載入明顯流暢了,基本上 fps 能達到 50 以上,但錄製回放載入的總時間略微變長了。使用時間分片方式可以避免頁面卡死,但是錄製回放的載入平均還需要幾秒鐘時間,部分大檔案可能需要十秒左右,我們在這種耗時任務處理的時候加一個 loading 效果,以防使用者在錄製檔案載入完成之前就開始播放。

有同學可能會問,既然都加 loading 了,為什麼還要時間分片呢?假如不進行時間分片,由於 JS 指令碼一直佔用主執行緒,阻塞 UI 執行緒,這個 loading 動畫是不會展示的,只有通過時間分片的方式,把主執行緒讓出來,才能讓一些優先順序更高的任務(例如 UI 渲染、頁面互動事件)執行,這樣 loading 動畫就有機會展示了。

進一步優化

使用時間分片並不是沒有缺點,正如上面提到的,錄製回放載入的總時間略微變長了。但是好在 10-20M 錄製檔案只出現在測試場景中,老師實際上課錄製的檔案都在 10M 以下,經過測試錄製回放可以在 2s 左右就載入完畢,學員不會等待很久。

假如後續錄製檔案很大,需要怎麼優化呢?之前提到的 unpack 過程,我們沒有放到 worker 執行緒執行,這是因為考慮到放在 worker 執行緒,主執行緒還得等待 worker 執行緒執行完畢,跟放在主執行緒執行沒有區別。但是受到時間分片啟發,我們可以將 unpack 的任務也進行分片處理,然後根據 navigator.hardwareConcurrency 這個 API,開啟多執行緒(執行緒數等於使用者 CPU 邏輯核心數),以並行的方式執行 unpack ,由於利用多核 CPU 效能,應該能夠顯著提升錄製檔案載入速率。

總結

這篇文章中,我們通過 performance 皮膚的火焰圖分析了呼叫棧和執行耗時,進而排查出兩個引起效能問題的因素:Vue 複雜物件遞迴響應式,和錄製回放檔案載入。

對於 Vue 複雜物件遞迴響應式引起的耗時問題,本文提出的解決方案是,將該物件轉為非響應式資料。對於錄製回放檔案載入引起的耗時問題,本文提出的方案是使用時間分片。

由於 requestIdleCallback API 的相容性及觸發頻率不穩定問題,本文參考了 React 17 原始碼分析瞭如何實現 requestIdleCallback 排程,並最終採用 React 原始碼實現了時間分片。經過實際測試,優化前頁面卡頓 20s 左右,優化後已經察覺不到卡頓,fps 能達到 50 以上。但是使用時間分片之後,錄製檔案載入時間略微變長了。後續的優化方向是將 unpack 過程進行分片,開啟多執行緒,以並行方式執行 unpack,充分利用多核 CPU 效能。

參考

· vue-9-perf-secrets

· React Fiber很難?六個問題助你理解

· requestIdleCallback - MDN

· requestIdleCallback - caniuse

· 實現React requestIdleCallback排程能力

詳情可點選這裡檢視

相關文章