圖解Chrome:HTML/CSS/JS是如何在瀏覽器中,渲染成你看到的頁面?
Chrome 算是程序員的標(biāo)配了,從全球的市場份額來看,它在全球市場的份額已經(jīng)超過 60%。
在 Chrome 10 周年之際,官方發(fā)布了一個(gè)系列文章,用圖解的方式,很清晰的講解了現(xiàn)代瀏覽器的運(yùn)行原理。
渲染器進(jìn)程的內(nèi)部工作原理
本系列分為 4 個(gè)部分,主要講解關(guān)于現(xiàn)代瀏覽器的運(yùn)行原理,本文為該系列的第 3 篇。在之前的文章中,我們介紹了多進(jìn)程架構(gòu)和導(dǎo)航的完整流程,而在這篇文章中,我們將探究在渲染器進(jìn)程的內(nèi)部,到底發(fā)生了什么。
渲染器進(jìn)程涉及到 Web 性能相關(guān)的多個(gè)方面,由于渲染器進(jìn)程中處理了很多的邏輯,不是一篇文章可以全面講解的,因此本文僅作為一個(gè)概述。如果你有興趣深入研究,可以在《Why Performance Matters》這篇文章里找到更多的資料。
渲染器進(jìn)程處理Web內(nèi)容
所有選項(xiàng)卡內(nèi)發(fā)生的邏輯,都由渲染器進(jìn)程負(fù)責(zé)。在渲染器進(jìn)程中,主線程處理了服務(wù)器發(fā)送給用戶的大部分代碼。如果你使用到 Web Workder 或者Service Worker,那 JavaScript 中的這部分代碼,將由工作線程處理。Compositor(合成器) 和 Raster(光柵) 線程也在渲染器內(nèi)運(yùn)行,從而實(shí)現(xiàn)高效、流暢的渲染頁面。
渲染器進(jìn)程的核心工作是將 HTML,CSS 和 JavaScript 轉(zhuǎn)換為用戶可以與之交互的網(wǎng)頁。
上圖中,描述了具有主線程、工作線程、Compositor 線程、Raster 線程的渲染器進(jìn)程,以及他們之間的關(guān)系。
解析
構(gòu)建 DOM
當(dāng)渲染器進(jìn)程收到一個(gè)導(dǎo)航請(qǐng)求,并開始接收 HTML 數(shù)據(jù),主線程將開始處理文本字符串(HTML),將其解析成 DOM(Document Object Model)。
DOM 是 Web 頁面的內(nèi)部的邏輯樹文檔結(jié)構(gòu),Web 開發(fā)人員可以通過 JavaScript 腳本與之交互數(shù)據(jù),以及通過標(biāo)準(zhǔn) API 來操作 DOM 節(jié)點(diǎn)。
將 HTML 文檔解析成 DOM 是完全依照于 HTML 協(xié)議。并且在 HTML 協(xié)議中,瀏覽器不會(huì)對(duì)錯(cuò)誤的 HTML 進(jìn)行錯(cuò)誤提示。例如,缺少結(jié)束的 </p> 標(biāo)簽時(shí),這依然是一個(gè)有效的 HTML。類似 Hi! <b>I'm <i>Chrome</b>!</i> 中,b 標(biāo)簽在 i 標(biāo)簽之前關(guān)閉這樣的錯(cuò)誤,會(huì)被 HTML 理解為 Hi! <b>I'm <i>Chrome</i></b><i>!</i>。這是因?yàn)?HTML 規(guī)范的主要原則是優(yōu)雅的處理這些錯(cuò)誤,而不是嚴(yán)格檢查。
如果你對(duì)這些規(guī)范感到好奇,可以閱讀 HTML 規(guī)范中的 “解析器中的錯(cuò)誤處理和奇怪案例介紹” 部分。
解析器中的錯(cuò)誤處理和奇怪案例介紹:
https://html.spec.whatwg.org/multipage/parsing.html#an-introduction-to-error-handling-and-strange-cases-in-the-parser
子資源加載
一個(gè)完整的 Web 站點(diǎn)通常會(huì)包含圖片、CSS 和 JS 等外部資源,這些文件都需要從網(wǎng)絡(luò)或者本地緩存中加載。主線程可以在解析構(gòu)建 DOM 的時(shí)候,將他們逐個(gè)請(qǐng)求,但是為了加快速度,會(huì)同時(shí)使用 “預(yù)加載掃描(Preload Scanner)”。
如果 “預(yù)加載掃描” 發(fā)現(xiàn)有類似 <img> 或 <link> 這樣的標(biāo)簽時(shí),會(huì)由 HTML 解析器對(duì)該資源生成一個(gè) Tokens,然后在瀏覽器進(jìn)程中,通過網(wǎng)絡(luò)或者本地緩存來加載資源。
上圖描述了,主線程解析 HTML 并構(gòu)建 DOM 樹的過程。
JS 可以阻止解析
當(dāng) HTML 解析器遇到 <script> 標(biāo)簽的時(shí)候,它會(huì)暫停解析 HTML 文檔,然后對(duì)這個(gè) JS 腳本進(jìn)行加載、解析和執(zhí)行。
這么設(shè)計(jì)的原因,是因?yàn)?JS 可以使用類似 document.write() 方法來改變 DOM 的結(jié)構(gòu)。這就是 HTML 解析器在重新解析 HTML 之前,必須等待 JS 腳本執(zhí)行的原因。
如果你對(duì) JS 執(zhí)行中發(fā)生的事情細(xì)節(jié)有興趣,V8 團(tuán)隊(duì)有一篇文章深入的對(duì)此進(jìn)行了講解,有興趣可以看看。
V8 團(tuán)隊(duì)深入研究:
https://mathiasbynens.be/notes/shapes-ics
提示瀏覽器如何加載資源
HTML 遇到 JS 腳本則暫停對(duì) HTML 的解析,這并不是絕對(duì)的。
Web 開發(fā)人員可以通過多種方式的配置,告知瀏覽器如何更優(yōu)雅的加載資源。如果你的 JS 腳本中,沒有使用到類似 document.write() 這樣的方法,你可以在 script 標(biāo)簽中添加 async 或 defer 標(biāo)記,然后瀏覽器會(huì)異步加載和運(yùn)行此 JS 腳本,不會(huì)阻斷解析。如果需要,也可以使用 JavaScript Modules,還可以通過 <link rel="preload"> 標(biāo)簽向?yàn)g覽器明確標(biāo)記此為重要的資源,將在頁面加載完成之后被立刻使用,對(duì)于這類資源,它會(huì)在頁面加載生命周期的早期,被優(yōu)先加載。
樣式渲染(Style)
僅僅解析成 DOM,還不足以完成頁面渲染,因?yàn)檫€可以通過在 CSS 中,設(shè)置元素的樣式來豐富渲染效果。
主線程將解析 CSS,并將效果渲染到指定的 DOM 節(jié)點(diǎn)上,關(guān)于 CSS 選擇器如何定位到指定的 DOM 節(jié)點(diǎn),可以通過 DevTools 來查看相關(guān)信息。
上圖中,主線程解析 CSS 并添加渲染樣式。
即使你不使用任何 CSS 樣式,每個(gè) DOM 節(jié)點(diǎn)依然存在默認(rèn)的渲染樣式。例如,h1 標(biāo)簽在視覺上就大于 h2 標(biāo)簽,并且每個(gè)元素還有默認(rèn)的邊距。這是因?yàn)闉g覽器具有默認(rèn)樣式表。
如果你對(duì) Chrome 的默認(rèn) CSS 是什么樣的有興趣,可以在源碼中看到具體細(xì)節(jié)。
Chrome 的默認(rèn) CSS:
https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/css/html.css
布局(Layout)
到現(xiàn)在,渲染器進(jìn)程知道每個(gè) DOM 的結(jié)構(gòu)和樣式了,但是這依然不足以渲染頁面。想象一下,你正視圖通過文字向朋友描述一副畫,“有一個(gè)大的紅色圓圈和一個(gè)小的藍(lán)色方塊”,這些信息不足以讓你的朋友還原這幅畫。
這就牽扯到布局(Layout),布局是對(duì)元素定位的過程,主線程遍歷 DOM 并計(jì)算樣式,然后創(chuàng)建布局樹(Layout Tree),在布局樹中,包含 X、Y 坐標(biāo)和邊框大小等信息。布局樹是一個(gè)與 DOM 樹類似的結(jié)構(gòu),但是它僅僅包含了頁面上可見內(nèi)容相關(guān)的信息。
舉個(gè)例子,如果某個(gè)元素設(shè)置了 display:none,則該元素將不會(huì)出現(xiàn)在布局樹中,但是它會(huì)出現(xiàn)在 DOM 樹中,而如果該元素被設(shè)置為 visibility:hidden 則它會(huì)存在于布局樹中。類似的例子還有 p::before{content:"Hi!"} 這樣的偽類,它會(huì)存在于布局樹中,而不會(huì)存在于 DOM 樹中。
如上圖所示,在主線程中渲染樣式,并生成布局樹和 DOM 樹。
計(jì)算頁面布局是一個(gè)很復(fù)雜的工作,即使最簡單的從上到下的塊流結(jié)構(gòu),也必須考慮字體的大小以及如何劃分每一塊,因?yàn)樗鼈儠?huì)影響當(dāng)前段落的大小和形狀,然后影響下一塊所在的位置。
CSS 樣式可以設(shè)置元素浮動(dòng)到某一側(cè)、隱藏 overflow 的元素,或者改變排版方向。布局是一個(gè)非常復(fù)雜的工作,在 Chrome 中,有一個(gè)完整的工程師團(tuán)隊(duì)負(fù)責(zé)布局。如果你的對(duì)他們工作的細(xì)節(jié)感興趣,可以參閱 BinkOn 會(huì)議的記錄。
BinkOn:
https://www.youtube.com/watch?v=Y5Xa4H2wtVA
繪制(Paint)
擁有 DOM、CSS 和 LayoutTree 仍然不足以渲染頁面。假設(shè)你正在嘗試重繪一幅畫,你除了需要知道元素的大小、外觀和位置之外,還需要知道它們的繪制順序。
例如:z-index 屬性將改變?cè)氐膶蛹?jí),在這種情況下,按 HTML 中編寫的元素順序進(jìn)行繪制,將導(dǎo)致渲染結(jié)果和預(yù)期不符。
如上圖所示,因?yàn)闆]有正確的考慮 z-index,將導(dǎo)致頁面被錯(cuò)誤的渲染。
在這個(gè)繪制的過程中,主線程遍歷布局樹,然后創(chuàng)建繪制記錄。繪制記錄是一個(gè)繪制過程的注釋,例如“背景優(yōu)先,然后是文本,***是矩形”。如果你曾經(jīng)使用 JS 在 <canvas> 上繪制元素,那么你對(duì)此過程應(yīng)該會(huì)很熟悉。
如上圖所示,主線程遍歷布局樹,并生成繪制記錄。
更新渲染管道的成本很高
渲染管道(Rendering Pipeline)中最重要的任務(wù),就是在每個(gè)步驟開始前,根據(jù)前一次操作的結(jié)果,來創(chuàng)建新的數(shù)據(jù)。例如,如果布局樹中的某些內(nèi)容發(fā)生更改,則需要為文檔的受影響部分重新生成“繪制”順序。
渲染管道(Rendering Pipeline)中最重要的任務(wù),就是在每個(gè)步驟開始前,根據(jù)前一次操作的結(jié)果,來創(chuàng)建新的數(shù)據(jù)。例如,如果布局樹中的某些內(nèi)容發(fā)生變動(dòng),則需要為文檔中受影響的部分,重新生成“繪制記錄”。
為元素設(shè)置的動(dòng)畫,瀏覽器必須在每一幀之間執(zhí)行這些操作。我們大多數(shù)顯示器每秒刷新 60 次(60fps),如果你對(duì)每一幀都做了處理,那動(dòng)畫對(duì)人眼而言就是平滑的,但是如果某些幀沒有被處理到或者丟失了,則會(huì)導(dǎo)致動(dòng)畫不連貫,出現(xiàn)頁面的“卡頓”。
哪怕渲染的計(jì)算可以跟上屏幕刷新,可因?yàn)榇擞?jì)算過程發(fā)生在主線程上,當(dāng)執(zhí)行 JavaScript 腳本時(shí),可能導(dǎo)致渲染過程被阻斷。
即使渲染的計(jì)算可以跟上屏幕的刷新速度,可因?yàn)榇擞?jì)算是在主線程上執(zhí)行的,這就意味著 JS 代碼的執(zhí)行,也可能導(dǎo)致它被阻斷。
如上圖,時(shí)間軸上的動(dòng)畫幀,被 JS 阻止了一幀。
為此,你可以將 JavaScript 操作劃分成小塊,并在每幀上執(zhí)行requestAnimationFrame(),還可以在 Web Workers 中運(yùn)行 JavaScript,以避免阻塞主線程。
如圖所示,在動(dòng)畫幀的時(shí)間軸上,運(yùn)行較小的 JavaScript 塊。
合成(Compositing)
如何繪制一個(gè)頁面?
現(xiàn)在瀏覽器知道文檔的結(jié)構(gòu),每個(gè)元素的樣式,頁面的形狀和繪制順序,它是如何繪制頁面的?將此信息轉(zhuǎn)換為屏幕上的像素稱為光柵化(rasterizing)。
光柵化是將幾何數(shù)據(jù)經(jīng)過一系列變換后最終轉(zhuǎn)換為像素,從而呈現(xiàn)在顯示設(shè)備上的過程。
也許處理這種情況的一種無腦方案,是在視口(ViewPort)內(nèi)部將每個(gè)組件都光柵化。如果用戶滾動(dòng)頁面,則移動(dòng)光柵幀,并通過更多光柵元素填充缺少的部分。
這就是 Chrome ***發(fā)布時(shí)處理光柵化的方式,但是,現(xiàn)代瀏覽器運(yùn)行一個(gè)更復(fù)雜的被稱為合成(Compositing)的進(jìn)程。
什么是合成(Compositing)
合成是一種將頁面的各個(gè)元素進(jìn)行分層,分別光柵化,并在合成器線程中以一個(gè)單獨(dú)的線程合成新頁面的技術(shù)。如果頁面發(fā)生滾動(dòng),由于圖層已經(jīng)光柵化,因此它需要做的就是合成一個(gè)新幀。通過移動(dòng)圖層同時(shí)合成新幀,可以以相同的方式實(shí)現(xiàn)動(dòng)畫。
你可以在 DevTools 中的 Layout panel 來查看看圖層。
分層
為了確定每個(gè)元素所在的層,主線程遍歷布局樹以創(chuàng)建層樹(Layer Tree)。如果頁面的某元素應(yīng)該是一個(gè)單獨(dú)的圖層(例如側(cè)滑菜單),那么你可以在 CSS 中,使用 will-change 屬性提示瀏覽器。
如上圖,在主線程中遍歷布局樹,并生成層樹。
雖然理想情況下,應(yīng)該為每個(gè)元素生成圖層,但是對(duì)過多的小圖層進(jìn)行合并,可能會(huì)比對(duì)頁面的每幀上柵格化小元素更慢,因此測量應(yīng)用程序的渲染性能就非常重要。有關(guān)主題的更多信息,請(qǐng)參閱 Stick to Compositor-Only Properties 和 Manage Layer Count。
Stick to Compositor-Only Properties 和 Manage Layer Count:
https://developers.google.com/web/fundamentals/performance/rendering/stick-to-compositor-only-properties-and-manage-layer-count
光柵和合成,脫離主線程
一旦創(chuàng)建了層樹并確定了繪制順序,主線程就會(huì)將該信息提交給合成器線程。合成器線程會(huì)光柵化每個(gè)圖層,一個(gè)圖層可能想一個(gè)完整的頁面那么大,因此合成器線程將他們分成圖塊,并將每個(gè)圖塊發(fā)送到光柵線程。光柵線程格式化每個(gè)元素,并將他們存儲(chǔ)在 GPU 內(nèi)存中。
圖17:光柵線程創(chuàng)建光柵位圖并發(fā)送到GPU
合成器線程可以優(yōu)先考慮不同的光柵線程,以便 ViewPort(或附近)的元素可以被優(yōu)先光柵化。圖層還具有多個(gè)不同分辨率的傾斜度,以便對(duì)內(nèi)容的放大等操作。
一旦元素被光柵化,合成器線程會(huì)收集被稱為 “繪制矩形(Draw Quads)” 的信息,用以創(chuàng)建一個(gè)合成幀(Compositor Frame)。
然后通過 IPC 將合成幀提交給瀏覽器進(jìn)程。此時(shí),可以從 UI 線程添加另一個(gè)合成幀用于瀏覽器的 UI 更新,或者從其他渲染器進(jìn)程中添加擴(kuò)展。這些合成幀被發(fā)送到 GPU 中,用以在屏幕上顯示。如果觸發(fā)滾動(dòng)事件,合成器線程會(huì)創(chuàng)建另一個(gè)合成幀發(fā)送到 GPU。
上圖中,合成器線程創(chuàng)建合成幀。將此幀發(fā)送到瀏覽器進(jìn)程然后發(fā)送到 GPU。
合成(Compositor)的好處,是它可以在不影響主線程的情況下完成。合成器線程不需要等待樣式計(jì)算或者 JS 腳本執(zhí)行,這就是為什么 “僅合成動(dòng)畫” 被認(rèn)為是平滑性能的***選擇。如果需要再次計(jì)算不會(huì)或者重新繪制,則必須涉及到主線程。
合成動(dòng)畫:
https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/
小結(jié)
在這篇文章中,我們研究了從解析到合成的渲染流程,希望你現(xiàn)在有興趣探究有關(guān)網(wǎng)站性能優(yōu)化的更多內(nèi)容。
在下一篇文章中,將更詳細(xì)的介紹合成器線程,并解釋當(dāng)用戶觸發(fā) mouse move 和 click 時(shí),會(huì)發(fā)生什么。
【本文為51CTO專欄作者“張旸”的原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)通過微信公眾號(hào)聯(lián)系作者獲取授權(quán)】
戳這里,看該作者更多好文