還在為網頁渲染效能優化而苦惱嗎?

晨風明悟發表於2018-07-17

部落格 有更多精品文章喲。

渲染原理

在討論效能優化之前,我們有必要了解一些瀏覽器的渲染原理。不同的瀏覽器進行渲染有著不同的實現方式,但是大體流程都是差不多的,我們通過 Chrome 瀏覽器來大致瞭解一下這個渲染流程。

還在為網頁渲染效能優化而苦惱嗎?

關鍵渲染路徑

關鍵渲染路徑是指瀏覽器將 HTML、CSS 和 JavaScript 轉換成實際運作的網站必須採取的一系列步驟,通過渲染流程圖我們可以大致概括如下:

  1. 處理 HTML 並構建 DOM Tree。
  2. 處理 CSS 並構建 CSSOM Tree。
  3. 將 DOM Tree 和 CSSOM Tree 合併成 Render Object Tree。
  4. 根據 Render Object Tree 計算節點的幾何資訊並以此進行佈局。
  5. 繪製頁面需要先構建 Render Layer Tree 以便用正確的順序展示頁面,這棵樹的生成與 Render Object Tree 的構建同步進行。然後還要構建 Graphics Layer Tree 來避免不必要的繪製和使用硬體加速渲染,最終才能在螢幕上展示頁面。

DOM Tree

DOM(Document Object Model——文件物件模型)是用來呈現以及與任意 HTML 或 XML 互動的 API 文件。DOM 是載入到瀏覽器中的文件模型,它用節點樹的形式來表現文件,每個節點代表文件的構成部分。

需要說明的是 DOM 只是構建了文件標記的屬性和關係,並沒有說明元素需要呈現的樣式,這需要 CSSOM 來處理。

構建流程

獲取到 HTML 位元組資料後,會通過以下流程構建 DOM Tree:

還在為網頁渲染效能優化而苦惱嗎?

  1. 編碼:HTML 原始位元組資料轉換為檔案指定編碼的字串。
  2. 詞法分析(標記化):對輸入字串進行逐字掃描,根據 構詞規則 識別單詞和符號,分割成一個個我們可以理解的詞彙(學名叫 Token )的過程。
  3. 語法分析(解析器):對 Tokens 應用 HTML 的語法規則,進行配對標記、確立節點關係和繫結屬性等操作,從而構建 DOM Tree 的過程。

詞法分析和語法分析在每次處理 HTML 字串時都會執行這個過程,比如使用 document.write 方法。

還在為網頁渲染效能優化而苦惱嗎?

詞法分析(標記化)

HTML 結構不算太複雜,大部分情況下識別的標記會有開始標記、內容標記和結束標記,對應一個 HTML 元素。除此之外還有 DOCTYPE、Comment、EndOfFile 等標記。

標記化是通過狀態機來實現的,狀態機模型在 W3C 中已經定義好了。

想要得到一個標記,必須要經歷一些狀態,才能完成解析。我們通過一個簡單的例子來了解一下流程。

<a href="www.w3c.org">W3C</a>
複製程式碼

還在為網頁渲染效能優化而苦惱嗎?

  • 開始標記:<a href="www.w3c.org">
    1. Data state:碰到 <,進入 Tag open state
    2. Tag open state:碰到 a,進入 Tag name state 狀態
    3. Tag name state:碰到 空格,進入 Before attribute name state
    4. Before attribute name state:碰到 h,進入 Attribute name state
    5. Attribute name state:碰到 =,進入 Before attribute value state
    6. Before attribute value state:碰到 ",進入 Attribute value (double-quoted) state
    7. Attribute value (double-quoted) state:碰到 w,保持當前狀態
    8. Attribute value (double-quoted) state:碰到 ",進入 After attribute value (quoted) state
    9. After attribute value (quoted) state:碰到 >,進入 Data state,完成解析
  • 內容標記:W3C
    1. Data state:碰到 W,保持當前狀態,提取內容
    2. Data state:碰到 <,進入 Tag open state,完成解析
  • 結束標記:</a>
    1. Tag open state:碰到 /,進入 End tag open state
    2. End tag open state:碰到 a,進入 Tag name state
    3. Tag name state:碰到 >,進入 Data state,完成解析

通過上面這個例子,可以發現屬性是開始標記的一部分。

語法分析(解析器)

在建立解析器後,會關聯一個 Document 物件作為根節點。

我會簡單介紹一下流程,具體的實現過程可以在 Tree construction 檢視。

解析器在執行過程中,會對 Tokens 進行迭代;並根據當前 Token 的型別轉換到對應的模式,再在當前模式下處理 Token;此時,如果 Token 是一個開始標記,就會建立對應的元素,新增到 DOM Tree 中,並壓入還未遇到結束標記的開始標記棧中;此棧的主要目的是實現瀏覽器的容錯機制,糾正巢狀錯誤,具體的策略在 W3C 中定義。更多標記的處理可以在 狀態機演算法 中檢視。

參考資料

  1. 瀏覽器的工作原理:新式網路瀏覽器幕後揭祕 —— 解析器和詞法分析器的組合
  2. 瀏覽器渲染過程與效能優化 —— 構建DOM樹與CSSOM樹
  3. 在瀏覽器的背後(一) —— HTML語言的詞法解析
  4. 在瀏覽器的背後(二) —— HTML語言的語法解析
  5. 50 行程式碼的 HTML 編譯器
  6. AST解析基礎: 如何寫一個簡單的html語法分析庫
  7. WebKit中的HTML詞法分析
  8. HTML文件解析和DOM樹的構建
  9. 從Chrome原始碼看瀏覽器如何構建DOM樹
  10. 構建物件模型 —— 文件物件模型 (DOM)

CSSOM Tree

載入

在構建 DOM Tree 的過程中,如果遇到 link 標記,瀏覽器就會立即傳送請求獲取樣式檔案。當然我們也可以直接使用內聯樣式或嵌入樣式,來減少請求;但是會失去模組化和可維護性,並且像快取和其他一些優化措施也無效了,利大於弊,價效比實在太低了;除非是為了極致優化首頁載入等操作,否則不推薦這樣做。

阻塞

CSS 的載入和解析並不會阻塞 DOM Tree 的構建,因為 DOM Tree 和 CSSOM Tree 是兩棵相互獨立的樹結構。但是這個過程會阻塞頁面渲染,也就是說在沒有處理完 CSS 之前,文件是不會在頁面上顯示出來的,這個策略的好處在於頁面不會重複渲染;如果 DOM Tree 構建完畢直接渲染,這時顯示的是一個原始的樣式,等待 CSSOM Tree 構建完畢,再重新渲染又會突然變成另外一個模樣,除了開銷變大之外,使用者體驗也是相當差勁的。另外 link 標記會阻塞 JavaScript 執行,在這種情況下,DOM Tree 是不會繼續構建的,因為 JavaScript 也會阻塞 DOM Tree 的構建,這就會造成很長時間的白屏。

通過一個例子來更加詳細的說明:

<!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">
  <script>
    var startDate = new Date();
  </script>
  <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet">
  <script>
    console.log("link after script", document.querySelector("h2"));
    console.log("經過 " + (new Date() - startDate) + " ms");
  </script>
  <title>效能</title>
</head>
<body>
  <h1>標題</h1>
  <h2>標題2</h2>
</body>
</html>
複製程式碼

首先需要在 Chrome 控制檯的 Network 皮膚設定網路節流,讓網路速度變慢,以便更好進行除錯。

還在為網頁渲染效能優化而苦惱嗎?

下圖說明 JavaScript 的確需要在 CSS 載入並解析完畢之後才會執行。

還在為網頁渲染效能優化而苦惱嗎?

為什麼需要阻塞 JavaScript 的執行呢?

因為 JavaScript 可以操作 DOM 和 CSSOM,如果 link 標記不阻塞 JavaScript 執行,這時 JavaScript 操作 CSSOM,就會發生衝突。更詳細的說明可以在 使用 JavaScript 新增互動 這篇文章中查閱。

解析

CSS 解析的步驟與 HTML 的解析是非常類似的。

詞法分析

CSS 會被拆分成如下一些標記:

