你必須懂的前端效能優化

張炳發表於2019-07-28

從輸入URL載入起看方向

從輸入 URL 到頁面載入完成的過程:

  1. 首先做 DNS 查詢,如果這一步做了智慧 DNS 解析的話,會提供訪問速度最快的 IP 地址回來
  2. 接下來是 TCP 握手,應用層會下發資料給傳輸層,這裡 TCP 協議會指明兩端的埠號,然後下發給網路層。網路層中的 IP 協議會確定 IP 地址,並且指示了資料傳輸中如何跳轉路由器。然後包會再被封裝到資料鏈路層的資料幀結構中,最後就是物理層面的傳輸了
  3. TCP 握手結束後會進行 TLS 握手,然後就開始正式的傳輸資料
  4. 資料在進入服務端之前,可能還會先經過負責負載均衡的伺服器,它的作用就是將請求合理的分發到多臺伺服器上,這時假設服務端會響應一個 HTML 檔案
  5. 首先瀏覽器會判斷狀態碼是什麼,如果是 200 那就繼續解析,如果 400 或 500 的話就會報錯,如果 300 的話會進行重定向,這裡會有個重定向計數器,避免過多次的重定向,超過次數也會報錯
  6. 瀏覽器開始解析檔案,如果是 gzip 格式的話會先解壓一下,然後通過檔案的編碼格式知道該如何去解碼檔案
  7. 檔案解碼成功後會正式開始渲染流程,先會根據 HTML 構建 DOM 樹,有 CSS 的話會去構建 CSSOM 樹。如果遇到 script 標籤的話,會判斷是否存在 async 或者 defer ,前者會並行進行下載並執行 JS,後者會先下載檔案,然後等待 HTML 解析完成後順序執行,如果以上都沒有,就會阻塞住渲染流程直到 JS 執行完畢。遇到檔案下載的會去下載檔案,這裡如果使用 HTTP 2.0 協議的話會極大的提高多圖的下載效率。
  8. 初始的 HTML 被完全載入和解析後會觸發 DOMContentLoaded 事件
  9. CSSOM 樹和 DOM 樹構建完成後會開始生成 Render 樹,這一步就是確定頁面元素的佈局、樣式等等諸多方面的東西
  10. 在生成 Render 樹的過程中,瀏覽器就開始呼叫 GPU 繪製,合成圖層,將內容顯示在螢幕上了

我們從輸入 URL 到顯示頁面這個過程中,涉及到網路層面的,有三個主要過程:

  • DNS 解析
  • TCP 連線
  • HTTP 請求/響應

對於 DNS 解析和 TCP 連線兩個步驟,我們前端可以做的努力非常有限。相比之下,HTTP 連線這一層面的優化才是我們網路優化的核心。

HTTP 優化有兩個大的方向:

  • 減少請求次數
  • 減少單次請求所花費的時間

瀏覽器快取策略

瀏覽器快取機制有四個方面,它們按照獲取資源時請求的優先順序依次排列如下:

  1. Memory Cache
  2. Service Worker Cache
  3. HTTP Cache
  4. Push Cache

MemoryCache

MemoryCache,是指存在記憶體中的快取。從優先順序上來說,它是瀏覽器最先嚐試去命中的一種快取。從效率上來說,它是響應速度最快的一種快取。瀏覽器秉承的是“節約原則”,我們發現,Base64 格式的圖片,幾乎永遠可以被塞進 memory cache,這可以視作瀏覽器為節省渲染開銷的“自保行為”;此外,體積不大的 JS、CSS 檔案,也有較大地被寫入記憶體的機率——相比之下,較大的 JS、CSS 檔案就沒有這個待遇了,記憶體資源是有限的,它們往往被直接甩進磁碟。

Service Worker Cache

Service Worker 是一種獨立於主執行緒之外的 Javascript 執行緒。它脫離於瀏覽器窗體,因此無法直接訪問 DOM。這樣獨立的個性使得 Service Worker 的“個人行為”無法干擾頁面的效能,這個“幕後工作者”可以幫我們實現離線快取、訊息推送和網路代理等功能。我們藉助 Service worker 實現的離線快取就稱為 Service Worker Cache。

HTTP Cache

它又分為強快取和協商快取。優先順序較高的是強快取,在命中強快取失敗的情況下,才會走協商快取。

