前端監控系列4 | SDK 體積與效能優化實踐

語言: CN / TW / HK

背景

位元組各類業務擁有眾多使用者群,作為位元組前端效能監控 SDK,自身若存在效能問題,則會影響到數以億計的真實使用者的體驗,所以此類 SDK 自身的效能在設計之初,就必須達到一個非常極致的水準。

與此同時,隨著業務不斷迭代,功能變得越來越多,對監控的需求也會變得越來越多。例如,今天 A 業務更新了架構,想要自定義效能指標的獲取規則,明天 B 業務接入了微前端框架,需要監控子應用的效能。在解決這些業務需求的同時,我們會不斷加入額外的判斷邏輯、配置項。同時由於使用者的電腦效能、瀏覽器環境的不同,我們又要解決各種相容性問題,加入 polyfill 等程式碼,不可避免地造成 SDK 體積膨脹,效能劣化。那麼我們是如何在需求和功能不斷迭代的情況下,持續追蹤和優化 SDK 的體積和效能的呢?

SDK 體積優化

通常而言,體積的優化是最容易拿到收益的一項。

由於監控 SDK 通常作為第一個指令碼被載入到頁面中,體積的膨脹不僅會增加使用者的下載時間,還會增加瀏覽器解析指令碼的時間。對於體積優化,我們可以從巨集觀和微觀兩個角度去實現。

微觀上,我們會去儘可能去精簡所有的表達,剝離冗餘重複程式碼,同時儘可能減少以下寫法的出現:

  1. 過多的 class 和過長的屬性方法名

Class 的定義會被轉換成 function 宣告 + prototype 賦值,以及常用程式碼壓縮工具無法對 object 屬性名壓縮,過多的面向物件寫法會讓編譯後的 js 程式碼體積膨脹得非常快。例如下列程式碼:

class ClassWithLongName {     methodWithALongLongName() {} }

經過 ts 轉換後會變成:

var ClassWithLongName = /** @class */ (function () {     function ClassWithLongName() {     }     ClassWithLongName.prototype.methodWithALongLongName = function () { };     return ClassWithLongName; }());

壓縮後代碼為:

var ClassWithLongName=function(){function n(){}return n.prototype.methodWithALongLongName=function(){},n}();

可以看到以上長命名都無法被壓縮。

如果使用函數語言程式設計來代替面向物件程式設計,能夠很好的避免程式碼無法被壓縮的情況:

function functionWithLongName() {   return function MethodWithALongLongName(){} }

經過壓縮後變成:

function n(){return function(){}}

相較於 class 的版本,壓縮後的程式碼減小了50%以上。

  1. 內部函式傳參使用陣列代替物件

原理同上,物件中的欄位名通常不會被程式碼壓縮工具壓縮。同時合理使用 TS named tuple 型別可以保證程式碼可維護性。

function report(event, {optionA, optionB, optionC, optionD}: ObjectType){ }

改為:

function report(event, [optionA, optionB, optionC, optionD]: NamedTupleType){ }

  1. 在不需要判斷 nullable 時,儘可能避免 ?. ?? ??= 等操作符的出現。同理,儘可能避免一些例如 spread 操作符、generator 等新語法,這些語法在編譯成 es5 後通常會引入額外的 polyfill。

TS 會將這些操作符轉換成非常長的程式碼,例如 a?.b會被轉換成:

a === null || a === void 0 ? void 0 : a.b

過多的 nullish 操作符也是程式碼體積增加的一個原因。

當然,以上只列舉了部分體積優化措施,還有更多優化方法要結合具體程式碼而議。對於我們的前端監控 SDK,為了效能和體積是可以犧牲一些開發體驗的,並且由於使用 TS 型別系統,並不會對程式碼維護增加很多負擔。

從巨集觀上,我們應該思考如何減少 SDK 所依賴的模組,減少產物包含的內容,增加產物的“信噪比”,有以下幾個方式:

  1. 拆分檔案

我們可以分離出 SDK 中不是必須提前執行的邏輯,拆分成非同步載入的檔案,僅將必須提前執行的邏輯加入初始指令碼。同時將不同功能拆分成不同檔案,業務按需載入,這樣可以最大程度減少對首屏載入時間的影響。

  1. 儘可能避免 polyfill 的使用