還在為網頁渲染效能優化而苦惱嗎?

CSS 的色值使用十六進位制優於函式形式的表示?

函式形式是需要再次計算的,在進行詞法分析時會將它變成一個函式標記,由此看來使用十六進位制的確有所優化。

還在為網頁渲染效能優化而苦惱嗎?

語法分析

每個 CSS 檔案或嵌入樣式都會對應一個 CSSStyleSheet 物件(authorStyleSheet),這個物件由一系列的 Rule(規則) 組成;每一條 Rule 都會包含 Selectors(選擇器) 和若干 Declearation(宣告),Declearation 又由 Property(屬性)和 Value(值)組成。另外,瀏覽器預設樣式表(defaultStyleSheet)和使用者樣式表(UserStyleSheet)也會有對應的 CSSStyleSheet 物件,因為它們都是單獨的 CSS 檔案。至於內聯樣式,在構建 DOM Tree 的時候會直接解析成 Declearation 集合。

還在為網頁渲染效能優化而苦惱嗎?

內聯樣式和 authorStyleSheet 的區別

所有的 authorStyleSheet 都掛載在 document 節點上,我們可以在瀏覽器中通過 document.styleSheets 獲取到這個集合。內聯樣式可以直接通過節點的 style 屬性檢視。

通過一個例子,來了解下內聯樣式和 authorStyleSheet 的區別:

<!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">
  <style>
    body .div1 {
      line-height: 1em;
    }
  </style>
  <link rel="stylesheet" href="./style.css">
  <style>
    .div1 {
      background-color: #f0f;
      height: 20px;
    }
  </style>
  <title>Document</title>
</head>
<body>
  <div class="div1" style="background-color: #f00;font-size: 20px;">test</div>
</body>
</html>
複製程式碼

可以看到一共有三個 CSSStyleSheet 物件,每個 CSSStyleSheet 物件的 rules 裡面會有一個 CSSStyleDeclaration,而內聯樣式獲取到的直接就是 CSSStyleDeclaration。

還在為網頁渲染效能優化而苦惱嗎?

需要屬性合併嗎?

在解析 Declearation 時遇到屬性合併,會把單條宣告轉變成對應的多條宣告,比如:

.box {
  margin: 20px;
}
複製程式碼

margin: 20px 就會被轉變成四條宣告;這說明 CSS 雖然提倡屬性合併,但是最終還是會進行拆分的;所以屬性合併的作用應該在於減少 CSS 的程式碼量。

計算

為什麼需要計算?

因為一個節點可能會有多個 Selector 命中它,這就需要把所有匹配的 Rule 組合起來,再設定最後的樣式。

準備工作

為了便於計算,在生成 CSSStyleSheet 物件後,會把 CSSStyleSheet 物件最右邊 Selector 型別相同的 Rules 存放到對應的 Hash Map 中,比如說所有最右邊 Selector 型別是 id 的 Rules 就會存放到 ID Rule Map 中;使用最右邊 Selector 的原因是為了更快的匹配當前元素的所有 Rule,然後每條 Rule 再檢查自己的下一個 Selector 是否匹配當前元素。

idRules
classRules
tagRules
...
*
複製程式碼
選擇器命中

一個節點想要獲取到所有匹配的 Rule,需要依次判斷 Hash Map 中的 Selector 型別(id、class、tagName 等)是否匹配當前節點,如果匹配就會篩選當前 Selector 型別的所有 Rule,找到符合的 Rule 就會放入結果集合中;需要注意的是萬用字元總會在最後進行篩選。

從右向左匹配規則

上文說過 Hash Map 存放的是最右邊 Selector 型別的 Rule,所以在查詢符合的 Rule 最開始,檢驗的是當前 Rule 最右邊的 Selector;如果這一步通過,下面就要判斷當前的 Selector 是不是最左邊的 Selector;如果是,匹配成功,放入結果集合;否則,說明左邊還有 Selector,遞迴檢查左邊的 Selector 是否匹配,如果不匹配,繼續檢查下一個 Rule。

為什麼需要從右向左匹配呢?

先思考一下正向匹配是什麼流程,我們用 div p .yellow 來舉例,先查詢所有 div 節點,再向下查詢後代是否是 p 節點,如果是,再向下查詢是否存在包含 class="yellow" 的節點,如果存在則匹配;但是不存在呢?就浪費一次查詢,如果一個頁面有上千個 div 節點,而只有一個節點符合 Rule,就會造成大量無效查詢,並且如果大多數無效查詢都在最後發現,那損失的效能就實在太大了。

這時再思考從右向左匹配的好處,如果一個節點想要找到匹配的 Rule,會先查詢最右邊 Selector 是當前節點的 Rule,再向左依次檢驗 Selector;在這種匹配規則下,開始就能避免大多無效的查詢,當然效能就更好,速度更快了。

設定樣式

設定樣式的順序是先繼承父節點,然後使用使用者代理的樣式,最後使用開發者(authorStyleSheet)的樣式。

authorStyleSheet 優先順序

放入結果集合的同時會計算這條 Rule 的優先順序;來看看 blink 核心對優先順序權重的定義:

switch (m_match) {
  case Id: 
    return 0x010000;
  case PseudoClass:
    return 0x000100;
  case Class:
  case PseudoElement:
  case AttributeExact:
  case AttributeSet:
  case AttributeList:
  case AttributeHyphen:
  case AttributeContain:
  case AttributeBegin:
  case AttributeEnd:
    return 0x000100;
  case Tag:
    return 0x000001;
  case Unknown:
    return 0;
}
return 0;
複製程式碼

因為解析 Rule 的順序是從右向左進行的,所以計算優先順序也會按照這個順序取得對應 Selector 的權重後相加。來看幾個例子:

/*
 * 65793 = 65536 + 1 + 256
 */
#container p .text {
  font-size: 16px;
}

/*
 * 2 = 1 + 1
 */
div p {
  font-size: 14px;
}
複製程式碼

當前節點所有匹配的 Rule 都放入結果集合之後,先根據優先順序從小到大排序,如果有優先順序相同的 Rule,則比較它們的位置。

內聯樣式優先順序

authorStyleSheet 的 Rule 處理完畢,才會設定內聯樣式;內聯樣式在構建 DOM Tree 的時候就已經處理完成並存放到節點的 style 屬性上了。

內聯樣式會放到已經排序的結果集合最後,所以如果不設定 !important,內聯樣式的優先順序是最大的。

!important 優先順序

在設定 !important 的宣告前,會先設定不包含 !important 的所有宣告,之後再新增到結果集合的尾部;因為這個集合是按照優先順序從小到大排序好的,所以 !important 的優先順序就變成最大的了。

書寫 CSS 的規則

結果集合最後會生成 ComputedStyle 物件,可以通過 window.getComputedStyle 方法來檢視所有宣告。

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

可以發現圖中的宣告是沒有順序的,說明書寫規則的最大作用是為了良好的閱讀體驗,利於團隊協作。

調整 Style

這一步會調整相關的宣告;例如宣告瞭 position: absolute;,當前節點的 display 就會設定成 block

參考資料

  1. 從Chrome原始碼看瀏覽器如何計算CSS
  2. 探究 CSS 解析原理
  3. Webkit核心探究【2】——Webkit CSS實現
  4. Webkit CSS引擎分析
  5. css載入會造成阻塞嗎?
  6. 原來 CSS 與 JS 是這樣阻塞 DOM 解析和渲染的
  7. 外鏈 CSS 延遲 DOM 解析和 DOMContentLoaded
  8. CSS/JS 阻塞 DOM 解析和渲染
  9. 構建物件模型 —— CSS 物件模型 (CSSOM)
  10. 阻塞渲染的 CSS

Render Object Tree

在 DOM Tree 和 CSSOM Tree 構建完畢之後,才會開始生成 Render Object Tree(Document 節點是特例)。

建立 Render Object

在建立 Document 節點的時候,會同時建立一個 Render Object 作為樹根。Render Object 是一個描述節點位置、大小等樣式的視覺化物件。

