【優化】356- 你必須懂的前端性能優化

  • 2019 年 10 月 6 日
  • 筆記

從輸入URL加載起看方向

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

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

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

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

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

HTTP 優化有兩個大的方向:

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

瀏覽器緩存策略

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

  • Memory Cache
  • Service Worker Cache
  • HTTP Cache
  • 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個步驟:

  • 接收
  • 解析
  • 查詢,緩存查看是否有本地副本可用,如果沒有,就獲取一份副本
  • 新鮮度檢測, 緩存查看已緩存副本是否足夠新鮮,如果不是,就詢問服務器是否有任何更新。
  • 創建響應,緩存會用新的首部和已緩存的主體來構建一條響應報文。
  • 發送,緩存通過網絡將響應發回給客服端。
  • 日誌

強緩存

強緩存是利用 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 Cachae

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搜索引擎的問題。

瀏覽器渲染過程解析

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

  • 處理 HTML 並構建 DOM 樹。
  • 處理 CSS 構建 CSSOM 樹
  • 將 DOM 與 CSSOM 合併成一個渲染樹。
  • 根據渲染樹來布局,計算每個節點的位置。
  • 調用 GPU 繪製,合成圖層,顯示在屏幕上。

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

  • 獲取DOM後分割為多個圖層
  • 對每個圖層的節點計算樣式結果(Recalculate style–樣式重計算)
  • 為每個節點生成圖形和位置(Layout–迴流和重布局)
  • 將每個節點繪製填充到圖層位圖中(Paint Setup和Paint–重繪)
  • 圖層作為紋理上傳至GPU
  • 複合多個圖層到頁面上生成最終屏幕圖像(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>

進化一:

// 只獲取一次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運行機制,對渲染是大有幫助的。

事件循環中的異步隊列有兩種: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 流程了——不需要再消耗多餘的一次渲染,不需要再等待一輪事件循環,直接為用戶呈現最即時的更新結果。

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

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

節流與防抖

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