polyfill 會顯著增加產物體積,我們儘可能不使用存在相容性的方法。甚至在不需要相容低端瀏覽器環境時,我們可以不使用 polyfill。

  1. 減少重複的常量字串的出現次數

對於多次重複出現的常量字串,提取成公共變數。例如

a.addEventListener('load', cb) b.addEventListener('load', cb) c.addEventListener('load', cb)

我們可以將 addEventListener和 load 提取公共變數:

let ADD_EVENT_LISTENER = 'addEventLister' let LOAD = 'load' a[ADD_EVENT_LISTENER](LOAD, cb) b[ADD_EVENT_LISTENER](LOAD, cb) c[ADD_EVENT_LISTENER](LOAD, cb)

此段程式碼壓縮後會變成:

let d="addEventLister",e="load";a[d](e,cb),b[d](e,cb),c[d](e,cb);

我們還可以使用 TSTransformer 或者 babel plugin 來幫我們自動地完成上述過程。

值得注意的是,這個方法在 web 端並不能取得很好的收益,因為瀏覽器在傳輸資料時會做 gzip 壓縮,已經將重複資訊用最高效的演算法壓縮了,我們做的並不會比 gzip 更好。但是在需要嵌入移動端 app 的監控 SDK 來說,這一做法能減少約 10 ~ 15% 產物體積。

除了體積優化以外,隨著需求不斷增加,功能不斷完善,不可避免的會影響到 SDK 的效能。接下來,我們介紹如何測量並優化 SDK 的效能。

使用工具進行效能衡量

通常來說,監控類 SDK 最有可能影響效能的地方為:

  1. 監控初始化時執行各類監聽的過程。
  1. 監控事件上報請求對業務的影響。
  1. SDK 維護資料快取時的記憶體使用情況。

接下來,我們著重從以上幾個維度來衡量並優化 SDK 的效能。

效能衡量過程

使用 Benchmark 效能衡量工具的目的便是為了知道 SDK 執行過程中每一個函式執行的耗時,給業務帶來多大的影響,是否會引起 longtask。由於我們的監控 SDK 包含了效能、請求、資源等各類前端監控能力,這些功能的實現依賴對頁面各類事件的監聽、效能指標的獲取、請求物件的包裝。除此之外,SDK還提供給使用者(開發者)呼叫的方法,例如配置頁面資訊、自定義埋點、更改監控行為等能力。根據 SDK 以上行為和能力,我們將測試分為兩個模組:

  1. 接入 SDK 後自動執行的各類監控,這些行為大部分會在頁面載入之初執行,若此部分效能劣化,會嚴重影響到所有前端業務使用者的首屏載入。
  1. 使用者端(開發者)呼叫的方法,我們會將此類方法包裝成 client 物件以 npm 包的形式給開發者呼叫,這部分方法的執行由使用者控制,可能存在頻繁呼叫的情況,因此也應避免耗時過長的調用出現。

在過往文章前端監控系列1| 位元組的前端監控 SDK 是怎樣設計的中我們講到,我們的 SDK 在設計時已經做到的儘可能的解耦,各個模組各司其職,這一特點非常便於我們針對各個模組方法進行單獨的效能衡量。