每個非 display: none | contents 的節點都會建立一個 Render Object,流程大致如下:生成 ComputedStyle(在 CSSOM Tree 計算這一節中有講),之後比較新舊 ComputedStyle(開始時舊的 ComputedStyle 預設是空);不同則建立一個新的 Render Object,並與當前處理的節點關聯,再建立父子兄弟關係,從而形成一棵完整的 Render Object Tree。

佈局(重排)

Render Object 在新增到樹之後,還需要重新計算位置和大小;ComputedStyle 裡面已經包含了這些資訊,為什麼還需要重新計算呢?因為像 margin: 0 auto; 這樣的宣告是不能直接使用的,需要轉化成實際的大小,才能通過繪圖引擎繪製節點;這也是 DOM Tree 和 CSSOM Tree 需要組合成 Render Object Tree 的原因之一。

佈局是從 Root Render Object 開始遞迴的,每一個 Render Object 都有對自身進行佈局的方法。為什麼需要遞迴(也就是先計運算元節點再回頭計算父節點)計算位置和大小呢?因為有些佈局資訊需要子節點先計算,之後才能通過子節點的佈局資訊計算出父節點的位置和大小;例如父節點的高度需要子節點撐起。如果子節點的寬度是父節點高度的 50%,要怎麼辦呢?這就需要在計運算元節點之前,先計算自身的佈局資訊,再傳遞給子節點,子節點根據這些資訊計算好之後就會告訴父節點是否需要重新計算。

數值型別

所有相對的測量值(remem、百分比...)都必須轉換成螢幕上的絕對畫素。如果是 emrem,則需要根據父節點或根節點計算出畫素。如果是百分比,則需要乘以父節點寬或高的最大值。如果是 auto,需要用 (父節點的寬或高 - 當前節點的寬或高) / 2 計算出兩側的值。

盒模型

眾所周知,文件的每個元素都被表示為一個矩形的盒子(盒模型),通過它可以清晰的描述 Render Object 的佈局結構;在 blink 的原始碼註釋中,已經生動的描述了盒模型,與原先耳熟能詳的不同,滾動條也包含在了盒模型中,但是滾動條的大小並不是所有的瀏覽器都能修改的。

// ***** THE BOX MODEL *****
// The CSS box model is based on a series of nested boxes:
// http://www.w3.org/TR/CSS21/box.html
//                              top
//       |----------------------------------------------------|
//       |                                                    |
//       |                   margin-top                       |
//       |                                                    |
//       |     |-----------------------------------------|    |
//       |     |                                         |    |
//       |     |             border-top                  |    |
//       |     |                                         |    |
//       |     |    |--------------------------|----|    |    |
//       |     |    |                          |    |    |    |
//       |     |    |       padding-top        |####|    |    |
//       |     |    |                          |####|    |    |
//       |     |    |    |----------------|    |####|    |    |
//       |     |    |    |                |    |    |    |    |
//  left | ML  | BL | PL |  content box   | PR | SW | BR | MR |
//       |     |    |    |                |    |    |    |    |
//       |     |    |    |----------------|    |    |    |    |
//       |     |    |                          |    |    |    |
//       |     |    |      padding-bottom      |    |    |    |
//       |     |    |--------------------------|----|    |    |
//       |     |    |                      ####|    |    |    |
//       |     |    |     scrollbar height ####| SC |    |    |
//       |     |    |                      ####|    |    |    |
//       |     |    |-------------------------------|    |    |
//       |     |                                         |    |
//       |     |           border-bottom                 |    |
//       |     |                                         |    |
//       |     |-----------------------------------------|    |
//       |                                                    |
//       |                 margin-bottom                      |
//       |                                                    |
//       |----------------------------------------------------|
//
// BL = border-left
// BR = border-right
// ML = margin-left
// MR = margin-right
// PL = padding-left
// PR = padding-right
// SC = scroll corner (contains UI for resizing (see the 'resize' property)
// SW = scrollbar width
複製程式碼
box-sizing

box-sizing: content-box | border-boxcontent-box 遵循標準的 W3C 盒子模型,border-box 遵守 IE 盒子模型。

它們的區別在於 content-box 只包含 content area,而 border-box 則一直包含到 border。通過一個例子說明:

// width
// content-box: 40
// border-box: 40 + (2 * 2) + (1 * 2)
div {
  width: 40px;
  height: 40px;
  padding: 2px;
  border: 1px solid #ccc;
}
複製程式碼

參考資料

  1. 從Chrome原始碼看瀏覽器如何layout佈局
  2. Chromium網頁Render Object Tree建立過程分析
  3. 瀏覽器的工作原理:新式網路瀏覽器幕後揭祕 —— 呈現樹和 DOM 樹的關係
  4. 談談我對盒模型的理解
  5. 渲染樹構建、佈局及繪製

Render Layer Tree

Render Layer 是在 Render Object 建立的同時生成的,具有相同座標空間的 Render Object 屬於同一個 Render Layer。這棵樹主要用來實現層疊上下文,以保證用正確的順序合成頁面。

建立 Render Layer

滿足層疊上下文條件的 Render Object 一定會為其建立新的 Render Layer,不過一些特殊的 Render Object 也會建立一個新的 Render Layer。

建立 Render Layer 的原因如下:

  • NormalLayer
    • position 屬性為 relative、fixed、sticky、absolute
    • 透明的(opacity 小於 1)、濾鏡(filter)、遮罩(mask)、混合模式(mix-blend-mode 不為 normal)
    • 剪下路徑(clip-path)
    • 2D 或 3D 轉換(transform 不為 none)
    • 隱藏背面(backface-visibility: hidden)
    • 倒影(box-reflect)
    • column-count(不為 auto)或者column-widthZ(不為 auto)
    • 對不透明度(opacity)、變換(transform)、濾鏡(filter)應用動畫
  • OverflowClipLayer
    • 剪下溢位內容(overflow: hidden)

另外以下 DOM 元素對應的 Render Object 也會建立單獨的 Render Layer:

  • Document
  • HTML
  • Canvas
  • Video

如果是 NoLayer 型別,那它並不會建立 Render Layer,而是與其第一個擁有 Render Layer 的父節點共用一個。

參考資料

  1. 無線效能優化:Composite —— 從 LayoutObjects 到 PaintLayers
  2. Chromium網頁Render Layer Tree建立過程分析
  3. WEBKIT 渲染不可不知的這四棵樹

Graphics Layer Tree

軟體渲染

軟體渲染是瀏覽器最早採用的渲染方式。在這種方式中,渲染是從後向前(遞迴)繪製 Render Layer 的;在繪製一個 Render Layer 的過程中,它的 Render Objects 不斷向一個共享的 Graphics Context 傳送繪製請求來將自己繪製到一張共享的點陣圖中。

硬體渲染

有些特殊的 Render Layer 會繪製到自己的後端儲存(當前 Render Layer 會有自己的點陣圖),而不是整個網頁共享的點陣圖中,這些 Layer 被稱為 Composited Layer(Graphics Layer)。最後,當所有的 Composited Layer 都繪製完成之後,會將它們合成到一張最終的點陣圖中,這一過程被稱為 Compositing;這意味著如果網頁某個 Render Layer 成為 Composited Layer,那整個網頁只能通過合成來渲染。除此之外,Compositing 還包括 transform、scale、opacity 等操作,所以這就是硬體加速效能好的原因,上面的動畫操作不需要重繪,只需要重新合成就好。

上文提到軟體渲染只會有一個 Graphics Context,並且所有的 Render Layer 都會使用同一個 Graphics Context 繪製。而硬體渲染需要多張點陣圖合成才能得到一張完整的影象,這就需要引入 Graphics Layer Tree。

Graphics Layer Tree 是根據 Render Layer Tree 建立的,但並不是每一個 Render Layer 都會有對應的 Composited Layer;這是因為建立大量的 Composited Layer 會消耗非常多的系統記憶體,所以 Render Layer 想要成為 Composited Layer,必須要給出建立的理由,這些理由實際上就是在描述 Render Layer 具備的特徵。如果一個 Render Layer 不是 Compositing Layer,那就和它的祖先共用一個。

