如何從 0 到 1 搭建性能檢測系統(tǒng)
本文首發(fā)于政采云前端團隊博客:如何從?0?到 1 搭建性能檢測系統(tǒng)
https://www.zoo.team/article/performance-testing-system

前言
前端頁面性能對用戶留存、用戶直觀體驗有著重要影響,當(dāng)頁面加載時間超過 2 秒后,加載時間每增加一秒,就會有大量的用戶流失,所以做好頁面性能優(yōu)化,無疑對網(wǎng)站來說是一個非常重要的步驟。
那如何才能知道一個頁面的性能情況呢?知道了頁面性能情況后又如何進行優(yōu)化呢?一個頁面的性能指標(biāo)非常多,面對一大堆性能指標(biāo),可能一個老手也一時間不知道從何開始分析。而且不同團隊,負(fù)責(zé)的業(yè)務(wù)不同,性能分析的指標(biāo)也不能夠一概而論。打個比方說,對于一般的電商網(wǎng)站,一定會有很多圖片,那圖片加載的性能提升對網(wǎng)站的性能提升作用就比較大。而對于一些由表單組成的中臺頁面,提升圖片加載速度的收益遠(yuǎn)小于電商網(wǎng)站。
總結(jié)來說,不同的團隊有著各自不同的業(yè)務(wù),業(yè)務(wù)之間千差萬別,性能指標(biāo)也不能一概而論,所以用一套統(tǒng)一的檢測模型覆蓋所有場景是不現(xiàn)實的。本文將介紹如何定制一個屬于自己團隊的性能檢測平臺。
先看下政采云的性能檢測平臺——百策

在聊性能指標(biāo)之前,先講一下 Lighthouse。
Lighthouse
Lighthouse 是一個開源的自動化工具,用于分析和改善 Web 應(yīng)用的質(zhì)量。運行 Lighthouse 共有 4 種方式,分別在 Chrome 開發(fā)者工具,Chrome 擴展程序,Node CLI 和 Node module。百策主要基于 Node module 方式,在其基礎(chǔ)上進行擴展開發(fā),Lighthouse 詳細(xì)使用參見 Git:https://github.com/GoogleChrome/lighthouse
下圖為 Lighthouse 檢測頁面性能的一個最終結(jié)果,可以看到其實指標(biāo)已經(jīng)比較完善了。

可能有人會問,為什么不直接使用 Lighthouse。首先,由于不可描述的原因,國內(nèi)直接使用 Chrome 開發(fā)者工具中的 Lighthouse 時,會一直處于 Lighthouse is warming up 狀態(tài)。其次,Chrome 擴展程序?qū)τ谛枰卿浀捻撁嬉膊恢С?。最后,對于前言中,某一些定制需?Lighthouse 也不能全然滿足,所以要基于 Lighthouse 進行定制,做一個滿足業(yè)務(wù)要求的性能檢測平臺。
整體設(shè)計架構(gòu)
下圖是百策系統(tǒng)的一個整體架構(gòu)
前端主要使用的是 Antd 和 Antd Charts,包含常規(guī)頁面的展示和部分性能走勢圖表的展示。 服務(wù)端基于 nestjs 開發(fā),接入 Sentry 做報警監(jiān)控。helmet 用于保護系統(tǒng)免受一些眾所周知的 Web 漏洞影響。 node-schedule 用于每周定時計算已統(tǒng)計入系統(tǒng)的頁面性能,并通過 nodemailer 發(fā)送郵件。 Compression 主要用于啟用 gzip。 最主要的檢測服務(wù)基于 Puppeteer 和 Lighthouse 開發(fā)。

百策采集頁面性能數(shù)據(jù)的流程
百策系統(tǒng)監(jiān)控頁面的方式主要采用的方式是合成監(jiān)控,對于什么是合成監(jiān)控,可以參考此文章:螞蟻金服如何把前端性能監(jiān)控做到極致 (https://www.infoq.cn/article/Dxa8aM44oz*Lukk5Ufhy)。總結(jié)來說,合成監(jiān)控的優(yōu)勢就是:能夠采集的數(shù)據(jù)更豐富,并且可以根據(jù)不同的場景定制不同的運行環(huán)境等。首先百策要根據(jù)不同的場景,比如政采云前臺頁面、政采云中臺頁面制定不同的檢測模型。其次百策的主要目標(biāo)是提升頁面性能,并且需要保證環(huán)境和硬件條件一致的情況下對頁面做性能比對,所以選擇采用合成監(jiān)控更加適合。
先看下 Chrome Lighthouse 的架構(gòu)圖(圖來源于 Lighthouse Git),主要基于 4 個主要步驟實現(xiàn),分別是交互驅(qū)動,收集,審計以及記錄組成,參考了 Chrome Lighthouse,百策的檢測模型邏輯也主要由這 4 步組成:
1、頁面交互后,發(fā)起請求調(diào)用服務(wù)。
2、遍歷當(dāng)前頁面所需要的收集器,合并為一個總的收集器,并采集數(shù)據(jù)。
3、將第二步采集到的數(shù)據(jù)做性能計算和評分。
4、將性能檢測結(jié)果存入數(shù)據(jù)庫。

百策采集頁面性能數(shù)據(jù)的實現(xiàn)方案
百策實現(xiàn)頁面性能數(shù)據(jù)采集的方案主要依靠無頭瀏覽器 Puppeteer 結(jié)合 Lighthouse,Puppeteer 是 Chrome 團隊提供的一個無界面 Chrome 工具,人稱無頭瀏覽器,通過 API 來控制 Node 端的 Chrome。百策的主要邏輯是在服務(wù)端起一個無需顯示的 Chrome,通過 Lighthouse 的 API 新建一個標(biāo)簽頁并打開,Lighthouse 會計算具體的性能指標(biāo),具體的檢測邏輯可以參考下圖。接下來我會用關(guān)鍵代碼說明如何實現(xiàn)其中的關(guān)鍵步驟。

○ 開始入口
以下是百策價值 1 個億的代碼,主要流程如下,鉤子函數(shù)是用于在頁面打開的不同時間獲取性能數(shù)據(jù)
/**
??*?執(zhí)行頁面信息收集
??*
??*?@param?{PassContext}?passContext
??*/
async?run(runOptions:?RunOptions)?{
??const?gathererResults?=?{};
??//?使用?Puppeteer?創(chuàng)建無頭瀏覽器,創(chuàng)建頁面
??const?passContext?=?await?this.prepare(runOptions);
??try?{
????//?根據(jù)用戶是否輸入了用戶名和密碼判斷是否要登錄政采云
????await?this.preLogin(passContext);
????????//?頁面打開前的鉤子函數(shù)
????await?this.beforePass(passContext);
????????//?打開頁面,獲取頁面數(shù)據(jù)
????await?this.getLhr(passContext);
????????//?頁面打開后的鉤子函數(shù)
????await?this.afterPass(passContext,?gathererResults);
????????//?收集頁面性能
????return?await?this.collectArtifact(passContext,?gathererResults);
??}?catch?(error)?{
????throw?error;
??}?finally?{
????//?關(guān)閉頁面和無頭瀏覽器
????await?this.disposeDriver(passContext);
??}
}
○ 創(chuàng)建無頭瀏覽器
創(chuàng)建無頭瀏覽器和頁面,并指定瀏覽器對應(yīng)的寬高,指定運行的參數(shù),關(guān)于瀏覽器的參數(shù)可以參考如下文章:Puppeteer API (https://zhaoqize.github.io/puppeteer-api-zh_CN/#?product=Puppeteer&version=v5.3.0&show=api-puppeteerlaunchoptions)??梢詫?headless 設(shè)置為 false 看到瀏覽器的創(chuàng)建和 page 的新建,本地調(diào)試可以使用。
/**
??*?登錄前準(zhǔn)備工作,創(chuàng)建瀏覽器和頁面
??*
??*?@param?{RunOptions}?runOptions
??*/
async?prepare(runOptions:?RunOptions)?{
??//?puppeteer?啟動的配置項
??const?launchOptions:?puppeteer.LaunchOptions?=?{
????headless:?true,?//?是否無頭模式
????defaultViewport:?{?width:?1440,?height:?960?},?//?指定打開頁面的寬高
????//?瀏覽器實例的參數(shù)配置,具體配置可以參考此鏈接:https://peter.sh/experiments/chromium-command-line-switches/
????args:?['--no-sandbox',?'--disable-dev-shm-usage'],
????executablePath:?'/usr/bin/chromium-browser',?//?默認(rèn)?Chromium?執(zhí)行的路徑,此路徑指的是服務(wù)器上?Chromium?安裝的位置
??};
??//?服務(wù)器上運行時使用服務(wù)器上獨立安裝的?Chromium
??//?本地運行的時候使用?node_modules?中的?Chromium
??if?(process.env.NODE_ENV?===?'development')?{
????delete?launchOptions.executablePath;
??}
??//?創(chuàng)建瀏覽器對象
??const?browser?=?await?puppeteer.launch(launchOptions);
??//?獲取瀏覽器對象的默認(rèn)第一個標(biāo)簽頁
??const?page?=?(await?browser.pages())[0];
??//?返回瀏覽器和頁面對象
??return?{?browser,?page?};
}
○ 模擬登錄
模擬登錄的場景可以參考另一篇,自動化 Web 性能分析之 Puppeteer 爬蟲實踐中的第四節(jié),大致的實現(xiàn)邏輯如下:通過無頭瀏覽器打開政采云登錄頁,通過 Puppeteer API 模擬輸入用戶名密碼,并模擬點擊登錄按鈕。根據(jù)同一瀏覽器下相同的域名共享 Cookie 的特性,再新開標(biāo)簽頁打開需要檢測的 URL,便可以開始性能檢測。
○ 打開頁面
如何在 Puppeteer 中使用 Lighthouse 可以參考 Using Puppeteer with Lighthouse (https://github.com/GoogleChrome/lighthouse/blob/master/docs/puppeteer.md)。下面的代碼主要檢測的是桌面端 Web 頁面的性能,后續(xù)會放開更改檢測環(huán)境的功能:可以根據(jù)政采云域名來判斷頁面是手機端還是電腦端,根據(jù)不同的系統(tǒng)環(huán)境,切換不同的瀏覽器參數(shù)。
/**
??*?在?Puppeteer?中使用?Lighthouse
??*
??*?@param?{RunOptions}?runOptions
??*/
async?getLhr(passContext:?PassContext)?{
??//?獲取瀏覽器對象和檢測鏈接
??const?{?browser,?url?}?=?passContext;
??//?開始檢測
??const?{?artifacts,?lhr?}?=?await?lighthouse(url,?{
????port:?new?URL(browser.wsEndpoint()).port,
????output:?'json',
????logLevel:?'info',
????emulatedFormFactor:?'desktop',
????throttling:?{
??????rttMs:?40,
??????throughputKbps:?10?*?1024,
??????cpuSlowdownMultiplier:?1,
??????requestLatencyMs:?0,?//?0?means?unset
??????downloadThroughputKbps:?0,
??????uploadThroughputKbps:?0,
????},
????disableDeviceEmulation:?true,
????onlyCategories:?['performance'],?//?是否只檢測?performance
????//?chromeFlags:?['--disable-mobile-emulation',?'--disable-storage-reset'],
??});
??//?回填數(shù)據(jù)
??passContext.lhr?=?lhr;
??passContext.artifacts?=?artifacts;
}
○ 鉤子函數(shù)
鉤子函數(shù)實際是一個抽象類,在運行不同的 Gathering 時,對應(yīng)的 Class 會實現(xiàn)該抽象類。鉤子函數(shù)的主要功能在于不同時期注冊回調(diào),主要有 2 個鉤子函數(shù),beforePass 和 afterPass。beforePass 的作用主要是在頁面還沒加載前先注冊一些監(jiān)聽器,比如說想在頁面 load 之后,就拿到 DOM 節(jié)點的深度,那就需要在 beforePass 中注冊監(jiān)聽。afterPass 主要是頁面性能統(tǒng)計完成之后,返回結(jié)構(gòu)化的數(shù)據(jù)。
/**
??*?執(zhí)行所有收集器中的?afterPass?方法
??*
??*?@param?{PassContext}?passContext
??*?@param?{GathererResults}?gathererResults
??*/
async?afterPass(passContext:?PassContext,?gathererResults:?GathererResults)?{
??const?{?page,?gatherers?}?=?passContext;
??//?遍歷所有收集器,執(zhí)行?afterPass?方法
??for?(const?gatherer?of?gatherers)?{
????const?gathererResult?=?await?gatherer.afterPass(passContext);
????gathererResults[gatherer.name]?=?gathererResult;
??}
??//?執(zhí)行完所有方法后截圖記錄
??gathererResults.screenshotBuffer?=?await?page.screenshot();
}
○ 收集器的實現(xiàn)
百策總共有 6 個收集器,分別是 Domstats Gathering,Image Elements Gathering,Lighthouse Gathering,Metrics Gathering, Network Recorder Gathering 和 Performance Gathering。
每個收集器都會實現(xiàn)特定的收集功能:
Domstats Gathering:收集 DOM 相關(guān)的數(shù)據(jù),比如 DOM 元素數(shù)量,DOM 最大深度,document 是否有滾動條等。 Image Elements Gathering:收集所有的圖片,并記錄下圖片的寬高,定位等屬性。 Lighthouse Gathering:收集 Lighthouse 相關(guān)的指標(biāo):比如 FCP、LCP、TBT、CLS 等等。 Metrics Gathering:收集 JS 事件監(jiān)聽數(shù)量,JS 堆棧大小等。 Network Recorder Gathering:收集所有頁面請求,包括狀態(tài)碼,請求方式,請求頭,響應(yīng)頭等。 Performance Gathering:主要記錄了 window.performance 下的一些數(shù)據(jù),用于計算一些時間。
以 Domstats Gathering 做為例子,詳細(xì)說明如何獲取頁面檢測數(shù)據(jù)。首先實現(xiàn)抽象類的 2 個方法:beforePass 和 afterPass。beforePass 的實現(xiàn)邏輯是對 page 對象添加 domcontentloaded 時間點的監(jiān)聽方法,監(jiān)聽方法的主要功能是判斷 document 是否有橫向滾動條。afterPass 方法主要是獲取 Lighthouse lhr 中的數(shù)據(jù),分析并得到 DOM 最大深度,DOM 節(jié)點數(shù)等。
import?{?Gatherer?}?from?'./gatherer';
import?{?PassContext?}?from?'../interfaces/pass-context.interface';
//?實現(xiàn)?Gatherer?抽象類
export?default?class?DOMStats?extends?Gatherer?{
??horizontalScrollBar;
??/**
??*?頁面打開前的鉤子函數(shù)
??*
??*?@param?{PassContext}?passContext
??*/
??async?beforePass(passContext:?PassContext)?{
????const?{?browser?}?=?passContext;
????//?當(dāng)瀏覽器的對象發(fā)生變化的時候,說明新打開頁面了,此時可以獲取到標(biāo)簽頁?page?對象
????browser.on('targetchanged',?async?target?=>?{
??????const?page?=?await?target.page();
??????//?等待?dom?文檔加載完成的時候
??????page.on('domcontentloaded',?async?()?=>?{
????????//?通過?evaluate?方法可以獲取到頁面上的元素和方法
????????this.horizontalScrollBar?=?await?page.evaluate(()?=>?{
??????????return?document.body.scrollWidth?>?document.body.clientWidth;
????????});
??????});
????});
??}
??/**
??*?頁面執(zhí)行結(jié)束后的鉤子函數(shù)
??*
??*?@param?{PassContext}?passContext
??*/
??async?afterPass(passContext:?PassContext)?{
????const?{?artifacts?}?=?passContext;
????????//?從?lighthouse?結(jié)果對象?lhr?中獲取?dom?節(jié)點的?depth,width?和?totalBodyElements
????const?{
??????DOMStats:?{?depth,?width,?totalBodyElements?},
????}?=?artifacts;
????return?{
??????numElements:?totalBodyElements,
??????maxDepth:?depth.max,
??????maxWidth:?width.max,
??????hasHorizontalScrollBar:?!!this.horizontalScrollBar,
????};
??}
}
等待所有 Gathering 都執(zhí)行完成之后,數(shù)據(jù)就可以落庫了。
○ 根據(jù)模型計算得分
數(shù)據(jù)入庫后還要根據(jù)不同的模型計算不同的得分。前臺頁面重展示,并且圖片加載會比較多,中臺頁面重表單提交,所以不同的模型一定有不同的計算邏輯。在政采云,前臺頁面我們使用的框架是 Vue, 中臺頁面使用的是 React(部分頁面由于歷史原因用的還是 jQuery)。所以大致可以根據(jù)框架來區(qū)分模型。判斷框架是 Vue 還是 React 可以根據(jù) DOM 是否包含 _reactRootContainer 和 __vue__ 來判斷。
/**
??*?計算得分方法,根據(jù)模型上的得分配置項最終生成得分并入庫
??*
??*?@param?{Artifact}?artifact
??*?@param?{string[]}?whitelist
??*/
async?calc(artifact:?Artifact,?whitelist?:?string[]):?Promise?{
??//?根據(jù)每條?metaid?動態(tài)加載不同的計算方法文件,每個?metaid?指的就是一個性能評分指標(biāo),比如說是否有橫向滾動條
??const?audit?=?await?import(`../audits/${this.meta.id}`).then(m?=>?m.default);
????//?執(zhí)行每個計算方法文件中的?audit?方法,計算得分,比如沒有橫向滾動條的時候得5分,有橫向滾動條不得分
??const?{?rawValue,?score,?displayValue,?details?=?[]?}?=?audit.audit(artifact,?whitelist);
??const?auditDto?=?new?AuditDto();
??auditDto.id?=?this.meta.id;
????//?檢測指標(biāo)名稱展示
??auditDto.title?=?this.meta.title;
????//?檢測指標(biāo)描述
??auditDto.description?=?this.meta.description;
????//?檢測指標(biāo)詳情
??auditDto.details?=?details;
????//?檢測指標(biāo)登記,判斷是否計算入得分
??auditDto.level?=?this.level;
??//?扣分上限根據(jù)不同的?meta,可能上限也有不同,upperLimitScore?指的是扣分上限,從數(shù)據(jù)庫獲取
??auditDto.score?=?score?*?this.weight?<=?-this.upperLimitScore???-this.upperLimitScore?:?score?*?this.weight;
????//?得分情況
??auditDto.rawValue?=?rawValue;
????//?得分如何展示
??auditDto.displayValue?=?displayValue;
??return?auditDto;
}
以下是政采云前臺模型,每一項都是一個檢測指標(biāo),告警項只做提示,不實際扣分,前臺主要以圖片加載和展示為準(zhǔn),所以模型設(shè)計上,會更加側(cè)重頁面加載時間的關(guān)鍵指標(biāo),并且會著重考慮圖片的展示。
前面內(nèi)容主要介紹了百策的數(shù)據(jù)采集和評分功能,這也是百策最主要的功能。除了核心功能外,百策還有數(shù)據(jù)看板、提供性能解決方案、性能走勢,性能對比,定時監(jiān)測等功能。在這篇文章中我也不一一闡述了。
○ 自動檢測
當(dāng)然除了上面這些手動檢測以外,百策也支持自動檢測。自動檢測的主要目的是統(tǒng)計所有收錄在系統(tǒng)中的頁面,統(tǒng)計哪些頁面性能優(yōu)化的最好,哪些優(yōu)化欠佳。具體的邏輯:每周五 2 點會對所有收錄在百策中的頁面進行檢測,將檢測成績最高的 10 個頁面,檢測成績最低的 10 個頁面,檢測成績進步最快的 10 個頁面,自動檢測的邏輯主要通過 node-schedule 實現(xiàn)。發(fā)送郵件可以 ejs 實現(xiàn)渲染模版,定義好模版后通過 nodemailer 發(fā)送即可。
import?{
??Injectable,
??OnModuleInit,
}?from?'@nestjs/common';
import?*?as?schedule?from?'node-schedule';
@Injectable()
export?class?ScheduleService?implements?OnModuleInit?{
??onModuleInit()?{
????this.init();
??}
??async?init()?{
????//?本地啟動時不執(zhí)行一系列定時任務(wù)
????if?(process.env.NODE_ENV?!==?'development')?{
??????//?每周五02:00開始收集頁面性能
??????schedule.scheduleJob(`hawkeye-weekly-report`,?'0?0?2?*?*?5',?async?()?=>?{
????????//?調(diào)用檢測接口記錄性能評分
????????await?this.report();
??????});
??????//?每周五18:00發(fā)送周報
??????schedule.scheduleJob(`hawkeye-weekly-send`,?'0?0?18?*?*?5',?async?()?=>?{
????????//?發(fā)送郵件的具體實現(xiàn)方法,主要通過?ejs?渲染模版,通過?nodemailer?發(fā)送郵件
????????await?this.send();
???????});
????}
??}
}

○ 對接魯班
關(guān)于魯班是什么,可以參考這篇文章:前端工程實踐之可視化搭建系統(tǒng),用一句話來總結(jié),可以說魯班就是政采云的頁面搭建系統(tǒng)。
在對接魯班時,主要包括了魯班頁面的性能數(shù)據(jù)的錄入和魯班頁面的錄入(方便后續(xù)每周定時檢測)。
魯班性能數(shù)據(jù)的錄入:和在魯班生成頁面時提供一個檢測按鈕,調(diào)用百策性能評分接口,生成檢測數(shù)據(jù)。 魯班頁面的錄入:在魯班的新頁面上線的時候,會自動調(diào)用百策錄入接口,新增的頁面會被錄入到百策系統(tǒng)中。
結(jié)尾
如果你也想搭建一個屬于自己的性能檢測平臺,并且恰巧看到了這篇文章,希望此文對你有所幫助。
本文最主要講的是如何搭建一個性能平臺。當(dāng)你已經(jīng)能夠搭建性能平臺之后,不妨可以思考下業(yè)務(wù)頁面的檢測模型。
關(guān)注我
大家也可以關(guān)注我的公眾號《腦洞前端》獲取更多更新鮮的前端硬核文章,帶你認(rèn)識你不知道的前端。
