頁面加載性能優化


頁面加載性能優化

在互聯網網站百花齊放的今天,網站響應速度是用戶體驗的第一要素,其重要性不言而喻,這里有幾個關於響應時間的重要條件:

用戶在瀏覽網頁時,不會注意到少於0.1秒的延遲;

少於1秒的延遲不會中斷用戶的正常思維, 但是一些延遲會被用戶注意到;

延遲時間少於10秒,用戶會繼續等待響應;

延遲時間超過10秒后,用戶將會放棄並開始其他操作;

因此大家都開始注重性能優化,很多廠商都開始做一些性能優化。比較有名的是雅虎軍規,不過隨着瀏覽器和協議等的發展,有一些已經逐漸被淘汰了。因此建議大家以歷史的目光看待它。比如.盡量減少HTTP請求數這一條,在HTTP2協議下就不管用了,因為HTTP2實現了HTTP復用,HTTP請求變少,反而降低性能。因此一定要結合歷史環境看待具體的優化原則和手段,否則會適得其反。

雅虎軍規中文版: http://www.cnblogs.com/xianyulaodi/p/5755079.html

隨着移動互聯網的高速發展,移動終端的數量正在以指數級增長,很多廠商對於移動端體驗都開始重視起來了。比如Google Chrome 的工程師 Alex就提出了Progressive Web App(以下簡稱PWA),用來提高移動端web的性能。PWA的核心是Service Worker, 通過它可以使得在JS主線程之外,程序員通過編程的方式控制網絡請求,結合瀏覽器本身的緩存,往往可以達到非常棒的用戶體驗。PWA提出了許多類似Native的“功能”- 比如離線加載和桌面快捷方式,使得移動端web體驗更加友好。另外加上web端本身的特性-比如快速迭代,可索引(你可以通過搜索引擎搜索,而native app 則不行)等,使得更多的人投入到給web端用戶提供更佳的用戶體驗的PWA中去。Google在更早的時候,提出了AMP。 2017年Google dev上海站就宣傳了PWA 和 AMP,並且通過一張動圖形象地展示了兩者(PWA的P和A翻過來,然后W上下翻轉就是AMP,反之亦然)。AMP是一種面向手機端的輕量級的web展現,通過將重量級元素重新實現等方式提高了手機端性能。 另外諸如使用asm.js 使得代碼更容易編譯成機器指令,也是性能優化的一環。如果你仔細查看應用執行的profile的時候,你會發現js代碼compile的時間會很久,尤其你寫了很多無用js代碼,或者沒必要第一時間執行的代碼的時候,這種影響更加大。js代碼最終也是編譯成二進制給機器執行,而js是動態語言,也就是說js代碼是執行到哪編譯到哪,這是和java這樣的靜態語言的一個很大的差別。V8已經對這部分做了相當大的優化,一般情況下我們不必擔心,通常來講這也不會成為性能瓶頸,因為這些時間和網絡IO的時間根本不是一個數量級。但是在特定場合,提前編譯成更容易解釋執行的代碼,可能就會派上用場。

過早優化是萬惡之源

在我剛剛接觸前端的時候,經常看到這樣的性能優化例子:

// bad for(var i = 0;i < data.length;i++){ // do something... } // good for(var i = 0,len = data.length;i < len;i++){ // do something... } 

理由是上面的會每次去計算data.length。個人上面的優化非常可笑,且不說實際運行情況怎樣。就算下面的性能比上面的好,我覺得這樣的性能優化應該交給編譯器來做,不應該交給上層業務去做,這樣做反而喪失了可理解性,大家很明顯的看出上面的更容易理解,不是嗎?

過早的優化,會讓人陷入心理誤區。這種心理誤區就是典型的手中有錘子,處處都是釘子。

還有一點就是如果過早優化,往往會一葉障目。性能優化要遵守木桶原理,即影響系統性能的永遠是系統的性能短板。如果過早優化,往往會頭痛醫腳,忙手忙腳卻毫無收效。

一個經典問題

讓我們回憶一下瀏覽器從加載url開始到頁面展示出來,經過了哪些步驟:

  1. 瀏覽器調用loadUrl解析url
  2. 瀏覽器調用DNS模塊,如果瀏覽器有dns緩存則直接返回IP。 否則查詢本地機器的DNS,並逐層往上查找。

    最終返回IP,然后將其存到DNS緩存並設置過期時間。

tips: 在chrome瀏覽器中, 可以輸入 chrome://dns/ 查看chrome緩存的dns記錄

  1. 瀏覽器調用網絡模塊。 網絡模塊和目標IP 建立TCP連接,途中經過三次握手。
  2. 瀏覽器發送http請求,請求格式如下:
header (空行) body 
  1. 請求到達目標機器,並通過端口與目標web server 建立連接。
  2. web server 獲取到請求流,對請求流進行解析,然后經過一些列處理,可能會查詢數據庫等, 最終返回響應流到前端。
  3. 瀏覽器下載文檔(content download),並對文檔進行解析。解析的過程如下所示:

圖4.1

知道了瀏覽器加載網頁的步驟,我們就可以從上面每一個環節采取”相對合適“的策略優化加載速度。 比如上面第二步驟會進行dns查找,那么dns查找是需要時間的,如果提前將dns解析並進行緩存,就可以減少這部分性能損失。在比如建立TCP連接之后,保持長連接的情況下可以串行發送請求。熟悉異步的朋友肯定知道串行的損耗是很大的,它的加載時間取決於資源加載時間的和。而采取並行的方式是所有加載時間中最長的。這個時候我們可以考慮減少http 請求或者使用支持並行方式的協議(比如HTT2協議)。如果大家熟悉瀏覽器的原理或者仔細觀察網絡加載圖的化,會發現同時加載的資源有一個上限(根據瀏覽器不同而不同),這是瀏覽器對於單個域名最大建立連接個數的限制,所以可以考慮增加多個domain來進行優化。類似的還有很多,留給大家思考。但是總結下來只有兩點,一是加載優化,即提高資源加載的速度。第二個是渲染優化,即資源拿到之后到解析完成的階段的優化。

