JavaScript 工作原理之十一-渲染引擎及效能優化小技巧

tristan發表於2018-06-17

原文請查閱這裡,略有刪減,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland

本系列持續更新中,Github 地址請查閱這裡

這是 JavaScript 工作原理的第十一章。

迄今為止,之前的 JavaScript 工作原理系列文章集中於關注 JavaScript 語言本身的功能,在瀏覽器中的執行情況,如何優化等等。

然而,當在構建網路應用的時候,不僅僅只是編寫自己執行的 JavaScript 程式碼。所編寫的 JavaScript 程式碼與執行環境息息相關。理解 JavaScript 執行環境,它的執行原理以及其組成會讓你構建出更好的應用並且一旦讓應用程式執行於各種環境下的時候,讓你更加胸有成竹地應對潛在的問題。

JavaScript 工作原理之十一-渲染引擎及效能優化小技巧

那麼,讓我們一探瀏覽器主要元件吧:

  • 使用者介面: 包括位址列,後退和前進按鈕,書籤選單等等。本質上,這裡包含了除顯示使用者所看到的網頁本身的視窗以外的瀏覽器的每個部分。
  • 瀏覽器引擎: 處理使用者介面和渲染引擎的互動
  • 渲染引擎: 負責顯示網頁。渲染引擎解析 HTML 和 CSS 並在螢幕上顯示解析的內容。
  • 網路: 使用各個平臺的不同實現所發起的諸如 XHR 請求的網路呼叫,這些網路呼叫是基於跨平臺的介面實現的。
  • UI 後端: 負責繪製諸如核取方塊和視窗的核心部件。它暴露出一個平臺無關的泛型介面。它底層使用作業系統 UI 方法。
  • JavaScript 引擎: 我們在之前的系列文章中有詳細介紹過。基本上,這是 JavaScript 程式碼執行的地方。
  • 資料儲存: 網路應用可能需要本地儲存所有資料。支援的儲存機制型別包括 localStorage, indexDB, WebSQL 以及 FileSystem

本文將專注介紹渲染引擎,因為它是用來處理 HTML 和 CSS 的解析和視覺化的,而這些是大多數的 JavaScript 應用需要持續進行互動的方面。

渲染引擎概述

渲染引擎的主要職責即在瀏覽器螢幕上顯示請求的頁面。

渲染引擎可以顯示 HTML,XML 文件以及圖片。如果使用額外的外掛,就可以顯示諸如 PDF 的不同型別的文件。

渲染引擎

與 JavaScript 引擎類似,不同瀏覽器也使用不同的渲染引擎。以下為比較流行的引擎:

  • Gecko-Firefox
  • WebKit-Safari
  • Blink-Chrome, Opera(從版本 15 開始)

渲染過程

渲染引擎從網路層獲取到請求的文件內容。

JavaScript 工作原理之十一-渲染引擎及效能優化小技巧

構建 DOM 樹

渲染引擎的第一步即解析 HTML 文件和轉化解析的元素為 DOM 樹 上的實際 DOM 節點。

假設有如下的文字輸入框:

<html>
  <head>
    <meta charset="UTF-8">
    <link rel="stylesheet" type="text/css" href="theme.css">
  </head>
  <body>
    <p> Hello, <span> friend! </span> </p>
    <div> 
      <img src="smiley.gif" alt="Smiley face" height="42" width="42">
    </div>
  </body>
</html>
複製程式碼

HTML 的 DOM 樹類似這樣:

JavaScript 工作原理之十一-渲染引擎及效能優化小技巧

基本上,每個元素是直接包含於其內的元素的父節點。然後依次類推。

構建 CSSOM 樹

CSSOM 即 CSS Object Model。當瀏覽器構建頁面的 DOM 樹的時候,它在 head 標籤部分遇到一個引用外部 theme.css 樣式表的 link 標籤。表示它可能需要樣式表來渲染頁面,於是便馬上分派一個請求來獲取樣式表。假設以下為 theme.css 檔案內容:

body { 
  font-size: 16px;
}

p { 
  font-weight: bold; 
}

