作者 | 邱俊濤
性能問題是軟件開發中的常見問題,我們在幾乎每個項目在某個時期(往往是在后期快要交付的時候,或者已經上線以后收到用戶反饋)都或多或少會遇到。這篇文章想要從流程方面和具體的技術細節上對軟件性能優化上遇到的問題做一些總結和分類,以方便在后續類似的場景下可以提供給開發者一個參考。
嚴格意義上,這篇文章并沒有太多的新內容,甚至有一些具體的技術細節我在另一篇文章中已經討論過,這里主要還是提供一些常見的關于性能優化思路的總結。
在修改之前
性能優化之法,曰立,曰測,曰理,曰拆,曰分,曰剝,曰拖,曰緩。
我們討論性能提升,往往需要首先建立一套測量機制。因為僅憑直覺來猜測可能的性能瓶頸非常低效,而且往往直覺認為有性能問題的地方未必真有問題。一旦測量機制建立,則猶如我們代碼有了單元測試/集成測試的守護,總的來說會向著正確的方向演進。
立字訣
重中之重的是,定義好指標。即DoD(Definition of Done),我們需要回答的問題是:什么是好的性能?達到何種標準就算是提升,而達不到就算是失敗?這一點從項目的確立角度非常關鍵。
如果說希望某個頁面的性能較之以前來說,加載時間提高20%為成功,則一切的后續開發可以做到有的放矢,而不至于無疾而終。
測字訣
一旦我們定義好了何為提升。接下來就需要建立相應的測量機制,并設置基線。這一步相當于將上一步定義好的標準實例化到build pipeline中,使得具體目標可視化起來,從而每次的修改都能看到和目標的差距。
比如從請求發出到頁面渲染完成(比如檢測到某個標的在頁面上的存在與否),總共耗時3秒,然后我們將3秒設置為基線,并圍繞這個基線設置測試的上限。和其他測試一樣,如果后續的代碼修改使得頁面渲染時間大于基線值,則build失敗。與之對應的還可以有諸如bundle的尺寸(壓縮后的靜態資源大小)首次渲染時間等等指標。
有了具體的目標,我們就可以設置相應的測試機制。比如通過運行yslow或者其他lighthouse來進行。
理字訣
當我們定義了性能優化成功的含義,也有了相應的反饋機制,如何做才會成為最重要的主題。對于這個問題,常用的工具就是分析和分類。
首先需要的分析“慢”的類型,是純性能問題,還是架構問題,或者是軟件設計上的問題。純性能的問題往往較為具體,也最容易解決,比如使用了性能較低的包作為依賴,則只需要替換為性能更好的庫即可;又或者使用debounce/throttle來減少對函數的頻繁調用等等。
與純粹的性能問題相對應的另一大類問題,都可以歸結到設計問題(大到軟件架構,小到模塊間的耦合/依賴等問題)。這類問題通常需要引入的修改比較大,但是收益也會很高,而且長期來看,對于代碼的可維護性和缺陷率也會帶來好的回報。
因此,這一步的目標是識別出哪些問題可以通過簡單修改就可以達成,而另外的一些則需要大的改動。事實是,有可能對于我們之前定義好的基線,只需要解決純粹的性能問題就可以達成,那我們也無需花費大量的工作在更大的修改上。
總綱
或曰,性能優化之訣竅,唯推拖二字也。推者,不是我的事兒我絕不干,誰愛干誰干。拖者,能明天做的事兒,今天絕不去碰。
如果純粹的最佳實踐無法滿足要求,我們則需要花費更多的時間來重構代碼的設計來滿足性能需求。
我們將通過一些具體的例子來仔細討論。總的來說,我們需要識別代碼中的耦合問題,并在合理的方向上進行抽象,并完成拆分,使得每個獨立的模塊/組件都盡可能的高內聚,低耦合。
拆字訣
比如在文中討論的Avatar和Tooltip的例子,頭像組件Avartar的核心功能并不包含Tooltip,而且兩者的耦合程度其實很低,可以通過拆分的方式將其隔離。
修改后的Avatar不再將Tooltip做為依賴:
分字訣
在另外一些情況下,一個組件和其依賴間的耦合較為緊密,但是又不具備不可替代性。比如在文中討論的InlineEdit和InlineDialog的場景。
這時候可以通過render props來進行控制反轉,使得組件不再依賴于某個具體實現,而是一個接口。這樣所有實現了該接口的組件都可以即插即用,又可以節省默認依賴的部分開銷(定義在package.json中的)。
注意這種場景和“拆字訣”里的場景非常類似,不過區別是這里拆分出去的組件和當前組件間有一個隱式的協定:即需要接受render傳遞過去的所有參數。
比如上面的例子中,editView并不是完全自由定義的,它需要或者接受或者忽略isInvalid和error這樣的參數。
剝字訣
在一些場景中,與其提供一個大而全的組件,我們可以將該組件適度的附加功能剝離,并形成不同的組件,通過不同的entry-points導出。這樣用戶可以按需安裝。一個典型的例子是lodash的早起版本,用戶如果需要使用partition,仍然需要導入整個包:
通過不同的entry-point,你可以僅僅導入你需要的函數:
類似的,比如你的button組件,你可以提供標準button,加載中的button,或者高級button等不同類型,以便用戶按需使用。
拖字訣
以React為例,我們既可以使用原生的React.lazy也可以使用諸如loadable之類的庫來實現按需加載。即不到最后一刻(需要渲染DOM的時候)絕不加載。這在很多場景下,特別是提升頁面初始頁的時候非常有用。比如首頁上的User Profile里隱藏著一個巨大的DropdownMenu,我們完全可以當用戶第一次使用時再加載。
并提供一個placeholder在加載時:
緩字訣
我們這里介紹的最后一種方法是緩存,將耗時的,且不會頻繁發生變化的計算結果保存起來,以提高后續的訪問速度。這個模式既可以是代碼層級,將數據存放在內存中或者LocalStorage/SessionStorage中。另一方面,這條原則從架構層面也是適用的,比如我們引入靜態資源存儲在CDN上,動態資源存儲于緩存服務器等。
還是以React為例,我們可以使用:
- 使用useMemo緩存數據
- 使用useCallback緩存事件響應函數
- 使用memo對靜態組件(特別是葉子節點)進行緩存
比如對于一個葉子節點Toggle
使用API級別的緩存之后,寫起來可能是這樣的:
另外應該注意的是,使用額外的API如useMemo或者useCallback本身也是有消耗的,在實際場景里需要結合上面提到的測字訣來確保實際數字上的改善,而不是對迷信API。
小結
本文對性能優化中常見的一些方法和模式做了一些總結。在開始實施之前,我們需要確定對性能優化成功與否的定義。然后我們需要設立基線以及與之匹配的測試,這樣我們在任何時候都可以確知我們的優化有沒有效果,或者與預期之間的差距,從而時刻保證目標清晰。接下來需要對性能問題的現象進行初步的分析和分類,比如是架構上的缺陷,或者是微觀代碼層面沒有采用最佳實踐等。
接下來,我們討論了幾類常見的優化方法。比如根據耦合度的拆分,根據復雜/分化程度的拆分,使用接口來實現依賴倒置,以及緩存的使用等。這些具體的做法在不同的技術棧上可能有不同的具體實踐(比如Angular中可能有lazy的對照物,或者可以在vue中采用類似的技術來實現memo等),但是這些思路是比較通用的,可以應用在類似的場景。
原文鏈接:??前端性能優化心法 (qq.com)??