How Javascript works (Javascript工作原理) (十一) 渲染引擎及性能優化小技巧
個人總結:讀完這篇文章需要20分鐘,這篇文章主要講解了瀏覽器中引擎的渲染機制。
DOMtree ----|
|----> RenderTree
CSSOMtree ----|
這是 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 開始)
渲染過程
渲染引擎從網絡層獲取到請求的文檔內容。
構建 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 樹類似這樣:
基本上,每個元素是直接包含於其內的元素的父節點。然後依次類推。
構建 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 的大概模樣:
想知道為什麽 CSSOM 是樹狀結構的嗎?當為頁面上的任意對象計算其最終的樣式集的時候,瀏覽器先把最為通用的樣式規則應用於該節點(比如,它是 body 的子節點,會先應用 body 的所有樣式)然後通過應用更為具體的樣式規則來遞歸重定義計算的樣式。
讓我們看下具體的例子吧。body
中的 span
標簽中的任何文字樣式為字體大小 16 像素且字體顏色為紅色。這些樣式繼承自 body
元素。p
元素的子元素 span
由於應用了更為具體的樣式從而不會顯示其內容(display:none
)。
還有,請註意以上 CSSOM 樹並不完整而且只顯示了樣式表中指定的重寫樣式。每個瀏覽器提供了一份默認的樣式集即 『用戶代理樣式』- 這即當沒有提供任何樣式的時候的默認顯示樣式。我們的樣式只是簡單地重寫了這些默認樣式。
構建渲染樹
HTML 中的可視化指令和 CSSOM 樹的樣式數據結合起來創建渲染樹。
你可能為問渲染樹是什麽?它是按順序構建可視化元素並顯示在屏幕上的樹。它是帶有相應的樣式的 HTML 的視覺表現。該樹旨在按正確的順序繪制內容。
在 Webkit 中渲染樹中的每個節點即是一個渲染器或者渲染器對象。
以下為以上的 DOM 和 CSSOM 樹合成的渲染器樹的大概模樣:
為了創建渲染樹,瀏覽器大概做了幾下幾件事:
- 從 DOM 樹的根節點開始,遍歷每個可見節點。一些節點是不可見的(比如,script 標簽,meta 標簽等等),然後會被忽略,因為它們並不會在渲染的輸出中顯示。一些節點通過樣式隱藏然後也會被忽略。比如以上例子中的 span 節點,因為為其顯式設置了
display: none
的樣式。 - 瀏覽器為每個可見節點應用相對應的 CSSOM 規則並應用這些樣式規則。
- 釋放出包含內容及其經過計算的樣式的可見節點。
可以瀏覽下 RenderObject 的源碼(Webkit 中):https://github.com/WebKit/webkit/blob/fde57e46b1f8d7dde4b2006aaf7ebe5a09a6984b/Source/WebCore/rendering/RenderObject.h
看一下這個類的一些核心構件吧:
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 屬性準備的吧?
優化渲染性能
若想要優化網絡應用的性能,需要關註五個主要的方面。這些方面是你可以進行控制的:
- JavaScript-之前的文章中有介紹了編寫不阻塞 UI ,高效的代碼等等。談到渲染時候,需要考慮 JavaScript 代碼是如何和頁面上的 DOM 元素進行交互的。JavaScript 會在界面上做很多的更改,特別是在單頁應用中。
- 樣式計算-這個過程即應用樣式規則到匹配選擇器的元素上。一旦定義了樣式規則,它們會應用於對應的元素,然後計算每個元素的最終樣式。
- 布局-一旦瀏覽器了解元素應用的樣式規則,它會開始計算元素所占用的空間和其在瀏覽器屏幕上的顯示位置。根據網頁的布局模型定義一個元素的布局會影響到其它的元素。比如,
<body>
標簽的寬度會影響到其子孫元素的寬度等等。這即意味著布局過程是相當耗時的。繪制是在多個圖層完成的。 - 繪制-該階段即開始填充實際像素。這一過程包括繪制文字,顏色,圖片,邊框,陰影等所有每個元素的可見部分。
- 合成-因為頁面部分被繪制成潛在的多層,它們必須在屏幕以正確的順序進行繪制,這樣頁面渲染才會正常。這是至關重要的,特別是對於那些重疊元素。
優化 JavaScript 代碼
JavaScript 經常會在瀏覽器端觸發視覺改變。尤其是在構建 SPA 的過程中會更多。
這裏有一些優化 JavaScript 中部分代碼來提升渲染效率的建議:
-
避免使用
setTimeout
或者setInterval
來進行視覺的更改。這些會在幀的某個時間點調用callback
,有可能是在幀的末尾。這樣就會造成卡頓。必須在幀的開始觸發視覺更改。 -
把耗時的 JavaScript 移入之前提到的網頁工作線程。
-
使用微任務來處理跨多個幀的 DOM 更改。這是為了預防當任務需要訪問 DOM,而網絡工作線程無法辦到的情況的。
意即需要把一個大型的任務分割為多個小任務然後根據不同的任務性質在
requestAnimationFrame
,setTimeout
或setInterval
中執行。
優化樣式
通過添加和移除元素及更改屬性等等修改 DOM 會導致瀏覽器重新計算元素樣式及大多數情況整個頁面或者部分頁面的布局。
使用以下方法來優化渲染:
- 減少選擇器的復雜度。選擇器復雜度會占用超過計算所需元素樣式的 50% 的時間,剩余時間即構建樣式本身。
- 減少必須產生樣式計算的元素的個數。本質上,直接更改少數元素的樣式而不是使整個頁面的樣式失效。
優化布局
布局是很耗費瀏覽器性能的。考慮以下優化方案:
- 盡可能減少布局的數量。當更改樣式的時候,瀏覽器檢查樣式更改是否需要重新計算布局。一般而言更改諸如 width, height, left, top 等和幾何學相關的屬性會需要布局。所以,盡可能避免修改它們。
- 盡可能使用
flexbox
來進行布局而不是老式的布局模型。它會渲染得更快並且會極大地提升網絡應用的性能。 - 避免強制同步布局。需要記住的是當運行 JavaScript的時候,上一幀的老的布局值是已知的且可以被查詢得到。當訪問
box.offsetHeight
這並不會造成性能問題。然而,如果在訪問它之前更改它的樣式(比如為元素動態添加樣式類),瀏覽器將不得不首先應用樣式更改然後運行布局計算樣式。這將會非常耗時和耗資源,所以盡力避免這樣做。
優化繪制
這經常會是所有任務中最耗時的,所以盡量避免觸發繪制。優化方案:
- 更改除 transfroms 或者 opacity 外的屬性會觸發繪制。所以省著點用啊。
- 當觸發一個布局也會觸發繪制,因為更改元素的幾何信息會更改元素的展示效果。
- 通過提升層和動畫編排來減少繪制區域。
擴展
參考谷歌官方關於性能的文檔,提升元素使用如下的代碼:
.moving-element {
will-change: transform;
}
使用 FASTDOM 來避免強制同步布局和抖動。
另外關於 JavaScript 代碼的優化方面,避免去處理一些微優化,比如使用 offsetTop
比用 getBoundingClientRect
速度更快,但這得基於所創建的網絡應用而言,假設創建一個遊戲,對性能要求非常高而且調用這些方法的地方多,那麽性能的提升將會很可觀的。還記得以前經常會去使用諸如 jsperf 來測試某個方法的速度,千萬別鉆牛角尖,因地制宜,避免掉入去計較那些微小的優化而付出過大的精力。
關於渲染可以使用一些骨架圖來提升用戶體驗。
一些想法
- 關於性能的體驗,其實你可以想象成造房子吧,假如是整個翻修當然是會更加耗時,但是如果裝修某個區域就會提升性能。
- 然後有其中的某個屬性會提升性能,這可能理解為『工欲善其事必先利其器』。
- 關於任務的切分可以理解為,建設設計的哲學,小技巧。
參考資源
- https://developers.google.com/web/fundamentals/performance/critical-rendering-path/constructing-the-object-model
- https://developers.google.com/web/fundamentals/performance/rendering/reduce-the-scope-and-complexity-of-style-calculations
- https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/#The_parsing_algorithm
How Javascript works (Javascript工作原理) (十一) 渲染引擎及性能優化小技巧