span { 
  color: red; 
}

p span { 
  display: none; 
}

img { 
  float: right; 
}
複製程式碼

與 HTML 一樣,渲染引擎需要把 CSS 轉化為瀏覽器可以操作的東西-即 CSSOM。以下為 CSSOM 的大概模樣:

JavaScript 工作原理之十一-渲染引擎及效能優化小技巧

想知道為什麼 CSSOM 是樹狀結構的嗎?當為頁面上的任意物件計算其最終的樣式集的時候,瀏覽器先把最為通用的樣式規則應用於該節點(比如,它是 body 的子節點,會先應用 body 的所有樣式)然後通過應用更為具體的樣式規則來遞迴重定義計算的樣式。

讓我們看下具體的例子吧。body 中的 span 標籤中的任何文字樣式為字型大小 16 畫素且字型顏色為紅色。這些樣式繼承自 body 元素。p 元素的子元素 span 由於應用了更為具體的樣式從而不會顯示其內容(display:none)。

還有,請注意以上 CSSOM 樹並不完整而且只顯示了樣式表中指定的重寫樣式。每個瀏覽器提供了一份預設的樣式集即 『使用者代理樣式』- 這即當沒有提供任何樣式的時候的預設顯示樣式。我們的樣式只是簡單地重寫了這些預設樣式。

構建渲染樹

HTML 中的視覺化指令和 CSSOM 樹的樣式資料結合起來建立渲染樹。

你可能為問渲染樹是什麼?它是按順序構建視覺化元素並顯示在螢幕上的樹。它是帶有相應的樣式的 HTML 的視覺表現。該樹旨在按正確的順序繪製內容。

在 Webkit 中渲染樹中的每個節點即是一個渲染器或者渲染器物件。

以下為以上的 DOM 和 CSSOM 樹合成的渲染器樹的大概模樣:

JavaScript 工作原理之十一-渲染引擎及效能優化小技巧

為了建立渲染樹,瀏覽器大概做了幾下幾件事:

  • 從 DOM 樹的根節點開始,遍歷每個可見節點。一些節點是不可見的(比如,script 標籤,meta 標籤等等),然後會被忽略,因為它們並不會在渲染的輸出中顯示。一些節點通過樣式隱藏然後也會被忽略。比如以上例子中的 span 節點,因為為其顯式設定了 display: none 的樣式。
  • 瀏覽器為每個可見節點應用相對應的 CSSOM 規則並應用這些樣式規則。
  • 釋放出包含內容及其經過計算的樣式的可見節點。

可以瀏覽下 RenderObject 的原始碼(Webkit 中):github.com/WebKit/webk…

看一下這個類的一些核心構件吧:

class RenderObject : public CachedImageClient {
  // 重繪整個物件。當邊框顏色改變或者邊框樣式更改的時候呼叫。
  
  Node* node() const { ... }
  
  RenderStyle* style;  // 計算的樣式
  const RenderStyle& style() const;
  
  ...
}
複製程式碼

每個渲染器物件代表一個矩形區域通常是和一個節點的 CSS 盒模型相對應。它包括諸如寬度,高度以及定位的幾何資訊。

渲染樹佈局

當建立了渲染器並且新增到渲染樹的時候,它並沒有定位和大小的資訊。計算這些值即稱為佈局。

HTML 使用了流式佈局模型,意即大多數情況下可以一次性計算出渲染器的幾何資訊。座標系統是相對於根渲染器的。這裡使用 Top 和 left 座標。

佈局是一個遞迴的過程-它從根渲染器開始進行渲染,根渲染器即 HTML 文件的 html 元素。佈局繼續通過一部分或者整個渲染器層級結構遞迴進行,為每個需要計算幾何資訊的渲染器計算其資訊。

根渲染器的定位為 0,0 和大小即為瀏覽器視窗的視覺化部分(比如 viewport)。

進行佈局的過程即計算出每個節點在螢幕上顯示的準確位置。

繪製渲染樹

該階段,遍歷渲染器樹然後呼叫渲染器的 paint() 方法來在螢幕上顯示其內容。

