背景

一直以來,效能都是技術層面不可避開的話題,尤其在中大型複雜專案中。猶如汽車整車效能,追求極速的同時,還要保障舒適性和實用性,而在汽車製造的每個環節、零件整合情況、發動機調校等等,都會最終影響使用者體感以及商業達成,如下圖效能對收益的影響。

效能優化是一個體系化、整體性的事情,印刻在專案開發環節的各個細節中,也是體現技術深度的大的戰場。下面我將以Quick BI的複雜系統為背景,深扒整個效能優化的思路和手段,以及體系化的思考。

如何定位效能問題?

通常來講,我們對動畫的幀率是比較敏感的(16ms內),但如果出現效能問題,我們的實際體感可能就一個字:“慢”,但這並不能為我們解決問題提供任何幫助,由此我們需要剖析這個字背後的整條鏈路。

上圖是瀏覽器通用的處理流程,結合我們的場景,我這裡抽象成以下幾個步驟:

可以看出,主要的耗時階段分為兩個:

階段一:資源包下載(Download Code)

階段二:執行 & 取數(Script Execution & Fetch Data)

如何深入這兩個階段,我們一般會用以下幾個主要的工具來分析:

Network

首先我們要使用的一個工具是Chrome的Network,它能幫助我們初步定位瓶頸所在的環節:

如圖示例,在Network中可以一目瞭然看到整個頁面的:載入時間(Finish)、載入資源大小、請求數量、每個請求耗時及耗時點、資源優先順序等等。上面示例可以很明顯看出:整個頁面載入的資源很大,接近了30MB。

Coverage(程式碼覆蓋率)

對於複雜的前端工程,其工程構建的產物一般會存在冗餘甚至未被使用的情況,這些無效載入的程式碼可以通過Coverage工具來實時分析:

如上圖示例可以看到:整個頁面28.3MB,其中19.5MB都未被使用(執行),其中engine-style.css檔案的使用率只有不到0.7%

資源大圖

剛才我們已經知道前端資源的利用率非常低,那麼具體是哪些無效程式碼被引入進來了?這時候我們要藉助webpack-bundle-analyzer來分析整個的構建產物(產物stats可以通過webpack --profile --json=stats.json輸出):

如上例,結合我們當前業務可以看到構建產物的問題:

第一,初始包過大(common.js)

第二,存在多個重複包(momentjs等)

第三,依賴的第三方包體積過大

模組依賴關係

有了資源構建大圖,我們也大概知道了可優化的點,但在一個系統中,成百上千的模組一般都是通過互相引用的方式組織在一起,打包工具再通過依賴關係將其構建在一起(比如打成common.js單個檔案),想要直接移除掉某個模組程式碼或依賴可能並非易事,由此我們可能需要一定程度抽絲剝繭,藉助工具理清系統中模組的依賴關係,再通過調整依賴或載入方式來作優化:

上圖我們使用到的是webpack官方的analyse工具(其他工具還有:webpack-xray,Madge),只需要將資源大圖stats.json上傳即可得到整個依賴關係大圖

Performance

前面講到的都是和資源載入相關的工具,那麼在分析 “執行 & 取數” 環節我們使用什麼,Chrome提供了非常強大的工具:Performance:

如上圖示例,我們可以至少發現幾個點:主流程串化、長任務、高頻任務。

如何優化效能?

結合剛才提到的分析工具,剛才提到的 “資源包下載”、“執行 & 取數” 兩個大的階段我們基本上已經覆蓋到,其根本問題和解法也在不斷的分析中逐步有了思路,這裡我將結合我們這裡的場景,給出一些不錯的優化思路和效果

大包按需載入

要知道,前端工程構建打包(如webpack)一般是從entry出發,去尋找整棵依賴樹(直接依賴),從而根據這棵樹產出多個js和css檔案bundle或trunk,而一個模組一旦出現在依賴樹中,那麼當頁面載入entry的時候,同時也會載入該模組。

