前端效能精進之瀏覽器(四)——呈現

2023-03-13 09:01:06

  現如今,在呈現一個頁面時,在瀏覽器中會開啟眾多程序,包括瀏覽器、渲染、外掛、GPU、網路等程序。

  瀏覽器程序負責儲存、介面、下載等管理。在渲染程序中,執行著熟知的主執行緒、合成執行緒、JavaScript 直譯器、排版引擎等。

  而呈現一個頁面大致可分為 4 個步驟:

  1. 瀏覽器程序處理使用者在位址列的輸入,然後將 URL 傳送給網路程序。
  2. 網路程序傳送 URL 請求,在接收到響應資料後進行解析,接著轉發給瀏覽器程序。
  3. 瀏覽器程序收到響應後,傳送「提交導航」訊息到渲染程序。
  4. 渲染程序開始接收網路程序傳送的資料,並進行檔案渲染。

  基於上述步驟可以聯想到,呈現的優化分為兩部分:資源和渲染。

  像上一節的影象其實也屬於資源部分,只是內容比較多就單獨建立了章節。

  本文所用的範例程式碼已上傳至 Github

一、資源

  HTTP Archive 關於 2022 年頁面大小的報告指出,按大小升序後,排在中間位置的移動頁面大概有 70 個請求。

  包括 22 個影象、21 個指令碼、7 個 CSS以及 2 個 HTML,指令碼和 CSS 佔了 40% 的請求。

  除了對這些資源進行尺寸優化之外,還可以對它們的載入進行優化。

1)優先順序

  瀏覽器會給不同資源給予不同的請求優先順序。

  以 Chrome 為例,分為多個等級,包括 Highest 、High、Low 和 Lowest 等,如下圖所示。

  

  HTML 和 head 元素中的 CSS 優先順序是最高的,head 元素中的指令碼是高優先順序,非同步請求的指令碼是低優先順序。

  若優先順序不符合預期,可以通過一些設定修改優先順序,例如為 script 元素宣告 async/defer,它的優先順序就會變成低。

  在 img 元素中,新增了一個 fetchPriority 屬性(如下所示),當值是 high 時,意味著這是一張重要的影象,瀏覽器會提升優先順序立即開始請求。

<img src="hero.png" fetchpriority="high" />

2)link 元素

  link 元素常用來載入 CSS 檔案,但它還支援些其他功能,接下來會一一介紹。

  當 link 的 rel 屬性值為 preload 時,就能預載入資源,如下所示。

<link rel="preload" href="demo.js" as="script" />

  as 屬性是告知瀏覽器載入的資源型別,包括 style、script、font、image 等。

  預載入可提升資源的優先順序,不過當資源在幾秒後未使用時,瀏覽器會發出告警。

  當 link 的 rel 屬性值為 preconnect 時,就能預連線站點,如下所示。

<link rel="preconnect" href="https://www.pwstrick.com" />

  另一個與連線相關的型別是 dns-prefetch(如下所示),用來處理 DNS 查詢,即 DNS 預解析。

<link rel="dns-prefetch" href="https://www.pwstrick.com" />

  當 link 的 rel 屬性值為 prefetch 時,就能預提取資源,如下所示。

<link rel="prefetch" href="demo.js" />

  預提取會讓資源的優先順序降為最低,用於讓某些非關鍵資源提前請求,可為使用者的下一步互動做準備。

3)script 元素

  延遲(defer)和非同步(async)的出現是為了解決 script 元素阻塞 HTML 解析的問題,下圖描繪了 script 元素的 3 種執行機制。

  

  第一行是預設的執行機制,在解析HTML檔案時,一遇到 script 元素就停止解析,改成下載外部指令碼,然後執行指令碼,執行完後才會繼續解析。

  第二行是使用了 defer 屬性後的執行機制,HTML 檔案的解析和外部指令碼的下載是同時進行的,解析完後才會執行指令碼。

  第三行是使用了async 屬性後的執行機制,HTML 檔案的解析和外部指令碼的下載也是同時進行,但下載完後就開始執行指令碼,執行完後才會繼續解析。

