前言
本文主要介紹如下內容:
- 瀏覽器的高層結構
- 瀏覽器的渲染原理
- 瀏覽器如何加載JAVAscript腳本
- JavaScript引擎如何工作
引入
在瀏覽器中輸入URL并回車后都發生了什么?
讓我們從大家最熟悉的這個面試問題引入,先不往下看文章,你能脫口而出的說出答案嘛?如果可以恭喜你,你可以跳過這一小節。如果不可以那就還是看一下吧~
- URL解析:從URL中抽取出域名字段
- DNS域名解析: 查找瀏覽器緩存:瀏覽器會緩存2-30分鐘訪問過網站的DNS信息,如未找到 檢查系統緩存:檢查hosts文件,它保存了一些訪問過網站的域名和IP的數據,如未找到 檢查路由器緩存:路由器有自己的DNS緩存,如未找到 檢查ISP DNS緩存:ISP服務商DNS緩存(本地服務器緩存),如未找到 遞歸查詢:從根域名服務器到頂級域名服務器再到極限域名服務器依次搜索對應目標域名的IP
- 瀏覽器與服務器建立TCP連接(3次握手): 第一次握手:客戶端向服務器端發送請求等待服務器確認 第二次握手:服務器收到請求并確認,回復一個指令 第三次握手:客戶端收到服務器的回復指令并返回確認
- 請求和傳輸數據:服務器解析客戶端請求,并返回相應的數據
- 瀏覽器渲染頁面:這里先空著,我們下面講瀏覽器原理時展開
- 關閉TCP連接:當數據完成請求到返回的過程之后,根據Connection的Keep-Alive屬性可以選擇是否斷開TCP連接,四次揮手釋放。
Emmmm,到這里這道題目基本解答完畢,但是卻引出了另一個問題,瀏覽器從服務端拿到數據后做了什么才將網頁呈現到我們的顯示器上,下面就讓我們一起來探索瀏覽器的秘密吧~~
瀏覽器的高層結構
首先讓我們看一下瀏覽器的主要組件:
- 用戶界面:包括地址欄、前進/后退按鈕、書簽菜單等。除了瀏覽器主窗口顯示的您請求的頁面外,其他顯示的各個部分都屬于用戶界面。
- 瀏覽器引擎:在用戶界面和呈現引擎之間傳送指令。
- 呈現引擎:負責顯示請求的內容。如果請求的內容是 html,它就負責解析 HTML 和 css 內容,并將解析后的內容顯示在屏幕上。
- 網絡:用于網絡調用,比如 HTTP 請求。其接口與平臺無關,并為所有平臺提供底層實現。
- 用戶界面后端:用于繪制基本的窗口小部件,比如組合框和窗口。其公開了與平臺無關的通用接口,而在底層使用操作系統的用戶界面方法。
- Javascript解析器:用于解析和執行 JavaScript 代碼。
- 數據存儲:這是持久層。瀏覽器需要在硬盤上保存各種數據,例如 Cookie。新的 HTML 規范 (HTML5) 定義了“網絡數據庫”,這是一個完整(但是輕便)的瀏覽器內數據庫。
值得注意的是,和大多數瀏覽器不同,Chrome瀏覽器的每個標簽頁都分別對應一個呈現引擎實例。每個標簽頁都是一個獨立的進程。
瀏覽器的呈現引擎
在瀏覽器的主要組件中我們最關注的就是瀏覽器的呈現引擎,因為呈現引擎,顧名思義,它決定了呈現在瀏覽器的內容。呈現引擎也叫做瀏覽器內核,不同瀏覽器使用的呈現引擎是不一樣的。常見瀏覽器使用的呈現引擎如下:
呈現引擎 瀏覽器 Trident(MSHTML) IE,MaxThon,TT,The World,360,搜狗瀏覽器等 Gecko Netscape6 及以上版本,FF,MozillaSuite/SeaMonkey 等 Presto Opera7及以上。 [Opera內核原為:Presto,現為:Blink] Webkit Safari,Chrome等。[Chrome:Blink(WebKit 的分支)] EdgeHTML Microsoft Edge。 [此內核其實是從 MSHTML fork 而來,刪掉了幾乎所有的 IE私有特性]
下面我們將以Webkit為例講解瀏覽器呈現引擎工作的主要流程,Gecko的工作流程與Webkit基本是相同的,只是術語略有不同。
Webkit的主要工作流程
上圖展示的是webkit的主要工作流程,接下來我們按照圖片的流程來逐漸闡述Webkit是如何工作的。但在這之前我們先要明白從HTTP請求回來開始,呈現引擎的整個工作流程不是一步做完再做下一步,而是一條流水線。
從HTTP請求回來,就產生了流式的數據,后續的DOM樹構建、CSS計算、渲染、合成、繪制,都是盡可能地流式處理前一步的產出:即不需要等到上一步驟完全結束,就開始處理上一步的輸出,這樣我們在瀏覽網頁時,才會看到逐步出現的頁面。
HTML解析:從HTML到DOM樹
Webkit會用HTML解析算法將HTML轉換成DOM樹。下面讓我們看一下HTML到DOM樹的轉換:
接下來讓我們了解一下HTML解析算法,HTML解析算法的流程如下圖所示:
它分為標記化和樹構建兩個過程:
- 標記化:將輸入的內容解析成多個標記(HTML標記包括起始標記,結束標記,屬性名稱,屬性值)。標記生成器識別標記,傳遞給樹構造器,然后接受下一個字符以識別下一個標記;如此反復直至結束。標記化算法是通過狀態機實現的。
- 樹構建:標記生成器發送的每個節點都由樹構建器進行處理,規范中定義了每個標記所對應的DOM元素,這些元素會在接收到對應的標記時構建,這些元素不僅會被添加到DOM樹,還會添加到開放元素的堆棧中。此堆棧用于糾正嵌套錯誤和處理未關閉的標記,其算法也可以用狀態機來表示。
HTML解析完成后,瀏覽器會將文檔狀態標注為交互狀態,并開始解析那些處于deferred模式的腳本,然后文檔狀態設置為完成,一個加載時間將隨之觸發。
CSS解析:從CSS到StyleSheet對象
CSS解析器會將CSS文件解析成StyleSheet對象,下面讓我們來看一下CSS到StyleSheet的轉換:
這里我們不講CSS構建的過程,感興趣的小伙伴可以看一下參考資料里的重學前端,我們簡單的介紹一下CSS選擇器的特點,這是由CSS設計原則所決定的。
- 選擇器出現的順序必定跟構建DOM樹的順序一致,即保證選擇器在構建到當前節點時,已經可以準確判斷該節點所匹配的CSS規則,不需要后續節點信息。
- CSS樣式匹配時是從右向左匹配的,DOM找到它所有匹配的CSS樣式后再做加權計算,確定最終樣式,所以也就不難理解chrome操作臺內樣式表信息為何那樣展示了。
構建呈現樹:整合DOM樹和StyleSheet對象為呈現樹
構建呈現樹時,需要計算每一個呈現對象的可視化屬性。每個DOM節點都有一個"attach"方法,在節點插入DOM樹時會調用節點的attach方法,計算該節點的樣式屬性生成呈現器。下面讓我們看一下整合(webkit的術語叫‘附加’)的過程:
排版:將呈現器盒子放到對應的位置
所有的呈現器都有一個“layout”或者“reflow”方法,每一個呈現器都會調用其需要進行布局的子代的layout方法。有很多排版方法:正常流文字排版,絕對定位,浮動元素排版,flex排版等。
渲染:把每一個呈現器對應的盒子變成位圖
這里的渲染是借用計算機圖形學里面的解釋,就是把模型變成位圖的過程。
位圖就是在內存里建立一張二維表格,把一張圖片的每個像素對應的顏色保存進去(位圖信息也是DOM樹中占據瀏覽器內存最多的信息,我們在做內存占用優化時,主要就是考慮這一部分)。
合成:合成位圖,提升性能
這個過程實際上是一個性能考量,它并非實現瀏覽器的必要一環。合成的過程就是根據合成策略合并位圖。合成策略就是最大限度的減少繪制次數,它是“猜測”可能變化的元素,將它排除到合成之外。
目前,主流瀏覽器一般根據position、transform等屬性來決定合成策略,來“猜測”這些元素未來可能發生變化。但是,這樣的猜測準確性有限,所以新的CSS標準中,規定了will-change屬性,可以由業務代碼來提示瀏覽器的合成策略,靈活運用這樣的特性,可以大大提升合成策略的效果。
繪制:將位圖繪制到屏幕上,變成肉眼可見的圖像的過程
一般來說,瀏覽器并不需要用代碼來處理這個過程,瀏覽器只需要把最終要顯示的位圖交給操作系統即可。
到這里我們已經將Webkit主要的工作流程捋了一遍,現在讓我們來總結一下,從HTTP請求回來的數據通過HTML解析器和CSS解析器,分別解析成DOM樹和StyleSheet對象,然后整合兩者生成呈現樹,呈現樹調用layout進行排版,然后通過渲染將呈現器盒子變成位圖,根據合成策略合成位圖提升繪制性能,把位圖給操作系統讓其繪制到屏幕上。看到這里我們很容易就理解了一個小知識點:CSS不會阻塞DOM的解析,但會阻塞DOM的渲染。
現在我們已經以Webkit為例介紹了呈現引擎的主要工作流程,但是我們似乎還遺漏了些什么。對的,Javascript,我們好像一直沒有提及當Webkit解析到JavaScript代碼時會怎么處理,接下來就讓我們一起來看一看這一部分知識吧~~
瀏覽器加載JavaScript腳本
正常加載流程
瀏覽器加載JavaScript腳本,主要通過<script>元素完成。其正常流程如下
- 瀏覽器的呈現引擎持有渲染的控制權,它正常解析HTML頁面
- 解析遇到<script>標簽,呈現引擎移交控制權給Javascript引擎(例如chrome的V8)
- 如果<script>標簽引用了外部腳本那就先下載再執行,否則直接執行代碼
- JavaScript引擎執行完畢移交控制權給呈現引擎,呈現引擎繼續解析
加載外部腳本時,瀏覽器會暫停頁面渲染,等待腳本下載并執行完成后,再繼續渲染。原因是 JavaScript 代碼可以修改 DOM,所以必須把控制權讓給它,否則會導致復雜的線程競賽的問題。
defer屬性
瀏覽器解析到包含defer屬性的<script>元素時,其運行流程如下
- 瀏覽器的呈現引擎持有渲染的控制權,它正常解析HTML頁面
- 解析遇到包含defer屬性的<script>標簽,繼續解析HTML,同時并行下載外鏈腳本
- 解析完成,文檔處于交互狀態時開始解析處于deferred模式的腳本
- 腳本解析完畢后,將文檔狀態設置為完成,DOMContentLoaded事件隨之觸發
使用defer屬性時需要注意的點:
- defer屬性下載的腳本文件在DOMContentLoaded事件觸發前執行(即剛剛讀取完</html>標簽)
- defer屬性可以保證執行順序就是它們在頁面上出現的順序
- 對于內置而不是加載外部腳本的script標簽,以及動態生成的script標簽,defer屬性不起作用
- 使用defer加載的外部腳本不應該使用document.write方法
async屬性
瀏覽器解析到包含async屬性的<script>元素時,其運行流程如下
- 瀏覽器的呈現引擎持有渲染的控制權,它正常解析HTML頁面
- 解析遇到包含async屬性的<script>標簽,繼續解析HTML,讓另一進程同時并行下載外鏈腳本
- 腳本下載完成,瀏覽器暫停解析HTML,開始執行下載的腳本
- 腳本執行完畢,瀏覽器恢復解析HTML
使用async屬性時需要注意的點:
- async屬性可以保證腳本下載的同時,瀏覽器繼續渲染
- async屬性無法保證腳本的執行順序,哪個先下載結束就先執行哪一個
- 包含async屬性的腳本不應該使用document.write方法
- 如果同時使用async和defer屬性,后者不起作用,瀏覽器行為由async屬性決定
腳本的動態加載
<script>元素還可以動態生成,生成后再插入頁面,從而實現腳本的動態加載。動態生成的script標簽不會阻塞頁面渲染,也就不會造成瀏覽器假死。但是問題在于,這種方法無法保證腳本的執行順序,哪個腳本文件先下載完成,就先執行哪個。如果想避免這個問題,可以設置async屬性為false。還可以監聽腳本的onload事件來為腳本指定回調。
CSS阻塞JS加載
因為JS腳本可能會引用DOM的樣式做計算,所以為了保證腳本計算的正確性,Firefox瀏覽器會等到腳本前面的所有樣式表,都下載并解析完,再執行腳本;Webkit則是一旦發現腳本引用了樣式,就會暫停執行腳本,等到樣式表下載并解析完,再恢復執行。
此外,對于來自同一個域名的資源,比如腳本文件、樣式表文件、圖片文件等,瀏覽器一般有限制,同時最多下載6~20個資源,即最多同時打開的 TCP 連接有限制,這是為了防止對服務器造成太大壓力。如果是來自不同域名的資源,就沒有這個限制。所以,通常把靜態文件放在不同的域名之下,以加快下載速度。
瀏覽器預解析
WebKit和Firefox都進行了這項優化。在執行腳本時,其他線程會解析文檔的其余部分,找出并加載需要通過網絡加載的其他資源。通過這種方式,資源可以在并行連接上加載,從而提高總體速度。請注意,預解析器不會修改DOM樹,而是將這項工作交由主解析器處理;預解析器只會解析外部資源(例如外部腳本、樣式表和圖片)的引用。
Emmmm,到這里我們就了解了瀏覽器的呈現引擎只負責解析HTML和CSS,遇到JS時它會把控制權交給JS的引擎來解析和執行。因為JS引擎拿走了渲染的控制權,所以JS顯而易見會阻塞DOM的解析,為了讓JS不阻塞DOM的解析瀏覽器進行了異步加載以及預解析等優化。嗯,我們已經了解了呈現引擎,接下來讓我們了解一下它的小伙伴Javascript引擎的工作流程吧~~
Javascript引擎的工作原理
首先,讓我們了解幾個概念,這會幫助我們更好的理解js代碼是如何執行的。
- Javascript引擎:從頭到尾負責整個 JavaScript 程序的編譯及執行過程。
- 編譯器:負責語法分析及代碼生成等臟活累活。具體工作如下圖所示:
了解基礎的概念后,現在讓我們一起來捋一遍Javascript引擎是如何工作的吧~~
- 一個Javascript引擎常駐于內存中,它等待著宿主(例如瀏覽器)把Javascript代碼傳遞給它執行。
- 當宿主向它傳遞Javascript代碼時,它將Javascript源代碼丟給編譯器編譯成可執行代碼。
- 然后引擎開始執行可執行代碼,執行過程如下:
將宿主(例如瀏覽器)發起的宏觀任務添加到宏觀任務隊列,如果JS引擎主線程的任務棧是空的,它會自動從宏觀任務隊列拉取任務并執行,在執行過程中遇到setTimeout等異步代碼會先放到計時器模塊,計時器模塊計時結束后加入將其加入宏觀任務隊列;在執行過程中遇到Promise等代碼會將其作為一個微觀任務加入到當前宏觀任務末尾的微觀任務隊列中。當前宏觀任務正常任務執行完畢后會執行當前宏觀任務末尾的微觀任務隊列里面的任務,微觀任務隊列內任務執行完畢后該宏觀任務執行完畢,主線程任務棧會拉取下一個宏觀任務。在此期間宿主環境隨時可能在宏觀任務隊列添加任務,JS引擎也隨時可能在當前宏觀任務隊列末尾的微觀任務隊列添加微觀任務。如圖所示,形成了一個事件循環。
Emmmm,如果小伙伴們想進一步的了解JS引擎的工作細節,我推薦以下文章和視頻來chrome的V8引擎是如何工作的。
- V8 是怎么跑起來的 —— V8的JavaScript 執行管道
- 從v8的內存管理算法出發-教你如何管理內存
感恩大家還能看到這里,文章可能還有一些地方不是特別完善,我會慢慢迭代完善的~~
結尾
最后談一點點自己的感想,我個人一直覺得學習原理很重要,最近學習了圈外解決問題的課程就更加堅定了我的信念。因為解決問題的第一步就是澄清問題,而學習原理可以幫助我們更快的定位問題所在從而解決問題。愛因斯坦曾說,如果給我一個小時解答一道決定我生死的問題,我會花55分鐘來弄清楚這道題目到底是在問什么。一旦清楚它到底在問什么,剩下的5分鐘足夠回答這個問題。在實際的工作中也確實如此,一旦程序出了問題,我們往往花大量的時間在調試上,而一旦找到了問題解決起來就很快了。
因為前端工程師打交道最多的就是瀏覽器,了解瀏覽器的工作原理不管是對寫代碼還是對項目的性能優化都會有所幫助,所以我斷斷續續看了許多關于瀏覽器工作原理的文章及書籍小冊,終于覺得是時候整理輸出一些東西,希望可以加深自己的理解,更希望可以對小伙伴們有所幫助。如果文章中有什么表述不對的地方,歡迎大家在評論區指正。最后感謝閱讀這篇文章的小伙伴們。