[譯] 現代瀏覽器內部揭祕(第三部分)

ssshooter發表於2018-10-14

渲染程式的內部機制

這是關於瀏覽器工作原理部落格系列四部分中的第三部分。之前,我們介紹了多程式架構導航流。在這篇文章中,我們將一探渲染程式的內部機制。

渲染程式涉及 Web 效能的許多方面。由於渲染程式的流程太複雜,因此本文只進行概述。如果你想深入瞭解,可以在 the Performance section of Web Fundamentals 找到相關資源。

渲染程式處理網站內容

渲染程式負責標籤頁內發生的所有事情。在渲染程式中,主執行緒處理伺服器傳送到使用者的大部分程式碼。如果你使用 web worker 或 service worker,部分 JavaScript 將由工作執行緒處理。合成和光柵執行緒也在渲染程式內執行,以高效,流暢地呈現頁面。

渲染程式的核心工作是將 HTML、CSS 和 JavaScript 轉換為使用者可以與之互動的網頁。

Renderer process

圖 1:渲染程式內部包含主執行緒、工作執行緒、合成執行緒和光柵執行緒

解析(Parsing)

DOM 的構建

當渲染程式收到導航的提交訊息並開始接收 HTML 資料時,主執行緒開始解析文字字串(HTML)並將其轉換為文件物件模型(DOM)。

DOM 是一個頁面在瀏覽器內部表現,也是 Web 開發人員可以通過 JavaScript 與之互動的資料結構和 API。

將 HTML 到 DOM 的解析由 HTML Standard 規定。你可能已經注意到,將 HTML 提供給瀏覽器這一過程從不會引發錯誤。像 Hi! <b>I'm <i>Chrome</b>!</i> 這樣的錯誤標記,會被理解為 Hi! <b>I'm <i>Chrome</i></b><i>!</i>,這是因為 HTML 規範會優雅地處理這些錯誤。如果你好奇這是如何做到的,可以閱讀 An introduction to error handling and strange cases in the parser 的 HTML 規範部分。

子資源載入

網站通常使用影象、CSS 和 JavaScript 等外部資源,這些檔案需要從網路或快取載入。在解析構建 DOM 時,主執行緒按處理順序逐個請求它們,但為了加快速度,“預載入掃描器(preload scanner)”會同時執行。如果 HTML 文件中有 <img><link> 之類的內容,則預載入掃描器會檢視由 HTML 解析器生成的標記,並在瀏覽器程式中向網路執行緒傳送請求。

DOM

圖 2:主執行緒解析 HTML 並構建 DOM 樹

JavaScript 阻塞解析

當 HTML 解析器遇到 <script> 標記時,會暫停解析 HTML 文件,開始載入、解析並執行 JavaScript 程式碼。為什麼?因為JavaScript 可以使用諸如 document.write() 的方法來改寫文件,這會改變整個 DOM 結構(HTML 規範裡的 overview of the parsing model 中有一張不錯的圖片)。這就是 HTML 解析器必須等待 JavaScript 執行後再繼續解析 HTML 文件原因。如果你對 JavaScript 執行中發生的事情感到好奇,可以看看 V8 團隊就此發表的演講和部落格文章

提示瀏覽器如何載入資源

Web 開發者可以通過多種方式向瀏覽器傳送提示,以便很好地載入資源。如果你的 JavaScript 不使用 document.write(),你可以在 <script> 標籤新增 asyncdefer 屬性,這樣瀏覽器會非同步載入執行 JavaScript 程式碼,而不阻塞解析。如果合適,你也可以使用 JavaScript 模組。可以使用 <link rel="preload"> 告知瀏覽器當前導航肯定需要該資源,並且你希望儘快下載。有關詳細資訊請參閱 Resource Prioritization – Getting the Browser to Help You

樣式計算

只擁有 DOM 不足以確定頁面的外觀,因為我們會在 CSS 中設定頁面元素的樣式。主執行緒解析 CSS 並確定每個 DOM 節點計算後的樣式。這是有關基於 CSS 選擇器對每個元素應用何種樣式的資訊,這可以在 DevTools 的 computed 部分中看到。

computed style

圖 3:主執行緒解析 CSS 以新增計算後樣式

