網頁渲染性能優化

渲染原理

在討論性能優化以前,咱們有必要了解一些瀏覽器的渲染原理。不一樣的瀏覽器進行渲染有着不一樣的實現方式,可是大致流程都是差很少的,咱們經過 Chrome 瀏覽器來大體瞭解一下這個渲染流程。javascript

關鍵渲染路徑

關鍵渲染路徑是指瀏覽器將 HTML、CSS 和 JavaScript 轉換成實際運做的網站必須採起的一系列步驟,經過渲染流程圖咱們能夠大體歸納以下:css

  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

構建流程

獲取到 HTML 字節數據後,會經過如下流程構建 DOM Tree:html5

  1. 編碼:HTML 原始字節數據轉換爲文件指定編碼的字符串。
  2. 詞法分析(標記化):對輸入字符串進行逐字掃描,根據 構詞規則 識別單詞和符號,分割成一個個咱們能夠理解的詞彙(學名叫 Token )的過程。
  3. 語法分析(解析器):對 Tokens 應用 HTML 的語法規則,進行配對標記、確立節點關係和綁定屬性等操做,從而構建 DOM Tree 的過程。

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

詞法分析(標記化)

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

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

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

<a href="www.w3c.org">W3C</a>

  • 開始標記:<a href="www.w3c.org">github

    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,完成解析
  • 內容標記:W3Cweb

    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 規則的過程。從中知道規則以後,將應用規則並計算每一個元素的最終樣式。

佈局。在知道對一個元素應用哪些規則以後,瀏覽器便可開始計算它要佔據的空間大小及其在屏幕的位置。網頁的佈局模式意味着一個元素可能影響其餘元素,例如 <body> 元素的寬度通常會影響其子元素的寬度以及樹中各處的節點,所以對於瀏覽器來講,佈局過程是常常發生的。

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

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

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

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

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

若是你修改一個 DOM 元素的 Paint Only 屬性,好比背景圖片、文字顏色或陰影等,這些屬性不會影響頁面的佈局,所以瀏覽器會在完成樣式計算以後,跳過佈局過程,只會繪製和渲染層合併過程。
  1. JS / CSS > 樣式 > 合成

道](./assets/frame-no-layout-paint.jpg)

若是你修改一個非樣式且非繪製的 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; 是相似的,可是這個屬性只對重繪有優化,對重排是沒有效果的,由於它只是隱藏,可是節點還在文檔流中的。
  2. 設置 position: absolute | fixed;。節點會脫離文檔流,這時由於不用考慮這個節點對其餘節點的影響,因此重排的開銷會比較小。
  3. 使用虛擬 DOM,例如 Vue、React 等。
  4. 使用 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 瀏覽器的調試控制檯,幫助咱們更加詳盡的瞭解網頁各方面的狀況;從而有針對性的優化網頁。

文章參考了不少資料,這些資料都在每一節的末尾給出。它們具備很是大的價值,有一些細節,本文可能並無整理,能夠經過查看它們來更爲深刻的瞭解。