每一個 Graphics Layer 都會有對應的 Graphics Context。Graphics Context 負責輸出當前 Render Layer 的點陣圖,點陣圖儲存在系統記憶體中,作為紋理(可以理解為 GPU 中的點陣圖)上傳到 GPU 中,最後 GPU 將多張點陣圖合成,然後繪製到螢幕上。因為 Graphics Layer 會有單獨的點陣圖,所以在一般情況下更新網頁的時候硬體渲染不像軟體渲染那樣重新繪製相關的 Render Layer;而是重新繪製發生更新的 Graphics Layer。

提升原因

Render Layer 提升為 Composited Layer 的理由大致概括如下,更為詳細的說明可以檢視 無線效能優化:Composite —— 從 PaintLayers 到 GraphicsLayers

  • iframe 元素具有 Composited Layer。
  • video 元素及它的控制欄。
  • 使用 WebGL 的 canvas 元素。
  • 硬體加速外掛,例如 flash。
  • 3D 或透視變換(perspective transform) CSS 屬性。
  • backface-visibility 為 hidden。
  • 對 opacity、transform、fliter、backdropfilter 應用了 animation 或者 transition(需要是 active 的 animation 或者 transition,當 animation 或者 transition 效果未開始或結束後,提升的 Composited Layer 會恢復成普通圖層)。
  • will-change 設定為 opacity、transform、top、left、bottom、right(其中 top、left 等需要設定明確的定位屬性,如 relative 等)。
  • 有 Composited Layer 後代並本身具有某些屬性。
  • 元素有一個 z-index 較低且為 Composited Layer 的兄弟元素。
為什麼需要 Composited Layer?
  1. 避免不必要的重繪。例如網頁中有兩個 Layer a 和 b,如果 a Layer 的元素髮生改變,b Layer 沒有發生改變;那隻需要重新繪製 a Layer,然後再與 b Layer 進行 Compositing,就可以得到整個網頁。
  2. 利用硬體加速高效實現某些 UI 特性。例如滾動、3D 變換、透明度或者濾鏡效果,可以通過 GPU(硬體渲染)高效實現。
層壓縮

由於重疊的原因,可能會產生大量的 Composited Layer,就會浪費很多資源,嚴重影響效能,這個問題被稱為層爆炸。瀏覽器通過 Layer Squashing(層壓縮)處理這個問題,當有多個 Render Layer 與 Composited Layer 重疊,這些 Render Layer 會被壓縮到同一個 Composited Layer。來看一個例子:

<!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">
  <style>
    div {
      position: absolute;
      width: 100px;
      height: 100px;
    }
    .div1 {
      z-index: 1;
      top: 10px;
      left: 10px;
      will-change: transform;
      background-color: #f00;
    }
    .div2 {
      z-index: 2;
      top: 80px;
      left: 80px;
      background-color: #f0f;
    }
    .div3 {
      z-index: 2;
      top: 100px;
      left: 100px;
      background-color: #ff0;
    }
  </style>
  <title>Document</title>
</head>
<body>
  <div class="div1"></div>
  <div class="div2"></div>
  <div class="div3"></div>
</body>
</html>
複製程式碼

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

可以看到後面兩個節點重疊而壓縮到了同一個 Composited Layer。

有一些不能被壓縮的情況,可以在 無線效能優化:Composite —— 層壓縮 中檢視。

參考資料

  1. 無線效能優化:Composite —— 從-PaintLayers-到-GraphicsLayers
  2. Webkit 渲染基礎與硬體加速
  3. Chromium網頁Graphics Layer Tree建立過程分析
  4. Chrome中的硬體加速合成
  5. 瀏覽器渲染流程 詳細分析
  6. WebKit 渲染流程基礎及分層加速

效能優化

上文簡單介紹了瀏覽器渲染流程上的各個組成部分,下面我們通過畫素管道來研究如何優化視覺變化效果所引發的更新。

畫素管道

還在為網頁渲染效能優化而苦惱嗎?

JavaScript。一般來說,我們會使用 JavaScript 來實現一些視覺變化的效果。比如用 jQuery 的 animate 函式做一個動畫、對一個資料集進行排序或者往頁面裡新增一些 DOM 元素等。當然,除了 JavaScript,還有其他一些常用方法也可以實現視覺變化效果,比如:CSS Animations、Transitions 和 Web Animation API。

樣式計算。此過程是根據匹配選擇器(例如 .headline 或 .nav > .nav__item)計算出哪些元素應用哪些 CSS 規則的過程。從中知道規則之後,將應用規則並計算每個元素的最終樣式。

佈局。在知道對一個元素應用哪些規則之後,瀏覽器即可開始計算它要佔據的空間大小及其在螢幕的位置。網頁的佈局模式意味著一個元素可能影響其他元素,例如 元素的寬度一般會影響其子元素的寬度以及樹中各處的節點,因此對於瀏覽器來說,佈局過程是經常發生的。

繪製。繪製是填充畫素的過程。它涉及繪出文字、顏色、影象、邊框和陰影,基本上包括元素的每個可視部分。繪製一般是在多個表面(通常稱為層)上完成的。

合成。由於頁面的各部分可能被繪製到多層,由此它們需要按正確順序繪製到螢幕上,以便正確渲染頁面。對於與另一元素重疊的元素來說,這點特別重要,因為一個錯誤可能使一個元素錯誤地出現在另一個元素的上層。

渲染時的每一幀都會經過管道的各部分進行處理,但並不意味著所有的部分都會執行。實際上,在實現視覺變化效果時,管道針對指定幀通常有三種方式:

  1. JS / CSS > 樣式 > 佈局 > 繪製 > 合成

還在為網頁渲染效能優化而苦惱嗎?

如果你修改一個 DOM 元素的 Layout 屬性,也就是改變了元素的樣式(比如 width、height 或者 position 等),那麼瀏覽器會檢查哪些元素需要重新佈局,然後對頁面激發一個 reflow(重排)過程完成重新佈局。被 reflow(重排)的元素,接下來也會激發繪製過程,最後激發渲染層合併過程,生成最後的畫面。

  1. JS / CSS > 樣式 > 繪製 > 合成

還在為網頁渲染效能優化而苦惱嗎?

如果你修改一個 DOM 元素的 Paint Only 屬性,比如背景圖片、文字顏色或陰影等,這些屬性不會影響頁面的佈局,因此瀏覽器會在完成樣式計算之後,跳過佈局過程,只會繪製和渲染層合併過程。

  1. JS / CSS > 樣式 > 合成

還在為網頁渲染效能優化而苦惱嗎?

如果你修改一個非樣式且非繪製的 CSS 屬性,那麼瀏覽器會在完成樣式計算之後,跳過佈局和繪製的過程,直接做渲染層合併。這種方式在效能上是最理想的,對於動畫和滾動這種負荷很重的渲染,我們要爭取使用第三種渲染過程。

影響 Layout、Paint 和 Composite 的屬性都可以通過 CSS Triggers 網站查閱。

重新整理率

上面提到每一幀都要經過畫素管道處理,也就是說每一幀都是一次重新渲染。我們需要引出另外一個概念:重新整理率。

重新整理率是一秒鐘能夠重新渲染多少次數的指標。目前大多數裝置的螢幕重新整理率為 60 次/秒;因此如果在頁面中有動畫、漸變、滾動效果,那麼瀏覽器每一次重新渲染的時間間隔必須跟裝置的每一次重新整理保持一致,才能比較流暢。需要注意的是,大多數瀏覽器也會對重新渲染的時間間隔進行限制,因為即使超過螢幕重新整理率,使用者體驗也不會提升。

重新整理率(Hz)取決與顯示器的硬體水平。 幀率(FPS)取決於顯示卡或者軟體制約。

每次重新渲染的時間不能超過 16.66 ms(1 秒 / 60 次)。但實際上,瀏覽器還有很多整理工作,因此我們的所有工作最好在 10 毫秒之內完成。如果超過時間,重新整理率下降,就會導致頁面抖動,感覺卡頓。

還在為網頁渲染效能優化而苦惱嗎?

優化 JavaScript 執行

JavaScript 是觸發視覺變化的主要因素,時機不當或長時間執行的 JavaScript 可能是導致效能下降的常見原因。針對 JavaScript 的執行,下面有一些常用的優化措施。

