精讀《深入瞭解現代瀏覽器三》

黃子毅發表於2021-12-14

Inside look at modern web browser 是介紹瀏覽器實現原理的系列文章,共 4 篇,本次精讀介紹第三篇。

概述

本篇巨集觀的介紹 renderer process 做了哪些事情。

瀏覽器 tab 內 html、css、javascript 內容基本上都由 renderer process 的主執行緒處理,除了一些 js 程式碼會放在 web worker 或 service worker 內,所以瀏覽器主執行緒核心工作就是解析 web 三劍客並生成可互動的使用者介面。

解析階段

首先 renderer process 主執行緒會解析 HTML 文字為 DOM(Document Object Model),只譯為中文就是文件物件模型,所以首先要把文字結構化才能繼續處理。不僅是瀏覽器,程式碼的解析也得首先經歷 Parse 階段。

對於 HTML 的 link、img、script 標籤需要載入遠端資源的,瀏覽器會呼叫 network thread 優先並行處理,但遇到 script 標籤就必須停下來優先執行,因為 js 程式碼可能會改變任何 dom 物件,這可能導致瀏覽器要重新解析。所以如果你的程式碼沒有修改 dom 的副作用,可以新增 async、defer 標籤,或 JS 模組的方式使瀏覽器不必等待 js 的執行。

樣式計算

只有 DOM 是不夠的,style 標籤申明的樣式需要作用在 DOM 上,所以基於 DOM,瀏覽器要生成 CSSOM,這個 CSSOM 主要是基於 css 選擇器(selector)確定作用節點的。

佈局

有了 DOM、CSSOM 仍然不足以繪製網頁,因為我們僅知道結構和樣式,但不知道元素的位置,這就需要生成 LayoutTree 以描述佈局的結構。

LayoutTree 和 DOM 結構很像了,但比如 display: none 的元素不會出現在 LayoutTree 上,所以 LayoutTree 僅考慮渲染結構,而 DOM 是一個綜合描述結構,它不適合直接用來渲染。

原文特別提到,LayoutTree 有個很大的技術難點,即排版,Chrome 專門有一整個團隊在攻克這個技術難題。為什麼排版這麼難?可以從這幾個例子中體會冰山一角:盒模型間碰撞、字型撐開內容導致換行,引發更大區域的重新排版、一個盒模型撐開擠壓另一個盒模型,但另一個盒模型大小變化後內容排版也隨之變化,導致盒模型再次變化,這個變化又導致了外部其它盒模型的佈局變化。

佈局最難的地方在於,需要對所有奇奇怪怪的佈局定式做一個儘量合理的處理,而很多時候佈局定式間規則是相互衝突的。而且這還不考慮佈局引擎的修改在數億網頁上引發未知 BUG 的風險。

繪圖

有了 DOM、CSSOM、LayoutTree 就夠了嗎?還不行,還缺少最後一環 PaintRecord,這個指繪圖記錄,它會記錄元素的層級關係,以決定元素繪製的順序。因為 LayoutTree 僅決定了物理結構,但不決定元素的上下空間結構。

有了 DOM、CSSOM、LayoutTree、PaintRecord 之後,終於可以繪圖了。然而當 HTML 變化時,重繪的代價是巨大的,因為上面任何一步的計算結果都依賴前面一步,HTML 改變時,需要對 DOM、CSSOM、LayoutTree、PaintRecord 進行重新計算。

大部分時候瀏覽器都可以在 16ms 內完成,使 FPS 保持在 60 左右,但當頁面結構過於複雜,這些計算本身超過了 16ms,或其中遇到 js 程式碼的阻塞,都會導致使用者感覺到卡頓。當然對於 js 卡頓問題可以通過 requestAnimationFrame 把邏輯運算分散在各幀空閒時進行,也可以獨立到 web worker 裡。

合成

繪圖的步驟稱為 rasterizing(光柵化)。在 Chrome 最早釋出時,採用了一種較為簡單的光柵化方案,即僅渲染可是區域內的畫素點,當滾動後,再補充渲染當前滾動位置的畫素點。這樣做會導致渲染永遠滯後於滾動。

現在一般採用較為成熟的合成技術(compositing),即將渲染內容分層繪製與渲染,這可以大大提升效能,並可通過 CSS 屬性 will-change 手動申明為一個新層(不要濫用)。