對一條http get 報文的基本快取處理過程包括7個步驟:

  1. 接收
  2. 解析
  3. 查詢,快取檢視是否有本地副本可用,如果沒有,就獲取一份副本
  4. 新鮮度檢測, 快取檢視已快取副本是否足夠新鮮,如果不是,就詢問伺服器是否有任何更新。
  5. 建立響應,快取會用新的首部和已快取的主體來構建一條響應報文。
  6. 傳送,快取通過網路將響應發回給客服端。
  7. 日誌

圖片描述

強快取

強快取是利用 http 頭中的 Expires 和 Cache-Control 兩個欄位來控制的。強快取中,當請求再次發出時,瀏覽器會根據其中的 expires 和 cache-control 判斷目標資源是否“命中”強快取,若命中則直接從快取中獲取資源,不會再與服務端發生通訊。

是否足夠新鮮時期:
通過 Expires: XXXX XXX XXX GMT (絕對日期時間,http/1.0) 或者 Cache-Control:max-age=XXXX (相對日期時間,http/1.1)在文件標明過期日期。

Cache-Control 相對於 expires 更加準確,它的優先順序也更高。當 Cache-Control 與 expires 同時出現時,我們以 Cache-Control 為準。

關鍵字理解

public 與 private 是針對資源是否能夠被代理服務快取而存在的一組對立概念。如果我們為資源設定了 public,那麼它既可以被瀏覽器快取,也可以被代理伺服器快取;如果我們設定了 private,則該資源只能被瀏覽器快取。private 為預設值。

no-store與no-cache,no-cache 繞開了瀏覽器:我們為資源設定了 no-cache 後,每一次發起請求都不會再去詢問瀏覽器的快取情況,而是直接向服務端去確認該資源是否過期(即走我們下文即將講解的協商快取的路線)。no-store 比較絕情,顧名思義就是不使用任何快取策略。在 no-cache 的基礎上,它連服務端的快取確認也繞開了,只允許你直接向服務端傳送請求、並下載完整的響應。

協商快取

協商快取依賴於服務端與瀏覽器之間的通訊。協商快取機制下,瀏覽器需要向伺服器去詢問快取的相關資訊,進而判斷是重新發起請求、下載完整的響應,還是從本地獲取快取的資源。如果服務端提示快取資源未改動(Not Modified),資源會被重定向到瀏覽器快取,這種情況下網路請求對應的狀態碼是 304。

協商快取的實現:從 Last-Modified 到 Etag,詳細自己百度,這裡不再詳細展開。

HTTP 快取決策

圖片描述

當我們的資源內容不可複用時,直接為 Cache-Control 設定 no-store,拒絕一切形式的快取;否則考慮是否每次都需要向伺服器進行快取有效確認,如果需要,那麼設 Cache-Control 的值為 no-cache;否則考慮該資源是否可以被代理伺服器快取,根據其結果決定是設定為 private 還是 public;然後考慮該資源的過期時間,設定對應的 max-age 和 s-maxage 值;最後,配置協商快取需要用到的 Etag、Last-Modified 等引數。

Push Cache

Push Cache 是指 HTTP2 在 server push 階段存在的快取。

  • Push Cache 是快取的最後一道防線。瀏覽器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情況下才會去詢問 Push Cache。
  • Push Cache 是一種存在於會話階段的快取,當 session 終止時,快取也隨之釋放。
  • 不同的頁面只要共享了同一個 HTTP2 連線,那麼它們就可以共享同一個 Push Cache。

CDN瞭解一番

CDN 的核心點有兩個,一個是快取,一個是回源。

“快取”就是說我們把資源 copy 一份到 CDN 伺服器上這個過程,“回源”就是說 CDN 發現自己沒有這個資源(一般是快取的資料過期了),轉頭向根伺服器(或者它的上層伺服器)去要這個資源的過程。
CDN 往往被用來存放靜態資源。所謂“靜態資源”,就是像 JS、CSS、圖片等不需要業務伺服器進行計算即得的資源。而“動態資源”,顧名思義是需要後端實時動態生成的資源,較為常見的就是 JSP、ASP 或者依賴服務端渲染得到的 HTML 頁面。

那“非純靜態資源”呢?它是指需要伺服器在頁面之外作額外計算的 HTML 頁面。具體來說,當我開啟某一網站之前,該網站需要通過許可權認證等一系列手段確認我的身份、進而決定是否要把 HTML 頁面呈現給我。這種情況下 HTML 確實是靜態的,但它和業務伺服器的操作耦合,我們把它丟到CDN 上顯然是不合適的。

另外,CDN的域名必須和主業務伺服器的域名不一樣,要不,同一個域名下面的Cookie各處跑,浪費了效能流量的開銷,CDN域名放在不同的域名下,可以完美地避免了不必要的 Cookie 的出現!

