本文是筆者對Mario Kosaka寫的inside look at modern web browser系列文章的翻譯。這裏的翻譯不是指直譯,而是結合我的的理解將做者想表達的意思表達出來,並且會盡可能補充一些相關的內容來幫助你們更好地理解。javascript
這篇文章是我對以前發表的窺探瀏覽器內部原理系列文章的一個整合,你們若是以爲內容太多能夠按篇查看之前的文章:css
在本篇文章中,我將會從Chrome瀏覽器的高層次架構(high-level architecture)開始提及,一直深刻講到頁面渲染流水線(rendering pipeline)的具體細節。若是你想知道瀏覽器是怎麼把你編寫的代碼轉變成一個可用的網站,或者你不知道爲何一些特定的代碼寫法能夠提升網站的性能的,那你就來對地方了,這篇文章就是爲你準備的。html
首先咱們先了解一些關鍵的計算機術語以及Chrome瀏覽器的多進程架構。html5
要想理解瀏覽器的運行環境,咱們先要搞明白一些計算機組件以及它們的做用。java
首先咱們要說的是計算機的大腦 - CPU(Central Processing Unit)。CPU是計算機裏面的一塊芯片,上面有一個或者多個核心(core)。咱們能夠把CPU的一個核心(core)比喻成一個辦公室工人,他功能強大,上知天文下知地理,琴棋書畫無所不能,它能夠串行地一件接着一件處理交給它的任務。好久以前的時候大多數CPU只有一個核心,不過在如今的硬件設備上CPU一般會有多個核心,由於多核心CPU能夠大大提升手機和電腦的運算能力。 git
四個CPU核心愉快地在各自工位上一個接着一個地處理交給它們的任務github
圖形處理器 - 或者說GPU(Graphics Processing Unit)是計算機的另一個重要組成部分。和功能強大的CPU核心不同的是,單個GPU核心只能處理一些簡單的任務,不過它勝在數量多,單片GPU上會有不少不少的核心能夠同時工做,也就是說它的並行計算能力是很是強的。圖形處理器(GPU)顧名思義一開始就是專門用來處理圖形的,因此在說到圖形使用GPU(using)或者GPU支持(backed)時,人們就會聯想到圖形快速渲染或者流暢的用戶體驗相關的概念。最近幾年來,隨着GPU加速概念的流行,在GPU上單獨進行的計算也變得愈來愈多了。 web
每一個GPU核心手裏只有一個扳手,這就說明它的能力是很是有限的,但是它們人多啊!chrome
當你在手機或者電腦上打開某個應用程序的時候,背後實際上是CPU和GPU支撐着這個應用程序的運行。一般來講,你的應用要經過操做系統提供的一些機制才能跑在CPU和GPU上面。 canvas
計算機的三層架構,最下層是硬件機器,操做系統夾在中間,最上層是運行的應用
在深刻到瀏覽器的架構以前咱們還得了解一下進程(process)和線程(thread)的相關概念。進程能夠當作正在被執行的應用程序(executing program)。而線程是跑在進程裏面的,一個進程裏面可能有一個或者多個線程,這些線程能夠執行任何一部分應用程序的代碼。
進程就像一個大魚缸,而線程就是浴缸裏面暢遊的魚兒
當你啓動一個應用程序的時候,操做系統會爲這個程序建立一個進程同時還爲這個進程分配一片私有的內存空間,這片空間會被用來存儲全部程序相關的數據和狀態。當你關閉這個程序的時候,這個程序對應的進程也會隨之消失,進程對應的內存空間也會被操做系統釋放掉。
進程使用系統分配的內存空間去存儲應用的數據
有時候爲了知足功能的須要,建立的進程會叫系統建立另一些進程去處理其它任務,不過新建的進程會擁有全新的獨立的內存空間而不是和原來的進程共用內存空間。若是這些進程須要通訊,它們要經過IPC機制(Inter Process Communication)來進行。不少應用程序都會採起這種多進程的方式來工做,由於進程和進程之間是互相獨立的它們互不影響,換句話來講,若是其中一個工做進程(worker process)掛掉了其餘進程不會受到影響,並且掛掉的進程還能夠重啓。
不一樣的進程經過IPC來通訊
那麼瀏覽器是怎麼使用進程和線程來工做的呢?其實大概能夠分爲兩種架構,一種是單進程架構,也就是隻啓動一個進程,這個進程裏面有多個線程工做。第二種是多進程架構,瀏覽器會啓動多個進程,每一個進程裏面有多個線程,不一樣進程經過IPC進行通訊。
單進程和多進程瀏覽器的架構圖
上面的圖表架構其實包含了瀏覽器架構的具體實現了,在現實中其實並無一個你們都遵循的瀏覽器實現標準,因此不一樣瀏覽器的實現方式可能會徹底不同。
爲了更好地在本系列文章中展開論述,咱們主要討論最新的Chrome瀏覽器架構,它採用的是多進程架構,如下是架構圖:
Chrome的多進程架構圖,多個渲染進程的卡片(render process)是用來代表Chrome會爲每個tab建立一個渲染進程。
Chrome瀏覽器會有一個瀏覽器進程(browser process),這個進程會和其餘進程一塊兒協做來實現瀏覽器的功能。對於渲染進程(renderer process),Chrome會盡量爲每個tab甚至是頁面裏面的每個iframe都分配一個單獨的進程。
如下是各個進程具體負責的工做內容:
進程 | 負責的工做 |
---|---|
Browser | 負責瀏覽器的「Chrome」部分, 包括導航欄,書籤, 前進和後退按鈕。同時這個進程還會控制那些咱們看不見的部分,包括網絡請求的發送以及文件的讀寫。 |
Renderer | 負責tab內和網頁展現相關的全部工做。 |
Plugin | 控制網頁使用的全部插件,例如flash插件。 |
GPU | 負責獨立於其它進程的GPU任務。它之因此被獨立爲一個進程是由於它要處理來自於不一樣tab的渲染請求並把它在同一個界面上畫出來。 |
不一樣的進程負責瀏覽器不一樣部分的界面內容
除了上面列出來的進程,Chrome還有不少其餘進程在工做,例如擴展進程(Extension Process)和工具進程(utility process)。若是你想看一下你的Chrome瀏覽器如今有多少個進程在跑能夠點擊瀏覽器右上角的更多按鈕,選擇更多工具和任務管理器:
那麼爲何Chrome會採起多進程架構工做呢?
其中一個好處是多進程可使瀏覽器具備很好的容錯性。對於大多數簡單的情景來講,Chrome會爲每一個tab單獨分配一個屬於它們的渲染進程(render process)。舉個例子,假如你有三個tab,你就會有三個獨立的渲染進程。當其中一個tab的崩潰時,你能夠隨時關閉這個tab而且其餘tab不受到影響。但是若是全部的tab都跑在同一個進程的話,它們就會有連帶關係,一個掛所有掛。
不一樣的tab會有不一樣的渲染進程來負責
Chrome採用多進程架構的另一個好處就是能夠提供安全性和沙盒性(sanboxing)。由於操做系統能夠提供方法讓你限制每一個進程擁有的能力,因此瀏覽器可讓某些進程不具有某些特定的功能。例如,因爲tab渲染進程可能會處理來自用戶的隨機輸入,因此Chrome限制了它們對系統文件隨機讀寫的能力。
不過多進程架構也有它很差的地方,那就是進程的內存消耗。因爲每一個進程都有各自獨立的內存空間,因此它們不能像存在於同一個進程的線程那樣共用內存空間,這就形成了一些基礎的架構(例如V8 JavaScript引擎)會在不一樣進程的內存空間同時存在的問題,這些重複的內容會消耗更多的內存。因此爲了節省內存,Chrome會限制被啓動的進程數目,當進程數達到必定的界限後,Chrome會將訪問同一個網站的tab都放在一個進程裏面跑。
一樣的優化方法也能夠被使用在瀏覽器進程(browser process)上面。Chrome瀏覽器的架構正在發生一些改變,目的是將和瀏覽器自己(Chrome)相關的部分拆分爲一個個不一樣的服務,服務化以後,這些功能既能夠放在不一樣的進程裏面運行也能夠合併爲一個單獨的進程運行。
這樣作的主要緣由是讓Chrome在不一樣性能的硬件上有不一樣的表現。當Chrome運行在一些性能比較好的硬件時,瀏覽器進程相關的服務會被放在不一樣的進程運行以提升系統的穩定性。相反若是硬件性能很差,這些服務就會被放在同一個進程裏面執行來減小內存的佔用。其實在此次架構變化以前,Chrome在Android上面已經開始採起相似的作法了。
Chrome將瀏覽器相關的服務放在同一個進程裏面運行和放在不一樣的進程運行的區別
網站隔離(Site Isolation)是最近Chrome瀏覽器啓動的功能,這個功能會爲網站內不一樣站點的iframe分配一個獨立的渲染進程。以前說過Chrome會爲每一個tab分配一個單獨的渲染進程,但是若是一個tab只有一個進程的話不一樣站點的iframe都會跑在這個進程裏面,這也意味着它們會共享內存,這就有可能會破壞同源策略。同源策略是瀏覽器最核心的安全模型,它能夠禁止網站在未經贊成的狀況下去獲取另一個站點的數據,所以繞過同源策略是不少安全攻擊的主要目的。而進程隔離(proces isolation)是隔離網站最好最有效的辦法了。再加上CPU存在Meltdown和Spectre的隱患,網站隔離變得勢在必行。所以在Chrome 67版本以後,桌面版的Chrome會默認開啓網站隔離功能,這樣每個跨站點的iframe都會擁有一個獨立的渲染進程。
網站隔離功能會讓跨站的iframe擁有獨立的進程
網站隔離技術匯聚了咱們工程師好幾年的研發努力,它其實遠遠沒有想象中那樣只是爲不一樣站點的iframe分配一個獨立的渲染進程那麼簡單,由於它從根本上改變了各個iframe之間的通訊方式。網站隔離後,對於有iframe的網站,當用戶打開右邊的devtool時,Chrome瀏覽器其實要作不少幕後工做才能讓開發者感受不出這和以前的有什麼區別,這實際上是很難實現的。對於一些很簡單的功能,例如在devtool裏面用Ctrl + F鍵在頁面搜索某個關鍵詞,Chrome都要遍歷多個渲染進程去完成。因此咱們的瀏覽器工程師在網站隔離這個功能發佈後都感嘆這是一個里程碑式的成就。
咱們探討了瀏覽器高層次的架構設計以及多進程架構的帶來的好處。同時咱們還討論了服務化和網站隔離這些和瀏覽器多進程架構息息相關的技術。接下來咱們要開始深刻了解這些進程和線程是如何呈現咱們的網站頁面的了。
讓咱們來看一個用戶瀏覽網頁最簡單的情景:你在瀏覽器導航欄裏面輸入一個URL而後按下回車鍵,瀏覽器接着會從互聯網上獲取相關的數據並把網頁展現出來。在本篇文章中,咱們將會重點關注這個簡單場景中網站數據請求以及瀏覽器在呈現網頁以前作的準備工做 - 也就是導航(navigation)的過程。
上面的文章中提到,瀏覽器中tab外面發生的一切都是由瀏覽器進程(browser process)控制的。瀏覽器進程有不少負責不一樣工做的線程(worker thread),其中包括繪製瀏覽器頂部按鈕和導航欄輸入框等組件的UI線程(UI thread)、管理網絡請求的網絡線程(network thread)、以及控制文件讀寫的存儲線程(storage thread)等。當你在導航欄裏面輸入一個URL的時候,其實就是UI線程在處理你的輸入。
UI,網絡和存儲線程都是屬於瀏覽器進程的
當用戶開始在導航欄上面輸入內容的時候,UI線程(UI thread)作的第一件事就是詢問:「你輸入的字符串是一些搜索的關鍵詞(search query)仍是一個URL地址呢?」。由於對於Chrome瀏覽器來講,導航欄的輸入既多是一個能夠直接請求的域名還多是用戶想在搜索引擎(例如Google)裏面搜索的關鍵詞信息,因此當用戶在導航欄輸入信息的時候UI線程要進行一系列的解析來斷定是將用戶輸入發送給搜索引擎仍是直接請求你輸入的站點資源。
UI線程在詢問輸入的字符串是搜索關鍵詞仍是一個URL
當用戶按下回車鍵的時候,UI線程會叫網絡線程(network thread)初始化一個網絡請求來獲取站點的內容。這時候tab上會展現一個提示資源正在加載中的旋轉圈圈,並且網絡線程會進行一系列諸如DNS尋址以及爲請求創建TLS鏈接的操做。
UI線程告訴網絡線程跳轉到mysite.com
這時若是網絡線程收到服務器的HTTP 301重定向響應,它就會告知UI線程進行重定向而後它會再次發起一個新的網絡請求。
網絡線程在收到HTTP響應的主體(payload)流(stream)時,在必要的狀況下它會先檢查一下流的前幾個字節以肯定響應主體的具體媒體類型(MIME Type)。響應主體的媒體類型通常能夠經過HTTP頭部的Content-Type來肯定,不過Content-Type有時候會缺失或者是錯誤的,這種狀況下瀏覽器就要進行MIME類型嗅探來肯定響應類型了。MIME類型嗅探並非一件容易的事情,你能夠從Chrome的源代碼的註釋來了解不一樣瀏覽器是如何根據不一樣的Content-Type來判斷出主體具體是屬於哪一個媒體類型的。
響應的頭部有Content-Type信息,而響應的主體有真實的數據
若是響應的主體是一個HTML文件,瀏覽器會將獲取的響應數據交給渲染進程(renderer process)來進行下一步的工做。若是拿到的響應數據是一個壓縮文件(zip file)或者其餘類型的文件,響應數據就會交給下載管理器(download manager)來處理。
網絡線程在詢問響應的數據是否是來自安全源的HTML文件
網絡線程在把內容交給渲染進程以前還會對內容作SafeBrowsing檢查。若是請求的域名或者響應的內容和某個已知的病毒網站相匹配,網絡線程會給用戶展現一個警告的頁面。除此以外,網絡線程還會作CORB(Cross Origin Read Blocking)檢查來肯定那些敏感的跨站數據不會被髮送至渲染進程。
在網絡線程作完全部的檢查後而且可以肯定瀏覽器應該導航到該請求的站點,它就會告訴UI線程全部的數據都已經被準備好了。UI線程在收到網絡線程的確認後會爲這個網站尋找一個渲染進程(renderer process)來渲染界面。
網絡線程告訴UI線程去尋找一個渲染進程來渲染界面
因爲網絡請求可能須要長達幾百毫秒的時間才能完成,爲了縮短導航須要的時間,瀏覽器會在以前的一些步驟裏面作一些優化。例如在第二步中當UI線程發送URL連接給網絡線程後,它其實已經知曉它們要被導航到哪一個站點了,因此在網絡線程幹活的時候,UI線程會主動地爲這個網絡請求啓動一個渲染線程。若是一切順利的話(沒有重定向之類的東西出現),網絡線程準備好數據後頁面的渲染進程已經就準備好了,這就節省了新建渲染進程的時間。不過若是發生諸如網站被重定向到不一樣站點的狀況,剛剛那個渲染進程就不能被使用了,它會被摒棄,一個新的渲染進程會被啓動。
到這一步的時候,數據和渲染進程都已經準備好了,瀏覽器進程(browser process)會經過IPC告訴渲染進程去提交本次導航(commit navigation)。除此以外瀏覽器進程還會將剛剛接收到的響應數據流傳遞給對應的渲染進程讓它繼續接收到來的HTML數據。一旦瀏覽器進程收到渲染線程的回覆說導航已經被提交了(commit),導航這個過程就結束了,文檔的加載階段(document loading phase)會正式開始。
到了這個時候,導航欄會被更新,安全指示符(security indicator)和站點設置UI(site settings UI)會展現新頁面相關的站點信息。當前tab的會話歷史(session history)也會被更新,這樣當你點擊瀏覽器的前進和後退按鈕也能夠導航到剛剛導航完的頁面。爲了方便你在關閉了tab或窗口(window)的時候還能夠恢復當前tab和會話(session)內容,當前的會話歷史會被保存在磁盤上面。
瀏覽器進程經過IPC來對渲染進程發起渲染頁面的請求
當導航提交完成後,渲染進程開始着手加載資源以及渲染頁面。我會在後面的文章中講述渲染進程渲染頁面的具體細節。一旦渲染進程「完成」(finished)渲染,它會經過IPC告知瀏覽器進程(注意這發生在頁面上全部幀(frames)的onload事件都已經被觸發了並且對應的處理函數已經執行完成了的時候),而後UI線程就會中止導航欄上旋轉的圈圈。
我這裏用到「完成」這個詞,由於後面客戶端的JavaScript仍是能夠繼續加載資源和改變視圖內容的。
渲染進程經過IPC告訴瀏覽器進程頁面已經加載完成了
一個最簡單的導航情景已經描述完了!但是若是這時用戶在導航欄上輸入一個不同的URL會發生什麼呢?若是是這樣,瀏覽器進程會從新執行一遍以前的那幾個步驟來完成新站點的導航。不過在瀏覽器進程作這些事情以前,它須要讓當前的渲染頁面作一些收尾工做,具體就是詢問一下當前的渲染進程需不須要處理一下beforeunload事件。
beforeunload能夠在用戶從新導航或者關閉當前tab時給用戶展現一個「你肯定要離開當前頁面嗎?」的二次確認彈框。瀏覽器進程之因此要在從新導航的時候和當前渲染進程確認的緣由是,當前頁面發生的一切(包括頁面的JavaScript執行)是不受它控制而是受渲染進程控制,因此它也不知道里面的具體狀況。
注意:不要隨便給頁面添加beforeunload事件監聽,你定義的監聽函數會在頁面被從新導航的時候執行,所以這會增長重導航的時延。beforeunload事件監聽函數只有在十分必要的時候才能被添加,例如用戶在頁面上輸入了數據,而且這些數據會隨着頁面消失而消失。
瀏覽器進程經過IPC告訴渲染進程它將要離開當前頁面導航到新的頁面了
若是從新導航是在頁面內被髮起的呢?例如用戶點擊了頁面的一個連接或者客戶端的JavaScript代碼執行了諸如window.location = "newsite.com"的代碼。這種狀況下,渲染進程會本身先檢查一個它有沒有註冊beforeunload事件的監聽函數,若是有的話就執行,執行完後發生的事情就和以前的狀況沒什麼區別了,惟一的不一樣就是此次的導航請求是由渲染進程給瀏覽器進程發起的。
若是是從新導航到不一樣站點(different site)的話,會有另一個渲染進程被啓動來完成此次重導航,而當前的渲染進程會繼續處理如今頁面的一些收尾工做,例如unload事件的監聽函數執行。Overview of page lifecycle states這篇文章會介紹頁面全部的生命週期狀態,the Page Lifecycle API會教你如何在頁面中監聽頁面狀態的變化。
瀏覽器進程告訴新的渲染進程去渲染新的頁面而且告訴當前的渲染進程進行收尾工做
這個導航過程最近發生的一個改變是引進了service worker的概念。由於Service worker能夠用來寫網站的網絡代理(network proxy),因此開發者能夠對網絡請求有更多的控制權,例如決定哪些數據緩存在本地以及哪些數據須要從網絡上面從新獲取等等。若是開發者在service worker裏設置了當前的頁面內容從緩存裏面獲取,當前頁面的渲染就不須要從新發送網絡請求了,這就大大加快了整個導航的過程。
這裏要重點留意的是service worker其實只是一些跑在渲染進程裏面的JavaScript代碼。那麼問題來了,當導航開始的時候,瀏覽器進程是如何判斷要導航的站點存不存在對應的service worker並啓動一個渲染進程去執行它的呢?
其實service worker在註冊的時候,它的做用範圍(scope)會被記錄下來(你能夠經過文章The Service Worker Lifecycle瞭解更多關於service worker做用範圍的信息)。在導航開始的時候,網絡線程會根據請求的域名在已經註冊的service worker做用範圍裏面尋找有沒有對應的service worker。若是有命中該URL的service worker,UI線程就會爲這個service worker啓動一個渲染進程(renderer process)來執行它的代碼。Service worker既可能使用以前緩存的數據也可能發起新的網絡請求。
網絡線程會在收到導航任務後尋找有沒有對應的service worker
UI線程會啓動一個渲染進程來運行找到的service worker代碼,代碼具體是由渲染進程裏面的工做線程(worker thread)執行
在上面的例子中,你應該能夠感覺到若是啓動的service worker最後仍是決定發送網絡請求的話,瀏覽器進程和渲染進程這一來一回的通訊包括service worker啓動的時間其實增長了頁面導航的時延。導航預加載就是一種經過在service worker啓動的時候並行加載對應資源的方式來加快整個導航過程效率的技術。預加載資源的請求頭會有一些特殊的標誌來讓服務器決定是發送全新的內容給客戶端仍是隻發送更新了的數據給客戶端。
UI線程在啓動一個渲染進程去運行service worker代碼的同時會並行發送網絡請求
瞭解了導航具體都發生了哪些事情以及瀏覽器優化導航效率採起的一些技術方案,接着讓咱們深刻了解瀏覽器的渲染進程是如何解析咱們的HTML/CSS/JavaScript來呈現出網頁內容的。
渲染進程會影響到Web性能的不少方面。頁面渲染的時候發生的東西實在太多了,這裏只能做一個大致的介紹。若是你想要了解更多相關的內容,Web Fundamentals的Performance欄目有不少資源能夠查看。
渲染進程負責標籤(tab)內發生的全部事情。在渲染進程裏面,主線程(main thread)處理了絕大多數你發送給用戶的代碼。若是你使用了web worker或者service worker,相關的代碼將會由工做線程(worker thread)處理。合成(compositor)以及光柵(raster)線程運行在渲染進程裏面用來高效流暢地渲染出頁面內容。
渲染進程的主要任務是將HTML,CSS,以及JavaScript轉變爲咱們能夠進程交互的網頁內容。
渲染進程裏面有:一個主線程(main thread),幾個工做線程(worker threads),一個合成線程(compositor thread)以及一個光柵線程(raster thread)
上文提到,渲染進程在導航結束的時候會收到來自瀏覽器進程提交導航(commit navigation)的消息,在這以後渲染進程就會開始接收HTML數據,同時主線程也會開始解析接收到的文本數據(text string)並把它轉化爲一個DOM(Document Object Model)對象
DOM對象既是瀏覽器對當前頁面的內部表示,也是Web開發人員經過JavaScript與網頁進行交互的數據結構以及API。
如何將HTML文檔解析爲DOM對象是在HTML標準中定義的。不過在你的web開發生涯中,你可能歷來沒有遇到過瀏覽器在解析HTML的時候發生錯誤的情景。這是由於瀏覽器對HTML的錯誤容忍度很大。舉些例子:若是一個段落缺失了閉合p標籤(</p>),這個頁面仍是會被當作爲有效的HTML來處理;Hi! <b>I'm <i>Chrome</b>!</i> (閉合b標籤寫在了閉合i標籤的前面) ,雖然有語法錯誤,不過瀏覽器會把它處理爲Hi! <b>I'm <i>Chrome</i></b><i>!</i>。若是你想知道瀏覽器是如何對這些錯誤進行容錯處理的,能夠參考HTML規範裏面的An introduction to error handling and strange cases in the parser內容。
除了HTML文件,網站一般還會使用到一些諸如圖片,CSS樣式以及JavaScript腳本等子資源。這些文件會從緩存或者網絡上獲取。主線程會按照在構建DOM樹時遇到各個資源的循序一個接着一個地發起網絡請求,但是爲了提高效率,瀏覽器會同時運行「預加載掃描」(preload scanner)程序。若是在HTML文檔裏面存在諸如<img>或者<link>這樣的標籤,預加載掃描程序會在HTML解析器生成的token裏面找到對應要獲取的資源,並把這些要獲取的資源告訴瀏覽器進程裏面的網絡線程。
主線程會解析HTML內容而且構建出DOM樹
當HTML解析器碰到script標籤的時候,它會中止HTML文檔的解析從而轉向JavaScript代碼的加載,解析以及執行。爲何要這樣作呢?由於script標籤中的JavaScript可能會使用諸如document.write()
這樣的代碼改變文檔流(document)的形狀,從而使整個DOM樹的結構發生根本性的改變(HTML規範裏面的overview of the parsing model部分有很好的示意圖)。由於這個緣由,HTML解析器不得不等JavaScript執行完成以後才能繼續對HTML文檔流的解析工做。若是你想知道JavaScipt的執行過程都發生了什麼,V8團隊有不少關於這個話題的討論以及博客。
Web開發者能夠經過不少方式告訴瀏覽器如何才能更加優雅地加載網頁須要用到的資源。若是你的JavaScript不會使用到諸如document.write()
的方式去改變文檔流的內容的話,你能夠爲script標籤添加一個async或者defer屬性來使JavaScript腳本進行異步加載。固然若是能知足到你的需求,你也可使用JavaScript Module。同時<link rel="preload">
資源預加載能夠用來告訴瀏覽器這個資源在當前的導航確定會被用到,你想要儘快加載這個資源。更多相關的內容,你可閱讀Resource Prioritization - Getting the Browser to Help You這篇文章。
擁有了DOM樹咱們還不足以知道頁面的外貌,由於咱們一般會爲頁面的元素設置一些樣式。主線程會解析頁面的CSS從而肯定每一個DOM節點的計算樣式(computed style)。計算樣式是主線程根據CSS樣式選擇器(CSS selectors)計算出的每一個DOM元素應該具有的具體樣式,你能夠打開devtools來查看每一個DOM節點對應的計算樣式。
主線程解析CSS來肯定每一個元素的計算樣式
即便你的頁面沒有設置任何自定義的樣式,每一個DOM節點仍是會有一個計算樣式屬性,這是由於每一個瀏覽器都有本身的默認樣式表。由於這個樣式表的存在,頁面上的h1標籤必定會比h2標籤大,並且不一樣的標籤會有不一樣的magin和padding。若是你想知道Chrome的默認樣式是長什麼樣的,你能夠直接查看代碼。
前面這些步驟完成以後,渲染進程就已經知道頁面的具體文檔結構以及每一個節點擁有的樣式信息了,但是這些信息仍是不能最終肯定頁面的樣子。舉個例子,假如你如今想經過電話告訴你的朋友你身邊的一幅畫的內容:「畫布上有一個紅色的大圓圈和一個藍色的正方形」,單憑這些信息你的朋友是很難知道這幅畫具體是什麼樣子的,由於他不知道大圓圈和正方形具體在頁面的什麼位置,是正方形在圓圈前面呢仍是圓圈在正方形的前面。
你站在一幅畫面前經過電話告訴你朋友畫上的內容
渲染網頁也是一樣的道理,只知道網站的文檔流以及每一個節點的樣式是遠遠不足以渲染出頁面內容的,還須要經過佈局(layout)來計算出每一個節點的幾何信息(geometry)。佈局的具體過程是:主線程會遍歷剛剛構建的DOM樹,根據DOM節點的計算樣式計算出一個佈局樹(layout tree)。佈局樹上每一個節點會有它在頁面上的x,y座標以及盒子大小(bounding box sizes)的具體信息。佈局樹長得和先前構建的DOM樹差很少,不一樣的是這顆樹只有那些可見的(visible)節點信息。舉個例子,若是一個節點被設置爲了display:none,這個節點就是不可見的就不會出如今佈局樹上面(visibility:hidden的節點會出如今佈局樹上面,你能夠思考一下這是爲何)。一樣的,若是一個僞元素(pseudo class)節點有諸如p::before{content:"Hi!"}
這樣的內容,它會出如今佈局上,而不存在於DOM樹上。
主線程會遍歷每一個DOM tree節點的計算樣式信息來生成一棵佈局樹
即便頁面的佈局十分簡單,佈局這個過程都是很是複雜的。例如頁面就是簡單地從上而下展現一個又一個段落,這個過程就很複雜,由於你須要考慮段落中的字體大小以及段落在哪裏須要進行換行之類的東西,它們都會影響到段落的大小以及形狀,繼而影響到接下來段落的佈局。
瀏覽器得考慮段落是否是要換行
若是考慮到CSS的話將會更加複雜,由於CSS是一個很強大的東西,它可讓元素懸浮(float)到頁面的某一邊,還能夠遮擋住頁面溢出的(overflow)元素,還能夠改變內容的書寫方向,因此單是想一下你就知道佈局這個過程是一個十分艱鉅和複雜的任務。對於Chrome瀏覽器,咱們有一整個負責佈局過程的工程師團隊。若是你想知道他們工做的具體內容,他們在BlinkOn Conference上面的相關討論被錄製了下來,有時間的話你能夠去看一下。
知道了DOM節點以及它的樣式和佈局其實仍是不足以渲染出頁面來的。爲何呢?舉個例子,假如你如今想對着一幅畫畫一幅同樣的畫,你已經知道了畫布上每一個元素的大小,形狀以及位置,你仍是得思考一下每一個元素的繪畫順序,由於畫布上的元素是會互相遮擋的(z-index)。
一我的拿着畫筆站在畫布前面,在思考着是先畫一個圓仍是先畫一個正方形
舉個例子,若是頁面上的某些元素設置了z-index屬性,繪製元素的順序就會影響到頁面的正確性。
單純按照HTML佈局的順序繪製頁面的元素是錯誤的,由於元素的z-index元素沒有被考慮到
在繪畫這個步驟中,主線程會遍歷以前獲得的佈局樹(layout tree)來生成一系列的繪畫記錄(paint records)。繪畫記錄是對繪畫過程的註釋,例如「首先畫背景,而後是文本,最後畫矩形」。若是你曾經在canvas畫布上有使用過JavaScript繪製元素,你可能會覺着這個過程不是很陌生。
主線程遍歷佈局樹來生成繪畫記錄
關於渲染流水線有一個十分重要的點就是流水線的每一步都要使用到前一步的結果來生成新的數據,這就意味着若是某一步的內容發生了改變的話,這一步後面全部的步驟都要被從新執行以生成新的記錄。舉個例子,若是佈局樹有些東西被改變了,文檔上那些被影響到的部分的繪畫順序是要從新生成的。
DOM+Style,佈局以及繪畫樹
若是你的頁面元素有動畫效果(animating),瀏覽器就不得不在每一個渲染幀的間隔中經過渲染流水線來更新頁面的元素。咱們大多數顯示器的刷新頻率是一秒鐘60次(60fps),若是你在每一個渲染幀的間隔都能經過流水線移動元素,人眼就會看到流暢的動畫效果。但是若是流水線更新時間比較久,動畫存在丟幀的情況的話,頁面看起來就會很「卡頓」。
流水線更新沒有遇上屏幕刷新,動畫就有點卡
即便你的渲染流水線更新是和屏幕的刷新頻率保持一致的,這些更新是運行在主線程上面的,這就意味着它可能被一樣運行在主線程上面的JavaScript代碼阻塞。
某些動畫幀被JavaScript阻塞了
對於這種狀況,你能夠將要被執行的JavaScript操做拆分爲更小的塊而後經過requestAnimationFrame
這個API把他們放在每一個動畫幀中執行。想知道更多關於這方面的信息的話,能夠參考Optimize JavaScript Execution。固然你還能夠將JavaScript代碼放在WebWorkers中執行來避免它們阻塞主線程。
在動畫幀上運行一小段JavaScript代碼
到目前爲止,瀏覽器已經知道了關於頁面如下的信息:文檔結構,元素的樣式,元素的幾何信息以及它們的繪畫順序。那麼瀏覽器是如何利用這些信息來繪製出頁面來的呢?將以上這些信息轉化爲顯示器的像素的過程叫作光柵化(rasterizing)。
可能一個最簡單的作法就是隻光柵化視口內(viewport)的網頁內容。若是用戶進行了頁面滾動,就移動光柵幀(rastered frame)而且光柵化更多的內容以補上頁面缺失的部分。Chrome的第一個版本其實就是這樣作的。然而,對於現代的瀏覽器來講,它們每每採起一種更加複雜的叫作合成(compositing)的作法。
最簡單的光柵化過程
合成是一種將頁面分紅若干層,而後分別對它們進行光柵化,最後在一個單獨的線程 - 合成線程(compositor thread)裏面合併成一個頁面的技術。當用戶滾動頁面時,因爲頁面各個層都已經被光柵化了,瀏覽器須要作的只是合成一個新的幀來展現滾動後的效果罷了。頁面的動畫效果實現也是相似,將頁面上的層進行移動並構建出一個新的幀便可。
你能夠經過Layers panel在DevTools查看你的網站是如何被瀏覽器分紅不一樣的層的。
頁面合成過程
爲了肯定哪些元素須要放置在哪一層,主線程須要遍歷渲染樹來建立一棵層次樹(Layer Tree)(在DevTools中這一部分工做叫作「Update Layer Tree」)。若是頁面的某些部分應該被放置在一個單獨的層上面(滑動菜單)但是卻沒有的話,你能夠經過使用will-change
CSS屬性來告訴瀏覽器對其分層。
主線程遍歷佈局樹來生成層次樹
你可能會想要給頁面上全部的元素一個單獨的層,然而當頁面的層超過必定的數量後,層的合成操做要比在每一個幀中光柵化頁面的一小部分還要慢,所以衡量你應用的渲染性能是十分重要的一件事情。想要獲取關於這方面的更多信息,能夠參考文章Stick to Compositor-Only Properties and Manage Layer Count。
一旦頁面的層次樹建立出來而且頁面元素的繪製順序肯定後,主線程就會向合成線程(compositor thread)提交這些信息。而後合成線程就會光柵化頁面的每一層。由於頁面的一層可能有整個網頁那麼大,因此合成線程須要將它們切分爲一塊又一塊的小圖塊(tiles)而後將圖塊發送給一系列光柵線程(raster threads)。光柵線程會柵格化每一個圖塊而且把它們存儲在GPU的內存中。
光柵線程建立圖塊的位圖併發送給GPU
合成線程能夠給不一樣的光柵線程賦予不一樣的優先級(prioritize),進而使那些在視口中的或者視口附近的頁面能夠先被光柵化。爲了響應用戶對頁面的放大和縮小操做,頁面的圖層(layer)會爲不一樣的清晰度配備不一樣的圖塊。
當圖層上面的圖塊都被柵格化後,合成線程會收集圖塊上面叫作繪畫四邊形(draw quads)的信息來構建一個合成幀(compositor frame)。
上面的步驟完成以後,合成線程就會經過IPC向瀏覽器進程(browser process)提交(commit)一個渲染幀。這個時候可能有另一個合成幀被瀏覽器進程的UI線程(UI thread)提交以改變瀏覽器的UI。這些合成幀都會被髮送給GPU從而展現在屏幕上。若是合成線程收到頁面滾動的事件,合成線程會構建另一個合成幀發送給GPU來更新頁面。
合成線程構建出合成幀,合成幀會被髮送給瀏覽器進程而後再發送給GPU
合成的好處在於這個過程沒有涉及到主線程,因此合成線程不須要等待樣式的計算以及JavaScript完成執行。這也就是爲何說只經過合成來構建頁面動畫是構建流暢用戶體驗的最佳實踐的緣由了。若是頁面須要被從新佈局或者繪製的話,主線程必定會參與進來的。
瞭解了渲染進程從解析HTML文件到合成頁面整個的渲染流水線後,在接下來剩下的文章內容中,咱們將要查看合成線程更多的細節,來了解一下當用戶在頁面移動鼠標(mouse move)以及進行點擊(click)的時候瀏覽器會作些什麼事情。
當你聽到「輸入事件」(input events)的時候,你可能只會想到用戶在文本框中輸入內容或者對頁面進行了點擊操做,但是從瀏覽器的角度來看的話,輸入其實表明着來自於用戶的任何手勢動做(gesture)。因此用戶滾動頁面
,觸碰屏幕
以及移動鼠標
等操做均可以看做來自於用戶的輸入事件。
當用戶作了一些諸如觸碰屏幕的手勢動做時,瀏覽器進程(browser process)是第一個能夠接收到這個事件的地方。但是瀏覽器進程只能知道用戶的手勢動做發生在什麼地方而不知道如何處理,這是由於標籤內(tab)的內容是由頁面的渲染進程(render process)負責的。所以瀏覽器進程會將事件的類型(如touchstart
)以及座標(coordinates)發送給渲染進程。爲了能夠正確地處理這個事件,渲染進程會找到事件的目標對象(target)而後運行這個事件綁定的監聽函數(listener)。
點擊事件從瀏覽器進程路由到渲染進程
在上面的文章中,咱們查看了合成線程是如何經過合併頁面已經光柵化好的層來保障流暢滾動體驗(scroll smoothly)的。若是當前頁面不存在任何用戶事件的監聽器(event listener),合成線程徹底不須要主線程的參與就能建立一個新的合成幀來響應事件。但是若是頁面有一些事件監聽器(event listeners)呢?合成線程是如何判斷出這個事件是否須要路由給主線程處理的呢?
由於頁面的JavaScript腳本是在主線程(main thread)中運行的,因此當一個頁面被合成的時候,合成線程會將頁面那些註冊了事件監聽器的區域標記爲「非快速滾動區域」(Non-fast Scrollable Region)。因爲知道了這些信息,當用戶事件發生在這些區域時,合成線程會將輸入事件發送給主線程來處理。若是輸入事件不是發生在非快速滾動區域,合成線程就無須主線程的參與來合成一個新的幀。
非快速滾動區域有用戶事件發生時的示意圖
Web開發的一個常見的模式是事件委託(event delegation)。因爲事件會冒泡,你能夠給頂層的元素綁定一個事件監聽函數來做爲其全部子元素的事件委託者,這樣子節點的事件就能夠統一被頂層的元素處理了。所以你可能看過或者寫過相似於下面的代碼:
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
})
複製代碼
只用一個事件監聽器就能夠服務到全部的元素,乍一看這種寫法仍是挺實惠的。但是,若是你從瀏覽器的角度去看一下這段代碼,你會發現上面給body元素綁定了事件監聽器後實際上是將整個頁面都標記爲一個非快速滾動區域,這就意味着即便你頁面的某些區域壓根就不在意是否是有用戶輸入,當用戶輸入事件發生時,合成線程每次都會告知主線程而且會等待主線程處理完它才幹活。所以這種狀況下合成線程就喪失提供流暢用戶體驗的能力了(smooth scrolling ability)。
當整個頁面都是非快速滾動區域時頁面的事件處理示意圖
爲了減輕這種狀況的發生,您能夠爲事件監聽器傳遞passive:true
選項。 這個選項會告訴瀏覽器您仍要在主線程中偵聽事件,但是合成線程也能夠繼續合成新的幀。
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, {passive: true});
複製代碼
當合成線程向主線程發送輸入事件時,主線程要作的第一件事是經過命中測試(hit test)去找到事件的目標對象(target)。具體的命中測試流程是遍歷在渲染流水線中生成的繪畫記錄(paint records)來找到輸入事件出現的x, y座標上面描繪的對象是哪一個。
主線程經過遍歷繪畫記錄來肯定在x,y座標上的是哪一個對象
在上面的文章中咱們有說過顯示器的刷新頻率一般是一秒鐘60次以及咱們能夠經過讓JavaScript代碼的執行頻率和屏幕刷新頻率保持一致來實現頁面的平滑動畫效果(smooth animation)。對於用戶輸入來講,觸摸屏通常一秒鐘會觸發60到120次點擊事件,而鼠標通常則會每秒觸發100次事件,所以輸入事件的觸發頻率其實遠遠高於咱們屏幕的刷新頻率。
若是每秒將諸如touchmove
這種連續被觸發的事件發送到主線程120次,由於屏幕的刷新速度相對來講比較慢,它可能會觸發過量的點擊測試以及JavaScript代碼的執行。
事件淹沒了屏幕刷新的時間軸,致使頁面很卡頓
爲了最大程度地減小對主線程的過多調用,Chrome會合並連續事件(例如wheel
,mousewheel
,mousemove
,pointermove
,touchmove
),並將調度延遲到下一個requestAnimationFrame
以前。
和以前相同的事件軸,但是此次事件被合併並延遲調度了
任何諸如keydown
,keyup
,mouseup
,mousedown
,touchstart
和touchend
等相對不怎麼頻繁發生的事件都會被當即派送給主線程。
對於大多數web應用來講,合併事件應該已經足夠用來提供很好的用戶體驗了,然而,若是你正在構建的是一個根據用戶的touchmove
座標來進行繪圖的應用的話,合併事件可能會使頁面畫的線不夠順暢和連續。在這種狀況下,你可使用鼠標事件的getCoalescedEvents
來獲取被合成的事件的詳細信息。
左邊是順暢的觸摸手勢,右邊是事件合成後不那麼連續的手勢
window.addEventListener('pointermove', event => {
const events = event.getCoalescedEvents();
for (let event of events) {
const x = event.pageX;
const y = event.pageY;
// draw a line using x and y coordinates.
}
});
複製代碼
在這篇文章中,咱們以Chrome瀏覽器爲例子探討了瀏覽器的內部工做原理。若是你以前歷來沒有想過爲何DevTools推薦你在事件監聽器中使用passive:true
選項或者在script標籤中寫async
屬性的話,我但願這篇文章能夠給你一些關於瀏覽器爲何須要這些信息來提供更快更流暢的用戶體驗的緣由。
不一樣網站的性能調整可能會有所不一樣,你要本身衡量本身網站的性能並肯定最適合提高你的網站性能的方案。 你能夠查看Chrome DevTools團隊的一些教程來學習如何才能衡量本身網站的性能。
若是你想更進一步,你能夠了解一下Feature Policy這個新的Web平臺功能,這個功能能夠在你構建項目的時候提供一些保護讓您的應用程序具備某些行爲並防止你犯下錯誤。例如,若是你想確保你的應用代碼不會阻塞頁面的解析(parsing),你能夠在同步腳本策略(synchronius scripts policy)中運行你的應用。具體作法是將sync-script
設置爲'none',這樣那些會阻塞頁面解析的JavaScript代碼會被禁止執行。這樣作的好處是避免你的代碼阻塞頁面的解析,並且瀏覽器無須擔憂解析器(parser)暫停。
我是進擊的大蔥,關注我和我一塊兒進步成獨當一面的全棧工程師!
文章首發於:窺探現代瀏覽器架構(一)
關注個人我的公衆號獲取個人最新技術推送!