4)資料預請求

  在使用者端的 WebView 中,每次請求後端介面大概要花 100~200ms,如果把這段時間省下來,那麼也能減少白屏時間。

  資料預請求是將請求時機由業務發起提前到使用者點選時,並行傳送資料請求,縮短資料等待時間,如下圖所示。

  

  這種改造需要使用者端配合,現在簡單介紹下我們公司當時實現的方案,流程圖如下所示。

  

  首屏資料的介面資訊,可以通過一些設定關聯起來,比如一個單獨的設定介面。

  使用者端在拿到資料後,就會快取到一個全域性變數中,等待指令碼讀取。

  注意,到底是使用者端先拿到資料,還是網頁先拿到,這個無法確定,並且預請求只能以 get 方法通訊。

  具體的實現方案如下:

  • 使用者端分析出當前 URL 中的路徑和引數,其中 refresh 引數(有的話)是一個時間戳(秒),這個引數用來控制使用者端是否需要重新請求設定介面。
  • 當分析的 URL 引數中無 refresh 欄位時,存取 https://xxx.com/settings 介面,並將URL路徑、使用者端預設帶的引數(包含使用者ID等)和 URL 本身的引數全部傳遞過來(如下所示),然後本地快取。
https://xxx.com/settings?path=game%2Fstrick&uid=xxxxx&refresh=1618451992
  • 使用者端會將 settings 介面的響應資料快取到本地,而 key 就是當前 URL,也就是說 URL 不變的話,預設就不會去請求 settings 介面。若要穿透快取,那麼加上 refresh 引數,賦一個與之前不同的值即可。
  • settings 介面返回的 JSON 格式,包含 urls 欄位(如下所示),是個陣列,由介面集合組成,已經拼接好引數。
{
    "urls": [
        "http://xxx.com/xx/xx?id=2",
        "http://xxx.com/yy/yy?uid=1"
    ]
}
  • 使用者端將讀取到的資料注入到 WebView 的全域性物件中,可以用全域性變數同步讀取,名字可自行約定,例如叫 TheLClientResponse,讀取方式:window.TheLClientResponse,JSON 格式如下,其中 key 是 api 的路徑,如果無資料可以返回 null。
{
    "xx/xx": {
        code: 0,
        msg: "test",
        data: {
            list: []
        }
    },
    "yy/yy": {
        code: 0,
        msg: "test",
        data: {
            list: []
        }
    }
}

5)字型

  CSS3 提供了 @font-face 規則允許為網頁指定自定義字型,其宣告和使用如下所示。

@font-face {
  font-family: "iconfont";
  src: url("../font/iconfont.woff2") format("woff2"),
    url("../font/iconfont.woff") format("woff"),
    url("../font/iconfont.ttf") format("truetype");
}
.iconfont {
  font-family: "iconfont";
}

  上述字型來源於 iconfont,為了相容性考慮,往往會提供多個格式的字型。

  其中 ttf 是一種未壓縮的格式,另外兩種內部都做過壓縮。在 2022 年大概有 75%~78% 的網頁在使用 woff2 格式的字型。

  使用字型除了改變文字外形之外,還有一種普遍用法是用來顯示 icon 小圖示。

  CSS3 提供了 font-display 屬性用於指定字型的渲染方式,在 @font-face 中宣告,2022 年用的最多的值是 swap。

  swap 會讓文字先按瀏覽器預設的字型展示,當字型載入完成後,再將其替換掉。在慢網中,會看到字型的前後變化。

  所以應該儘快載入字型,才能讓使用者享受到最優的體驗。

  瀏覽器在解析 CSS 檔案時,並不會馬上下載 @font-face 中的字型檔案。

  只有當發現 HTML 中有非空節點使用該字型時,才會開始下載。

  如果要提早下載,那麼可以使用預載入,如下所示。