即使你不提供任何 CSS,每個 DOM 節點都具有計算樣式。像 <h1> 標籤看起來比 <h2> 標籤大,每個元素都有 margin,這是因為瀏覽器具有預設樣式表。如果你想知道更多 Chrome 的預設 CSS,可以在這裡看到原始碼

佈局

現在,渲染程式知道每個節點的樣式和文件的結構,但這不足以渲染頁面。想象一下,你正試圖通過手機向朋友描述一幅畫:“這裡有一個大紅圈和一個小藍方塊”,這並不能讓你的朋友知道這幅畫究竟長什麼樣。

game of human fax machine

圖 4:一個人站在一幅畫前,電話線與另一個人相連

佈局是計算元素幾何形狀的過程。主執行緒遍歷 DOM,計算樣式並建立佈局樹,其中包含 x y 座標和邊界框大小等資訊。佈局樹可能與 DOM 樹結構類似,但它僅包含頁面上可見內容相關的資訊。如果一個元素應用了 display:none,那麼該元素不是佈局樹的一部分(但 visibility:hidden 的元素在佈局樹中)。類似地,如果應用瞭如 p::before{content:"Hi!"} 的偽類,則即使它不在 DOM 中,也包含於佈局樹中。

layout

圖 5:主執行緒遍歷計算樣式後的 DOM 樹,以此生成佈局樹

layout.gif

圖 6:由於換行而移動的盒子佈局

確定頁面佈局是一項很有挑戰性的任務。即使是從上到下的塊流這樣最簡單的頁面佈局,也必須考慮字型的大小以及換行位置,這些因素會影響段落的大小和形狀,進而影響下一個段落的位置。

CSS 可以使元素浮動到一側、隱藏溢位的元素、更改書寫方向。你可以想象這一階段的任務之艱鉅。Chrome 瀏覽器有整個工程師團隊負責佈局。BlinkOn 會議的一些訪談記錄了他們工作的細節,有興趣可以瞭解一下,挺有趣的。

繪製

drawing game

圖 7:一個人拿著筆站在畫布前,思考著她應該先畫圓形還是先畫方形

擁有 DOM、樣式和佈局仍然不足以渲染頁面。假設你正在嘗試重現一幅畫。你知道元素的大小、形狀和位置,但你仍需要判斷繪製它們的順序。

例如,可以為某些元素設定 z-index,此時按 HTML 中編寫的元素的順序繪製會導致錯誤的渲染。

z-index fail

圖 8:因為沒有考慮 z-index,頁面元素按 HTML 標記的順序出現,導致錯誤的渲染影象

在繪製步驟中,主執行緒遍歷佈局樹建立繪製記錄。繪製記錄是繪圖過程的記錄,就像是“背景優先,然後是文字,然後是矩形”。如果你使用過 JavaScript 繪製了 <canvas> 元素,那麼這個過程對你來說可能很熟悉。

paint records

圖 9:主執行緒遍歷佈局樹並生成繪製記錄

更新渲染管道的成本很高

trees.gif

圖 10:DOM + Style、佈局和繪製樹的生成順序

渲染管道中最重要的事情是:每個步驟中,前一個操作的結果用於後一個操作建立新資料。例如,如果佈局樹中的某些內容發生改變,需要為文件的受影響部分重新生成“繪製”指令。

如果要為元素設定動畫,則瀏覽器必須在每個幀之間執行這些操作。大多數顯示器每秒重新整理螢幕 60 次(60 fps),當螢幕每幀都在變化,人眼會覺得動畫很流暢。但是,如果動畫丟失了中間一些幀,頁面看起來就會卡頓(janky)。

jage jank by missing frames

圖 11:時間軸上的動畫幀

即使渲染操作能跟上螢幕重新整理,這些計算也會在主執行緒上執行,這意味著當你的應用程式執行 JavaScript 時動畫可能會被阻塞。

jage jank by JavaScript

圖 12:時間軸上的動畫幀,但 JavaScript 阻塞了一幀

你可以將 JavaScript 操作劃分為小塊,並使用 requestAnimationFrame() 在每個幀上執行。有關此主題的更多資訊,請參閱 Optimize JavaScript Execution。你也可以在 Web Worker 中執行 JavaScript 以避免阻塞主執行緒。