window.requestAnimationFrame

在沒有 requestAnimationFrame 方法的時候,執行動畫,我們可能使用 setTimeoutsetInterval 來觸發視覺變化;但是這種做法的問題是:回撥函式執行的時間是不固定的,可能剛好就在末尾,或者直接就不執行了,經常會引起丟幀而導致頁面卡頓。

還在為網頁渲染效能優化而苦惱嗎?

歸根到底發生上面這個問題的原因在於時機,也就是瀏覽器要知道何時對回撥函式進行響應。setTimeoutsetInterval 是使用定時器來觸發回撥函式的,而定時器並無法保證能夠準確無誤的執行,有許多因素會影響它的執行時機,比如說:當有同步程式碼執行時,會先等同步程式碼執行完畢,非同步佇列中沒有其他任務,才會輪到自己執行。並且,我們知道每一次重新渲染的最佳時間大約是 16.6 ms,如果定時器的時間間隔過短,就會造成 過度渲染,增加開銷;過長又會延遲渲染,使動畫不流暢。

requestAnimationFrame 方法不同與 setTimeoutsetInterval,它是由系統來決定回撥函式的執行時機的,會請求瀏覽器在下一次重新渲染之前執行回撥函式。無論裝置的重新整理率是多少,requestAnimationFrame 的時間間隔都會緊跟螢幕重新整理一次所需要的時間;例如某一裝置的重新整理率是 75 Hz,那這時的時間間隔就是 13.3 ms(1 秒 / 75 次)。需要注意的是這個方法雖然能夠保證回撥函式在每一幀內只渲染一次,但是如果這一幀有太多工執行,還是會造成卡頓的;因此它只能保證重新渲染的時間間隔最短是螢幕的重新整理時間。

requestAnimationFrame 方法的具體說明可以看 MDN 的相關文件,下面通過一個網頁動畫的示例來了解一下如何使用。

let offsetTop = 0;
const div = document.querySelector(".div");
const run = () => {
  div.style.transform = `translate3d(0, ${offsetTop += 10}px, 0)`;
  window.requestAnimationFrame(run);
};
run();
複製程式碼

如果想要實現動畫效果,每一次執行回撥函式,必須要再次呼叫 requestAnimationFrame 方法;與 setTimeout 實現動畫效果的方式是一樣的,只不過不需要設定時間間隔。

參考資料
  1. 被譽為神器的requestAnimationFrame
  2. requestAnimationFrame 知多少?
  3. 淺析 requestAnimationFrame
  4. 告別定時器,走向 window.requestAnimationFrame()
  5. requestAnimationFrame 效能更好
  6. 談談requestAnimationFrame的動畫迴圈

window.requestIdleCallback

requestIdleCallback 方法只在一幀末尾有空閒的時候,才會執行回撥函式;它很適合處理一些需要在瀏覽器空閒的時候進行處理的任務,比如:統計上傳、資料預載入、模板渲染等。

以前如果需要處理複雜的邏輯,不進行分片,使用者介面很可能就會出現假死狀態,任何的互動操作都將無效;這時使用 setTimeout 就可以把任務拆分成多個模組,每次只處理一個模組,這樣能很大程度上緩解這個問題。但是這種方式具有很強的不確定性,我們不知道這一幀是否空閒,如果已經塞滿了一大堆任務,這時在處理模組就不太合適了。因此,在這種情況下,我們也可以使用 requestIdleCallback 方法來儘可能高效地利用空閒來處理分片任務。

如果一直沒有空閒,requestIdleCallback 就只能永遠在等待狀態嗎?當然不是,它的引數除了回撥函式之外,還有一個可選的配置物件,可以使用 timeout 屬性設定超時時間;當到達這個時間,requestIdleCallback 的回撥就會立即推入事件佇列。來看下如何使用:

// 任務佇列
const tasks = [
  () => {
    console.log("第一個任務");
  },
  () => {
    console.log("第二個任務");
  },
  () => {
    console.log("第三個任務");
  },
];

// 設定超時時間
const rIC = () => window.requestIdleCallback(runTask, {timeout: 3000})

function work() {
  tasks.shift()();
}

function runTask(deadline) {
  if (
    (
      deadline.timeRemaining() > 0 ||
      deadline.didTimeout
    ) &&
    tasks.length > 0
  ) {
    work();
  }

  if (tasks.length > 0) {
    rIC();
  }
}

rIC();
複製程式碼

回撥函式引數的詳細說明可以檢視 MDN 的文件。

改變 DOM

不應該在 requestIdleCallback 方法的回撥函式中改變 DOM。我們來看下在某一幀的末尾,回撥函式被觸發,它在一幀中的位置:

還在為網頁渲染效能優化而苦惱嗎?

回撥函式安排在幀提交之後,也就是說這時渲染已經完成了,佈局已經重新計算過;如果我們在回撥中改變樣式,並且在下一幀中讀取佈局資訊,那之前所作的所有佈局計算全都浪費掉了,瀏覽器會強制重新進行佈局計算,這也被稱為 強制同步佈局

如果真的想要修改 DOM,那麼最佳實踐是:在 requestIdleCallback 的回撥中構建 Document Fragment,然後在下一幀的 requestAnimationFrame 回撥進行真實的 DOM 變動。

Fiber

React 16 推出了新的協調器,Fiber Reconciler(纖維協調器)。它和原先 Stack Reconciler(棧協調器)不同的是:整個渲染過程不是連續不中斷完成的;而是進行了分片,分段處理任務,這就需要用到 requestIdleCallbackrequestAnimationFrame 方法來實現。requestIdleCallback 負責低優先順序的任務,requestAnimationFrame 負責動畫相關的高優先順序任務。

參考資料
  1. requestIdleCallback-後臺任務排程
  2. 你應該知道的requestIdleCallback
  3. 使用requestIdleCallback
  4. React Fiber初探 —— 調和(Reconciliation)

Web Worker

JavaScript 採用的是單執行緒模型,也就是說,所有任務都要在一個執行緒上完成,一次只能執行一個任務。有時,我們需要處理大量的計算邏輯,這是比較耗費時間的,使用者介面很有可能會出現假死狀態,非常影響使用者體驗。這時,我們就可以使用 Web Worker 來處理這些計算。

Web Worker 是 HTML5 中定義的規範,它允許 JavaScript 指令碼執行在主執行緒之外的後臺執行緒中。這就為 JavaScript 創造了 多執行緒 的環境,在主執行緒,我們可以建立 Worker 執行緒,並將一些任務分配給它。Worker 執行緒與主執行緒同時執行,兩者互不干擾。等到 Worker 執行緒完成任務,就把結果傳送給主執行緒。

Web Worker 與其說創造了多執行緒環境,不如說是一種回撥機制。畢竟 Worker 執行緒只能用於計算,不能執行更改 DOM 這些操作;它也不能共享記憶體,沒有 執行緒同步 的概念。

Web Worker 的優點是顯而易見的,它可以使主執行緒能夠騰出手來,更好的響應使用者的互動操作,而不必被一些計算密集或者高延遲的任務所阻塞。但是,Worker 執行緒也是比較耗費資源的,因為它一旦建立,就一直執行,不會被使用者的操作所中斷;所以當任務執行完畢,Worker 執行緒就應該關閉。

Web Workers API

一個 Worker 執行緒是由 new 命令呼叫 Worker() 建構函式建立的;建構函式的引數是:包含執行任務程式碼的指令碼檔案,引入指令碼檔案的 URI 必須遵守同源策略。

Worker 執行緒與主執行緒不在同一個全域性上下文中,因此會有一些需要注意的地方:

  • 兩者不能直接通訊,必須通過訊息機制來傳遞資料;並且,資料在這一過程中會被複制,而不是通過 Worker 建立的例項共享。詳細介紹可以查閱 worker中資料的接收與傳送:詳細介紹
  • 不能使用 DOM、windowparent 這些物件,但是可以使用與主執行緒全域性上下文無關的東西,例如 WebScoketindexedDBnavigator 這些物件,更多能夠使用的物件可以檢視Web Workers可以使用的函式和類
使用方式

