前言
在我們面試過程中,面試官經常會問到這麼一個問題,那就是從在瀏覽器位址列中輸入URL到頁面顯示,瀏覽器到底發生了什麼?這個問題看起來是老生常談,但是這個問題回答的好壞,確實可以很好的反映出面試者知識的廣度和深度。
本文從瀏覽器角度來告訴你,URL後輸入後按回車,瀏覽器內部究竟發生了什麼,讀完本文後,你將瞭解到:
- 瀏覽器內有哪些程式,這些程式都有些什麼作用
- 瀏覽器地址輸入URL後,內部的程式、執行緒都做了哪些事
- 我們與瀏覽器互動時,內部程式是怎麼處理這些互動事件的
原文地址 歡迎star
瀏覽器架構
在講瀏覽器架構之前,先理解兩個概念,程式
和執行緒
。
程式(process)是程式的一次執行過程,是一個動態概念,是程式在執行過程中分配和管理資源的基本單位,執行緒(thread)是CPU排程和分派的基本單位,它可與同屬一個程式的其他的執行緒共享程式所擁有的全部資源。
簡單的說呢,程式可以理解成正在執行的應用程式,而執行緒呢,可以理解成我們應用程式中的程式碼的執行器。而他們的關係可想而知,執行緒是跑在程式裡面的,一個程式裡面可能有一個或者多個執行緒,而一個執行緒,只能隸屬於一個程式。
大家都知道,瀏覽器屬於一個應用程式,而應用程式的一次執行,可以理解為計算機啟動了一個程式
,程式啟動後,CPU會給該程式分配相應的記憶體空間,當我們的程式得到了記憶體之後,就可以使用執行緒
進行資源排程,進而完成我們應用程式的功能。
而在應用程式中,為了滿足功能的需要,啟動的程式會建立另外的新的程式來處理其他任務,這些建立出來的新的程式擁有全新的獨立的記憶體空間,不能與原來的程式內向記憶體,如果這些程式之間需要通訊,可以通過IPC機制(Inter Process Communication)來進行。
很多應用程式都會採取這種多程式的方式來工作,因為程式和程式之間是互相獨立的它們互不影響
,也就是說,當其中一個程式掛掉了之後,不會影響到其他程式的執行,只需要重啟掛掉的程式就可以恢復執行。
瀏覽器的多程式架構
假如我們去開發一個瀏覽器,它的架構可以是一個單程式多執行緒的應用程式,也可以是一個使用IPC通訊的多程式應用程式。
不同的瀏覽器使用不同的架構,下面主要以Chrome為例,介紹瀏覽器的多程式架構。
在Chrome中,主要的程式有4個:
- 瀏覽器程式 (Browser Process):負責瀏覽器的TAB的前進、後退、位址列、書籤欄的工作和處理瀏覽器的一些不可見的底層操作,比如網路請求和檔案訪問。
- 渲染程式 (Renderer Process):負責一個Tab內的顯示相關的工作,也稱渲染引擎。
- 外掛程式 (Plugin Process):負責控制網頁使用到的外掛
- GPU程式 (GPU Process):負責處理整個應用程式的GPU任務
這4個程式之間的關係是什麼呢?
首先,當我們是要瀏覽一個網頁,我們會在瀏覽器的位址列裡輸入URL,這個時候Browser Process
會向這個URL傳送請求,獲取這個URL的HTML內容,然後將HTML交給Renderer Process
,Renderer Process
解析HTML內容,解析遇到需要請求網路的資源又返回來交給Browser Process
進行載入,同時通知Browser Process
,需要Plugin Process
載入外掛資源,執行外掛程式碼。解析完成後,Renderer Process
計算得到影像幀,並將這些影像幀交給GPU Process
,GPU Process
將其轉化為影像顯示螢幕。
多程式架構的好處
Chrome為什麼要使用多程式架構呢?
第一,更高的容錯性。當今WEB應用中,HTML,JavaScript和CSS日益複雜,這些跑在渲染引擎的程式碼,頻繁的出現BUG,而有些BUG會直接導致渲染引擎崩潰,多程式架構使得每一個渲染引擎執行在各自的程式中,相互之間不受影響,也就是說,當其中一個頁面崩潰掛掉之後,其他頁面還可以正常的執行不收影響。
第二,更高的安全性和沙盒性(sanboxing)。渲染引擎會經常性的在網路上遇到不可信、甚至是惡意的程式碼,它們會利用這些漏洞在你的電腦上安裝惡意的軟體,針對這一問題,瀏覽器對不同程式限制了不同的許可權,併為其提供沙盒執行環境,使其更安全更可靠
第三,更高的響應速度。在單程式的架構中,各個任務相互競爭搶奪CPU資源,使得瀏覽器響應速度變慢,而多程式架構正好規避了這一缺點。
多程式架構優化
之前的我們說到,Renderer Process
的作用是負責一個Tab內的顯示相關的工作,這就意味著,一個Tab,就會有一個Renderer Process,這些程式之間的記憶體無法進行共享,而不同程式的記憶體常常需要包含相同的內容。
瀏覽器的程式模式
為了節省記憶體,Chrome提供了四種程式模式(Process Models),不同的程式模式會對 tab 程式做不同的處理。
- Process-per-site-instance (default) - 同一個 site-instance 使用一個程式
- Process-per-site - 同一個 site 使用一個程式
- Process-per-tab - 每個 tab 使用一個程式
- Single process - 所有 tab 共用一個程式
這裡需要給出 site 和 site-instance 的定義
- site 指的是相同的 registered domain name(如: google.com ,bbc.co.uk)和scheme (如:https://)。比如a.baidu.com和b.baidu.com就可以理解為同一個 site(注意這裡要和 Same-origin policy 區分開來,同源策略還涉及到子域名和埠)。
-
site-instance 指的是一組 connected pages from the same site,這裡 connected 的定義是 can obtain references to each other in script code 怎麼理解這段話呢。滿足下面兩中情況並且開啟的新頁面和舊頁面屬於上面定義的同一個 site,就屬於同一個 site-instance
- 使用者通過
<a target="_blank">
這種方式點選開啟的新頁面 - JS程式碼開啟的新頁面(比如
window.open
)
- 使用者通過
理解了概念之後,下面解釋四個程式模式
首先是Single process
,顧名思義,單程式模式,所有tab都會使用同一個程式。接下來是Process-per-tab
,也是顧名思義,每開啟一個tab,會新建一個程式。而對於Process-per-site
,當你開啟 a.baidu.com 頁面,在開啟 b.baidu.com 的頁面,這兩個頁面的tab使用的是共一個程式,因為這兩個頁面的site相同,而如此一來,如果其中一個tab崩潰了,而另一個tab也會崩潰。
Process-per-site-instance
是最重要的,因為這個是 Chrome 預設使用的模式,也就是幾乎所有的使用者都在用的模式。當你開啟一個 tab 訪問 a.baidu.com ,然後再開啟一個 tab 訪問 b.baidu.com,這兩個 tab 會使用兩個程式。而如果你在 a.baidu.com 中,通過JS程式碼開啟了 b.baidu.com 頁面,這兩個 tab 會使用同一個程式。
預設模式選擇
那麼為什麼瀏覽器使用Process-per-site-instance
作為預設的程式模式呢?
Process-per-site-instance
相容了效能與易用性,是一個比較中庸通用的模式。
- 相較於 Process-per-tab,能夠少開很多程式,就意味著更少的記憶體佔用
- 相較於 Process-per-site,能夠更好的隔離相同域名下毫無關聯的 tab,更加安全
導航過程都發生了什麼
前面我們講了瀏覽器的多程式架構,講了多程式架構的各種好處,和Chrome是怎麼優化多程式架構的,下面從使用者瀏覽網頁這一簡單的場景,來深入瞭解程式和執行緒是如何呈現我們的網站頁面的。
網頁載入過程
之前我們我們提到,tab以外的大部分工作由瀏覽器程式Browser Process
負責,針對工作的不同,Browser Process 劃分出不同的工作執行緒:
- UI thread:控制瀏覽器上的按鈕及輸入框;
- network thread:處理網路請求,從網上獲取資料;
- storage thread: 控制檔案等的訪問;
第一步:處理輸入
當我們在瀏覽器的位址列輸入內容按下回車時,UI thread
會判斷輸入的內容是搜尋關鍵詞(search query)還是URL,如果是搜尋關鍵詞,跳轉至預設搜尋引擎對應都搜尋URL,如果輸入的內容是URL,則開始請求URL。
第二步:開始導航
回車按下後,UI thread
將關鍵詞搜尋對應的URL或輸入的URL交給網路執行緒Network thread
,此時UI執行緒使Tab前的圖示展示為載入中狀態,然後網路程式進行一系列諸如DNS定址,建立TLS連線等操作進行資源請求,如果收到伺服器的301重定向響應,它就會告知UI執行緒進行重定向然後它會再次發起一個新的網路請求。
第三步:讀取響應
network thread
接收到伺服器的響應後,開始解析HTTP響應報文,然後根據響應頭中的Content-Type
欄位來確定響應主體的媒體型別(MIME Type),如果媒體型別是一個HTML檔案,則將響應資料交給渲染程式(renderer process)來進行下一步的工作,如果是 zip 檔案或者其它檔案,會把相關資料傳輸給下載管理器。
與此同時,瀏覽器會進行 Safe Browsing 安全檢查,如果域名或者請求內容匹配到已知的惡意站點,network thread 會展示一個警告頁。除此之外,網路執行緒還會做 CORB(Cross Origin Read Blocking)檢查來確定那些敏感的跨站資料不會被髮送至渲染程式。
第四步:查詢渲染程式
各種檢查完畢以後,network thread 確信瀏覽器可以導航到請求網頁,network thread 會通知 UI thread 資料已經準備好,UI thread 會查詢到一個 renderer process 進行網頁的渲染。
瀏覽器為了對查詢渲染程式這一步驟進行優化,考慮到網路請求獲取響應需要時間,所以在第二步開始,瀏覽器已經預先查詢和啟動了一個渲染程式,如果中間步驟一切順利,當 network thread 接收到資料時,渲染程式已經準備好了,但是如果遇到重定向,這個準備好的渲染程式也許就不可用了,這個時候會重新啟動一個渲染程式。
第五步:提交導航
到了這一步,資料和渲染程式都準備好了,Browser Process
會向 Renderer Process
傳送IPC訊息來確認導航,此時,瀏覽器程式將準備好的資料傳送給渲染程式,渲染程式接收到資料之後,又傳送IPC訊息給瀏覽器程式,告訴瀏覽器程式導航已經提交了,頁面開始載入。
這個時候導航欄會更新,安全指示符更新(地址前面的小鎖),訪問歷史列表(history tab)更新,即可以通過前進後退來切換該頁面。
第六步:初始化載入完成
當導航提交完成後,渲染程式開始載入資源及渲染頁面(詳細內容下文介紹),當頁面渲染完成後(頁面及內部的iframe都觸發了onload事件),會向瀏覽器程式傳送IPC訊息,告知瀏覽器程式,這個時候UI thread會停止展示tab中的載入中圖示。
網頁渲染原理
導航過程完成之後,瀏覽器程式把資料交給了渲染程式,渲染程式負責tab內的所有事情,核心目的就是將HTML/CSS/JS程式碼,轉化為使用者可進行互動的web頁面。那麼渲染程式是如何工作的呢?
渲染程式中,包含執行緒分別是:
- 一個主執行緒(main thread)
- 多個工作執行緒(work thread)
- 一個合成器執行緒(compositor thread)
- 多個光柵化執行緒(raster thread)
不同的執行緒,有著不同的工作職責。
構建DOM
當渲染程式接受到導航的確認資訊後,開始接受來自瀏覽器程式的資料,這個時候,主執行緒會解析資料轉化為DOM(Document Object Model)物件。
DOM為WEB開發人員通過JavaScript與網頁進行互動的資料結構及API。
子資源載入
在構建DOM的過程中,會解析到圖片、CSS、JavaScript指令碼等資源,這些資源是需要從網路或者快取中獲取的,主執行緒在構建DOM過程中如果遇到了這些資源,逐一發起請求去獲取,而為了提升效率,瀏覽器也會執行預載入掃描(preload scanner)程式,如果HTML中存在img
、link
等標籤,預載入掃描程式會把這些請求傳遞給Browser Process
的network thread進行資源下載。
JavaScript的下載與執行
構建DOM過程中,如果遇到<script>
標籤,渲染引擎會停止對HTML的解析,而去載入執行JS程式碼,原因在於JS程式碼可能會改變DOM的結構(比如執行document.write()
等API)
不過開發者其實也有多種方式來告知瀏覽器應對如何應對某個資源,比如說如果在<script>
標籤上新增了 async
或 defer
等屬性,瀏覽器會非同步的載入和執行JS程式碼,而不會阻塞渲染。
樣式計算 - Style calculation
DOM樹只是我們頁面的結構,我們要知道頁面長什麼樣子,我們還需要知道DOM的每一個節點的樣式。主執行緒在解析頁面時,遇到<style>
標籤或者<link>
標籤的CSS資源,會載入CSS程式碼,根據CSS程式碼確定每個DOM節點的計算樣式(computed style)。
計算樣式是主執行緒根據CSS樣式選擇器(CSS selectors)計算出的每個DOM元素應該具備的具體樣式,即使你的頁面沒有設定任何自定義的樣式,瀏覽器也會提供其預設的樣式。
佈局 - Layout
DOM樹和計算樣式完成後,我們還需要知道每一個節點在頁面上的位置,佈局(Layout)其實就是找到所有元素的幾何關係的過程。
主執行緒會遍歷DOM 及相關元素的計算樣式,構建出包含每個元素的頁面座標資訊及盒子模型大小的佈局樹(Render Tree),遍歷過程中,會跳過隱藏的元素(display: none),另外,偽元素雖然在DOM上不可見,但是在佈局樹上是可見的。
繪製 - Paint
佈局 layout 之後,我們知道了不同元素的結構,樣式,幾何關係,我們要繪製出一個頁面,我們要需要知道每個元素的繪製先後順序,在繪製階段,主執行緒會遍歷佈局樹(layout tree),生成一系列的繪畫記錄(paint records)。繪畫記錄可以看做是記錄各元素繪製先後順序的筆記。
合成 - Compositing
文件結構、元素的樣式、元素的幾何關係、繪畫順序,這些資訊我們都有了,這個時候如果要繪製一個頁面,我們需要做的是把這些資訊轉化為顯示器中的畫素,這個轉化的過程,叫做光柵化
(rasterizing)。
那我們要繪製一個頁面,最簡單的做法是隻光柵化視口內(viewport)的網頁內容,如果使用者進行了頁面滾動,就移動光柵幀(rastered frame)並且光柵化更多的內容以補上頁面缺失的部分,如下:
Chrome第一個版本就是採用這種簡單的繪製方式,這一方式唯一的缺點就是每當頁面滾動,光柵執行緒都需要對新移進檢視的內容進行光柵化,這是一定的效能損耗,為了優化這種情況,Chrome採取一種更加複雜的叫做合成(compositing)的做法。
那麼,什麼是合成?合成是一種將頁面分成若干層,然後分別對它們進行光柵化,最後在一個單獨的執行緒 - 合成執行緒(compositor thread)裡面合併成一個頁面的技術。當使用者滾動頁面時,由於頁面各個層都已經被光柵化了,瀏覽器需要做的只是合成一個新的幀來展示滾動後的效果罷了。頁面的動畫效果實現也是類似,將頁面上的層進行移動並構建出一個新的幀即可。
為了實現合成技術,我們需要對元素進行分層,確定哪些元素需要放置在哪一層,主執行緒需要遍歷渲染樹來建立一棵層次樹(Layer Tree),對於新增了 will-change
CSS 屬性的元素,會被看做單獨的一層,沒有 will-change
CSS屬性的元素,瀏覽器會根據情況決定是否要把該元素放在單獨的層。
你可能會想要給頁面上所有的元素一個單獨的層,然而當頁面的層超過一定的數量後,層的合成操作要比在每個幀中光柵化頁面的一小部分還要慢,因此衡量你應用的渲染效能是十分重要的一件事情。
一旦Layer Tree被建立,渲染順序被確定,主執行緒會把這些資訊通知給合成器執行緒,合成器執行緒開始對層次數的每一層進行光柵化。有的層的可以達到整個頁面的大小,所以合成執行緒需要將它們切分為一塊又一塊的小圖塊(tiles),之後將這些小圖塊分別進行傳送給一系列光柵執行緒(raster threads)進行光柵化,結束後光柵執行緒會將每個圖塊的光柵結果存在GPU Process
的記憶體中。
為了優化顯示體驗,合成執行緒可以給不同的光柵執行緒賦予不同的優先順序,將那些在視口中的或者視口附近的層先被光柵化。
當圖層上面的圖塊都被柵格化後,合成執行緒會收集圖塊上面叫做繪畫四邊形(draw quads)的資訊來構建一個合成幀(compositor frame)。
- 繪畫四邊形:包含圖塊在記憶體的位置以及圖層合成後圖塊在頁面的位置之類的資訊。
- 合成幀:代表頁面一個幀的內容的繪製四邊形集合。
以上所有步驟完成後,合成執行緒就會通過IPC向瀏覽器程式(browser process)提交(commit)一個渲染幀。這個時候可能有另外一個合成幀被瀏覽器程式的UI執行緒(UI thread)提交以改變瀏覽器的UI。這些合成幀都會被髮送給GPU從而展示在螢幕上。如果合成執行緒收到頁面滾動的事件,合成執行緒會構建另外一個合成幀傳送給GPU來更新頁面。
合成的好處在於這個過程沒有涉及到主執行緒,所以合成執行緒不需要等待樣式的計算以及JavaScript完成執行。這就是為什麼合成器相關的動畫最流暢,如果某個動畫涉及到佈局或者繪製的調整,就會涉及到主執行緒的重新計算,自然會慢很多。
瀏覽器對事件的處理
當頁面渲染完畢以後,TAB內已經顯示出了可互動的WEB頁面,使用者可以進行移動滑鼠、點選頁面等操作了,而當這些事件發生時候,瀏覽器是如何處理這些事件的呢?
以點選事件(click event)為例,讓滑鼠點選頁面時候,首先接受到事件資訊的是Browser Process
,但是Browser Process只知道事件發生的型別和發生的位置,具體怎麼對這個點選事件進行處理,還是由Tab內的Renderer Process
進行的。Browser Process接受到事件後,隨後便把事件的資訊傳遞給了渲染程式,渲染程式會找到根據事件發生的座標,找到目標物件(target),並且執行這個目標物件的點選事件繫結的監聽函式(listener)。
渲染程式中合成器執行緒接收事件
前面我們說到,合成器執行緒可以獨立於主執行緒之外通過已光柵化的層建立組合幀,例如頁面滾動,如果沒有對頁面滾動繫結相關的事件,組合器執行緒可以獨立於主執行緒建立組合幀,如果頁面繫結了頁面滾動事件,合成器執行緒會等待主執行緒進行事件處理後才會建立組合幀。那麼,合成器執行緒是如何判斷出這個事件是否需要路由給主執行緒處理的呢?
由於執行 JS 是主執行緒的工作,當頁面合成時,合成器執行緒會標記頁面中繫結有事件處理器的區域為非快速滾動區域
(non-fast scrollable region),如果事件發生在這些存在標註的區域,合成器執行緒會把事件資訊傳送給主執行緒,等待主執行緒進行事件處理,如果事件不是發生在這些區域,合成器執行緒則會直接合成新的幀而不用等到主執行緒的響應。
而對於非快速滾動區域的標記,開發者需要注意全域性事件的繫結,比如我們使用事件委託,將目標元素的事件交給根元素body進行處理,程式碼如下:
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
})
在開發者角度看,這一段程式碼沒什麼問題,但是從瀏覽器角度看,這一段程式碼給body元素繫結了事件監聽器,也就意味著整個頁面都被編輯為一個非快速滾動區域,這會使得即使你的頁面的某些區域沒有繫結任何事件,每次使用者觸發事件時,合成器執行緒也需要和主執行緒通訊並等待反饋,流暢的合成器獨立處理合成幀的模式就失效了。
其實這種情況也很好處理,只需要在事件監聽時傳遞passtive
引數為 true,passtive
會告訴瀏覽器你既要繫結事件,又要讓組合器執行緒直接跳過主執行緒的事件處理直接合成建立組合幀。
document.body.addEventListener('touchstart',
event => {
if (event.target === area) {
event.preventDefault()
}
}, {passive: true});
查詢事件的目標物件(event target)
當合成器執行緒接收到事件資訊,判定到事件發生不在非快速滾動區域後,合成器執行緒會向主執行緒傳送這個時間資訊,主執行緒獲取到事件資訊的第一件事就是通過命中測試(hit test)去找到事件的目標物件。具體的命中測試流程是遍歷在繪製階段生成的繪畫記錄(paint records)來找到包含了事件發生座標上的元素物件。
瀏覽器對事件的優化
一般我們螢幕的幀率是每秒60幀,也就是60fps,但是某些事件觸發的頻率超過了這個數值,比如wheel,mousewheel,mousemove,pointermove,touchmove,這些連續性的事件一般每秒會觸發60~120次,假如每一次觸發事件都將事件傳送到主執行緒處理,由於螢幕的重新整理速率相對來說較低,這樣使得主執行緒會觸發過量的命中測試以及JS程式碼,使得效能有了沒必要是損耗。
出於優化的目的,瀏覽器會合並這些連續的事件,延遲到下一幀渲染是執行,也就是requestAnimationFrame
之前。
而對於非連續性的事件,如keydown,keyup,mousedown,mouseup,touchstart,touchend等,會直接派發給主執行緒去執行。
總結
瀏覽器的多程式架構,根據不同的功能劃分了不同的程式,程式內不同的使命劃分了不同的執行緒,當使用者開始瀏覽網頁時候,瀏覽器程式進行處理輸入、開始導航請求資料、請求響應資料,查詢新建渲染程式,提交導航,之後渲染又進行了解析HTML構建DOM、構建過程載入子資源、下載並執行JS程式碼、樣式計算、佈局、繪製、合成,一步一步的構建出一個可互動的WEB頁面,之後瀏覽器程式又接受頁面的互動事件資訊,並將其交給渲染程式,渲染程式內主程式進行命中測試,查詢目標元素並執行繫結的事件,完成頁面的互動。
本文大部分內容也是對inside look at modern web browser系列文章的整理、解讀和翻譯吧,整理過程還是收穫非常大的,希望讀者讀了本文只有有所啟發吧。