request animation frame

圖 13:時間軸上較小的 JavaScript 塊與動畫幀一起執行

合成

如何繪製一個頁面?

naive_rastering.gif

圖 14:簡單光柵處理示意動畫

現在瀏覽器知道文件的結構、每個元素的樣式、頁面的幾何形狀和繪製順序,它是如何繪製頁面的?把這些資訊轉換為螢幕上的畫素,我們稱為光柵化。

處理這種情況的一種簡單的方法是,先在光柵化視窗內的畫面,如果使用者滾動頁面,則移動光柵框,並光柵化填充缺少的部分。這就是 Chrome 首次釋出時處理光柵化的方式。但是,現代瀏覽器會執行一個更復雜的過程,我們稱為合成。

什麼是合成

composit.gif

圖 15:合成處理示意動畫

合成是一種將頁面的各個部分分層,分別光柵化,並在稱為合成執行緒的單獨執行緒中合成為頁面的技術。如果發生滾動,由於圖層已經光柵化,因此它所要做的只是合成一個新幀。動畫也可以以相同的方式(移動圖層和合成新幀)實現。

你可以在 DevTools 使用 Layers 皮膚 看看你的網站如何被分層。

分層

為了分清哪些元素位於哪些圖層,主執行緒遍歷佈局樹建立圖層樹(此部分在 DevTools 效能皮膚中稱為“Update Layer Tree”)。如果頁面的某些部分應該是單獨圖層(如滑入式側面選單)但沒拆分出來,你可以使用 CSS 中的 will-change 屬性來提示瀏覽器。

layer tree

圖 16:主執行緒遍歷佈局樹生成圖層樹

你可能想要為每個元素都分層,但是合成大量的圖層可能會比每幀都光柵化頁面的重新整理方式更慢,因此測量應用程式的渲染效能至關重要。有關這個主題的更多資訊,請參閱 Stick to Compositor-Only Properties and Manage Layer Count

主執行緒的光柵化和合成

一旦建立了圖層樹並確定了繪製順序,主執行緒就會將該資訊提交給合成執行緒。接著,合成執行緒會光柵化每個圖層。一個圖層可能會跟整個頁面一樣大,因此合成執行緒將它們分塊後傳送到光柵執行緒。光柵執行緒光柵化每個小塊後會將它們儲存在視訊記憶體中。

raster

圖17:光柵執行緒建立分塊的點陣圖併傳送到 GPU

合成執行緒會給不同的光柵執行緒設定優先順序,以便視窗(或附近)內的畫面可以先被光柵化。圖層還具有多個不同解析度的塊,可以處理放大操作等動作。

一旦塊被光柵化,合成執行緒會收集這些塊的資訊(稱為繪製四邊形)建立合成幀

繪製四邊形

包含諸如圖塊在記憶體中的位置,以及合成時繪製圖塊在頁面中的位置等資訊。

合成幀

一個繪製四邊形的集合,代表一個頁面的一幀。

接著,合成幀通過 IPC(程式間通訊)提交給瀏覽器程式。此時,可以從 UI 執行緒或其他外掛的渲染程式新增另一個合成幀。這些合成器幀被髮送到 GPU 然後在螢幕上顯示。如果接收到滾動事件,合成執行緒會建立另一個合成幀傳送到 GPU。

composit

圖 18:合成執行緒建立合成幀,將其傳送到瀏覽器程式,再接著傳送到 GPU

合成的好處是它可以在不涉及主執行緒的情況下完成。合成執行緒不需要等待樣式計算或 JavaScript 執行。這就是為什麼僅合成動畫被認為是流暢效能的最佳選擇。如果需要再次計算佈局或繪製,則必須涉及主執行緒。

總結

在這篇文章中,我們研究了渲染管道從解析到合成的整個過程,希望現在你能自主地去了解更多關於網站效能優化的資訊。

在本系列的下一篇也是最後一篇文章中,我們將更詳細地介紹合成執行緒,看看當使用者移動或點選滑鼠時會發生什麼。

你喜歡這篇文章嗎?如果你對之後的文章有任何問題或建議,我很樂意在下面的評論部分或推特 @kosamari 與你聯絡。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章