所以我們的思路是打破這種直接依賴,針對末端的模組改用非同步依賴方式,如下:

將同步的import { Marker } from '@antv/l7'改為非同步,這樣在構建時,被依賴的Marker會形成一個chunk,僅在此段程式碼執行時(按需),該thunk才被載入,從而減少了首屏包的體積。

然而上面方案會存在一個問題,構建會將整個@antv/l7作為一個chunk,而非Marker部分程式碼,導致該chunk的TreeShaking失效,體積很大。我們可以使用構建分片方式解決:

如上,先建立Marker的分片檔案,使之具備TreeShaking的能力,再在此基礎上作非同步引入。

下方是我們優化後的流程對比結果:

這一步,我們通過按需拆包,非同步載入,節省了資源下載時間和部分執行時間

資源預載入

其實我們在分析階段已經發現一個“主流程串化”的問題,js的執行是單執行緒,但瀏覽器實際上是多執行緒執行的,這裡面就包括非同步請求(fetch等),所以我們進一步的思路是把取數(Fetch Data)與資源下載通過多執行緒並行。

按照當前現狀,介面取數的邏輯一般是耦合在業務邏輯或資料處理邏輯中的,所以解耦(與UI、業務模組等解耦)的步驟必不可少,將純粹的fetch請求(及少量處理邏輯)剝離出來,放到優先順序更高的階段來發起請求。那麼放到什麼地方呢?我們知道,瀏覽器對資源的處理是有優先順序的,正常按如下順序:

  1. HTML/CSS/FONT
  2. Preload/SCRIPT/XHR
  3. Image/Audio/Video
  4. Prefetch

要做到資源拉取 和 發起取數並行,就有必要把取數提前到第1優先順序(HTML解析完畢後立即執行,而非等待SCRIPT標籤資源載入執行過程中發起請求),我們的流程會變成如下:

需要特別注意一點:由於JS的執行是序列,發起取數的那段邏輯必須要先於主流程邏輯執行,並且不能放到nextTick(如使用setTimeout(() => doFetch())),否則主流程會一直佔用CPU時間使得請求無法發出

主動任務排程

瀏覽器對資源也有優先順序策略,但它並不知道業務層面的我們,到底想要哪些資源先載入/執行,哪些資源後加載/執行,所以我們跳出來看,若把整個業務層面的資源載入+執行/取數流程拆成一個一個小的任務,這些任務全權由我們自己來控制其:打包粒度、載入時機、執行時機,是不是意味著能最大化利用CPU時間和網路資源了?

答案是肯定的,不過一般對於簡單的專案,瀏覽器本身的排程優先順序策略已經足夠滿足需要,但如果針對大型複雜專案,要做的相對極致的優化,就有必要引入“自定義任務排程”方案了。

以Quick BI為例,我們的前期目標是:讓首屏主要內容展現更加快速。那麼從資源載入、程式碼執行、取數層面是應該根據我們業務優先順序作CPU/網路分配的,比如:我希望“卡片的下拉選單”,在首屏主要內容展示完畢後或CPU空閒時,才開始載入(即降低優先順序,更甚至在使用者滑鼠移入卡片中時,又希望它提高優先順序立即開始載入並展示)。如下:

這裡我們封裝了一個任務排程器,其目的是可以宣告一段邏輯,在其某個依賴(Promise)完成後開始執行。我們的流程圖變化如下:

黃色區塊代表 作優先順序降級處理的部分模組,其幫助減少了整個首屏時間

TreeShaking

上面講方法大多從優先順序出發,其實在前端工程化日益複雜的時代(中大型專案已超幾十萬行程式碼),誕生了一個較為智慧的優化方案用於減少包大小,其思想很簡單:工具化分析依賴關係,將沒有被引用到的程式碼從最終產物中剔除掉。

聽起來很酷,實際用起來也非常不錯,但這裡想講一些很多其官網也不會提到的點 --- TreeShaking經常失效的情況:

副作用

副作用(Side Effects)通常表達的是對全域性(如window物件等)或環境會產生影響的程式碼。

如圖示例,b程式碼看似未被使用,但其檔案中存在console.log(b(1))這樣的程式碼,webpack等打包工具不敢輕易移除它,所以它會被照常打入。

解決方法

在package.json 或 webpack配置中明確指定哪些程式碼具備副作用(例如sideEffects: [“**/*.css”]),無副作用的程式碼將被移除

IIFE類程式碼

IIFE即會被立即執行的函式表示式(Immediately invoked function expression)

如圖,這型別的程式碼,會導致TreeShaking失效

解決方法

三個原則:

  • [避免]立即執行的函式呼叫
  • [避免]立即執行的new操作
  • [避免]立即影響全域性的程式碼

懶載入

我們在“按需載入”處提到過非同步import來做拆包會導致TreeShaking失效,這裡再進一步說明一下另外一個case:

如圖,由於index.ts同步import了bar.ts中的sharedStr,然後在某個地方,又同時非同步import('./bar'),這種情況下,會同時導致兩個問題:

  1. TreeShaking失效(unusedStr會被打入)
  2. 非同步懶載入失效(bar.ts會和index.ts打入到一起)

當代碼量達到一定量級,N個人協同開發就很容易出現這個問題

解決方法

  • [避免]同步和非同步import同個檔案

按需策略(Lazy)

其實前面有講到一些按需載入的方案,這裡我們適當延伸一下:既然資源包的載入可以做到按需,是否某個元件的渲染可以按需?某個物件例項的使用可以按需?某個資料快取的生成也可以按需?

懶元件(LazyComponent)

如圖,PieArc.private.ts對應一個複雜的React元件,PieArc通過makeLazyComponent封裝成預設懶載入的元件,只有在程式碼執行到此處時,元件才會載入並執行。甚至,還可以通過第二個引數(deps)申明依賴,待依賴(promise)完畢時,才載入和執行。

懶快取(LazyCache)

懶快取用於這種場景:需要在任何地方使用到資料流(或其他可訂閱資料)中的某個資料經過轉換後的結果,且僅在使用的那一刻才進行轉換

懶物件(LazyObject)

懶物件意即該物件只有在被使用的時候(屬性/方法被訪問、修改、刪除等等),才會被例項化

如圖,globalRecorder被引入時,其並未例項化,僅當呼叫globalRecorder.record()時進行例項化

資料流:節流渲染

中大型專案中為了方便狀態管理,通常會使用到資料流的方案,如下流程:

store中儲存的資料通常偏原子化,粒度非常小,比如state中有:a、b、c ...等N個原子屬性,某個元件依賴這N個屬性來作UI渲染,假設N個屬性會在不同的ACTION下被改變,且這些改變均在16ms內發生,那麼若N=20,則16ms內(1幀)會有20次View更新:

這顯然會引發非常大的效能問題,由此,我們需要對短時間的ACTION量作一個緩衝節流,待20次ACTION狀態改變完畢後,僅進行1次View更新,如下:

此方案在Quick BI以redux中介軟體的形式發揮作用,在複雜+頻繁資料更新場景起到了不錯的效果

思考

“君子以思患而豫防之”,當我們回過頭去看看,出現的這些效能問題,在架構設計、編碼階段是可以避免掉80%以上的,20%的則可以“空間<=>時間置換策略”等方式去平衡。所以,最佳的效能優化方案,是在於我們對每一段程式碼質量的執著:是否考慮到了這樣的模組依賴關係,可能帶來的構建產物體積問題?是否考慮到了這段邏輯可能的執行頻次?是否考慮到了隨著資料增長,空間或CPU佔用的可控性?等等。效能優化沒有銀彈,作為技術人,需要內修於心(熟知底層原理),把對效能的執念植入本能思考當中,方為銀彈。