繪製可以是全域性或增量式的(類似於佈局):

  • 全域性-重繪整個樹。
  • 增量-以某種方式只更改部分渲染器而不會影響到整顆樹。渲染器作廢其在螢幕上的矩形區域。這會導致作業系統把它看成是一個需要重繪的區域並生成一個 paint 事件。作業系統會智慧地把幾個區域合併成一個以提升渲染效能。

總之,理解繪製是個漸進式的過程是很重要的。為了更好的互動體驗,渲染引擎會試圖儘快在螢幕上顯示內容。它不會等待所有的 HTML 結構解析完成才開始構建和佈局渲染樹。會優先解析和顯示部分內容,與此同時持續處理從網路接收的剩下的內容項。

指令碼和樣式的處理順序

當解析器遇到 <script> 標籤的時候會立即解析和執行該標籤裡面的程式碼。整個文件的解析會停止直到指令碼執行完畢。意即該過程是同步的。

當 script 引用的是一個外部資源,必須首先獲取該資源(也是同步的)。所有的解析會停止直到獲取該指令碼資源。

HTML5 新增了一個選項來非同步載入該資源,這樣就可以使用另外的執行緒來解析和執行該資源。IE 可以使用 defer屬性,其它可以使用 async 屬性。IE10 以下使用 defer 屬性,IE10 以上也可以使用 async 屬性。

這裡有一個需要注意的地方即 IE10 以下對於 defer 的支援,開啟 https://caniuse.com 查詢即可發現對於 IE10 以下的支援是一些需要注意的地方即 defer 的指令碼有可能會在 DOMContentLoaded 事件之後才開始執行,參見這裡,這裡就不做試驗了,有興趣可以點選這裡測試下 IE 下的表現。

這裡稍微做一下引申,在 jQuery 原始碼中,ready.js 有一段如下的程式碼:

// Catch cases where $(document).ready() is called
// after the browser event has already occurred.
// Support: IE <=9 - 10 only
// Older IE sometimes signals "interactive" too soon
if ( document.readyState === "complete" ||
	( document.readyState !== "loading" && !document.documentElement.doScroll ) ) {

	// Handle it asynchronously to allow scripts the opportunity to delay ready
	window.setTimeout( jQuery.ready );

} else {

	// Use the handy event callback
	document.addEventListener( "DOMContentLoaded", completed );

	// A fallback to window.onload, that will always work
	window.addEventListener( "load", completed );
}
複製程式碼

裡面的 window.setTimeout( jQuery.ready ); 是允許指令碼有機會延遲執行 ready 事件。大概是為 IE script 標籤的 defer 屬性準備的吧?

優化渲染效能

若想要優化網路應用的效能,需要關注五個主要的方面。這些方面是你可以進行控制的:

  1. JavaScript-之前的文章中有介紹了編寫不阻塞 UI ,高效的程式碼等等。談到渲染時候,需要考慮 JavaScript 程式碼是如何和頁面上的 DOM 元素進行互動的。JavaScript 會在介面上做很多的更改,特別是在單頁應用中。
  2. 樣式計算-這個過程即應用樣式規則到匹配選擇器的元素上。一旦定義了樣式規則,它們會應用於對應的元素,然後計算每個元素的最終樣式。
  3. 佈局-一旦瀏覽器瞭解元素應用的樣式規則,它會開始計算元素所佔用的空間和其在瀏覽器螢幕上的顯示位置。根據網頁的佈局模型定義一個元素的佈局會影響到其它的元素。比如,<body> 標籤的寬度會影響到其子孫元素的寬度等等。這即意味著佈局過程是相當耗時的。繪製是在多個圖層完成的。
  4. 繪製-該階段即開始填充實際畫素。這一過程包括繪製文字,顏色,圖片,邊框,陰影等所有每個元素的可見部分。
  5. 合成-因為頁面部分被繪製成潛在的多層,它們必須在螢幕以正確的順序進行繪製,這樣頁面渲染才會正常。這是至關重要的,特別是對於那些重疊元素。

優化 JavaScript 程式碼