圖片優化

二進位制位數與色彩的關係

在計算機中,畫素用二進位制數來表示。不同的圖片格式中畫素與二進位制位數之間的對應關係是不同的。一個畫素對應的二進位制位數越多,它可以表示的顏色種類就越多,成像效果也就越細膩,檔案體積相應也會越大。

一個二進位制位表示兩種顏色(0|1 對應黑|白),如果一種圖片格式對應的二進位制位數有 n 個,那麼它就可以呈現 2^n 種顏色。

計算圖片大小

對於一張 100 100 畫素的圖片來說,影象上有 10000 個畫素點,如果每個畫素的值是 RGBA 儲存的話,那麼也就是說每個畫素有 4 個通道,每個通道 1 個位元組(8 位 = 1個位元組),所以該圖片大小大概為 39KB(10000 1 * 4 / 1024)。
但是在實際專案中,一張圖片可能並不需要使用那麼多顏色去顯示,我們可以通過減少每個畫素的調色盤來相應縮小圖片的大小。
瞭解瞭如何計算圖片大小的知識,那麼對於如何優化圖片,想必大家已經有 2 個思路了:

  • 減少畫素點
  • 減少每個畫素點能夠顯示的顏色

圖片型別要點

JPEG/JPG 特點:有失真壓縮、體積小、載入快、不支援透明,JPG 最大的特點是有失真壓縮。這種高效的壓縮演算法使它成為了一種非常輕巧的圖片格式。另一方面,即使被稱為“有損”壓縮,JPG的壓縮方式仍然是一種高質量的壓縮方式:當我們把圖片體積壓縮至原有體積的 50% 以下時,JPG 仍然可以保持住 60% 的品質。但當它處理向量圖形和 Logo 等線條感較強、顏色對比強烈的影象時,人為壓縮導致的圖片模糊會相當明顯。

PNG 特點:無失真壓縮、質量高、體積大、支援透明,PNG(可移植網路圖形格式)是一種無失真壓縮的高保真的圖片格式。8 和 24,這裡都是二進位制數的位數。按照我們前置知識裡提到的對應關係,8 位的 PNG 最多支援 256 種顏色,而 24 位的可以呈現約 1600 萬種顏色。PNG 圖片具有比 JPG 更強的色彩表現力,對線條的處理更加細膩,對透明度有良好的支援。它彌補了上文我們提到的 JPG 的侷限性,唯一的 BUG 就是體積太大。

SVG 特點:文字檔案、體積小、不失真、相容性好,SVG(可縮放向量圖形)是一種基於 XML 語法的影象格式。它和本文提及的其它圖片種類有著本質的不同:SVG 對影象的處理不是基於畫素點,而是是基於對影象的形狀描述。

Base64 特點:文字檔案、依賴編碼、小圖示解決方案,Base64 並非一種圖片格式,而是一種編碼方式。Base64 和雪碧圖一樣,是作為小圖示解決方案而存在的。

WebP 特點:年輕的全能型選手,WebP 像 JPEG 一樣對細節豐富的圖片信手拈來,像 PNG 一樣支援透明,像 GIF 一樣可以顯示動態圖片——它集多種圖片檔案格式的優點於一身。但是畢竟年輕,相容性存在一些問題。

渲染優化

客戶端渲染

在客戶端渲染模式下,服務端會把渲染需要的靜態檔案傳送給客戶端,客戶端載入過來之後,自己在瀏覽器裡跑一遍 JS,根據 JS 的執行結果,生成相應的 DOM。頁面上呈現的內容,你在 html 原始檔裡裡找不到——這正是它的特點。

服務端渲染

在服務端渲染的模式下,當使用者第一次請求頁面時,由伺服器把需要的元件或頁面渲染成HTML字串,然後把它返回給客戶端。頁面上呈現的內容,我們在 html 原始檔裡也能找到。服務端渲染解決了一個非常關鍵的效能問題——首屏載入速度過慢,也解決了SEO搜尋引擎的問題。

瀏覽器渲染過程解析

瀏覽器的渲染機制一般分為以下幾個步驟:

  1. 處理 HTML 並構建 DOM 樹。
  2. 處理 CSS 構建 CSSOM 樹
  3. 將 DOM 與 CSSOM 合併成一個渲染樹。
  4. 根據渲染樹來佈局,計算每個節點的位置。
  5. 呼叫 GPU 繪製,合成圖層,顯示在螢幕上。