<link rel="preload" href="../../assets/font/dakai.woff2" as="font" crossorigin="anonymous"/>

  crossorigin 屬性是必填的,表示允許跨域,若省略,就會有告警。

  還有一種優化方法是提取字型的子集(即有選擇性的將需要的字元組合在一起),減小字型檔案的尺寸,像圖示就比較適合這樣自定義。

二、渲染過程

  瀏覽器的渲染過程大致可分為 8 個階段,如下圖所示。

  

  下面的 1~5 步涉及主執行緒(main thread),6~8 步涉及合成執行緒(compositor thread)。

  1. 將 HTML 解析成 DOM 樹,並將其儲存在記憶體中,同時下載解析到的資源。
  2. 將 CSS 解析成樣式表(style sheets),即生成 CSSOM,在此階段會計算節點樣式,並把相對的值和單位都轉換成畫素。
  3. 通過 DOM 和樣式表生成佈局樹(layout tree),在此階段會計算元素的尺寸和座標,並且在樹中不包含隱藏元素,但會包含 CSS 中建立的內容。
  4. 對佈局樹進行分層,生成分層樹(layer tree),可控制繪畫順序,裁剪元素內容,CSS 中的 transform、z-index、will-change 等屬性都與層相關。
  5. 通過佈局樹和分層樹生成繪製列表,並將其提交給合成執行緒。
  6. 通過繪製列表和圖層生成圖塊(tile),因為渲染所有圖塊會比較昂貴,所以會劃分優先順序,例如視口中的可見圖塊優先順序會高。
  7. 圖塊在提交到光柵化(raster)執行緒池後,會被轉移到 GPU 中,加速光柵化處理,即轉換成點陣圖(bitmap),最終結果會儲存在 GPU 記憶體中。
  8. GPU 將點陣圖傳送回合成執行緒後,就會生成合成幀,處理完所有點陣圖後,合成器執行緒向瀏覽器傳送 Draw Quad 命令,開始在螢幕上顯示頁面。

  雖然這 8 個階段的執行過程比較複雜,但是在現代瀏覽器中,它們會在 1/60 秒(即 16.67 毫秒)內完成,下圖描述了整個渲染過程。

  

  優化渲染過程的核心就是縮短某個階段的執行時間,或者直接跳過某些階段。

1)流式渲染

  HTTP/1.1 協定支援分塊傳輸編碼(chunked transfer encoding),允許伺服器將網頁資料分成多塊後再進行傳輸。

  在響應頭中設定 Transfer-Encoding: chunked 就會啟用分塊傳輸編碼的響應格式。

  瀏覽器在知道 HTML 會被流式返回後,就不用等到 HTML 下載完成後再開始解析了。

  不過,目前流行的使用者端渲染(Client Side Render)其實並不需要專門的流式渲染,因為 HTML 的內容本來就少。

  若改成伺服器端渲染(Server Side Render),那就可根據實際情況進行流式渲染的優化了。

  具體的實現過程,本文不再贅述,可參考網上相關的方案,例如 Vue SSR 指南中的流式渲染

2)DOM

  HTML 在被解析時,一旦遇到 JavaScript,那麼就會被阻塞,如下圖所示。

  

  當遇到外部指令碼時,還會停止 DOM 樹的構建,轉由網路程序去請求 JavaScript 指令碼地址。

  CSS 本身並不會阻塞 DOM 樹的構建,但在與 JavaScript 結合使用時,會出現阻塞。

  在下面的範例中,JavaScript 會修改 demo.css 檔案中的樣式。

<link rel="stylesheet" href="demo.css" />
<div id='root'>內容</div>
<script>
  const root = document.getElementById('root');
  root.style.color = 'red';
</script>

  主執行緒在執行指令碼之前,需要先計算節點樣式(即解析 CSS 檔案),因此 DOM 樹就無法被繼續構建了。

  若要優化 DOM 樹的構建,除了儘量避免上述不科學的寫法之外,還可以從兩方面入手:減少關鍵資源請求的數量和大小。

  所謂關鍵資源(key resource),更確切的說就是網頁首屏的核心資源,沒有它們,那麼首屏將無法正確的呈現。

  減少資源的請求數量可以通過 2 個方法:

  • 將 CSS 或 JavaScript 內聯到 HTML 結構中,例如行動端的螢幕適配指令碼就比較適合內聯。
  • 指令碼元素可以增加 async 或 defer 的標記,具體可以參考上一節的 script 元素。

  關鍵資源的大小除了進行壓縮外,就是隻提取首屏需要的程式碼。

  將其他部分的程式碼合併到另一個檔案,待需要時再載入,或者使用上一節所說的預提取。