JavaScript 經常會在瀏覽器端觸發視覺改變。尤其是在構建 SPA 的過程中會更多。

這裡有一些優化 JavaScript 中部分程式碼來提升渲染效率的建議:

  • 避免使用 setTimeout 或者 setInterval 來進行視覺的更改。這些會在幀的某個時間點呼叫 callback,有可能是在幀的末尾。這樣就會造成卡頓。必須在幀的開始觸發視覺更改。

  • 把耗時的 JavaScript 移入之前提到的網頁工作執行緒

  • 使用微任務來處理跨多個幀的 DOM 更改。這是為了預防當任務需要訪問 DOM,而網路工作執行緒無法辦到的情況的。

    意即需要把一個大型的任務分割為多個小任務然後根據不同的任務性質在 requestAnimationFramesetTimeoutsetInterval 中執行。

優化樣式

通過新增和移除元素及更改屬性等等修改 DOM 會導致瀏覽器重新計算元素樣式及大多數情況整個頁面或者部分頁面的佈局。

使用以下方法來優化渲染:

  • 減少選擇器的複雜度。選擇器複雜度會佔用超過計算所需元素樣式的 50% 的時間,剩餘時間即構建樣式本身。
  • 減少必須產生樣式計算的元素的個數。本質上,直接更改少數元素的樣式而不是使整個頁面的樣式失效。

優化佈局

佈局是很耗費瀏覽器效能的。考慮以下優化方案:

  • 儘可能減少佈局的數量。當更改樣式的時候,瀏覽器檢查樣式更改是否需要重新計算佈局。一般而言更改諸如 width, height, left, top 等和幾何學相關的屬性會需要佈局。所以,儘可能避免修改它們。
  • 儘可能使用 flexbox 來進行佈局而不是老式的佈局模型。它會渲染得更快並且會極大地提升網路應用的效能。
  • 避免強制同步佈局。需要記住的是當執行 JavaScript的時候,上一幀的老的佈局值是已知的且可以被查詢得到。當訪問 box.offsetHeight 這並不會造成效能問題。然而,如果在訪問它之前更改它的樣式(比如為元素動態新增樣式類),瀏覽器將不得不首先應用樣式更改然後執行佈局計算樣式。這將會非常耗時和耗資源,所以盡力避免這樣做。

優化繪製

這經常會是所有任務中最耗時的,所以儘量避免觸發繪製。優化方案:

  • 更改除 transfroms 或者 opacity 外的屬性會觸發繪製。所以省著點用啊。
  • 當觸發一個佈局也會觸發繪製,因為更改元素的幾何資訊會更改元素的展示效果。
  • 通過提升層和動畫編排來減少繪製區域。

擴充套件

參考谷歌官方關於效能的文件,提升元素使用如下的程式碼:

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

使用 FASTDOM 來避免強制同步佈局和抖動。

另外關於 JavaScript 程式碼的優化方面,避免去處理一些微優化,比如使用 offsetTop 比用 getBoundingClientRect 速度更快,但這得基於所建立的網路應用而言,假設建立一個遊戲,對效能要求非常高而且呼叫這些方法的地方多,那麼效能的提升將會很可觀的。還記得以前經常會去使用諸如 jsperf 來測試某個方法的速度,千萬別鑽牛角尖,因地制宜,避免掉入去計較那些微小的優化而付出過大的精力。

關於渲染可以使用一些骨架圖來提升使用者體驗。

一些想法

  • 關於效能的體驗,其實你可以想象成造房子吧,假如是整個翻修當然是會更加耗時,但是如果裝修某個區域就會提升效能。
  • 然後有其中的某個屬性會提升效能,這可能理解為『工欲善其事必先利其器』。
  • 關於任務的切分可以理解為,建設設計的哲學,小技巧。

參考資源

打個廣告 ^.^

今日頭條招人啦!傳送簡歷到 likun.liyuk@bytedance.com ,即可走快速內推通道,長期有效!國際化PGC部門的JD如下:c.xiumi.us/board/v5/2H…,也可內推其他部門!

本系列持續更新中,Github 地址請查閱這裡

相關文章