圖解瀏覽器

01 瀏覽器架構演進
開篇我們先來簡單回顧下歷史,從 1993 年發(fā)布的第一款“好用”的瀏覽器 Mosaic,到 1994 年網景公司推出的紅極一時的 Navigator 瀏覽器,圖形用戶界面化的瀏覽器終于開始推動了 Web 技術的普及和發(fā)展。
微軟也隨后推出了 IE,加入戰(zhàn)場并取得瀏覽器大戰(zhàn)“一戰(zhàn)”的勝利。戰(zhàn)敗的網景公司索性將 Navigator 源代碼開源,創(chuàng)建了 Mozilla 基金會,并于 2004 年發(fā)布了 Firefox 瀏覽器。
蘋果公司于 2003 年發(fā)布了 Safari 瀏覽器,Google 公司于 2008 年發(fā)布了 Chrome 瀏覽器。Chrome 瀏覽器在瀏覽器大戰(zhàn)的“二戰(zhàn)”中技壓群雄,拔得頭籌?,F如今也是前端工程師最喜愛的瀏覽器,沒有之一。
Chrome 瀏覽器從 2007 年以前的單進程架構到現在的多進程架構,瀏覽器的架構在不斷的升級,變得更加穩(wěn)定、更加流暢、更加安全。目前 Chrome 的瀏覽器包括如下進程:
1 個瀏覽器(Browser)主進程 1 個 GPU 進程 1 個網絡(NetWork)進程 多個渲染進程(運行在沙箱模式下) 多個插件進程

不過,軟件工程可沒有銀彈。瀏覽器的架構體系也隨著調整變得更加復雜,也會有更高的資源占用。
那么如何尋求一種在資源占用和復雜架構體系之間的平衡便成為了一個難題。
小孩子才做選擇,魚和熊掌我都要!
Chrome 團隊在 2016 年使用“面向服務的架構”(Services Oriented Architecture,簡稱 SOA)的思想設計了新的 Chrome 架構。
他們將模塊重構成獨立的服務(Service),服務運行在獨立的進程中,想要訪問的話必須使用定義好的接口,通過 IPC 來進行通信。這樣的架構無疑更加內聚、松耦合、易于維護和擴展。

02 瀏覽器導航渲染流程
從輸入 URL 到頁面展示,這中間發(fā)生了什么?
這是一道十分常見的面試題,不過大多數人回答這個問題時都不夠系統(tǒng)和全面,可見這道題能夠充分考察應試者的知識深度。
我畫了一張圖整理了瀏覽器的導航渲染流程,下面我們來一起查缺補漏。

導航流程
用戶在地址欄輸入內容后,地址欄會將輸入的內容進行合成 URL。 當用戶輸入完內容并按下回車鍵時,瀏覽器會在當前頁面執(zhí)行 beforeunload 事件,你可以在這個鉤子中詢問是否要離開當前頁面,常見于一些表單提交的場景。 接下來開始導航流程,瀏覽器進入加載狀態(tài)。 瀏覽器的網絡進程會先查找緩存中是否存在該資源,有的話直接返回,如果沒有的話會發(fā)起 URL 請求。 接下來首先要進行的是 DNS 解析,獲得請求域名的服務器的 IP 地址(這個過程我也畫了一張圖,放在下文),如果協議是 HTTPS,還需要建立 TLS 連接。 接著利用目標服務器的 IP 地址建立 TCP 連接(三次握手),構建 HTTP 請求報文,發(fā)起請求。服務器收到請求后,會根據請求信息生成響應報文。 瀏覽器的網絡進程接收到響應報文后進行解析,如果狀態(tài)碼是 301 或者 302,則需要取得響應頭中的 Location 對應的地址進行重定向,再重新發(fā)起請求。 如果狀態(tài)碼是 200,瀏覽器會根據響應頭中的 Content-Type 字段來識別返回的響應體數據類型,從而進行不同的流程。如 text/html 代表 html 格式, application/octet-stream 代表字節(jié)流類型,瀏覽器會按照下載類型來處理。 如果是 HTML,瀏覽器會遵循 process-per-site-instance 默認策略準備渲染進程,準備好后就提交文檔(將網絡進程接收到的數據提交給渲染進程)。文檔被提交后,渲染進程便開始進行頁面解析和子資源的加載。
(當然在第 7 點中還有 300、303 等 3xx 的狀態(tài)碼,具體含義可以參考我的這一篇專欄 那些年與面試官交手過的HTTP問題)
process-per-site-instance 默認策略:每個標簽對應一個渲染進程,如果從一個頁面打開了一個新頁面,新打開的頁面與當前頁面還屬于同一個站點的話,那么新頁面會復用當前頁面的渲染進程。
渲染流程
渲染流程在上圖中一并畫了出來,需要經過以下幾個階段:
構建 DOM 樹 樣式計算 布局 分層 繪制 分塊 光柵化 合成
因為渲染流程的內容比較多,本文先不詳細展開,后面我們再開一篇專欄進行講解。
DNS
DNS 的解析是一個遞歸流程,順序如下圖中數字標記所示:

根 DNS 服務器:返回頂級域 DNS 服務器的 IP 地址 頂級 DNS 服務器:返回權威 DNS 服務器的 IP 地址 權威 DNS 服務器:返回相應主機的 IP 地址
03 垃圾回收
棧中的垃圾數據
先來看一段簡單的示例代碼:
function?hello?()?{
????var?name?=?'前端食堂'
????var?food?=?{?name:?'回鍋肉'?}?
????function?world?()?{
????????var?description?=?{?slogan:?'吃好每一頓飯'?}
????}
????world()
}
hello()
上面的代碼所對應的內存堆棧空間如下圖所示:

棧中的垃圾回收比較簡單,當一個函數執(zhí)行結束后,JavaScript 引擎會通過向下移動 ESP 來銷毀函數調用棧中所保存的執(zhí)行上下文,ESP 就是記錄當前執(zhí)行狀態(tài)的指針。
堆中的垃圾數據
先來看兩個概念,能夠幫助我們更好的理解堆中的垃圾回收操作。
代際假說
堆中的垃圾回收策略都是建立在代際假說的基礎之上,代際假說有以下兩個特點:
大部分對象在內存中存在的時間很短,簡單來說,就是很多對象一經分配內存,很快就變得不可訪問。 不死的對象,會活得更久。
分代收集
在 Chrome 瀏覽器引擎 V8 中會把堆分為新生代和老生代兩個區(qū)域,如下圖所示:
顧名思義,生存時間短的對象放在新生區(qū)中,生存時間久的對象放在老生區(qū)中。
堆中的垃圾回收需要用到垃圾回收器,分為主垃圾回收器和副垃圾回收器。

副垃圾回收器
負責新生區(qū)的垃圾回收,新生區(qū)區(qū)域不大(為了執(zhí)行效率),回收頻繁。
新生區(qū)中使用了 Scavenge 算法,該算法會把新生區(qū)的空間劃分為兩個區(qū)域,一半是對象區(qū)域,一半是空閑區(qū)域。
副垃圾回收器的工作流程如下:
首先對對象區(qū)域中的垃圾進行標記。 標記完成后,副垃圾回收器會將存活的對象復制到空閑區(qū)域中,為了避免產生內存碎片,還需要進行有序的排列,有序排列相當于內存整理。 完成復制后,將對象區(qū)域和空閑區(qū)域進行翻轉,就完成了垃圾回收的操作。
翻轉的這種操作可以讓對象區(qū)和空閑區(qū)無限重復的使用,不過由于新生區(qū)空間并不大,很容易會被存活的對象塞滿。所以 V8 引擎采用了對象晉升的策略,經過兩次垃圾回收后依然還能存活的對象會被晉升到老生區(qū)中。
主垃圾回收器
負責老生區(qū)中的垃圾回收,老生區(qū)中對象占用空間大,對象存活時間長。
除了上文說到的新生區(qū)中晉升的對象,一些大的對象也會直接被分配到老生區(qū)。
主垃圾回收器是使用了標記 - 清除(Mark-Sweep)的算法,工作流程如下:
首先是標記階段,從一組根元素開始遞歸遍歷,能到達的元素就是活動對象,否則就是垃圾。 然后使用標記 - 清除算法進行垃圾回收,不過回收后會產生大量不連續(xù)的內存碎片。 于是又產生了另外一種算法 標記 - 整理(Mark-Compact),整理時可以讓存活的對象都向一端移動,然后直接清除掉端邊界以外的內存。
全停頓
垃圾回收操作會暫停 JavaScript 的運行,回收完畢后才會恢復執(zhí)行,這種行為就是全停頓。
為了降低全停頓所帶來的卡頓,V8 引擎采用了增量標記(Incremental Marking) 算法進行優(yōu)化,將標記過程分為一個個小任務,這些小任務的執(zhí)行時間比較短,可以穿插在其他的 JavaScript 任務中間執(zhí)行,這樣就不會有明顯的卡頓了。
當然,V8 所采用的優(yōu)化方案不只這一種,而是多種方案綜合使用的,除了增量回收還有并行回收、并發(fā)回收等。
并行回收:垃圾回收器會使用多個輔助線程來并行執(zhí)行垃圾回收 并發(fā)回收:回收線程在執(zhí)行 JavaScript 的過程中,輔助線程在后臺執(zhí)行垃圾回收
如果你了解 React 的 Concurrent 模式中時間切片的原理,它的實現思想是不是與增量標記算法有異曲同工之妙呢。
04 核心網頁指標 Core Web Vitals
Google 大佬推出了 Core Web Vitals:目的是為了更好的簡化場景,幫助網站專注于最重要的指標以提升用戶體驗。
在 2020 年主要關注三個方面:加載、交互性和視覺穩(wěn)定性,并包括以下指標:

