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

語言: CN / TW / HK

youdao

項目背景

ydtech

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

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

youdao

發現問題

ydtech

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

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

youdao

問題排查

ydtech

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

解決 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 調用棧下,遞歸響應式的調用棧已經消失不見了:

使用時間分片解決回放文件加載耗時問題

但是對於用户來説,這樣仍然是不可接受的,我們繼續看一下哪裏耗時嚴重:

可以看到問題還是出在 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 動畫就有機會展示了。

youdao

進一步優化

ydtech

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

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

youdao

總結

ydtech

這篇文章中,我們通過 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調度能力

詳情可點擊閲讀原文查看