經過上面簡單的講解,我想大家對瀏覽器加載HTML開始到頁面呈現出來,有了一個大概的認識,后面我會更詳細地講解這個過程。有一個名詞叫關鍵路徑(Critical Path),它指的是從瀏覽器收到 HTML、CSS 和 JavaScript 字節到對其進行必需的處理,從而將它們轉變成渲染的像素。這一過程中有一些中間步驟,優化性能其實就是了解這些步驟中發生了什么。記住關鍵路徑上的資源有HTML,CSS,JavaScript,其中並不包括圖片,雖然圖片在我們的應用中非常普遍,但是圖片並不會阻止用戶的交互,因此不計算到關鍵路徑,關於圖片的優化我會在下面的小節中重點介紹。

為了讓大家有更清晰地認識,我將上面瀏覽器加載網站步驟中的第七步中的CSSOM和DOM以及render tree的構建過程,更詳細地講解一下。

瀏覽器請求服務端的HTML文件,服務端響應字節流給瀏覽器。瀏覽器接受到HTML然后根據指定的編碼格式進行解碼。完成之后會分析HTML內容,將HTML分成一個個token,然后根據不同token生成不同的DOM,最后根據HTML中的層級結構生成DOM樹。

圖4.02

其中要注意的是,如果碰到CSS標簽和JavaScript標簽(不是async或者defer的js腳本)會暫停渲染,等到資源加載完畢,繼續渲染。如果加載了CSS文件(內斂樣式同理),會在加載完成CSS之后生成CSSOM。CSSOM的生成過程類似,也是將CSS分成一個個token,然后根據不同token生成CSSOM,CSSOM是用來控制DOM的樣式的。最后將DOM和CSSOM合成render tree。

CSS 是阻塞渲染的資源。需要將它盡早、盡快地下載到客戶端,以便縮短首次渲染的時間。

為弄清每個對象在網頁上的確切大小和位置,瀏覽器從渲染樹的根節點開始進行遍歷,根據盒模型和CSS計算規則生成計算樣式(chrome中叫computed style),最后調用繪制線程將DOM繪制到頁面上。因此優化上面每一個步驟都非常重要。現在我們有了清晰的認識,關鍵資源HTML是一切的起點,沒有HTML后面就沒有意義。CSS應該盡快下載並解析,通常我們將css放在head里面優先加載執行,就像app shell的概念一樣。我們應該優先給用戶呈現最小子集,然后慢慢顯示其他的內容,就好像PJPEG(progressive jpeg)一樣。如下圖是一個漸進式渲染的一個例子(圖片來自developers.google.com):

圖4.03

我們還沒有討論JavaScript,理論上JavaScript既可以操作CSS,也可以直接修改DOM。瀏覽器不知道JavaScript的具體內容,因此默認情況下JavaScript會阻止渲染引擎的執行,轉而去執行JS線程,如果是外部 JavaScript 文件,瀏覽器必須停下來,等待從磁盤、緩存或遠程服務器獲取腳本,這就可能給關鍵渲染路徑增加數十至數千毫秒的延遲,除非遇到帶有async或者defer的標簽。向script標記添加異步關鍵字可以指示瀏覽器在等待腳本可用期間不阻止DOM構建,這樣可以顯著提升性能。

經過上面的分析,我們知道了關鍵路徑。我們可以借助chrome開發工具查看瀑布圖,分析網站的關鍵路徑,分析加載緩慢,影響網站速度的瓶頸點。

圖4.04

也可以使用一些工具檢測,比如前面提到的web performance test,也可以嘗試下Lighthouse。

圖4.05

在后面的小節,我會介紹performance api,大家可以在前端埋點,然后分析網站的性能指標,這也是對其他分析手法的一個重要補充。

性能優化系統論

性能優化屬於一個整體內容,它應該是一個軟件的特性。我將性能優化分為如下幾個步驟:

1. 測量

其中測量分為手動測量和自動測量。

  • 自動測量

通過瀏覽器自身的事件和指標,我們將這些數據收集起來。 這些和業務無關的,我們可以做到自動化。 有名的比如lighthouse這種產品, 自動化測量部分的最佳實踐是將自動化測量加入到CI中,通常會伴隨着評分等信息用於輔助開發者判斷。 開發者推送合並新的代碼到主分支的時候觸發執行,並且可以設置一個閥值,低於這個閥值不允許合並等。

  • 手動測量

通過腳本檢測指標。 比如 performanceObserver, 比如FID,Long task 等。基本上就是我們關心的指標且不能自動化檢測的就需要手動

2. 收集用戶真實數據,並提供可視化展現

將上面這一步檢測的結果放到一個dashboard中,可以直觀感受到。 這部分可以參考lighthouse的UI。 我會在后面介紹朱雀的時候詳細介紹。

3. 分析瓶頸點, 分析代碼,並優化代碼

可以通過一些工具分析,比如webpack-bunlde-analysiscode coverafe of chrome dev tool 等。 這部分的最佳實踐非常多, 通常來講隨着時間的推移,技術的更替,這部分內容也會不斷更新。 因此這部分我不打算深入講解,但是這部分很重要,這部分是真正落地的部分,更加偏實踐的部分。

一個重要的原則就是只傳輸給用戶所需要的代碼。

4. 重復上述步驟

可以看出性能優化是一個不斷反饋和優化的閉環,每一個環節都至關重要。下面我們一一分析各個環節。

瀏覽器性能指標

性能優化的第一步就是測量,沒有測量就沒有優化。我們不能為了優化而優化, 而是看到了某些點需要優化才去優化的。 而發現這個點一個重要的方式就是要靠測量。

說到測量,普遍接受的方式是,在瀏覽器中進行打點,將瀏覽器打開網頁的過程看作是一個旅行。 那么我們每到一個地方就拍張帶有時間的照片(事件),最后我們對這些照片按照時間進行排列, 然后分析哪部分是我們的瓶頸點等。

幾個關鍵的指標

白屏時間