瀏覽器會根據 LayoutTree 分析後得到 LayerTree(層樹),並根據它逐層渲染。

合成層會將繪圖內容切分為多個柵格並交由 GPU 渲染,因此效能會非常好。

精讀

從渲染分層看效能優化

本篇提到了瀏覽器渲染的 5 個重要環節:解析、樣式、佈局、繪圖、合成,是前端開發者日常工作中對瀏覽器體感最深的部分,也是優化最長髮生在的部分。

其實從效能優化角度來看,解析環節可以被替代為 JS 環節,因為現代 JS 框架往往沒有什麼 HTML 模版內容要解析,幾乎全是 JS 操作 DOM,所以可以看作 5 個新環節:JS、樣式、佈局、繪圖、合成。

值得注意的是,幾乎每層的計算都依賴上層的結果,但並不是每層都一定會重複計算,我們需要尤其注意以下幾種情況:

  1. 修改元素幾何屬性(位置、寬高等)會觸發所有層的重新計算,因為這是一個非常重量級的修改。
  2. 修改某個元素繪圖屬性(比如顏色和背景色),並不影響位置,則會跳過佈局層。
  3. 修改比如 transform 屬性會跳過佈局與繪圖層,這看上去很不可思議。

對於第三點,由於 transform 的內容會提升到合成層並交由 GPU 渲染,因此並不會與瀏覽器主執行緒的佈局、繪圖放在一起處理,所以視覺上這個元素的確產生了位移,但它和修改 lefttop 的位移在實現上卻有本質的不同。

所以站在瀏覽器開發者的角度,可以輕鬆理解為什麼這種優化不是奇技淫巧了,因為本身瀏覽器的實現就把佈局、繪圖與合成層的行為分離開了,不同的程式碼底層方案不同,效能肯定會不同。你可以通過 csstriggers 檢視不同 css 屬性會引發哪些層的重計算。

當然作為開發者還是可以吐槽,為什麼瀏覽器不能 “自動把 left toptransform 的實現細節遮蔽,並自動進行合理的分層”,然而如果瀏覽器廠商做不到這一點,開發者還是主動去了解實現原理吧。

隱式合成層、層爆炸、層自動合併

除了 transformwill-change 屬性外,還有很多種情況元素會提升到合成層,比如 videocanvasiframe,或 fixed 元素,但這些都有明確的規則,所以屬於顯示合成。

而隱式合成是指元素沒有被特別標記,但也被提升到合成層的情況,這種情況常見發生在 z-index 元素產生重疊時,下方的元素顯示申明提升到合成層,則瀏覽器為了保證 z-index 覆蓋關係,就要隱式把上方的元素提升到合成層。

層爆炸是指隱式合成的原因,當 css 出現一些複雜行為時(比如軌跡動畫),瀏覽器無法實時捕捉哪些元素位於當前元素上方,所以只好把所有元素都提升到合成層,當合成層數量過多,主執行緒與 GPU 的通訊可能會成為瓶頸,反而影響效能。

瀏覽器也會支援層自動合併,比如隱式提升到合成層時,多個元素會自動合併在一個合成層裡。但這種方式也並不總是靠譜,自動處理畢竟猜不到開發者的意圖,所以最好的優化方式是開發者主動干預。

我們只要注意將所有顯示提升到合成層的元素放在 z-index 的上方,這樣瀏覽器就有了判斷依據,不用再擔驚受怕會不會這個元素突然移動到某個元素的位置,導致壓住了那個元素,於是又不得不把這個元素給隱式提升到合成層以保證它們之間順序的正確性,因為這個元素本來就位於其它元素的最上方。

總結

讀完這篇文章,希望你能根據瀏覽器在渲染程式的實現原理,總結出更多程式碼級別的效能優化經驗。

最後想要吐槽的是,瀏覽器規範由於是逐步迭代的,因此看似都在描述位置的 css 屬性其實背後實現原理是不同的,雖然這個規則體現在 W3C 規範上,但如果僅從屬性名是很難看出來端倪的,因此想要做極致效能優化就必須瞭解瀏覽器實現原理。

討論地址是:精讀《深入瞭解現代瀏覽器三》· Issue #379 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章