3)重排和重繪

  重排(reflow)也叫回流,是指修改元素的幾何屬性後引起的重新渲染,涉及 7 個階段,如下圖所示,修改了元素的高度。

  

  觸發重排的情況有新增或刪除可見的元素、修改位置、邊距或內容等。

  重繪(repaint)是指修改元素的背景顏色後引起的重新渲染,但與重排不同,重繪將直接進入 Paint 階段,如下圖所示。

  

  重排和重繪都會降低渲染效能,因為它們都發生在主執行緒中,並且佈局、分層和繪製 3 個階段的計算過程比較昂貴。

  當在指令碼中獲取元素的尺寸、位置等排版相關的資訊時,就有可能觸發強制重排,例如呼叫 offsetTop、clientWidth、getComputedStyle() 等屬性或方法。

  優化它們的方式包括使用 cssText 或 CSS 類修一次性修改多個 CSS 屬性,批次修改 DOM,例如使用檔案片段 fragment、先隱藏元素再顯示等。

  在眾多的 CSS 屬性中,有兩個 CSS 屬性(transform 和 opacity)可以避開重排和重繪,直接進入合成階段。

  例如用 transform 屬性實現的元素變化,就不會佔用主執行緒,而是由合成執行緒處理,如下圖所示。

  

  值得一提的是,早期在指令碼中實現動畫,都會藉助定時器,但定時器無法精確的設定動畫幀之間的時間間隔。

  按螢幕重新整理率為每秒 60 次計算,那麼理論上每幀的間隔約等於是 16.67 毫秒。

  但實際情況比較複雜,間隔不一定是這個值,有可能出現丟幀,從而造成動畫不夠平滑流暢。

  為了解決動畫問題,瀏覽器提供了 requestAnimationFrame() 方法,在每一幀的開始執行設定的回撥。

  注意,只有當瀏覽器 GPU 生成點陣圖和螢幕顯示點陣圖保持同步時,才會觸發 requestAnimationFrame() 的回撥。

  在下面的範例中,讓絕對定位的 span 元素通過 requestAnimationFrame() 向右偏移。

<span id='container' style="position:absolute">內容</span>
<script>
  let left = 0;
  const frame = () => {
    const container = document.getElementById('container');
    container.style.left = `${left++}px`;
    if (left > 100) return;
    requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
</script>

  注意,requestAnimationFrame() 也是執行在主執行緒中,如果主執行緒繁忙,那麼也有可能延遲迴撥,造成動畫的卡頓。

  並且如果其回撥比較耗時(超過一幀),那麼就會阻礙後續的任務。

總結

  本文的第一章節詳細描述了資源的優化,並在開篇指出資源都存在著優先順序,瀏覽器會按優先順序進行請求。

  預載入可提升資源的優先順序,預提取可降低資源的優先順序,預連線可提前進行 TCP 連線或 DNS 查詢。

  script 元素有延遲和非同步兩種執行機制,可有效地防止 HTML 解析的阻塞。

  資料預請求需要與使用者端配合,本文給出了一份解決方案可供參考。

  自定義字型在頁面開發中有著廣泛的應用,常用的優化手段是預載入和減小尺寸。

  在第二章節中詳細分析了瀏覽器的渲染過程,這個過程大致可分為 8 個階段。

  圍繞這些階段,引出了流式渲染、DOM 樹構建的優化。

  在重排和重繪中,詳細說明了它們影響的階段,並且列舉了觸發原因,以及優化手段。

  最後提到了合成動畫,並且對比了 JavaScript 動畫的兩種實現方式。