用戶從打開頁面開始到有頁面開始呈現為止。白屏時間長是無法忍受的,因此有了很多的縮短白屏時間的方法。 比如減少首屏加載內容,首屏內容漸出等。白屏的測量方法最古老的方法是這樣的:

<head>
<script>
var t = new Date().getTime();
</script>
<link src="">
<link src="">
<link src="">

<script>
tNow = new Date().getTime() - t;
</script>
</head>

但是上面這種只能測量首屏有html內容的情況,比如像react這樣客戶端渲染的方式就不行了。如果采用客戶端渲染的方式,就需要在首屏接口返回, 並渲染頁面的地方打點記錄。

通過類似的方法我們還可以查看圖片等其他資源的加載時間,以圖片為例:

<img src="xx" id="test" /> <script> var startLoad = new Date().getTime() document.getElementById('test').addEventListener('load', function(){ var duration = new Date().getTime() - startLoad(); }, false) </script>

通過這種方法未免太麻煩,還在瀏覽器performance api 提供了很多有用的接口,方便大家計算各種性能指標。下面performance api 會詳細講解。

首屏加載時間

我們所說的首屏時間,就是指用戶在沒有滾動時候看到的內容渲染完成並且可以交互的時間。至於加載時間,則是整個頁面滾動到底部,所有內容加載完畢並可交互的時間。用戶可以進行正常的事件輸入交互操作。

firstscreenready - navigationStart 

這個時間就是用戶實際感知的網站快慢的時間。firstscreenready 沒有這個 performance api, 而且不同的渲染手段(服務端渲染和客戶端渲染計算方式也不同),不能一概而論。具體計算方案,這邊文章寫得挺詳細的。首屏時間計算

完全加載時間

通常網頁以兩個事件的觸發時間來確定頁面的加載時間.

  1. DOMContentLoaded 事件,表示直接書寫在HTML頁面中的內容但不包括外部資源被加載完成的時間,其中外部資源指的是css、js、圖片、flash等需要產生額外HTTP請求的內容。
  2. onload 事件,表示連同外部資源被加載完成的時間。
domComplete - domLoading

Performance API

上面介紹了古老的方法測量關鍵指標,主要原理就是基於瀏覽器從上到下加載的原理。只是上面的方法比較麻煩,不適合實際項目中使用。 實際項目中還是采用打點的方式。 即在關鍵的地方埋點,然后根據需要將打點信息進行計算得到我們希望看到的各項指標,performance api 就是這樣一個東西。

The Performance interface provides access to performance-related information for the current page. It’s part of the High Resolution Time API, but is enhanced by the Performance Timeline API, the Navigation Timing API, the User Timing API, and the Resource Timing API.
—— 摘自MDN

在瀏覽器console中輸入performance.timing

圖4.2

返回的各字節跟下面的performance流程的各狀態一一對應,並返回時間。這個和js中直接new Date().getTime()的時間是不一樣的。 這個時間和真實時間沒有關系,而且perfermance api精確度更高。

圖4.3

有了這個performance api 我們可以很方便的計算各項性能指標。如果performamce api ”埋的點“不夠我們用,我們還可以自定義一些我們關心的指標,比如請求時間(成功和失敗分開統計),較長js操作時間,或者比較重要的功能等。總之,只要我們想要統計的,我們都可以借助performance api 輕松實現。

performance api 更多介紹請查看 https://developer.mozilla.org/en-US/docs/Web/API/Performance

性能監測的手段

日志

監控是基於日志的

一個格式良好,內容全面的日志是實現監控的重要條件,可以說基礎決定上層建築。 良好的的日志系統通常有以下幾個部分構成:

  1. 接入層

日志由產生到進入日志系統的過程。 比如rsyslog生成的日志,通過logstash(transport and process your logs, events, or other data)接入到日志系統。這種比較簡單,由於是基於原生linux的日志系統,學習使用成本也比較低。公司不同系統如果需要介入日志系統,只需要將日志寫入log目錄,通過logstash等采集就可以了。

  1. 日志 處理層

這部分是日志系統的核心,處理層可以將接入層產生的日志進行分析。過濾日志發送到監控中心(監控系統狀態)和存儲中心(數據匯總,查詢等)

  1. 日志存儲層

將日志入庫,根據業務情況,建立索引。這部分通常還可以接入像elastic search這樣的庫,提供日志的查詢,上面的logstash 就是elastic家族的。

除了上面核心的幾層,通常還有其他層完成更為細化的工作。

通常來說,一個公司的日志有以下幾個方面

  1. 性能日志

記錄一些關鍵指標,具體的關鍵指標可以參閱“瀏覽器性能指標”一節。

  1. 錯誤日志

記錄后端的服務器錯誤(500,502等),前端的腳本錯誤(script error)。

  1. 硬件資源日志

記錄硬件資源的使用率,比如內存,網絡帶寬和硬盤等。

  1. 業務日志

記錄業務方比較關心的用戶的操作。方便根據用戶報的異常,定位問題。

  1. 統計日志

統計日志通常是基於存儲日志的內容進行統計。統計日志有點像數據庫視圖的感覺,通過視圖屏蔽了數據庫的結構信息,將數據庫一部分內容透出到用戶。用戶行為分析等通常都是基於統計日志分析的。有的公司甚至介入了可視化的日志統計系統(比如Kibana)。隨着人工智能的崛起,人工智能+日志是一個方向。

性能日志

性能日志的產出

除了上面介紹的關鍵指標記錄。我們通常還比較關心接口的響應速度。這時候我們可以通過打點的方式記錄。

性能日志的消費

我們已經產出了日志,有了日志數據源。那么如何消費數據呢? 現在普遍的做法是服務端將收集的日志進行轉儲,並通過可視化手段(圖標等)展示給管理員。還有一種是用戶自己消費,即自產自銷。用戶產生數據,同時自己消費,提供更加的用戶體驗。 詳情查閱 locus 但是性能日志明顯不能自產自銷,我們暫時只考慮第一種。

性能監測平台

