現(xiàn)代瀏覽器內(nèi)部機制 Part 3 | 渲染進程的一生
原文: Inside Look at Modern Web Browser (part 2)[1]
作者: Mariko Kosaka[2]
譯者: kyrieliu

這是本系列的第三篇文章(3/4),將會講述瀏覽器到底是怎樣工作的。在之前的文章中,我們介紹了現(xiàn)代瀏覽器的多進程架構和導航工作流,在這篇文章中,我們會對渲染進程內(nèi)部一探究竟。
渲染進程在很多層面上都和頁面性能息息相關。考慮到渲染進程內(nèi)部體系的有很多可以聊的東西,篇幅原因,這篇文章只會對其進行一個總體上的概覽。如果好奇的你想去了解更多,不妨去這里[3]看看。
渲染進程處理 Web 頁面的所有內(nèi)容
一個瀏覽器窗口之內(nèi)發(fā)生的所有事情,都是被渲染進程所掌握著的。前端工程師們的代碼由渲染進程中的主線程處理。如果使用了 web worker 或者 service worker,那其中的代碼將會由 worker 線程處理。Compositor 線程和 Raster 線程也運行在渲染進程中,它們的作用是高效平滑地渲染出一個頁面。
渲染進程最核心的工作是:將 HTML、CSS 和 JavaScript 代碼變成一個可與用戶交互的 Web 頁面。

解析文檔
構建 DOM 樹
當渲染進程接收到一條即將去導航的信號并開始接收 HTML 數(shù)據(jù)時,主線程就開始了自己的工作:解析 HTML 文本并將其轉換為文檔對象模型(Document Object Model,aka DOM)。
DOM 是瀏覽器內(nèi)部對一個頁面的抽象(代表),也是開發(fā)者可以利用 JavaScript 與之相交互的數(shù)據(jù)結構和 API。
瀏覽器按照HTML 標準[4] 去解析 HTML 文檔。你或許發(fā)現(xiàn)了一件事情,就是即使你隨便丟給瀏覽器一個 HTML 文檔都不會報錯。比如,沒有結束標簽的 </p> 仍然是一段有效的 HTML。一段錯誤的標簽比如:Hi! <b>我是<i>Chrome</b>!</i>(b 標簽在 i 標簽之前提前關閉了)會被當作 Hi! <b>I'm <i>Chrome</i></b><i>!</i> 去對待。這都是因為 HTML 本身才設計之初就希望優(yōu)雅的解決這些錯誤。如果你是個好奇的小朋友,可以去看看 An Introduction to Error Handling and Strange Cases in the Parser[5] 這篇文章的相關章節(jié)。
子資源加載
一個網(wǎng)站通常會用到很多外部資源比如圖片、CSS 和 JavaScript。這些文件都需要從網(wǎng)絡或是緩存中加載。當主線程在解析 HTML 文檔的時候發(fā)現(xiàn)了這些需要額外加載的資源,主線程會一個一個地去請求,但這樣會降低解析 HTML 的效率。因此為了提速,“預加載掃描器”閃亮登場了。如果在文檔中存在 img 標簽或是 link 標簽,預加載掃描器會“窺探”到 HTML 解析器生成的 token,并向瀏覽器進程中的網(wǎng)絡線程發(fā)起請求。

JavaScript 阻塞解析
當 HTML 解析器遇到了 script 標簽時,它會暫停對 HTML 的解析工作,轉而去加載、解析并執(zhí)行 JavaScript 代碼。為什么呢?因為 JavaScript 可能改變文檔的結構,比如用了 document.write() 之類的函數(shù)。這就是為什么 HTML 解析器必須在 JavaScript 執(zhí)行過后才恢復對 HTML 文檔的解析工作。如果你的 JavaScript 代碼在執(zhí)行時的事情有興趣的話,可以看看 V8 團隊的這篇文章[6]。
告訴瀏覽器你想怎樣加載資源
瀏覽器提供了很多不錯的方法給開發(fā)者,以幫助他們用不同的姿勢在頁面上加載資源。如果你的 JavaScript 代碼中沒有用到諸如 document.write() 之類的代碼,你可以在自己的 script 標簽上加上 async / defer 屬性,瀏覽器就會異步地加載并執(zhí)行 JavaScript 代碼并且不會阻塞對于文檔的解析。需要的話,也可以用到 JavaScript 模塊[7]。<link rel="preload"> 用于加載當前頁面一定會用到的資源,并且開發(fā)者希望瀏覽器能在第一時間加載這些資源。開發(fā)者可以針對不同的場景, 賦予資源不同的加載優(yōu)先級[8],瀏覽器會按照既定的規(guī)則依次加載這些資源,從而起到加載優(yōu)化的效果。
樣式的計算
只有 DOM 是無法完全知道一個頁面最終會是什么樣子的,因此我們還需要 CSS。主線程在完成對 CSS 的解析和計算后,才會為每個 DOM 節(jié)點賦予最終的樣式。你可以在 DevTools 的 Computed 中查看到頁面的相關信息。

即使開發(fā)者不提供任何的 CSS,每個 DOM 節(jié)點還是會有各自的樣式:<h1> 顯示的字體比 <h2> 大、每個元素都有自己的 margin 值。這是因為瀏覽器本身有一個默認的樣式表。如果你想知道 Chrome 的默認 CSS 是啥,去看看 Chrome 的源代碼[9]吧。
布局
現(xiàn)在渲染進程知道了文檔的結構和每個節(jié)點的樣式,但這還不足以去渲染一個頁面。想想一個場景,你試圖通過電話溝通告訴你朋友一幅畫長什么樣子。“有一個紅色的大圓和一個小藍方塊”,你朋友聽到以后肯定還是一臉懵逼:“你說了個寂寞?”

布局是查找元素幾何形狀的過程。主線程遍歷 DOM 并計算樣式,創(chuàng)建一個具體橫縱坐標以及盒子邊界大小數(shù)據(jù)的布局樹(layout tree)。布局樹可能與 DOM 樹相似,但它只包含和頁面即將呈現(xiàn)的節(jié)點相關的信息。如果某個元素設置了 display: none ,雖然它會呈現(xiàn)在 DOM 樹中但并不會包含于布局樹當中;如果有一個偽類元素 p::before{ content: 'Hi!' }, 那么它雖然不在 DOM 樹中,但仍然會出現(xiàn)在布局樹當中。

頁面布局的決策是一項很有挑戰(zhàn)的工作。即使是最簡單的頁面布局,比如一個從上至下的塊狀元素集合,也需要去決定字體顯示多大、在哪里換行...因為這些都會影響到段落的大小和形狀,這些也都會影響到相鄰的元素最終會顯示在哪里。

打個比方,布局就像是你在盡力還原一幅畫:你知道大小、形狀以及元素的坐標,但你還是需要決定先畫哪一個。實際情況是,某個元素可能被設置了 z-index,如果按照 HTML 的元素編寫順序去“畫”,就會導致錯誤的渲染效果。

在這一繪制的過程中,主線程遍歷布局樹從而去創(chuàng)建繪制記錄。繪制記錄是繪制進程的“筆記”,記下了諸如“先是背景,然后文字,接下來是矩形”這樣的記錄。如果你曾經(jīng)在 canvas 上用 JavaScript 畫過畫,那么這個過程對你來說就很熟悉了。

更新渲染的代價是很大的
在渲染中最需要關注到的事情是,在每一步中,先前的操作都會產(chǎn)生新的數(shù)據(jù)。舉個例子,如果布局樹的某些東西改變了,文檔中相關部分的繪制順序也需要重新安排一遍。

即使你的渲染操作跟上了屏幕的刷新頻率,但這些計算始終是運行在主線程上的,這就意味著當你的應用在運行 JavaScript 時,這些都會被阻塞掉。

你可以將 JavaScript 的操作分割為許多小的塊并放在 requestAnimationFrame() 里執(zhí)行,或者干脆把這些 JavaScript 丟在 Web Worker 里去執(zhí)行,這樣就能避免阻塞主線程。

合成
你會怎樣畫一個頁面呢?
現(xiàn)在瀏覽器知道了文檔的結構、每個元素的樣式、頁面的幾何構成以及繪制的順序,它會怎樣去繪制一個頁面呢?將這些信息轉化為屏幕上的像素,這個過程叫做光柵化。
什么是合成?
合成是一種將頁面的各個部分分為多層,分別對其進行光柵化并在成為合成器線程的單獨線程中作為頁面進行合成的技術。如果發(fā)生了滾動,因為每一層都已經(jīng)完成了光柵化,剩下需要做的就只是合成出一個窗口。可以通過移動圖層并合并新幀來以相同的方式實現(xiàn)動畫。
你可以在 DevTools 的 Layers 面板[11]中查看你的頁面是如何被切分成層的。
分層
為了確定每個元素各自應該在哪一層,主線程在遍歷了布局樹后生成了一個叫做 Layer Tree 的東西(不知道怎么翻譯就還是保留 Layer Tree 這個名字吧)。如果頁面的某一部分需要單獨的層級(比如滑入的側邊欄目錄)但卻沒有得到對應的層級,開發(fā)者可以用 CSS 的 will-change 屬性來告知瀏覽器。

你可能想給每一個元素一個單獨的渲染層,但是與每幀光柵化頁面的一小部分相比,在過多數(shù)量的圖層上進行合成可能會導致操作的速度變慢,因此衡量應用程序的渲染性能也是至關重要的一環(huán)。更多內(nèi)容,可以閱讀這篇文章:Stick to Compositor-Only Properties and Manage Layer Count[12]
主線程的光柵與合成
一旦 layer tree 形成并且繪制的順序定下來后,主線程會將這個消息提交給合成線程。然后,合成器線程將每個圖層光柵化。一個圖層有可能和整個頁面一樣大,所以合成器線程將它們切割為很多小塊,并將這些塊發(fā)送給光柵線程。光柵線程將一小塊完成光柵化后將其保存在 GPU 的內(nèi)存當中。

合成線程可以優(yōu)先處理不同的光柵線程,以便可以首先對可視區(qū)域(或者可視區(qū)域附近)中的事物進行光柵化。圖層還具有用于不同分辨率的多個拼貼,以處理諸如放大動作之類的事情。
光柵化后,合成器線程將收集成為“Draw Quads(繪制四邊形?)”的圖塊信息以創(chuàng)建 合成幀(Compositor frame)。
Draw quads: Contains information such as the tile's location in memory and where in the page to draw the tile taking in consideration of the page compositing.
Compositor frame: A collection of draw quads that represents a frame of a page.
合成幀會通過 IPC 被傳遞給瀏覽器進程。這時,來自其他 UI 線程的合成幀可能已經(jīng)被用于瀏覽器的 UI 變化了。合成幀被運送至 GPU,目的就是為了顯示在屏幕上。如果這時滾動頁面,合成器線程又會創(chuàng)建新的合成幀并將之發(fā)送到 GPU。

合成的優(yōu)勢是所有的合成操作都是獨立于主線程進行的。合成器線程不需要等待樣式的計算或是 JavaScript 的執(zhí)行。這就是 COA(Compositing Only Animations[13])能加速頁面性能的原因。如果頁面的布局或者繪制需要被重新計算,這種情況下主線程就會被牽扯進來。
總結
在這篇文章中,我們了解到了關于渲染的一系列操作:從解析到合成。如果你能認真閱讀到這里,那么恭喜你,目前市面上絕大多數(shù)關于頁面性能優(yōu)化的文章你都可以看懂了。
在下一篇(也是這個系列的最后一篇)文章中,我們會了解到合成器線程的更多細節(jié),比如當有用戶交互(mousemove、click)時會發(fā)生什么。
參考資料
Inside Look at Modern Web Browser (part 2): https://developers.google.com/web/updates/2018/09/inside-browser-part2
[2]Mariko Kosaka: https://developers.google.com/web/resources/contributors/kosamari
[3]這里: https://developers.google.cn/web/fundamentals/performance/why-performance-matters
[4]HTML 標準: https://html.spec.whatwg.org/
[5]An Introduction to Error Handling and Strange Cases in the Parser: https://html.spec.whatwg.org/multipage/parsing.html#an-introduction-to-error-handling-and-strange-cases-in-the-parser
[6]V8 團隊的這篇文章: https://mathiasbynens.be/notes/shapes-ics
[7]JavaScript 模塊: https://v8.dev/features/modules
[8]賦予資源不同的加載優(yōu)先級: https://developers.google.cn/web/fundamentals/performance/resource-prioritization
[9]Chrome 的源代碼: https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/resources/html.css
[10]BlinkOn: https://www.youtube.com/watch?v=Y5Xa4H2wtVA
[11]Layers 面板: https://blog.logrocket.com/eliminate-content-repaints-with-the-new-layers-panel-in-chrome-e2c306d4d752/?gi=cd6271834cea
[12]Stick to Compositor-Only Properties and Manage Layer Count: https://developers.google.cn/web/fundamentals/performance/rendering/stick-to-compositor-only-properties-and-manage-layer-count
[13]Compositing Only Animations: https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/
本文來自劉凱里,專注分享有趣的前端知識。劉哥還為大家準備了前端面試官手記,在他的公眾號回復【前端面試】即可獲取,歡迎大家的關注~