衡量所有 Core Web Vitals 最簡單的方法就是使用 web-vitals 庫,使用起來就像調用單個函數一樣簡單。
import?{getCLS,?getFID,?getLCP}?from?'web-vitals';
getCLS(console.log);
getFID(console.log);
getLCP(console.log);
也可以使用 Chrome 插件 Web Vitals Chrome 來幫助我們測量這些指標。
如果想要直接通過 Web API 來獲取這些指標的話可以參考下面的獲取方法:
在JavaScript中測量LCP 在JavaScript中測量FID 在JavaScript中測量CLS
LCP Largest Contentful Paint 最大內容繪制
LCP用于衡量標準報告視口內可見的最大圖像或文本塊的渲染時間,為了提供良好的用戶體驗,網站應努力在開始加載頁面的前2.5 秒內進行“最大內容繪制”。

優(yōu)化LCP方案
FID First Input Delay 首次交互延遲
FID用于衡量從用戶第一次與頁面進行交互到瀏覽器實際上能夠開始處理事件處理程序的時間。為了提供良好的用戶體驗,網站應努力使首次輸入延遲小于 100 毫秒。

下圖中米色方塊代表主線程處于忙碌階段,如果此時用戶進行輸入,則它必須等待任務完成時才能響應輸入,等待的時間也就是此頁面上該用戶的 FID 值。

優(yōu)化FID方案
CLS Cumulative Layout Shift 累積布局偏移

CLS用于測量在頁面的整個生命周期中發(fā)生的每一個意外的布局移動,它代表所有單獨布局轉移分數的總和。為了提供良好的用戶體驗,網站應努力使CLS分數小于0.1。
布局偏移分數
瀏覽器將查看視口大小以及兩個渲染幀之間的視口中不穩(wěn)定元素的移動。
布局偏移分數是該運動的兩個指標的乘積:影響分數和距離分數
layout?shift?score?=?impact?fraction?*?distance?fraction
影響分數
前一幀和當前幀的所有不穩(wěn)定元素的可見區(qū)域的并集(占視口總面積的一部分)是當前幀的影響分數。

在上圖中,有一個元素在一幀中占據了視口的一半。然后,在下一幀中,元素下移視口高度的 25%。紅色的虛線矩形表示兩個幀中元素的可見區(qū)域的并集,在這種情況下,其為總視口的 75%,因此其影響分數為 0.75。
距離分數
布局偏移分數方程的另一部分測量不穩(wěn)定元素相對于視口移動的距離。距離分數是任何不穩(wěn)定元素在框架中(水平或垂直)移動的最大距離除以視口的最大尺寸(寬度或高度,以較大者為準)。

在上圖中,最大視口尺寸是高度,不穩(wěn)定元素已經移動了視口高度的 25%,所以距離分數是 0.25。
所以,布局偏移分數:0.75 * 0.25 = 0.1875
優(yōu)化CLS方案
好了,本文到這里就結束了,文中參考的鏈接都整理到了下面,大家可以自行查閱。
站在巨人的肩膀上
圖解 Google V8 李兵 瀏覽器工作原理與實踐 李兵 Core Web Vitals https://web.dev/vitals/ web-vitals https://github.com/GoogleChrome/web-vitals/ LCP https://web.dev/lcp/ FID https://web.dev/fid/ CLS https://web.dev/cls/ 優(yōu)化FID方案 https://web.dev/optimize-fid/ 優(yōu)化LCP方案 https://web.dev/optimize-lcp/ 優(yōu)化CLS方案 https://web.dev/optimize-cls/