監控平台大公司基本都有自己的系統。比如有贊的Hawk,阿里的SunFire。小公司通常都是使用開源的監控系統或者干脆沒有。 我之前的公司就沒有什么監控平台,最多只是阿里雲提供的監控數據而已。所以我在這一方面做了一定的探索。並開始開發朱雀平台,但是限於精力有限,該計划最后沒有最終投入使用,還是蠻可惜的。性能監測的本質是基於監測的數據,提供方便的查詢和可視化的統計。並對超過臨界值(通常還有持續時長限制)發出警告。 上一節介紹了性能監控平台,提到了性能監控平台的兩個組成部分,一個是生產者一個是消費者。 這節介紹如何搭建一個監控平台。那么我先來看下整體的架構

圖4.4

為了方便講解,這里只實現一個最簡化的模型,讀者可以在此基礎上進一步划分子系統,比如接入SSO,存儲展示分離等。

客戶端

客戶端一方面上報埋點信息,另一方面上報軌跡信息。

上報埋點信息

這一部分主要借助一些手段,比如performance api 將網頁相關加載時間信息上報到后端。

performance.getEntriesByType("resource").forEach(function(r) { console.log(r.name + "" + r.duration) }) 

另一方面對特定的異步請求接口,打點。對用戶所有的交互操作打點(點擊,hover等)

const startTime = new Date().getTime(); fetch(url) .then(res => { const endTime = new Date().getTime(); report(url , 'success', endTime - startTime); }) .catch(err => { const endTime = new Date().getTime(); report(url, 'failure', endTime - startTime); }) 

上報軌跡信息

上傳軌跡信息就簡單了。如果是頁面粒度,直接在頁面上報就可以了。如果使用了前端路由,還可以在路由的鈎子函數中進行上報。

pageA.js // 上報軌跡 report('pageA',{userId: '876521', meta: {}}) 

這樣我們就有了數據源了。

服務端

服務端已經有了數據,后端需要將數據進行格式化,並輸出。

locus server

客戶端將自己的信息上報到server,由server進行統計匯總,並在合適的時候將處理后的數據下發到客戶端,指導客戶端的行為(如預加載)。

前面說了客戶端上傳的信息大概是

