那些年,我們一起做過(guò)的性能優(yōu)化
一直以來(lái),性能都是技術(shù)層面不可避開的話題,尤其在中大型復(fù)雜項(xiàng)目中。猶如汽車整車性能,追求極速的同時(shí),還要保障舒適性和實(shí)用性,而在汽車制造的每個(gè)環(huán)節(jié)、零件整合情況、發(fā)動(dòng)機(jī)調(diào)校等等,都會(huì)最終影響用戶體感以及商業(yè)達(dá)成,如下圖性能對(duì)收益的影響。
性能優(yōu)化是一個(gè)體系化、整體性的事情,印刻在項(xiàng)目開發(fā)環(huán)節(jié)的各個(gè)細(xì)節(jié)中,也是體現(xiàn)技術(shù)深度的大的戰(zhàn)場(chǎng)。下面我將以Quick BI的復(fù)雜系統(tǒng)為背景,深扒整個(gè)性能優(yōu)化的思路和手段,以及體系化的思考。
如何定位性能問(wèn)題?
通常來(lái)講,我們對(duì)動(dòng)畫的幀率是比較敏感的(16ms內(nèi)),但如果出現(xiàn)性能問(wèn)題,我們的實(shí)際體感可能就一個(gè)字:“慢”,但這并不能為我們解決問(wèn)題提供任何幫助,由此我們需要剖析這個(gè)字背后的整條鏈路。
上圖是瀏覽器通用的處理流程,結(jié)合我們的場(chǎng)景,我這里抽象成以下幾個(gè)步驟:
可以看出,主要的耗時(shí)階段分為兩個(gè):
階段一:資源包下載(Download Code)
階段二:執(zhí)行 & 取數(shù)(Script Execution & Fetch Data)
如何深入這兩個(gè)階段,我們一般會(huì)用以下幾個(gè)主要的工具來(lái)分析:
Network
首先我們要使用的一個(gè)工具是Chrome的Network,它能幫助我們初步定位瓶頸所在的環(huán)節(jié):
如圖示例,在Network中可以一目了然看到整個(gè)頁(yè)面的:加載時(shí)間(Finish)、加載資源大小、請(qǐng)求數(shù)量、每個(gè)請(qǐng)求耗時(shí)及耗時(shí)點(diǎn)、資源優(yōu)先級(jí)等等。上面示例可以很明顯看出:整個(gè)頁(yè)面加載的資源很大,接近了30MB。
Coverage(代碼覆蓋率)
對(duì)于復(fù)雜的前端工程,其工程構(gòu)建的產(chǎn)物一般會(huì)存在冗余甚至未被使用的情況,這些無(wú)效加載的代碼可以通過(guò)Coverage工具來(lái)實(shí)時(shí)分析:
如上圖示例可以看到:整個(gè)頁(yè)面28.3MB,其中19.5MB都未被使用(執(zhí)行),其中engine-style.css文件的使用率只有不到0.7%
資源大圖
剛才我們已經(jīng)知道前端資源的利用率非常低,那么具體是哪些無(wú)效代碼被引入進(jìn)來(lái)了?這時(shí)候我們要借助webpack-bundle-analyzer來(lái)分析整個(gè)的構(gòu)建產(chǎn)物(產(chǎn)物stats可以通過(guò)webpack --profile --json=stats.json輸出):
如上例,結(jié)合我們當(dāng)前業(yè)務(wù)可以看到構(gòu)建產(chǎn)物的問(wèn)題:
第一,初始包過(guò)大(common.js)
第二,存在多個(gè)重復(fù)包(momentjs等)
第三,依賴的第三方包體積過(guò)大
模塊依賴關(guān)系
有了資源構(gòu)建大圖,我們也大概知道了可優(yōu)化的點(diǎn),但在一個(gè)系統(tǒng)中,成百上千的模塊一般都是通過(guò)互相引用的方式組織在一起,打包工具再通過(guò)依賴關(guān)系將其構(gòu)建在一起(比如打成common.js單個(gè)文件),想要直接移除掉某個(gè)模塊代碼或依賴可能并非易事,由此我們可能需要一定程度抽絲剝繭,借助工具理清系統(tǒng)中模塊的依賴關(guān)系,再通過(guò)調(diào)整依賴或加載方式來(lái)作優(yōu)化:
上圖我們使用到的是webpack官方的analyse工具(其他工具還有:webpack-xray,Madge),只需要將資源大圖stats.json上傳即可得到整個(gè)依賴關(guān)系大圖
Performance
前面講到的都是和資源加載相關(guān)的工具,那么在分析 “執(zhí)行 & 取數(shù)” 環(huán)節(jié)我們使用什么,Chrome提供了非常強(qiáng)大的工具:Performance:
如上圖示例,我們可以至少發(fā)現(xiàn)幾個(gè)點(diǎn):主流程串化、長(zhǎng)任務(wù)、高頻任務(wù)。
如何優(yōu)化性能?
結(jié)合剛才提到的分析工具,剛才提到的 “資源包下載”、“執(zhí)行 & 取數(shù)” 兩個(gè)大的階段我們基本上已經(jīng)覆蓋到,其根本問(wèn)題和解法也在不斷的分析中逐步有了思路,這里我將結(jié)合我們這里的場(chǎng)景,給出一些不錯(cuò)的優(yōu)化思路和效果
大包按需加載
要知道,前端工程構(gòu)建打包(如webpack)一般是從entry出發(fā),去尋找整棵依賴樹(直接依賴),從而根據(jù)這棵樹產(chǎn)出多個(gè)js和css文件bundle或trunk,而一個(gè)模塊一旦出現(xiàn)在依賴樹中,那么當(dāng)頁(yè)面加載entry的時(shí)候,同時(shí)也會(huì)加載該模塊。
所以我們的思路是打破這種直接依賴,針對(duì)末端的模塊改用異步依賴方式,如下:
將同步的import { Marker } from '@antv/l7'改為異步,這樣在構(gòu)建時(shí),被依賴的Marker會(huì)形成一個(gè)chunk,僅在此段代碼執(zhí)行時(shí)(按需),該thunk才被加載,從而減少了首屏包的體積。
然而上面方案會(huì)存在一個(gè)問(wèn)題,構(gòu)建會(huì)將整個(gè)@antv/l7作為一個(gè)chunk,而非Marker部分代碼,導(dǎo)致該chunk的TreeShaking失效,體積很大。我們可以使用構(gòu)建分片方式解決:
如上,先創(chuàng)建Marker的分片文件,使之具備TreeShaking的能力,再在此基礎(chǔ)上作異步引入。
下方是我們優(yōu)化后的流程對(duì)比結(jié)果:
這一步,我們通過(guò)按需拆包,異步加載,節(jié)省了資源下載時(shí)間和部分執(zhí)行時(shí)間
資源預(yù)加載
其實(shí)我們?cè)诜治鲭A段已經(jīng)發(fā)現(xiàn)一個(gè)“主流程串化”的問(wèn)題,js的執(zhí)行是單線程,但瀏覽器實(shí)際上是多線程運(yùn)行的,這里面就包括異步請(qǐng)求(fetch等),所以我們進(jìn)一步的思路是把取數(shù)(Fetch Data)與資源下載通過(guò)多線程并行。
按照當(dāng)前現(xiàn)狀,接口取數(shù)的邏輯一般是耦合在業(yè)務(wù)邏輯或數(shù)據(jù)處理邏輯中的,所以解耦(與UI、業(yè)務(wù)模塊等解耦)的步驟必不可少,將純粹的fetch請(qǐng)求(及少量處理邏輯)剝離出來(lái),放到優(yōu)先級(jí)更高的階段來(lái)發(fā)起請(qǐng)求。那么放到什么地方呢?我們知道,瀏覽器對(duì)資源的處理是有優(yōu)先級(jí)的,正常按如下順序:
HTML/CSS/FONT
Preload/SCRIPT/XHR
Image/Audio/Video
Prefetch
要做到資源拉取 和 發(fā)起取數(shù)并行,就有必要把取數(shù)提前到第1優(yōu)先級(jí)(HTML解析完畢后立即執(zhí)行,而非等待SCRIPT標(biāo)簽資源加載執(zhí)行過(guò)程中發(fā)起請(qǐng)求),我們的流程會(huì)變成如下:
需要特別注意一點(diǎn):由于JS的執(zhí)行是串行,發(fā)起取數(shù)的那段邏輯必須要先于主流程邏輯執(zhí)行,并且不能放到nextTick(如使用setTimeout(() => doFetch())),否則主流程會(huì)一直占用CPU時(shí)間使得請(qǐng)求無(wú)法發(fā)出
主動(dòng)任務(wù)調(diào)度
瀏覽器對(duì)資源也有優(yōu)先級(jí)策略,但它并不知道業(yè)務(wù)層面的我們,到底想要哪些資源先加載/執(zhí)行,哪些資源后加載/執(zhí)行,所以我們跳出來(lái)看,若把整個(gè)業(yè)務(wù)層面的資源加載+執(zhí)行/取數(shù)流程拆成一個(gè)一個(gè)小的任務(wù),這些任務(wù)全權(quán)由我們自己來(lái)控制其:打包粒度、加載時(shí)機(jī)、執(zhí)行時(shí)機(jī),是不是意味著能最大化利用CPU時(shí)間和網(wǎng)絡(luò)資源了?
答案是肯定的,不過(guò)一般對(duì)于簡(jiǎn)單的項(xiàng)目,瀏覽器本身的調(diào)度優(yōu)先級(jí)策略已經(jīng)足夠滿足需要,但如果針對(duì)大型復(fù)雜項(xiàng)目,要做的相對(duì)極致的優(yōu)化,就有必要引入“自定義任務(wù)調(diào)度”方案了。
以Quick BI為例,我們的前期目標(biāo)是:讓首屏主要內(nèi)容展現(xiàn)更加快速。那么從資源加載、代碼執(zhí)行、取數(shù)層面是應(yīng)該根據(jù)我們業(yè)務(wù)優(yōu)先級(jí)作CPU/網(wǎng)絡(luò)分配的,比如:我希望“卡片的下拉菜單”,在首屏主要內(nèi)容展示完畢后或CPU空閑時(shí),才開始加載(即降低優(yōu)先級(jí),更甚至在用戶鼠標(biāo)移入卡片中時(shí),又希望它提高優(yōu)先級(jí)立即開始加載并展示)。如下:
這里我們封裝了一個(gè)任務(wù)調(diào)度器,其目的是可以聲明一段邏輯,在其某個(gè)依賴(Promise)完成后開始執(zhí)行。我們的流程圖變化如下:
黃色區(qū)塊代表 作優(yōu)先級(jí)降級(jí)處理的部分模塊,其幫助減少了整個(gè)首屏?xí)r間
TreeShaking
上面講方法大多從優(yōu)先級(jí)出發(fā),其實(shí)在前端工程化日益復(fù)雜的時(shí)代(中大型項(xiàng)目已超幾十萬(wàn)行代碼),誕生了一個(gè)較為智能的優(yōu)化方案用于減少包大小,其思想很簡(jiǎn)單:工具化分析依賴關(guān)系,將沒(méi)有被引用到的代碼從最終產(chǎn)物中剔除掉。
聽起來(lái)很酷,實(shí)際用起來(lái)也非常不錯(cuò),但這里想講一些很多其官網(wǎng)也不會(huì)提到的點(diǎn) --- TreeShaking經(jīng)常失效的情況:
副作用
副作用(Side Effects)通常表達(dá)的是對(duì)全局(如window對(duì)象等)或環(huán)境會(huì)產(chǎn)生影響的代碼。
如圖示例,b代碼看似未被使用,但其文件中存在console.log(b(1))這樣的代碼,webpack等打包工具不敢輕易移除它,所以它會(huì)被照常打入。
解決方法
在package.json 或 webpack配置中明確指定哪些代碼具備副作用(例如sideEffects: [“**/*.css”]),無(wú)副作用的代碼將被移除
IIFE類代碼
IIFE即會(huì)被立即執(zhí)行的函數(shù)表達(dá)式(Immediately invoked function expression)
如圖,這類型的代碼,會(huì)導(dǎo)致TreeShaking失效
解決方法
三個(gè)原則:
[避免]立即執(zhí)行的函數(shù)調(diào)用
[避免]立即執(zhí)行的new操作
[避免]立即影響全局的代碼
懶加載
我們?cè)?ldquo;按需加載”處提到過(guò)異步import來(lái)做拆包會(huì)導(dǎo)致TreeShaking失效,這里再進(jìn)一步說(shuō)明一下另外一個(gè)case:
如圖,由于index.ts同步import了bar.ts中的sharedStr,然后在某個(gè)地方,又同時(shí)異步import('./bar'),這種情況下,會(huì)同時(shí)導(dǎo)致兩個(gè)問(wèn)題:
TreeShaking失效(unusedStr會(huì)被打入)
異步懶加載失效(bar.ts會(huì)和index.ts打入到一起)
當(dāng)代碼量達(dá)到一定量級(jí),N個(gè)人協(xié)同開發(fā)就很容易出現(xiàn)這個(gè)問(wèn)題
解決方法
[避免]同步和異步import同個(gè)文件
按需策略(Lazy)
其實(shí)前面有講到一些按需加載的方案,這里我們適當(dāng)延伸一下:既然資源包的加載可以做到按需,是否某個(gè)組件的渲染可以按需?某個(gè)對(duì)象實(shí)例的使用可以按需?某個(gè)數(shù)據(jù)緩存的生成也可以按需?
懶組件(LazyComponent)
如圖,PieArc.private.ts對(duì)應(yīng)一個(gè)復(fù)雜的React組件,PieArc通過(guò)makeLazyComponent封裝成默認(rèn)懶加載的組件,只有在代碼執(zhí)行到此處時(shí),組件才會(huì)加載并執(zhí)行。甚至,還可以通過(guò)第二個(gè)參數(shù)(deps)申明依賴,待依賴(promise)完畢時(shí),才加載和執(zhí)行。
懶緩存(LazyCache)
懶緩存用于這種場(chǎng)景:需要在任何地方使用到數(shù)據(jù)流(或其他可訂閱數(shù)據(jù))中的某個(gè)數(shù)據(jù)經(jīng)過(guò)轉(zhuǎn)換后的結(jié)果,且僅在使用的那一刻才進(jìn)行轉(zhuǎn)換
懶對(duì)象(LazyObject)
懶對(duì)象意即該對(duì)象只有在被使用的時(shí)候(屬性/方法被訪問(wèn)、修改、刪除等等),才會(huì)被實(shí)例化
如圖,globalRecorder被引入時(shí),其并未實(shí)例化,僅當(dāng)調(diào)用globalRecorder.record()時(shí)進(jìn)行實(shí)例化
數(shù)據(jù)流:節(jié)流渲染
中大型項(xiàng)目中為了方便狀態(tài)管理,通常會(huì)使用到數(shù)據(jù)流的方案,如下流程:
store中存儲(chǔ)的數(shù)據(jù)通常偏原子化,粒度非常小,比如state中有:a、b、c ...等N個(gè)原子屬性,某個(gè)組件依賴這N個(gè)屬性來(lái)作UI渲染,假設(shè)N個(gè)屬性會(huì)在不同的ACTION下被改變,且這些改變均在16ms內(nèi)發(fā)生,那么若N=20,則16ms內(nèi)(1幀)會(huì)有20次View更新:
這顯然會(huì)引發(fā)非常大的性能問(wèn)題,由此,我們需要對(duì)短時(shí)間的ACTION量作一個(gè)緩沖節(jié)流,待20次ACTION狀態(tài)改變完畢后,僅進(jìn)行1次View更新,如下:
此方案在Quick BI以redux中間件的形式發(fā)揮作用,在復(fù)雜+頻繁數(shù)據(jù)更新場(chǎng)景起到了不錯(cuò)的效果
思考
“君子以思患而豫防之”,當(dāng)我們回過(guò)頭去看看,出現(xiàn)的這些性能問(wèn)題,在架構(gòu)設(shè)計(jì)、編碼階段是可以避免掉80%以上的,20%的則可以“空間<=>時(shí)間置換策略”等方式去平衡。所以,最佳的性能優(yōu)化方案,是在于我們對(duì)每一段代碼質(zhì)量的執(zhí)著:是否考慮到了這樣的模塊依賴關(guān)系,可能帶來(lái)的構(gòu)建產(chǎn)物體積問(wèn)題?是否考慮到了這段邏輯可能的執(zhí)行頻次?是否考慮到了隨著數(shù)據(jù)增長(zhǎng),空間或CPU占用的可控性?等等。性能優(yōu)化沒(méi)有銀彈,作為技術(shù)人,需要內(nèi)修于心(熟知底層原理),把對(duì)性能的執(zhí)念植入本能思考當(dāng)中,方為銀彈。
原文鏈接:http://click.aliyun.com/m/1000283335/