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

NO IMAGE

博客 有更多精品文章喲。

目錄

渲染原理

在討論性能優化之前,我們有必要了解一些瀏覽器的渲染原理。不同的瀏覽器進行渲染有著不同的實現方式,但是大體流程都是差不多的,我們通過 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 瀏覽器的調試控制檯,幫助我們更加詳盡的瞭解網頁各方面的情況;從而有針對性的優化網頁。

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

相關文章

[web前端性能優化]性能優化只有三步,你瞭解嗎

React怎麼實現Vue的組件

【深入吧,HTML5】性能&集成——HistoryAPI

【深入吧,HTML5】性能&集成——WebWorkers