Web Worker 規範中定義了兩種不同型別的執行緒;一個是 Dedicated Worker(專用執行緒),它的全域性上下文是 DedicatedWorkerGlobalScope 物件;另一個是 Shared Worker(共享執行緒),它的全域性上下文是 SharedWorkerGlobalScope 物件。其中,Dedicated Worker 只能在一個頁面使用,而 Shared Worker 則可以被多個頁面共享。

下面我來簡單介紹一下使用方式,更多的 API 可以檢視 使用 Web Workers

專用執行緒

下面程式碼最重要的部分在於兩個執行緒之間怎麼傳送和接收訊息,它們都是使用 postMessage 方法傳送訊息,使用 onmessage 事件進行監聽。區別是:在主執行緒中,onmessage 事件和 postMessage 方法必須掛載在 Worker 的例項上;而在 Worker 執行緒,Worker 的例項方法本身就是掛載在全域性上下文上的。

<!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>Web Worker 專用執行緒</title>
</head>
<body>
  <input type="text" name="" id="number1">
  <span>+</span>
  <input type="text" name="" id="number2">
  <button id="button">確定</button>
  <p id="result"></p>

  <script src="./main.js"></script>
</body>
</html>
複製程式碼
// main.js

const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");

// 1. 指定指令碼檔案,建立 Worker 的例項
const worker = new Worker("./worker.js");

button.addEventListener("click", () => {
  // 2. 點選按鈕,把兩個數字傳送給 Worker 執行緒
  worker.postMessage([number1.value, number2.value]);
});

// 5. 監聽 Worker 執行緒返回的訊息
// 我們知道事件有兩種繫結方式,使用 addEventListener 方法和直接掛載到相應的例項
worker.addEventListener("message", e => {
  result.textContent = e.data;
  console.log("執行完畢");
})
複製程式碼
// worker.js

// 3. 監聽主執行緒傳送過來的訊息
onmessage = e => {
  console.log("開始後臺任務");
  const result= +e.data[0]+ +e.data[1];
  console.log("計算結束");

  // 4. 返回計算結果到主執行緒
  postMessage(result);
}
複製程式碼
共享執行緒

共享執行緒雖然可以在多個頁面共享,但是必須遵守同源策略,也就是說只能在相同協議、主機和埠號的網頁使用。

示例基本上與專用執行緒的類似,區別是:

  • 建立例項的構造器不同。
  • 主執行緒與共享執行緒通訊,必須通過一個確切開啟的埠物件;在傳遞訊息之前,兩者都需要通過 onmessage 事件或者顯式呼叫 start 方法開啟埠連線。而在專用執行緒中這一部分是自動執行的。
// main.js

const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");

// 1. 建立共享例項
const worker = new SharedWorker("./worker.js");

// 2. 通過埠物件的 start 方法顯式開啟埠連線,因為下文沒有使用 onmessage 事件
worker.port.start();

button.addEventListener("click", () => {
  // 3. 通過埠物件傳送訊息
  worker.port.postMessage([number1.value, number2.value]);
});

// 8. 監聽共享執行緒返回的結果
worker.port.addEventListener("message", e => {
  result.textContent = e.data;
  console.log("執行完畢");
});
複製程式碼
// worker.js

// 4. 通過 onconnect 事件監聽埠連線
onconnect = function (e) {
  // 5. 使用事件物件的 ports 屬性,獲取埠
  const port = e.ports[0];

  // 6. 通過埠物件的 onmessage 事件監聽主執行緒傳送過來的訊息,並隱式開啟埠連線
  port.onmessage = function (e) {
    console.log("開始後臺任務");
    const result= e.data[0] * e.data[1];
    console.log("計算結束");
    console.log(this);

    // 7. 通過埠物件返回結果到主執行緒
    port.postMessage(result);
  }
}
複製程式碼
參考資料
  1. 優化 JavaScript 執行 —— 降低複雜性或使用 Web Worker
  2. 使用 Web Workers
  3. 深入 HTML5 Web Worker 應用實踐:多執行緒程式設計
  4. JS與多執行緒

防抖和節流函式

在進行改變視窗大小、滾動網頁、輸入內容這些操作時,事件回撥會十分頻繁的被觸發,嚴重增加了瀏覽器的負擔,導致使用者體驗非常糟糕。此時,我們就可以考慮採用防抖和節流函式來處理這類調動頻繁的事件回撥,同時它們也不會影響實際的互動效果。

我們先來簡單瞭解一下這兩個函式:

  • 防抖(debounce)函式。在持續觸發事件時,並不執行事件回撥;只有在一段時間之內,沒有再觸發事件的時候,事件回撥才會執行一次。

還在為網頁渲染效能優化而苦惱嗎?

  • 節流(throttle)函式。在持續觸發事件時,事件回撥也會不斷的間隔一段時間後執行一次。

還在為網頁渲染效能優化而苦惱嗎?

這兩個函式最大的區別在於執行的時機,防抖函式會在事件觸發停止一段時間後執行事件回撥;而節流函式會在事件觸發時不斷的間隔一段時間後執行事件回撥。我們用定時器來簡單實現一下這兩個函式,詳細版本可以參考 UnderscoreLodash —— debounceLodash —— throttle。節流函式其實在瀏覽器擁有 requestAnimationFrame 方法之後,使用這個方法呼叫事件回撥會更好一些。

實現防抖函式

每次執行到 debounce 返回的函式,都先把上一個定時器清理掉,再重新執行一個定時器;等到最後一次執行這個返回的函式的時候,定時器不會被清理,就可以正常等待定時器結束,執行事件回撥了。

function debounce(func, wait) {
  let timeout = null;
  
  return function run(...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(this, args);
    }, wait);
  }
};
複製程式碼
實現節流函式

在定時器存在的時候,不在重新生成定時器;等到定時器結束,事件回撥執行,就把定時器清空;在下一次執行 throttle 返回的函式的時候,再生成定時器,等待下一個事件回撥執行。

function throttle(func, wait) {
  let timeout = null;

  return function run(...args) {
    if (!timeout) {
      timeout = setTimeout(() => {
        timeout = null;
        func.apply(this, args);
      }, wait);
    }
  }
}
複製程式碼
參考資料
  1. JS的防抖與節流
  2. 使輸入處理程式去除抖動
  3. Underscore
  4. Lodash —— debounce
  5. Lodash —— throttle

降低 Style 的複雜性

我們知道 CSS 最重要的組成部分是選擇器和宣告,所以我會通過這兩方面來講解如何降低 Style 的複雜性。

避免選擇器巢狀

我們在 CSSOM Tree 這一節中瞭解到:巢狀的選擇器會從右向左匹配,這是一個遞迴的過程,而遞迴是一種比較耗時的操作。更不用說一些 CSS3 的選擇器了,它們會需要更多的計算,例如:

.text:nth-child(2n) .strong {
  /* styles */
}
複製程式碼

為了確定哪些節點應用這個樣式,瀏覽器必須先詢問這是擁有 "strong" class 的節點嗎?其父節點恰好是偶數的 "text" class 節點嗎?如此多的計算過程,都可以通過一個簡單的 class 來避免:

.text-even-strong {
  /* styles */
}
複製程式碼

這麼簡單的選擇器,瀏覽器只要匹配一次就可以了。為了準確描述網頁結構、可複用和程式碼共享等方面的考慮,我們可以使用 BEM 來協助開發。

BEM(塊,元素,修飾符)

BEM 簡單來講就是一種 class 的命名規範,它建議所有元素都有單個類,並且巢狀也能夠很好的組織在類中:

.nav {}
.nav__item {}
複製程式碼

如果節點需要與其他節點進行區分,就可以加入修飾符來協助開發:

.nav__item--active {}
複製程式碼

更為詳細的描述和用法可以檢視 Get BEM

使用開銷更小的樣式

因為螢幕顯示效果的不同,所以瀏覽器渲染每一個樣式的開銷也會不一樣。例如,繪製陰影肯定要比繪製普通背景的時間要長。我們來對比下這兩者之間的開銷。

<!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">
  <style>
    .simple {
      background-color: #f00;
    }
    .complex {
      box-shadow: 0 4px 4px rgba(0, 0, 0, 0.5);
    }
  </style>
  <title>效能優化</title>