在渲染DOM的時候,瀏覽器所做的工作實際上是:

  1. 獲取DOM後分割為多個圖層
  2. 對每個圖層的節點計算樣式結果(Recalculate style–樣式重計算)
  3. 為每個節點生成圖形和位置(Layout–迴流和重佈局)
  4. 將每個節點繪製填充到圖層點陣圖中(Paint Setup和Paint–重繪)
  5. 圖層作為紋理上傳至GPU
  6. 複合多個圖層到頁面上生成最終螢幕影象(Composite Layers–圖層重組)

基於渲染流程的 CSS 優化建議

CSS 選擇符是從右到左進行匹配的,比如 #myList li {}實際開銷相當高。

  • 避免使用萬用字元,只對需要用到的元素進行選擇。
  • 關注可以通過繼承實現的屬性,避免重複匹配重複定義。
  • 少用標籤選擇器。如果可以,用類選擇器替代。 錯誤:#dataList li{} 正確:.dataList{}
  • 不要畫蛇添足,id 和 class 選擇器不應該被多餘的標籤選擇器拖後腿。錯誤:.dataList#title 正確: #title
  • 減少巢狀。後代選擇器的開銷是最高的,因此我們應該儘量將選擇器的深度降到最低(最高不要超過三層),儘可能使用類來關聯每一個標籤元素。

CSS 的阻塞

CSS 是阻塞的資源。瀏覽器在構建 CSSOM 的過程中,不會渲染任何已處理的內容。即便 DOM 已經解析完畢了,只要 CSSOM 不 OK,那麼渲染這個事情就不 OK。我們將 CSS 放在 head 標籤裡 和儘快 啟用 CDN 實現靜態資源載入速度的優化。

JS 的阻塞

JS 引擎是獨立於渲染引擎存在的。我們的 JS 程式碼在文件的何處插入,就在何處執行。當 HTML 解析器遇到一個 script 標籤時,它會暫停渲染過程,將控制權交給 JS 引擎。JS 引擎對內聯的 JS 程式碼會直接執行,對外部 JS 檔案還要先獲取到指令碼、再進行執行。等 JS 引擎執行完畢,瀏覽器又會把控制權還給渲染引擎,繼續 CSSOM 和 DOM 的構建。

DOM渲染優化

先了解迴流和重繪

  • 迴流:當我們對 DOM 的修改引發了 DOM 幾何尺寸的變化(比如修改元素的寬、高或隱藏元素等)時,瀏覽器需要重新計算元素的幾何屬性(其他元素的幾何屬性和位置也會因此受到影響),然後再將計算的結果繪製出來。這個過程就是迴流(也叫重排)。
  • 重繪:當我們對 DOM 的修改導致了樣式的變化、卻並未影響其幾何屬性(比如修改了顏色或背景色)時,瀏覽器不需重新計算元素的幾何屬性、直接為該元素繪製新的樣式(跳過了上圖所示的迴流環節)。這個過程叫做重繪。

重繪不一定導致迴流,迴流一定會導致重繪。迴流比重繪做的事情更多,帶來的開銷也更大。在開發中,要從程式碼層面出發,儘可能把迴流和重繪的次數最小化。

例子剖析

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>DOM操作測試</title>
</head>
<body>
  <div id="container"></div>
</body>
</html>
for(var count=0;count<10000;count++){ 
  document.getElementById('container').innerHTML+='<span>我是一個小測試</span>'  //我們每一次迴圈都呼叫 DOM 介面重新獲取了一次 container 元素,額外開銷
} 

進化一:

// 只獲取一次container
let container = document.getElementById('container')
for(let count=0;count<10000;count++){ 
  container.innerHTML += '<span>我是一個小測試</span>'
} 

進化二:

//減少不必要的DOM更改
let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){ 
  // 先對內容進行操作
  content += '<span>我是一個小測試</span>'
} 
// 內容處理好了,最後再觸發DOM的更改
container.innerHTML = content

事實上,考慮JS 的執行速度,比 DOM 快得多這個特性。我們減少 DOM 操作的核心思路,就是讓 JS 去給 DOM 分壓。

在 DOM Fragment 中,DocumentFragment 介面表示一個沒有父級檔案的最小文件物件。它被當做一個輕量版的 Document 使用,用於儲存已排好版的或尚未打理好格式的XML片段。因為 DocumentFragment 不是真實 DOM 樹的一部分,它的變化不會引起 DOM 樹的重新渲染的操作(reflow),且不會導致效能等問題。
進化三:

let container = document.getElementById('container')
// 建立一個DOM Fragment物件作為容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
  // span此時可以通過DOM API去建立
  let oSpan = document.createElement("span")
  oSpan.innerHTML = '我是一個小測試'
  // 像操作真實DOM一樣操作DOM Fragment物件
  content.appendChild(oSpan)
}
// 內容處理好了,最後再觸發真實DOM的更改
container.appendChild(content)

進化四:
當涉及到過萬調資料進行渲染,而且要求不卡住畫面,如何解決?
如何在不卡住頁面的情況下渲染資料,也就是說不能一次性將幾萬條都渲染出來,而應該一次渲染部分 DOM,那麼就可以通過 requestAnimationFrame 來每 16 ms 重新整理一次。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <ul>
      控制元件
    </ul>
    <script>
      setTimeout(() => {
        // 插入十萬條資料
        const total = 100000
        // 一次插入 20 條,如果覺得效能不好就減少
        const once = 20
        // 渲染資料總共需要幾次
        const loopCount = total / once
        let countOfRender = 0
        let ul = document.querySelector('ul')
        function add() {
          // 優化效能,插入不會造成迴流
          const fragment = document.createDocumentFragment()
          for (let i = 0; i < once; i++) {
            const li = document.createElement('li')
            li.innerText = Math.floor(Math.random() * total)
            fragment.appendChild(li)
          }
          ul.appendChild(fragment)
          countOfRender += 1
          loop()
        }
        function loop() {
          if (countOfRender < loopCount) {
            window.requestAnimationFrame(add)
          }
        }
        loop()
      }, 0)
    </script>
  </body>
</html>

window.requestAnimationFrame() 方法告訴瀏覽器您希望執行動畫並請求瀏覽器在下一次重繪之前呼叫指定的函式來更新動畫。該方法使用一個回撥函式作為引數,這個回撥函式會在瀏覽器重繪之前呼叫。
注意:若您想要在下次重繪時產生另一個動畫畫面,您的回撥例程必須呼叫 requestAnimationFrame()。

Event Loop

我們先了解javascript執行機制,對渲染是大有幫助的,可以看我歷史文章JavaScript執行機制,
Javascript執行機制深入

事件迴圈中的非同步佇列有兩種:macro(巨集任務)佇列和 micro(微任務)佇列。
常見的 macro-task 比如: setTimeout、setInterval、 setImmediate、script(整體程式碼)、 I/O 操作、UI 渲染等。
常見的 micro-task 比如: process.nextTick、Promise、MutationObserver 等。

例子分析:

// task是一個用於修改DOM的回撥
setTimeout(task, 0)

上面程式碼,現在 task 被推入的 macro 佇列。但因為 script 指令碼本身是一個 macro 任務,所以本次執行完 script 指令碼之後,下一個步驟就要去處理 micro 佇列了,再往下就去執行了一次 render,必須等待下一次的loop。

Promise.resolve().then(task)

上面程式碼,我們結束了對 script 指令碼的執行,是不是緊接著就去處理 micro-task 佇列了?micro-task 處理完,DOM 修改好了,緊接著就可以走 render 流程了——不需要再消耗多餘的一次渲染,不需要再等待一輪事件迴圈,直接為使用者呈現最即時的更新結果。

當我們需要在非同步任務中實現 DOM 修改時,把它包裝成 micro 任務是相對明智的選擇。

上面說了重繪與迴流,Event loop,但很多人不知道的是,重繪和迴流其實和 Event loop 有關。

  1. 當 Event loop 執行完 Microtasks 後,會判斷 document 是否需要更新。因為瀏覽器是 60Hz 的重新整理率,每 16ms 才會更新一次。
  2. 然後判斷是否有 resize 或者 scroll ,有的話會去觸發事件,所以 resize 和 scroll 事件也是至少 16ms 才會觸發一次,並且自帶節流功能。
  3. 判斷是否觸發了 media query
  4. 更新動畫並且傳送事件
  5. 判斷是否有全屏操作事件
  6. 執行 requestAnimationFrame 回撥
  7. 執行 IntersectionObserver 回撥,該方法用於判斷元素是否可見,可以用於懶載入上,但是相容性不好
  8. 更新介面
  9. 以上就是一幀中可能會做的事情。如果在一幀中有空閒時間,就會去執行 requestIdleCallback 回撥。

節流與防抖

當使用者進行滾動,觸發scroll事件,使用者的每一次滾動都將觸發我們的監聽函式。函式執行是吃效能的,頻繁地響應某個事件將造成大量不必要的頁面計算。因此,我們需要針對那些有可能被頻繁觸發的事件作進一步地優化。節流與防抖就很有必要了!

詳細看歷史文章防抖動與節流

相關文章