下面我們以使用 benny (https://github.com/caderek/benny)  這一開源工具為例,展示一段方便理解 benchmark 過程的虛擬碼,僅作參考:

benny 是一個非常簡單易用的 benchmark 工具,通過 suite 方法建立測試用例組合,通過add方法新增需要測試的函式,cycle方法用於多次迴圈執行測試用例,complete用於新增測試完成之後的回撥函式。更多詳細的使用說明可以查閱官方文件。

``` const { suite, add, cycle, complete, save } = require('benny') // 衡量 SDK 各類監控初始化執行效能 suite(   'collectors setup',   add('route', () => route(context)),   add('exception', () => exception(context)),   add('ajax', () => ajax(context)),   add('FCP', getFCP),   add('LCP', getLCP),   add('longtask', getLongtask),   cycle(),   complete(), )

// 衡量 Client 例項方法耗時 suite(   'npm client',   add('set config', () => client.config({pid})),   add('set context', () => client.context.set({ something })),   add('send custom pv', () => client.sendPageView(pid)),   add('send custom event', () => client.sendCustom(ev)),   // ...    cycle(),   complete(), ) ```

通常這類 benchmark 工具都是在 Node 上執行的,但是我們的 SDK 是個前端監控 SDK,依賴了非常多的瀏覽器環境物件,我們幾乎不可能在 Node 環境去創造或模擬這些物件,我們有沒有辦法在瀏覽器裡去執行這段指令碼,做效能自動化測試呢?

利用 Puppeteer 在瀏覽器環境中執行 Benchmark

由於我們的前端監控依賴瀏覽器環境,我們可以將上述 benchmark 測試程式碼打包成 commonjs 之後放入 headless chrome 瀏覽器中執行,並通過 puppeteer 收集執行結果。

Puppeteer 是一個 Node 模組,提供了通過 Devtool Protocol 控制 Chrome 或者 Chromium 的能力。Puppeteer 預設執行 Chrome 的無頭版本,也可以通過設定執行 Chrome 使用者介面版。

下面是一段方便理解操作 puppeteer 過程的虛擬碼,僅作參考,實際情況較為複雜,需要等待未完成的非同步請求等:

``` const browser = await puppeteer.launch() const page = await browser.newPage() const cdp = await page.target().createCDPSession()

// 用於 benchmark 指令碼和 puppeteer 之間的通訊,用以收集結果 await page.evaluate(() => (window.benchmarks = [])) // 將 pushResult 方法暴露給瀏覽器,來將結果收集到 node 端 await page.exposeFunction(     'pushResult',     (result: any) => benchmark.results.push(result) )

await cdp.send('Profiler.enable') await cdp.send('Profiler.start')

// 開始執行 benchmark await page.addScriptTag({   content: file.toString(), })

await Promise.race([timeout, allBenchmarksDone()])

// profile 可用於繪製火焰圖 const { profile } = await cdp.send('Profiler.stop') await page.close() ```

通過執行以上指令碼,我們便可以在無頭瀏覽器中執行我們的效能測試指令碼,在測試指令碼產出結果後新增呼叫 pushResult 方法來收集測試結果。

在實際的 benchmark 測試中,我們發現開啟效能監聽(即執行各個效能監控的 PerformanceObserver.observe 方法)最大耗時達到了21ms,雖然看上去並不久,但若和其他監聽同時執行,加上引入業務程式碼的複雜性和移動端更弱的 CPU 效能,極有可能成為給業務帶來 longtask 的罪魁禍首。效能監控效能成為了瓶頸。

接下來,我們將效能監聽一個個拆分,用同樣的方式單獨測試每一個性能監聽的耗時。在實際的 benchmark 結果中,我們發現 fp、fcp、lcp、cls 監控耗時最大,加在一起超過了10ms,佔了一半以上,是我們之後需要重點優化的地方。

除此之外利用 puppeteer 的能力,我們不僅可以得到 benchmark 的結果,還可以獲取到整個 benchmark 過程的 profile 資料,利用 speedscope (https://github.com/jlfwong/speedscope/blob/main/README-zh_CN.md) 繪製出函式執行過程中的火焰圖:

繪製火焰圖的具體實現不在本文討論範圍內,感興趣的同學可以參考 speedscope 官方文件

此處顯示的時間為該用例執行總耗時(單次耗時*次數)

如何衡量非同步任務效能?

Benny 的 api 是支援非同步測試用例的,測量的是每個非同步函式從開始執行到 resolve 的時間。但通常這並不是我們想要的衡量的資料,因為非同步任務的執行過程中並不是一直佔據著主執行緒。對於一些非同步的定時任務(例如 SDK 的崩潰檢測、卡頓檢測、白屏檢測),將他們拆解為一系列可測的同步任務能更直觀的展示各個階段的效能耗時。

例如我們 SDK 的前端白屏檢測,由一個 mutationObserver 和觸發白屏檢測的函式組成。我們可以單獨對 mutationObserver 的回撥和觸發函式做效能衡量。

這兩個方法已沒有很好的優化方式了。但是根據 benchmark 結果並結合原始碼可以發現,效能監控所有指標項的開啟均為同步執行,每一項指標都會對頁面做事件監聽或者 PerformanceObserver 監聽,且這些原生監聽耗時都在毫秒級。於是我們對效能做了如下優化:

  1. 效能監控邏輯分片執行,將各項效能指標的監聽同步拆為非同步,用 requestIdleCallback (https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback)  做排程並區分優先順序。
  1. 多個性能指標監聽同一事件的公用監聽器,例如 CLS 和 LCP 都需要監聽 onBFCacheRestore,讓他們只做一次 addEventListener。
  1. 可以延遲執行的方法延遲執行,例如在高版本的 Chrome 中 PerformanceObserver 是有 buffer (https://www.w3.org/TR/performance-timeline-2/#dom-performanceobserverinit-buffered)  的,可以直接獲取到呼叫之前的效能指標,這些方法呼叫就可以等待頁面完全載入完成之後執行,從而儘可能減少對業務頁面首屏影響。

通過 Perfsee 的 Lab 結果分析效能問題

以上的 benchmark 流程得到的結果畢竟是一種理想化、單純的方法呼叫的效能情況,然而在實際瀏覽器環境中我們前端監控 SDK 對效能影響有多大呢,對於這一類頁面初始化即載入的 SDK 可以通過 Perfsee (https://perfsee.com/)  的 Lab 功能進行效能衡量。

Perfsee 是一個針對前端 web 應用在整個研發流程中的效能分析平臺。提供效能分析報告、產物分析報告、原始碼分析、競品分析等模組,定位與梳理效能問題,提供專業的優化方案來漸進地優化產品效能。

Lab 模組效能分析的依據是,使用 headless 瀏覽器執行使用者指定的頁面,通過執行時資料的收集,分析併產出關鍵效能指標分數、網路請求資訊、主執行緒 JS/渲染/Longtask 資訊供業務方參考優化。具體使用說明請檢視 perfsee.com (https://perfsee.com/docs/cn/lab/get-started)

注意,本文所展示 Perfsee 功能示例為早期版本,並不與開源版本功能和介面完全一致。

準備基準頁面作為對照組

我們的目的是衡量 SDK 對業務效能造成的影響,便需要找到一個基準頁面作為對比。此處以 React Server Component Demo (https://github.com/reactjs/server-components-demo)  為例作為基準頁面。該應用有以下幾個特點:

  1. 容易搭建,一個命令就能跑起來。
  1. 自身邏輯簡單,效能好,SDK 所造成的影響容易被放大觀察。
  1. SPA 應用,含有非同步載入的邏輯,更容易探測到監控 SDK 對頁面 FCP、LCP 等指標影響。
  1. 無外部網路請求,頁面結果穩定不易波動。

我們修改一下應用的邏輯,能夠通過 url 引數注入監控 sdk 指令碼,把它部署在伺服器上。接著,我們在 perfsee 平臺上配置好基準頁面和注入 SDK 的頁面這兩個 page,並觸發一次效能掃描。

檢視 Lab 效能報告

我們將沒有注入 SDK 的頁面作為空白組 (empty),注入了 SDK 的頁面作為實驗組 (with-sdk)。

首先我們需要配置好空白組和實驗組的 pages 以及 profile,觸發一次 snapshot 之後,我們得到了多份報告,我們可以點選 compare 將空白組和實驗組的資料進行比對。

圖片

在實際的 lab 效能掃描結果中,我們可以看到兩個頁面所有效能指標的對比。我們發現 sdk 的注入在 mobile profile(4倍降頻) 下還是給業務帶來了 fcp 70ms、lcp 90ms、load 200ms 的劣化。

圖片

同時我們還可以觀察到注入了 sdk 之後,fmp 和 lcp 之前的請求僅多了 1 個,這是符合預期的。不過這仍是我們保持觀察的指標之一,因為在一些中低端的環境中,頁面載入完成之前每發出一個請求就可能讓業務更高優先順序的請求被延後,從而引起頁面效能指標的下降。

切換到 Breakdown Tab,我們還可以看到頁面首屏時間線。我們需要重點關注幾個關鍵指標(load、fcp、lcp)之前的執行緒佔用情況,hover 在 load 之前這一黃色色塊上,我們發現 sdk 在 load 之前執行了 30ms,成為了拖慢了業務指標的原因之一。

圖片

此處截圖省略了一些內部資訊,一般情況下,如果需要更多資訊可以藉助 Source 模組來找到引起主執行緒密集計算的程式碼位置。

在這個例子中,這個呼叫未觸發 longtask,並且我們很容易發現這就是 SDK 初始化的邏輯,也是接下來需要優化的地方

問題分析與效能優化

通過上述 benchmark 工具和 perfsee lab 效能分析結果,我們可以看出 SDK 初始化邏輯以及大量的事件監聽確實對業務效能造成了一定影響。

例如上文火焰圖中所示每一個 onBFCacheRestore 都佔用了超過 15ms 的時間,我們在原始碼裡搜尋這個函式,此部分虛擬碼如下:

const onBFCacheRestore = (cb) => {     addEventListener('pageshow', (e) => {         if (e.persisted) cb(e)     }, true) }

BFCache (https://web.dev/bfcache/)  即 back-forward cache,可稱為“往返快取”,可以在使用者使用瀏覽器的“後退”和“前進”按鈕時加快頁面的轉換速度。這個快取不僅儲存頁面資料,還儲存了 DOM 和 JS 的狀態,實際上是將整個頁面都儲存在記憶體裡。如果頁面位於 BFCache 中,那麼再次開啟該頁面就不會觸發 onload 事件。

可以看到,耗時主要由 onBFCacheRestore 和 onHidden 兩個方法中的原生 addEventListener 造成。這些監聽本身都是在毫秒級的,回撥函式也沒有什麼優化空間,從實際場景考慮,這兩處回撥是為了監聽使用者頁面前進和返回的,並非優先順序最高的任務。

我們可以從以下幾個方面降低對業務造成的影響:

1. 監控任務切片執行,區分優先順序

對於監控 SDK 而言,除了必要的監聽以及事件預收集等任務,其他任何任務不應該阻礙到業務程式碼的執行。對於位元組前端監控需求而言,異常和請求監聽為必須前置執行的任務,其他所有事件監聽可以拆分為單獨的任務,所有的取樣、資料運算、上報請求等資料後處理邏輯只在空閒時執行,通過 requestIdleCallback 呼叫。

2. 減少重複監聽次數

多個性能指標監聽同一事件的公用監聽器,例如 CLS 和 LCP 這兩個指標都需要監聽 onBFCacheRestore,讓他們只做一次 addEventListener。

3. 請求數量的優化

我們 SDK 的指令碼是由一個必須最先執行的主指令碼(包含預收集、請求hook、錯誤監聽等邏輯)和多個通過不同配置開啟的非同步外掛指令碼(效能、資源、白屏等)組成,主指令碼的請求無法省略,而外掛指令碼可以通過接入 cdn combo 服務或自行搭建 combo 服務將多個請求合併成一個。

  • 對於事件上報請求,我們在內部維護一個快取,只有當間隔達到一定時間或者累計一定數量之後才會統一上報。在這個場景中,我們又需要考慮兩個問題:
    • 瀏覽器對請求併發量有限制,所以存在網路資源競爭的可能性
    • 瀏覽器在頁面解除安裝時會忽略非同步ajax請求,而同步 ajax 通常在現代瀏覽器中已被禁用

我們可以通過使用 navigator.sendBeacon 方法解決上述問題。

這個方法主要用於滿足統計和診斷程式碼的需要,這些程式碼通常嘗試在解除安裝(unload)文件之前向 Web 伺服器傳送資料。過早的傳送資料可能導致錯過收集資料的機會。然而,對於開發者來說保證在文件解除安裝期間傳送資料一直是一個困難。因為使用者代理通常會忽略在 unload (en-US) 事件處理器中產生的非同步 XMLHttpRequest

經過以上優化後,我們注入優化過後的 SDK 再次跑分。

優化後的 SDK 對業務 FCP、LCP、LOAD 等效能的影響已經降到了最低,已經達到了非常高的效能標準。

瞭解更多

位元組內部眾多業務方使用的前端監控解決方案已同步在火山引擎上,無論是外部企業開發者或個人開發者,均可通過接入該服務提升效能優化的效率。 *瞭解Perfsee 效能分析平臺:https://perfsee.com/docs/cn/*

位元組內部課程來襲,掘金會員免費看 !! 為了幫助大家近距離了解、學習來自位元組跳動工程師的技術知識,拓展技術視野、提升技術實力。掘金聯合位元組內部技術社群 ByteTech 共同推出「位元組內部課」。課程涉及前端、後端、客戶端、大資料、通用等技術方向,揭祕大廠員工技術成長之路,帶你體驗原汁原味位元組課程。 10月28號課程上新日,誠邀學習,獎品不斷~