</head>
<body>
  <div class="container"></div>
  <script>
    const div = document.querySelector(".container");
    let str = "";
    for (let i = 0; i < 1000; i++) {
      str += "<div class=\"simple\">background-color: #f00;</div>";
      // str += "<div class=\"complex\">box-shadow: 0, 4px, 4px, rgba(0,0,0,0.5);</div>";
    }
    div.innerHTML = str;
  </script>
</body>
</html>
複製程式碼

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

可以看到陰影的 Layout 是 31.35 ms,paint 是 6.43 ms;背景的 Layout 是 10.81 ms,paint 是 4.30 ms。Layout 的差異還是相當明顯的。

因此,如果可能,還是應該使用開銷更小的樣式替代當前樣式實現最終效果。

參考資料

  1. 縮小樣式計算的範圍並降低其複雜性
  2. CSS BEM 書寫規範

最小化重排(Reflow)和重繪(Repaint)

首先我們先來了解一下什麼是重排和重繪。

  • 重排是指因為修改 style 或調整 DOM 結構重新構建部分或全部 Render Object Tree 從而計算佈局的過程。這一過程至少會觸發一次,既頁面初始化。
  • 重繪是指重新繪製受影響的部分到螢幕。

觀察畫素通道會發現重繪不一定會觸發重排,比如改變某個節點的背景色,只會重新繪製這個節點,而不會發生重排,這是因為佈局資訊沒有發生變化;但是重排是一定會觸發重繪的。

下面的情況會導致重排或者重繪:

  • 調整 DOM 結構
  • 修改 CSS 樣式
  • 使用者事件,如頁面滾動,改變視窗大小等

瀏覽器優化策略

重排和重繪會不斷觸發,這是不可避免的。但是,它們非常消耗資源,是導致網頁效能低下的根本原因。

提高網頁效能,就是要降低重排和重繪的頻率和成本,儘可能少的觸發重新渲染。

瀏覽器面對集中的 DOM 操作時會有一個優化策略:建立一個變化的佇列,然後一次執行,最終只渲染一次。

div2.style.height = "100px";
div2.style.width = "100px";
複製程式碼

上面的程式碼在瀏覽器優化後只會執行一次渲染。但是,如果程式碼寫得不好變化的佇列就會立即重新整理,並進行渲染;這通常是在修改 DOM 之後,立即獲取樣式資訊的時候。下面的樣式資訊會觸發重新渲染:

  • offsetTop/offsetLeft/offsetWidth/offsetHeight
  • scrollTop/scrollLeft/scrollWidth/scrollHeight
  • clientTop/clientLeft/clientWidth/clientHeight
  • getComputedStyle()

提高效能的技巧

  1. 多利用瀏覽器優化策略。相同的 DOM 操作(讀或寫),應該放在一起。不要在讀操作中間插入寫操作。
  2. 不要頻繁計算樣式。如果某個樣式是通過重排得到的,那麼最好快取結果。避免下一次使用的時候,再進行重排。
// Bad
const div1 = document.querySelector(".div1");
div1.style.height = div1.clientHeight + 200 + "px";
div1.style.width = div1.clientHeight * 2 + "px";

// Good
const div2 = document.querySelector(".div2");
const div2Height = div1.clientHeight + 200;
div2.style.height = div2Height + "px";
div2.style.width = div2Height * 2 + "px";
複製程式碼
  1. 不要逐條改變樣式。通過改變 classNamecssText 屬性,一次性改變樣式。
// Bad
const top = 10;
const left = 10;
const div = document.querySelector(".div");
div.style.top = top + "px";
div.style.left = left + "px";

// Good
div.className += "addClass";

// Good
div.style.cssText += "top: 10px; left: 10px";
複製程式碼
  1. 使用離線 DOM。離線意味著不對真實的節點進行操作,可以通過以下方式實現:
  • 操縱 Document Fragment 物件,完成後再把這個物件加入 DOM Tree
  • 使用 cloneNode 方法,在克隆的節點上進行操作,然後再用克隆的節點替換原始節點
  • 將節點設為 display: none;(需要一次重排),然後對這個節點進行多次操作,最後恢復顯示(需要一次重排)。這樣一來,就用兩次重排,避免了更多次的重新渲染。
  • 將節點設為 visibility: hidden; 和設為 display: none; 是類似的,但是這個屬性只對重繪有優化,對重排是沒有效果的,因為它只是隱藏,但是節點還在文件流中的。
  1. 設定 position: absolute | fixed;。節點會脫離文件流,這時因為不用考慮這個節點對其他節點的影響,所以重排的開銷會比較小。
  2. 使用虛擬 DOM,例如 Vue、React 等。
  3. 使用 flexbox 佈局。flexbox 佈局的效能要比傳統的佈局模型高得多,下面是對 1000 個 div 節點應用 floatflex 佈局的開銷對比。可以發現,對於相同數量的元素和相同視覺的外觀,flex 佈局的開銷要小得多(float 37.92 ms | flex 13.16 ms)。

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

參考資料

  1. 網頁效能管理詳解
  2. 渲染優化:重排重繪與硬體加速
  3. 瀏覽器渲染流程 詳細分析
  4. CSS Animation效能優化

Composite 的優化

終於,我們到了畫素管道的末尾。對於這一部分的優化策略,我們可以從為什麼需要 Composited Layer(Graphics Layer)來入手。這個問題我們在構建 Graphics Layer Tree 的時候,已經說明過,現在簡單回顧一下:

  1. 避免不必要的重繪。
  2. 利用硬體加速高效實現某些 UI 特性。

根據 Composited Layer 的這兩個特點,可以總結出以下幾點優化措施。

使用 transformopacity 屬性來實現動畫

上文我們說過畫素管道的 Layout 和 Paint 部分是可以略過,只進行 Composite 的。實現這種渲染方式的方法很簡單,就是使用只會觸發 Composite 的 CSS 屬性;目前,滿足這個條件的 CSS 屬性,只有 transformopacity

還在為網頁渲染效能優化而苦惱嗎?

使用 transformopacity 需要注意的是:元素必須是 Composited Layer;如果不是,Paint 還是會照常觸發(Layout 要看情況,一般 transform 會觸發)。來看一個例子:

<!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">
  <style>
    .div {
      width: 100px;
      height: 100px;
      background-color: #f00;
      /* will-change: transform; */
    }
  </style>
  <title>效能優化</title>
</head>

<body>
  <div class="div"></div>
  <script>
    const div = document.querySelector(".div");
    const run = () => {
      div.style.transform = "translate(0, 100px)";
    };
    setTimeout(run, 2000);
  </script>
</body>
</html>
複製程式碼

我們將使用 transform 來向下位移,開始我們先不把 div 節點提升為 Composited Layer;通過下圖可以看到:還是會觸發 Layout 和 Paint 的。

還在為網頁渲染效能優化而苦惱嗎?

這時,把 div 節點提升為 Composited Layer,我們發現 Layout 和 Paint 已經被略過了,符合我們的預期。

還在為網頁渲染效能優化而苦惱嗎?

減少繪製的區域

如果不能避免繪製,我們就應該儘可能減少需要重繪的區域。例如,頁面頂部有一塊固定區域,當頁面某個其他區域需要重繪的時候,很可能整塊螢幕都要重繪,這時,固定區域也會被波及到。像這種情況,我們就可以把需要重繪或者受到影響的區域提升為 Composited Layer,避免不必要的繪製。

提升成 Composited Layer 的最佳方式是使用 CSS 的 will-change 屬性,它的詳細說明可以檢視 MDN 的文件。

.element {
  will-change: transform;
}
複製程式碼

對於不支援的瀏覽器,最簡單的 hack 方法,莫過於使用 3D 變形來提升為 Composited Layer 了。

.element {
  transform: translateZ(0);
}
複製程式碼

根據上文所講的例子,我們嘗試使用 will-change 屬性來讓固定區域避免重繪。

<!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">
  <style>
    .div {
      width: 100px;
      height: 100px;
      background-color: #f00;
    }
    .header {
      position: fixed;
      z-index: 9999;
      width: 100%;
      height: 50px;
      background-color: #ff0;
      /* will-change: transform; */
    }
  </style>
  <title>效能優化</title>
</head>

<body>
  <header class="header">固定區域</header>
  <div class="div">變動區域</div>
  <script>
    const div = document.querySelector(".div");
    const run = () => {
      div.style.opacity = 0.5;
    };
    setTimeout(run, 2000);
  </script>
</body>
</html>
複製程式碼

首先,我們來看下沒有經過優化的情況;順帶說明檢視瀏覽器一幀繪製詳情的過程。

  1. 開啟控制檯的 Performance 介面。
  2. 點選設定(標記 1),開啟繪製分析儀(標記 2)。
  3. 啟動 Record(標記 3),獲取到想要的資訊後,點選 Stop(標記 4), 停止 Record。
  4. 點選這一幀的 Paint(標記 5)檢視繪製詳情。
  5. 切換到 Paint Profiler 選項卡(標記 6),檢視繪製的步驟。

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

通過上面的圖片(標記 7 和標記 8)可以看到,固定區域的確被波及到,並且觸發重繪了。我們再對比使用 will-change 屬性優化過的情況,發現固定區域沒有觸發重繪。

還在為網頁渲染效能優化而苦惱嗎?

並且,我們也可以通過一幀(標記 1)的佈局詳情(標記 2),檢視固定區域(標記 3)是不是提升成 Composited Layer(標記 4),才避免的不必要繪製。

還在為網頁渲染效能優化而苦惱嗎?

合理管理 Composited Layer

提升成 Composited Layer 的確會優化效能;但是,要知道建立一個新的 Composited Layer 必須要額外的記憶體和管理,這是非常昂貴的代價。所以,在記憶體資源有限的裝置上,Composited Layer 帶來的效能提升,很可能遠遠抵不上建立多個 Composited Layer 的代價。同時,由於每一個 Composited Layer 的點陣圖都需要上傳到 GPU;所以,不免需要考慮 CPU 和 GPU 之間的頻寬以及用多大記憶體處理 GPU 紋理的問題。

我們通過 1000 個 div 節點,來對比普通圖層與提升成 Composited Layer 之後的記憶體使用情況。可以發現差距還是比較明顯的。

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

最小化提升

通過上文的說明,我們知道 Composited Layer 並不是越多越好。尤其是,千萬不要通過下面的程式碼提升頁面的所有元素,這樣的資源消耗將是異常恐怖的。

* {
  /* or transform: translateZ(0) */
  will-change: transform;
}
複製程式碼

最小化提升,就是要儘量降低頁面 Composited Layer 的數量。為了做到這一點,我們可以不把像 will-change 這樣能夠提升節點為 Composited Layer 的屬性寫在預設狀態中。至於這樣做的原因,我會在下面講解。

看這個例子,我們先把 will-change 屬性寫在預設狀態裡;然後,再對比去掉這個屬性後渲染的情況。

.box {
  width: 100ox;
  height: 100px;
  background-color: #f00;
  will-change: transform;
  transition: transform 0.3s;
}
.box:hover {
  transform: scale(1.5);
}
複製程式碼

使用 will-change 屬性提升的 Composited Layer:

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

普通圖層:

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

還在為網頁渲染效能優化而苦惱嗎?

我們發現區別僅在於,動畫的開始和結束,會觸發重繪;而動畫執行的時候,刪除或使用 will-change 是沒有任何分別的。

我們在構建 Graphics Layer Tree 的時候講到過這樣一條理由:

對 opacity、transform、fliter、backdropfilter 應用了 animation 或者 transition(需要是 active 的 animation 或者 transition,當 animation 或者 transition 效果未開始或結束後,提升的 Composited Layer 會恢復成普通圖層)。

這條理由賜予了我們動態提升 Composited Layer 的權利;因此我們應該多利用這一點,來減少不必要的 Composited Layer 的數量。

防止層爆炸

我們在 Graphics Layer Tree 中介紹過層爆炸,它指的是由於重疊而導致的大量額外 Composited Layer 的問題。瀏覽器的層壓縮可以在很大程度上解決這個問題,但是,有很多特殊的情況,會導致 Composited Layer 無法被壓縮;這就很可能產生一些不在我們預期中的 Composited Layer,也就是說還是會出現大量額外的 Composited Layer。

在層壓縮這一節,我們已經給出了使用層壓縮優化的例子,這裡就不再重複了。下面再通過解決一個無法被層壓縮的例子,來更為深入的瞭解如何防止層爆炸。

<!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">
  <style>
    .animating {
      width: 300px;
      height: 30px;
      line-height: 30px;
      background-color: #ff0;
      will-change: transform;
      transition: transform 3s;
    }

    .animating:hover {
      transform: translateX(100px);
    }

    ul {
      padding: 0;
      border: 1px solid #000;
    }

    .box {
      position: relative;
      display: block;
      width: auto;
      background-color: #00f;
      color: #fff;
      margin: 5px;
      overflow: hidden;
    }

    .inner {
      position: relative;
      margin: 5px;
    }
  </style>
  <title>效能優化</title>
</head>

<body>
  <div class="animating">動畫</div>
  <ul>
    <li class="box">
      <p class="inner">提升成合成層</p>
    </li>
    <li class="box">
      <p class="inner">提升成合成層</p>
    </li>
    <li class="box">
      <p class="inner">提升成合成層</p>
    </li>
    <li class="box">
      <p class="inner">提升成合成層</p>
    </li>
    <li class="box">
      <p class="inner">提升成合成層</p>
    </li>
  </ul>
</body>
</html>
複製程式碼

當我們的滑鼠移入 .animating 元素的時候,通過檢視 Layers 皮膚,可以很清晰的看到出現的大量 Composited Layer。

還在為網頁渲染效能優化而苦惱嗎?

這個例子雖然表面上看起來沒有發生重疊;但是,因為在執行動畫的時候,很可能與其他元素造成重疊,所以 .animating 元素會假設兄弟元素在一個 Composited Layer 之上。這時,又因為 .box 元素設定了 overflow: hidden; 導致自己與 .animating 元素有了不同的裁剪容器(Clipping Container),所以就出現了層爆炸的現象。

解決這個問題的辦法也很簡單,就是讓 .animating 元素的 z-index 比其他兄弟元素高。因為 Composited Layer 在普通元素之上,所以也就沒有必要提升普通元素,修正渲染順序了。這裡我在順便多說一句,預設情況下 Composited Layer 渲染順序的優先順序是比普通元素高的;但是在普通元素設定 position: relative; 之後,因為層疊上下文,並且在文件流後面的原因,所以會比 Composited Layer 的優先順序高。

.animating {
  position: relative;
  z-index: 1;
  ...
}
複製程式碼

還在為網頁渲染效能優化而苦惱嗎?

當然,如果兄弟元素一定要覆蓋在 Composited Layer 之上,那我們也可以把 overflow: hidden; 或者 position: relative; 去掉,來優化 Composited Layer 建立的數量或者直接就不建立 Composited Layer。

參考資料

  1. 無線效能優化:Composite
  2. 堅持僅合成器的屬性和管理層計數
  3. 簡化繪製的複雜度、減小繪製區域
  4. CSS Animation效能優化
  5. 使用CSS3 will-change提高頁面滾動、動畫等渲染效能
  6. CSS3硬體加速也有坑
  7. 深入理解CSS中的層疊上下文和層疊順序

總結

本文首先講了渲染需要構建的一些樹,然後通過這些樹與像管道各部分的緊密聯絡,整理了一些優化措施。例如,我們對合成所進行的優化措施,就是通過 Graphics Layer Tree 來入手的。

優化也不能盲目去做,例如,提升普通圖層為 Composite Layer 來說,使用不當,反而會造成非常嚴重的記憶體消耗。應當善加利用 Google 瀏覽器的除錯控制檯,幫助我們更加詳盡的瞭解網頁各方面的情況;從而有針對性的優化網頁。

文章參考了很多資料,這些資料都在每一節的末尾給出。它們具有非常大的價值,有一些細節,本文可能並沒有整理,可以通過檢視它們來更為深入的瞭解。

相關文章