{ userId: 876521, page: "index", area: "", age: "", // 其他群體特征 } 

我們稱地域,年齡等為群體特征,群體特征對於分析統計有非常重要的意義。

我們可以對單用戶進行匯總,也可以對群體特征進行匯總從而預測客戶的行為。

我們匯總的數據可能是:

{ userId: '876521', pages: { "index": { "detail": 0.5, "return": 0.4, "personnal-center": 0.1 } } } 

如上是以用戶為緯度進行分析。上面的數據代表,如果用戶876521在首頁(index),那么ta下一步訪問詳情頁(detail)的概率是50%,訪問個人中心(personal-center)的概率為10%,退出頁面概率為40%。

我們就可以在可能的情況下,用戶停留在首頁的時候預加載詳情頁。

我們還可以對群體特征進行匯總,

匯總的結果可能是:

{ age: 'teen', pages: { "index": { "detail": 0.5, "return": 0.4, "personnal-center": 0.1 } } } 

其實和上面差不多,不過這里並不是只是用來指導某一個用戶,而是可以指導同一個群體特征(這里指同一年齡段)的用戶。

zhuque server

客戶端會上傳性能信息給zhuque server。

zhuque server 在這里主要有兩個職責:

  1. 將用戶上報的數據可視化輸出出來,供不同的人查看。

這部分通常做起來簡單,做好難。 我在這方面經驗不夠多,就不誤導大家了。

  1. 提供警報服務,如頁面超長時間無響應,打不開,關鍵資源404等問題。

警報種類有很多,比如郵件,電話,短信,釘釘等。 我們只要設置好觸發條件,然后寫一個定時任務或者在請求級別進行檢查,如果滿足就觸發警報即可。邏輯非常簡單。

定時任務對系統的壓力較小,但是及時性較低,適合對實時性要求不強的業務。 請求級別檢查謉系統壓力較大,但是及時性有保障,適合對實時性要求非常高的業務。

性能優化的手段

要做性能優化,首先要對系統運行的過程有一個完整的理解,然后從各個環節分析,找到系統瓶頸,從而進行優化。在這里我不羅列性能優化的各種手段,而是從前端三層角度逐個描述下性能優化的常見優化方向和手段。如果大家希望有一個完整的優化清單, 這里有一份比較完整的Front-End-Checklist,對於性能優化,有一定的借鑒意義。另外你也可以訪問webpagetest測試你的網站的性能,並針對網站提供的反饋一步步優化你的網站加載速度,這些內容不在本文論述范圍。 性能優化一個最重要的原則是:永遠呈現必要的內容,我們可以通過懶加載非首屏資源,或者采用分頁的方式將數據”按需加載“。下面講述一些具體的優化手段。 很多人都知道,前端將應用分為三層,分別是結構層,表現層和行為層。我們就從三層角度講一下性能優化的方向。

這部分的優化手段指的是在給定傳輸文件大小的情況下去優化,也就是說不考慮傳輸層面的優化

結構層

結構層指的是DOM結構,而DOM結構通常是由HTML結構決定的,因此我們主要分析下HTML結構的性能優化點。 我們知道DOM操作是非常昂貴的,這在前面講述前端發展歷史的時候也提到了。如何減少DOM數量,減少DOM操作是優化需要 重點關注的地方。

AMP HTML

說到HTML優化,不得不提AMP HTML。 AMP的核心思想是提供移動端更佳的用戶體驗,。由AMP HTML, AMP JS 和 AMP Cache 三個核心部分組成。

AMP HTML is HTML with some restrictions for reliable performance.

下面是典型的AMP HTML

<!doctype html>
<html ⚡>
 <head>
   <meta charset="utf-8">
   <link rel="canonical" href="hello-world.html">
   <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
   <style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
   <script async src="https://cdn.ampproject.org/v0.js"></script>
 </head>
 <body>Hello World!</body>
</html>

可以看出AMP HTML由普通的HTML標簽和amp標簽組成。amp標簽是做什么呢?且聽我跟你說,DOM雖然操作比較昂貴,但是不同的DOM效率也是不意義的。比如渲染一個a標簽和渲染一個img或者table時間肯定不是一樣的。我們稱a標簽這樣渲染較快的元素為輕元素。稱table,img這樣的元素為重元素。那么我們就應該盡量避免重元素的出現,比如table 可以采用ul li 實現。 img之所以比較慢的原因是圖片下載雖然是異步的,但是會占用網絡線程,同時會多發一個請求(瀏覽器並發請求數是有限制的),因此可以進一步封裝稱輕元素(比如x-image,組件內部可以延遲發送圖片請求,等待主結構渲染完畢再發圖片請求)。 可以考慮將其封裝為web-component或者其他組件形式(如react組件)

回到剛才AMP HTML, 其實在amp 中 有一個amp-image這樣的接口,大概可以根據需要自己實現,上面我們說的x-image 其實就是實現了amp接口規范的組件。

減少沒有必要的嵌套

前面說到了盡可能使用輕元素。那么除了使用輕元素,還有一點也很重要,就是減少DOM數量。減少DOM數量的一個重要的途徑就是減少冗余標簽。比如我們通過新增加一個元素清除浮動。

<div class="clear"></div>

不過目前都是采用偽元素實現了。另一個途徑是減少嵌套層次。很多人都會在<form>或者<ul>外邊包上<div>標簽,為什么加上一個你根本不需要的<div>標簽呢?實際上你完全可以用CSS selector,實現同樣的效果。 重要的是,這種代碼我見過很多。

<div class="form">
    <form>
    ...
    </form>
</div>

完全可以這樣寫:

<form class="form">
    ...
</form>

表現層

表現層就是我們通常使用的CSS。CSS經過瀏覽器的解析會生成CSS TREE,進而和DOM TREE合成 RENDER TREE。有時候一些功能完全可以通過CSS去實現,沒有必要使用javaScript,尤其是動畫方面,CSS3增加了transition 和 transform 用來實現動畫,開發者甚至可以通過3D加速功能來充分發揮GPU的性能。因此熟練使用CSS,並掌握CSS的優化技巧是必不可少的。CSS 的性能優化通常集中在兩方面:

提高CSS的加載性能

提高加載性能就是減少加載所消耗的時間。簡單說就是減小CSS文件的大小,提高頁面的加載速度,盡可以的利用http緩存等。代碼層面我們要避免引入不需要的樣式,合理運用繼承減少代碼。

<body>
    <div class="list" />
</body>
body { color: #999 } .list {color: #999} // color 其實可以繼承body,因此這一行沒必要 

其他可以繼承的屬性有color,font-size,font-family等。

通常來說這部分和JS等靜態資源的優化道理是一樣的。只不過目前網站有一個理念是框架優先,即先加載網站的主題框架,這部分通常是靜態部分,然后動態加載數據,這樣給用戶的感覺是網站”很快“。而這部分的靜態內容,通常可以簡單的HTML結構(Nav + footer),加上CSS樣式來完成。 這就要求主題框架的CSS優先加載,我們設置可以將這部分框架樣式寫到內斂樣式中去,但是有的人覺得這樣不利於代碼的維護。

提高CSS代碼性能

瀏覽器對不同的代碼執行效率是不同的,復雜的樣式(多層嵌套)也會降低css解析效率,因此可以將復雜的嵌套樣式進行轉化。

.wrapper .list .item .success {} // 可以寫成如下: .wrapper .list .item-success {} 

還有一部分是網站的動畫,動畫通常來說要做到16ms以內,以讓用戶感覺到非常流暢。另外我們還可以通過3D加速來充分應用GPU的性能。 這里引用於江水的一句話:

只有在非常復雜的頁面,樣式非常多的時候,CSS 的性能瓶頸才會凸顯出來,這時候更多要考慮的應該是有沒有必要做這么復雜的頁面。

行為層

行為層指的是用戶交互方面的內容,在前端主要通過JavaScript實現。目前JavaScript 規范已經到es2017。 前端印象較為深刻的是 ES6(也就是ES2015),因為ES5是2009年發布的,之后過了6年,也就是2015年ES6才正式發布,其中增加了許多激動人心的新特性, 被廣大前端所熟悉。甚至曾一度稱目前前端狀態是536(HTML5,CSS3,ES6),可見其影響力。 JS或許是前端最昂貴的資源了,其相對於css,fonts,images的處理,需要更多的資源。這里有一篇很棒的文章the-cost-of-javascript-in-2018,詳細闡述了為什么js這么昂貴,以及如何改進。

毫不誇張的說,目前前端項目絕大多數代碼都是javascript。既然js用的這么多,為什么很少有人談js性能優化呢? 一是因為現在工業技術的發展,硬件設備的性能提升,導致前端計算性能通常不認為是一個系統的性能瓶頸。二是隨着V8引擎的發布,js執行速度得到了很大的提升。三是因為計算性能是本地CPU和內存的工作,其相對於網路IO根本不是一個數量級,因此人們更多關注的是IO方面的優化。那么為什么還要將js性能優化呢?一方面是目前前端會通過node做一些中間層,甚至是后端,因此需要重點關注內存使用情況,這和瀏覽器是大相徑庭的。另一方面是因為前端有時候也會寫一個復雜計算,也會有性能問題。 最后一點是我們是否可以通過JS去優化網絡IO的性能呢,比如使用JS API 操作 webWorker 或者使用localStorage緩存。

計算緩存

前端偶爾也會有一些數據比較大的計算。 對於一些復雜運算,通常我們會將計算結果進行緩存,以供下次使用。前面提到了純函數的概念,要想使用計算緩存,就要求函數要是純函數。一個簡單的緩存函數代碼如下:

// 定義 function memoize(func) { var cache = {}; var slice = Array.prototype.slice; return function() { var args = slice.call(arguments); if (args in cache) return cache[args]; else return (cache[args] = func.apply(this, args)); } } // 使用 function cal() {} const memoizeCal = memoize(cal); memoizeCal(1) // 計算,並將結果緩存 memoizeCal(1) // 直接返回 

網絡IO緩存

前面講了計算方面的優化,它的優化范圍是比較小的。因為並不是所有系統都會有復雜計算。但是網絡IO是所有系統都存在的,而且網絡IO是不穩定的。網絡IO的 速度和本地計算根本不是一個數量級,好在我們的瀏覽器處理網絡請求是異步的(當然可以代碼控制成同步的)。一種方式就是通過本地緩存,將網絡請求結果存放到本地,在下次請求的時候直接讀取,不需要重復發送請求。一個簡單的實現方法是:

function cachedFetch(url, options) { const cache = {}; if (cache[url]) return cache[url]; else { return fetch(url, options).then(res) { cache[url] = res return res; } } } 

當然上面的粗暴實現有很多問題,比如沒有緩存失效策略(比如可以采用LRU策略或者通過TTL),但是基本思想是這樣的。 這種方式的優點很明顯,就是顯著減少了系統反饋時間,當然缺點也同樣明顯。由於使用了緩存,當數據更新的時候,就要考慮緩存更新同步的問題,否則會造成數據不一致,造成不好的用戶體驗。

數據結構,算法優化

數據結構和算法的優化是前端接觸比較少的。但是如果碰到計算量比較大的運算,除了運用緩存之外,還要借助一定的數據結構優化和算法優化。 比如現在有50,000條訂單數據。

const orders = [{name: 'john', price: 20}, {name: 'john', price: 10}, ....] 

我需要頻繁地查找其中某個人某天的訂單信息。 我們可以采取如下的數據結構:

 const mapper = { 'john|2015-09-12': [] } 

這樣我們查找某個人某天的訂單信息速度就會變成O(1),也就是常數時間。你可以理解為索引,因為索引是一種數據結構,那么我們也可以使用其他數據結構和算法適用我們各自獨特的項目。對於算法優化,首先就要求我們能夠識別復雜度,常見的復雜度有O(n) O(logn) O(nlogn) O(n2)。而對於前端,最基本的要識別糟糕的復雜度的代碼,比如n三次方或者n階乘的代碼。雖然我們不需要寫出性能非常好的代碼,但是也盡量不要寫一些復雜度很高的代碼。

多線程計算

通過HTML5的新API webworker,使得開發者可以將計算轉交給worker進程,然后通過進程通信將計算結果回傳給主進程。毫無疑問,這種方法對於需要大量計算有着非常明顯的優勢。

代碼摘自Google performance

var dataSortWorker = new Worker("sort-worker.js"); dataSortWorker.postMesssage(dataToSort); // The main thread is now free to continue working on other things... dataSortWorker.addEventListener('message', function(evt) { var sortedData = evt.data; // Update data on screen... }); 

由於WebWorker 被做了很多限制,使得它不能訪問諸如window,document這樣的對象,因此如果你需要使用的話,就不得不尋找別的方法。

一種使用web worker的思路就是分而治之,將大任務切分為若干個小任務,然后將計算結果匯總,我們通常會借助數組這種數據結構來完成,下面是一個例子:

// 很多小任務組成的數組 var taskList = breakBigTaskIntoMicroTasks(monsterTaskList); // 使用更新的api requestAnimationFrame而不是setTimeout可以提高性能 requestAnimationFrame(processTaskList); function processTaskList(taskStartTime) { var taskFinishTime; do { // Assume the next task is pushed onto a stack. var nextTask = taskList.pop(); // Process nextTask. processTask(nextTask); // Go again if there’s enough time to do the next task. taskFinishTime = window.performance.now(); } while (taskFinishTime - taskStartTime < 3); if (taskList.length > 0) requestAnimationFrame(processTaskList); } 

線程安全問題都是由全局變量及靜態變量引起的。 若每個線程中對全局變量、靜態變量只有讀操作,而無寫操作,一般來說,這個全局變量是線程安全的;若有多個線程同時執行寫操作,就需要考慮線程同步,就可能產生線程安全問題。

大家可以不必太擔心,web worker已經在這方面做了很多努力,例如你沒有辦法去訪問非線程安全的組件或者是 DOM,此外你還需要通過序列化對象來與線程交互特定的數據。因此大家如果想寫出線程不安全的代碼,還真不是那么容易的。

V8引擎下的代碼優化

V8是由Google提出的,它通過將js代碼編譯成機器碼,而非元組碼或者解釋它們,進而提高性能。V8內部還封裝了很多高效的算法,很多開發者都會研究V8的源碼來提升自己。 這里有些js優化的實踐,詳情可看下這篇文章 還有很多其他有趣的研究

Benedikt Meurer(Tech Lead of JavaScript Execution Optimization in Chrome/V8)本人致力於V8的性能研究,寫了很多有深度的文章,並且開源了很多有趣的項目,有興趣的可以關注一下。

內存泄漏

前端中的內存泄漏不是很常見,但是還是有必要知道一下,最起碼能夠在出現問題的時候去解決問題。更為低級的語言如C語言,有申請內存malloc和銷毀內存free的操作。而在高級語言比如java和js,屏蔽了內存分配和銷毀的細節,然后通過GC(垃圾回收器)去清除不需要使用的內存。

只有開發人員自己知道什么時候應該銷毀內存。

好在內存銷毀還是有一定規律可循,目前GC的垃圾回收策略主要有兩種,一種是引用計數,另一種是不可達檢測。目前主流瀏覽器都實現了上述兩種算法,並且都會綜合使用兩種算法對內存進行優化。但是確實還存在上述算法無法覆蓋的點,比如閉包。因此還是依賴於開發者本身的意識,因此了解下內存泄漏的原理和解決方案還是非常有用的。下面講述容易造成內存泄漏的幾種情況。

尾調用

函數調用是有一定的開銷的,具體為需要為函數分配棧空間。如果遞歸調用的話,有可能造成爆棧。

function factorial(n) { if (n === 1) return 1; return n * factorial(n - 1); } factorial(100) // 一切正常 factorial(1000) // 有可能爆棧,但是現在瀏覽器做了優化,通常會輸出Infinite } 

如果在這里你使用了比較復雜的運算情況就會變糟,如果再加上閉包就更糟糕了。

閉包

由於js沒有私有屬性,js如果要實現私有屬性的功能,就要借助閉包實現。

 function closure() { var privateKey = 1; return function() { return privateKey } } 

但是由於js的垃圾回收機制,js會定期將沒有引用的內存釋放,如果使用閉包,函數會保持變量的引用,導致垃圾回收周期內不能將其銷毀,濫用閉包則可能產生內存泄漏。

圖片優化

上面從前端三層角度分析了性能優化的手段,但是還有一個,而且是占比非常大的資源沒有提到,那就是圖片。俗話說,一圖勝千言,圖片在目前的網站中占據了網站中大部分的流量。雖然圖片不會阻止用戶的交互,不影響關鍵路徑,但是圖片加載的速度對於用戶體驗來說非常重要。尤其是移動互聯網如此發達的今天,為用戶節省流量也是非常重要的。因此圖片優化主要有兩點,一點是在必要的時候使用圖片,不必要的時候換用其他方式。另一種就是壓縮圖片的體積。

明確是否需要圖片

首先要問問自己,要實現所需的效果,是否確實需要圖像。好的設計應該簡單,而且始終可以提供最佳性能。如果您可以消除圖像資源(與 HTML、CSS、JavaScript 以及網頁上的其他資源相比,需要的字節數通常更大),這種優化策略就始終是最佳策略。不過,如果使用得當,圖像傳達的信息也可能勝過千言萬語,因此需要由您來找到平衡點。

壓縮圖片體積

首先來看下圖片體積的決定因素。這里可能需要一些圖像學的相關知識。圖片分為位圖和矢量圖。位圖是用比特位來表示像素,然后由像素組成圖片。位圖有一個概念是位深,是指存儲每個像素所用的位數。那么對於位圖計算大小有一個公式就是圖片像素數 * 位深 bits。 注意單位是bits,也可以換算成方便查看的kb或者mb。

圖片像素數 = 圖片水平像素數 * 圖片垂直像素數

而矢量圖由數學向量組成,文件容量較小,在進行放大、縮小或旋轉等操作時圖象不會失真,缺點是不易制作色彩變化太多的圖象。那么矢量圖是電腦經過數據計算得到的,因此占據空間小。通常矢量圖和位圖也會相互轉化,比如矢量圖要打印就會點陣化成位圖。

下面講的圖片優化指的是位圖。知道了圖片大小的決定因素,那么減少圖片大小的方式就是減少分辨率或者采用位深較低的圖片格式。

減少分辨率

我們平時開發的時候,設計師會給我們1x2x3x的圖片,這些圖片的像素數是不同的。2x的像素數是1x的 2x2=4倍,而3x的像素數高達3x3=9倍。圖片直接大了9倍。因此前端使用圖片的時候最好不要直接使用3倍圖,然后在不同設備上平鋪,這種做法會需要依賴瀏覽器對其進行重新縮放(這還會占用額外的 CPU 資源)並以較低分辨率顯示,從而降低性能。 下面的表格數據來自Google Developers

圖4.004

請注意,在上述所有情況下,顯示尺寸只比各屏幕分辨率所需資源“小 10 個 CSS 像素”。不過,多余像素數及其相關開銷會隨圖像顯示尺寸的增加而迅速上升!因此,盡管您可能無法保證以精確的顯示尺寸提供每一個資源,但您應該確保多余像素數最少,並確保特別是較大資源以盡可能接近其顯示尺寸的尺寸提供。

我們可以使用媒體查詢或者srcset等針對不同屏幕加載不同資源。但是9倍這樣的大小我們還是很難接受。因此有了下面的方法。

減少位深

位深是用來表示一個顏色的字節數。位深是24位,表達的是使用256(2的24/3次方)位表示一個顏色。因此位深越深,圖片越精細。如果可能的話,減少位深可以減少體積。

壓縮

前面說了圖片大小 = 圖片像素數 * 位深, 其實更嚴格的是圖片大小 = 圖片像素數 * 位深 * 圖片質量, 因此圖片質量(q)越低,圖片會越小。 影響圖片壓縮質量的因素有很多,比如圖片的顏色種類數量,相鄰像素顏色相同的個數等等。對應着有很多的圖片壓縮算法,目前比較流行的圖片壓縮是webp格式。因此條件允許的話,盡量使用webp格式。

圖片優化的具體實踐

有了上面的理論之后,我們需要將理論具體運用在實踐上。 我們平時開發的時候會有縮略圖的需求,如果你將原始圖片加載過來通過css控制顯示的話,你會發現你會加載一個非常大的圖片,然而本身應該很小才對。那么如果我們可以控制只下載我們需要縮略顯示的部分就好了。我們希望可以通過https://test.imgix.net/some_file?w=395&h=96&crop=faces的方式指定圖片的大小,從而減少傳輸字節的浪費。已經有圖片服務商提供了這樣的功能。比如imgix。

imgix有一個優勢就是能夠找到圖片中有趣的區域並做裁剪。而不是僅僅裁剪出圖片的中心

上面提到的webp最好也可以通過CDN廠商支持,即我們上傳圖片的時候,CDN廠商對應存儲一份webp的。比如我們上傳一個png圖片https://img.alicdn.com/test/TB1XFdma5qAXuNjy1XdXXaYcVXa-29-32.png。然后我們可以通過https://img.alicdn.com/test/TB1XFdma5qAXuNjy1XdXXaYcVXa-29-32.webp訪問其對應的webp資源。我們就可以根據瀏覽器的支持情況加載webp或者png圖片了。

第二個有效的方式是懶加載,一個重要的思想就是只加載應該在此時展示的圖片。假如你正在使用react,那么你可以通過react-lazyload使用圖片懶加載。其他框架可以自行搜索。

import LazyLoad from 'react-lazyload'; <LazyLoad once height={200} offset={50}> <img srcSet={xxx} sizes={xxxxx} /</LazyLoad>

一個實例

前面說了性能優化更像是軟件的一個指標,是一種被全體人員(包括非技術人員)普遍認同的一個特性。 因此它需要各方面的人員通力合作,雖然從表現上來看,性能優化最終是開發人員(可以是前端,也可以是后端,DBA等),但是它一定要是全體員工的認同。

假設公司給出計划,計划在下一個季度將首次訪問移動端商城首頁的首屏時間優化到5s以內。 接下來訪問白屏時間在2s以內。 我們現在被要求來做這件事情,我們應該怎么做呢?

首先這個目標需要被更清晰的描述。 3s是一個具體的數字,本身沒有問題,但是這個目標本身缺乏限定條件。比如什么樣的手機,什么樣的網絡情況,什么樣的地址位置。

因此我們需要與決策者進行溝通,將限定條件搞清楚。假設我們搞清楚了限定條件, 是1000塊的中端機以及蘋果6s,在3g條件下,地理位置就在本省行政區域內。 接下來我們需要實際的測量數據,以便我們分析數據以及和優化后的數據做對比。

首次渲染

假設DNS查詢和TLS握手需要花費1.6s. 那么我們只剩下5s - 1.6s = 3.4s

1.6s 是根據實際測量結果給出的

國內三大運營商的3G網絡數據理論上是350kb/s , 由於地地形和周邊設施等因素,實際測試平均大約在100kb/s左右。 這里以100kb/s計算。

那么我們可以傳輸的文件大小理論上最大 為 100kb/s * 3.4s = 340kb. 因此我們需要將我們的網站的首屏加載的文件大小總和控制在340kb. 通常來說,控制在170kb以內比較理想。 我們按照170kb計算。

前面已經探討了網頁加載的原理,那么文件傳輸只是其中一部分, 但是由於其所占比例非常大,我們這里注明是理論上的最大值。

前面說過的170kb是gzip之后的文件大小,通常來說gzip對文件大小的壓縮比率為5x - 7x.

壓縮比率取決於算法本身,文件重復率等

那么壓縮前文件的大小為850kb - 1M 左右。

如今,我們的項目大都會引用一些UI框架如reactjs, vuejs,會用狀態管理庫,比如redux,vuex等,為了兼容低版本的瀏覽器會有polyfill,會有UI組件庫等等. 他們都會給我們的應用增加體積, 因此引入一個庫的時候一定要知道它給我們帶來了什么,我們是否需要它的全部功能。 比如momentjs,我們是否一定要引入這樣一個庫,是否可以使用更精簡的庫來代替,或者將locales去除等。

如上所說其實是項目的依賴,項目的依賴要比我們的代碼多得多。因此管理依賴要比管理我們的代碼本身更具有復雜性和挑戰性。

如下一個實際項目的業務代碼和依賴的代碼分布情況。

du -s src # 6 du -s node_modules # 257 

可以看出依賴的大小為業務代碼的40多倍。而且我的這個項目本身來說還屬於比較簡單的, 更復雜的項目通常這個比例會更大。你也可以試試你自己的項目。

明白了這些基本點,並且我們已經拿到了一組測量的數據。 通過這組數據,我們大可以分析出占用時間較長的環節。 然后我們就需要一些知識和工具幫助我們正確地找到真正的問題。 比如我們可以通過light house來測量數據,然后通過chrome dev tool來分析單次的訪問記錄。 比如我們可以找出耗時較長的任務是什么, 是重排還是JS執行等。 然后找到影響的相關代碼進行優化,最后別忘了驗證。 然后拿出前后的對比數據來給自己和大家看。

強烈建議大家將性能檢測加入到CI中,然后給項目進行打分。 低於一定分數的項目當成是構建失敗對待。 只有將性能的重要性提到這個高度,我們才能夠真正的不斷精進,而不是一時之快。 市面上這樣的工具很多,比如light-house-ci。

加入到CI另一個好處,項目的性能是透明可視化的,這對於管理層了解項目的情況尤為重要。不要小看這一點,很多管理層對於你的各種理論無動於衷, 但是他們對於數字(分數,性能指標)有着很高的興趣和敏感性。 如果你這么做了,那么你更會體會到性能優化遠不止技術上的優化, 它伴隨着很多其他過程和各方面的取舍。

非首次渲染

關於非首次渲染,我們可以通過網絡緩存的形式減少靜態資源的下載時間。這部分時間是相當可觀的,它占據了網頁訪問的大部分時間。

那么非首次訪問就不需要考慮網絡因素,那么影響非首次訪問速度的因素大概會有:

  1. 加載webview以及webview的啟動時間
  2. 從磁盤讀取緩存的時間
  3. 渲染的時間(執行代碼,layout,paint等)

明白了優化點之后,就需要對症下葯。 首先還是要測量各個部分實際的時間,然后利用工具診斷,發現問題。由於每一個部分都有可能拖慢整體的速度,並且引起各個部分變慢的因素理論上說是無限的。因此不可能在這里涵蓋,希望大家可以利用之前講過的技巧來分析並找到問題。

總結

如果你已經采取了非常多的優化手段,用戶還是覺得非常慢,怎么辦呢?要知道,性能好不好不是數據測量出來的,而是用戶的直觀感覺,就像我開篇講述的那樣。有一個方法可以在速度不變的情況下,讓用戶感覺更快,那就是合理使用動畫。如一個寫着當前90%進度的進度條,一個奔跑的小熊?但是一定要慎用,因為不合理的動畫設計,反而讓用戶反感,試想一下,當你看到一個期待已久的確定按鈕,但是它被一個奔跑的小熊擋在了身后,根本點不到,你內心會是怎樣的?

另外我在這里只是提供了性能優化的思路,並沒有覆蓋性能優化的所有點,比如google的protobuffer可以減少前后端傳輸數據的體積,進而提升性能。但是我們 有了上面的優化理論和思想,我相信這些東西都是可以看到並做到的


歡迎關注公眾號,進一步技術交流:

參考文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM