HTML Standard系列:瀏覽器是如何解析頁面和腳本的

前言

當咱們去探索瀏覽器運行原理的時候,咱們傾向於執行多個例子去推斷其內在的設計;在很長一段時間內,我也是這麼去探索瀏覽器這個黑盒的,但這麼作始終只是驗證例子自己而不能斷言瀏覽器實際的行爲。爲了追求真理(誤),決定寫這個系列的文章,從源頭探索瀏覽器的行爲準則。javascript

HTML Standard即由w3c制定的html規範,而咱們實際使用的瀏覽器的核心,諸如chrome內部的webkit,則是html規範的實現。全部界面行爲(不包括瀏覽器自身元件如書籤)都由HTML規範所描述,並由webkit(或者其餘瀏覽器引擎)實現。須要注意的是Javascript規範並非由w3c制定的,HTML規範只定義了其中的Document和window對象,也就是常說的DOM和BOM,咱們接下來探究的就是HTML的規範是如何定義的。css

讀完本文能夠得到的收穫

  • 粗略瞭解HTML Parser的每一個流程和做用。
  • Css/JavaScript等資源加載和HTML parser之間的關係
  • DOMContentLoaded、window.onload、onreadystatechange等事件的觸發時機

須要注意一些點:html

因爲上述緣由,在非webkit內核瀏覽器測試本文用例可能會有問題。前端

瀏覽器是如何解析HTML

準確的說瀏覽器不僅能解析HTML,還能解析包括XML文檔、大部分圖片格式、PDF文件等等,可是咱們如今只關注HTML是如何被解析的。java

Dom和HTML Parser

在開始探討瀏覽器解析html的流程以前,先對Dom進行一下定義:node

  • 用於瀏覽器內部的頁面抽象表示(瀏覽器根據Dom構建的渲染樹繪製頁面)。
  • 暴露給JavaScript操做的接口。

本文將HTML文本生成Dom的過程稱爲解析(Parser),將Dom Tree + Css生成Layout Tree再進行繪製的過程稱爲渲染(Render)。react

Dom對於瀏覽器而言,就像Virtual Dom於前端開發者,Dom並非真實的視圖(咱們所看到的界面),但瀏覽器能夠根據Dom Tree和Css計算出底層繪製指令。git

而HTML Parser產出的是Dom,而不是實際的界面,且Dom的變化並不會馬上致使繪製(相似react調用setState)。github

因此當HTML Parser阻塞意味着暫停產出新的節點(eg: HTMLElement),有些時候HTML Parser阻塞並不表明瀏覽器沒法繪製界面。(若是已經存在部分Dom和Css我爲何不能繪製?)web

讓咱們一步步揭開瀏覽器的神祕面紗。

HTML Parser 執行流程

當咱們打開一個網頁的時候,實際上瀏覽器發起了一個請求,最終將請求結果呈現給用戶。做爲開發者,咱們關心的是:在這個過程當中瀏覽器應該進行怎麼樣的準備工做?是如何去處理請求的響應的?帶着這兩個疑問,咱們繼續往下看。

那麼瀏覽器是如何作的呢?瀏覽器在請求資源之初會初始化一個獨立的上下文稱爲browsing context包含了:

  • 不包含任何Element的document(在請求返回前甚至不能知道document的類型)
  • 一個和document相對應的window對象
  • JavaScript運行環境,以保存腳本運行結果
  • 將this綁定到window上。

當請求的資源是一個HTML文件的時候(瀏覽器使用content-type識別),瀏覽器會初始化一個HTML Parser關聯到當前的document,並將響應結果傳入給HTML Parser進行解析,這是HTML Standard中規定的parser流程:

咱們來一步步理解這流程的意義。

Byte Stream Decoder & Input Stream Preprocessor

咱們從file system或者http response中拿到是字節流,拿到字節流後瀏覽器會嘗試去decode,會根據以下的設定選取解碼器

  • 根據http頭部字段conten-type獲取字符編碼。
  • 根據文檔的meta標籤獲取,例:<meta charset="UTF-8" / >
  • 若是上述兩個都沒有,瀏覽器會經過字節編碼嗅探算法決定字符編碼

此外規範還推薦使用兼容ascii編碼的編碼(例如utf-8)(vsc默認使用該編碼保存文檔)去編寫HTML文檔,由於規範使用ascii編碼探測meta標籤,進而獲取文檔的編碼。

一個使用了錯誤編碼解析的文檔:

至此,咱們的瀏覽器終於能正確的將字節流decode了,可是在處理字符構建Dom以前,還須要額外的預處理,稱爲Input Stream Preprocessor,這個步驟執行的作的事情僅僅是標準化換行符,由於在不一樣系統下使用文本使用的換行符是不一致的,例如Windows使用CRLF做爲換行符,而類Unix系統使用LF做爲換行符

除此以外,咱們看到Script Execution步驟經過調用document.write迴流到Input Stream Preprocessor(這也是爲何腳本執行會阻塞瀏覽器解析的緣由),顧名思義在腳本執行階段,document.write插入的內容會在注入到Input Stream中,而且做爲下一個解析點。咱們來看看效果:

<!DOCTYPE html>
<html lang="en">
...
<body>
  <p>
    parser first
  </p>
  <script> document.write('<p>parser second</p>') </script>
  <p>
    parser third
  </p>
</body>
</html>
複製代碼

能夠看到document.write調用的輸出要在腳本以後的標籤的前面。

Tokenizer & Tree Construction

Tokenizer在編譯器領域是比較常見的一個名詞,直譯過來就是令牌化的意思,咱們考慮一下HTML文檔中有多少種類型的字符

  • 文檔註釋
  • html標籤
  • 要展現的文本內容
  • 內聯的樣式代碼和腳本代碼
  • html保留字符如:&nbsp;
  • 還有不少我沒想到的!

而咱們接收到的是一串無狀態的字符串,爲了方便HTML解析,咱們須要將這一長串字符串,切分紅一系列子串,並打上相應的標籤,賦予對應的狀態,一個個的傳遞給Tree Construction,這就是Tokenizer的職責。

Tree Construction有一系列的插入狀態,確保node節點插入在合適的位置,若是一個節點出如今非法位置則會致使Parser Error(eg:在head內寫了個span標籤),Parser Error不必定會致使Parser終止,規範定義了一系列糾錯機制

<!-- 一系列的插入狀態保證了html解析成dom能生成以下的結構 -->
<!DOCTYPE html>
<html lang="en">
<head></head>
<body></body>
</html>

<!DOCTYPE html>
<html lang="en">
<head>
  <!-- 非法的節點類型,會致使Parser Error,根據糾錯機制會忽略該節點。 document.querySelector('#invalid')將會是null -->
  <span id="invalid">2</span>
</head>
<body></body>
</html>
複製代碼

Tree Construction最終會產出一個node節點並插入到Dom中,這時候咱們就能夠經過JavaScript去操做Dom了。

這樣就完事了?好戲如今纔開始! 在剛剛的敘述過程當中,隱藏了對具體標籤的解析方式,<script src="index.js" />和<link href="index.css" />怎麼能同樣呢?

接下來咱們說說樣式、腳本在HTML Parser執行過程當中的具體表現。

樣式、腳本和HTML Parser

坊間流傳的最廣的說法就是,樣式加載不會阻塞HTML解析,而腳本會。

這句話太過模糊,畢竟樣式有內聯樣式、外部樣式等,一樣腳本也有內聯腳本、外部腳本,而外部腳本又有defer、async這些屬性進行再次區分。

加載、解析樣式

樣式的解析並不屬於HTML Parser的工做內容,對於HTML Parser而言只須要把link或者style標籤插入到Dom中就完事了,因此對於style和link標籤內的樣式資源的加載和解析工做是經過並行的方式去執行的

所謂的並行就是將主線程(HTML Parser所在的線程)返回,並建立一個子線程(或其餘並行的實現方式eg:fiber),將接下來的任務(eg: 下載樣式和解析樣式)放到子線程中執行。

到此爲止,結論都指向樣式加載解析不阻塞HTML解析,大部分狀況下這個結論都是對的,說到這就要說下解析過程當中的一個全局變量script-blocking style sheet counter。

這個變量會在以下場景發生變化:

  • 當一個script-blocking style sheet開始解析的時候couter++
  • 當一個script-blocking style sheet解析完成的時候couter--

那麼什麼樣的樣式資源叫script-blocking style sheet呢?

  1. 一個含有href、type爲text/css且media值爲空或者符合當前媒體查詢的link標籤
  2. 一個使用了@import語法引入外部樣式資源的style標籤
// script-blocking style sheet
<link rel="stylesheet" type="text/css" href="./index.css"/>
<style> @import './index2.css'; </style>
複製代碼

如今只須要記住這個變量和腳本執行還有渲染有關係便可,具體聯繫咱們接着看腳本的加載模式。

加載、執行JavaScript

這是一張流傳比較廣的script加載流程圖,基本涵蓋了大部分script加載對HTML Parser的影響,但還有部分細節的缺失,咱們看看規範是怎麼定義的。

對於沒有defer/async屬性的script,咱們稱之爲pending parsing-blocking script,但須要注意的是:

  • type爲module的script,defer屬性默認爲true
  • 不包括使用JavaScript動態插入的腳本

那麼pending parsing-blocking script是如何加載執行的呢?

  1. 當HTML Parser遇到這種類型的腳本時,會退出當前的Parser任務,並未來自HTML Parser的全部任務凍結(凍結的效果是Event Loop不會執行HTML Parser的任務,也就是咱們常說的HTML Parser被阻塞了)。
  2. 並行的執行以下步驟(此時主線程在執行除了HTML Parser的其餘任務):
    1. 不斷查詢腳本是否加載完畢,查詢script-blocking style sheet counter是否等於0,直到兩個條件都爲true
    2. 恢復以前的Parser任務並推入到event loop中
    3. 解凍HTML Parser的任務
  3. 執行腳本

經過上面步驟,咱們看到在這個場景下,樣式的加載解析是會阻塞pending parsing-blocking script的執行的,進而致使HTML Parser的阻塞

<!DOCTYPE html>
<html lang="en">
<head>
   <title>Document</title>
   <!--在我解析完成以前,pending parsing-blocking script別想執行-->
   <link rel="stylesheet" type="text/css" href="./index.css?lazy=1000" /> 
</head>
<body>
    <!--我是pending parsing-blocking script,我會阻塞Parser, 但我要等到樣式解析完成後才能執行-->
    <script src="./index.js"></script> 
    <!--我要等到👆的腳本執行完成後才能解析-->
    <span>hello</span>
</body>
</html>
複製代碼

爲何要樣式解析要設計成阻塞這部分腳本執行的呢?考慮以下場景:

// index.css
.color {
    color: red
}
複製代碼
<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
   <link rel="stylesheet" type="text/css" href="index.css" /> 
</head>
<body>
    <p class="color">個人顏色是什麼</p>
    <script> const element = document.querySelector('.class'); const color = window.getComputedStyle(element).color; console.log(color) // rgb(255, 0, 0); // 若是script沒有等待css加載完畢就執行,會致使腳本獲取到錯誤樣式 </script>
</body>
</html>
複製代碼

async & defer

咱們知道script標籤還有async和defer兩種影響它加裝執行的屬性,咱們看看具體表現又是如何。須要知道的幾點:

  • type爲module的script,defer默認爲true。
  • async優先級高於defer,即當async存在的時候,忽略defer屬性。
  • 由JavaScript建立的script標籤async默認爲true。

當HTML Parser遇到帶有defer標識且沒有async標識的script標籤,HTML Parser會將對應的script存在一個名爲 list of scripts that will execute when the document has finished parsing隊列中(有序),而且進行並行下載(不會阻塞主線程),但不會執行;而是等到HTML Parsing完成後,再回頭執行這個隊列,具體執行時機咱們後面還會講。

當HTML Parser遇到帶async標識的script標籤的時候,Parser依然會選擇把它存起來先,存在一個名爲set of scripts that will execute as soon as possible的集合中,而後開啓並行下載,與defer不一樣的是,當async script加載完成後,會馬上尋找機會執行(event loop next tick);這樣形成的結果是async script的運行時機不可預測,且是無序的(下載完的先執行);且當在HTML Parsing完成以前,async script下載完畢,依然會阻塞後續的Parser任務(可是async script下載期間不阻塞Parser)

還有一種類型的script list名爲list of scripts that will execute in order as soon as possible,目前我探索出來的僅有以下狀況符合這種類型的腳本:

// 由JavaScript建立,且aysnc爲false的script element
const script = document.createElement('script')
script.async = false; // javascript 建立的script標籤async默認爲true
script.src = 'dy.js';
document.body.append(script);
複製代碼

對於這種類型的腳本,解析和執行形式和async相似,惟一不一樣的是這種類型的腳本,會按照添加的順序執行,而async script是無序的;另外這兩種腳本的執行都是不會被樣式表的解析所阻塞的

到這裏咱們應當還有如下疑問:

  • 若是個人腳本async script到解析結束都沒下載完,我如何確認一個可以使用async script腳本的時間?
  • 個人defer腳本到底啥時候執行?啥時候能用?window發出onload事件後能夠用了嗎?

解析完成後的工做以及window.onload觸發的時機

HTML Parser在完成解析工做後,還有一些事項須要收尾,好比觸發一些事件,把沒跑的腳本給跑了等等;須要留意的是HTML Parser解析工做完成後不表明樣式表的解析工做完成了,畢竟解析樣式表的工做不屬於Parser,這意味着script-blocking style sheet counter有可能大於0

一個前置知識document.readyState能夠是三個值之一:lodaing、interactive、complete,加載文檔時是loading,當狀態發生改變時會觸發document.onreadystatechange事件。

解析完HTML文本後Parser會執行如下步驟:

  • 將document.readyState設置成interactive,觸發onreadystatechange事件(此時意味着全部DOM元素均可以被操做了)。
  • 暫停本任務(本任務指的就是當前Parser,暫停後會運行存在event loop中的其餘任務,直到後續條件達成)直到script-blocking style sheet counter爲0,且defer腳本下載完畢,而後執行全部的defer script
  • 觸發DOMContentLoaded事件,此時可能存在部分async script沒有下載完
  • 暫停本任務,直到全部的async script和list of scripts that will execute in order as soon as possible內的腳本下載完畢,而後執行這些腳本,對於後者列表內的腳本有序的,可是這兩個之間的腳本可能交錯執行。
  • 暫停本任務,直到全部dom元素的onload/onerror事件所有觸發(eg:img)
  • 將document.readyState改爲complete觸發window.onload(此時意味着全部腳本可用,全部DOM節點都觸發了onload/onerror事件)
  • 到這裏HTML Parser的任務就結束了,接下來會將控制權返回給event loop。

寫在最後

部分總結

  • 對於加載了外部資源的樣式表,會阻塞除了動態插入和擁有async屬性以外的腳本的執行。
  • async腳本和動態插入的sync腳本加載完成後馬上執行且時間不可測,前者無序,後者有序
  • 沒有async、defer屬性的腳本會阻塞Parser進行下載,全部的腳本執行都會阻塞Parser。
  • Parser會在解析完全部DOM以後,執行defer script
  • document在complete以前,會運行完全部的腳本

感想和預告

瞭解這些底層邏輯有助於咱們,在進行編譯時優化時擁有方向,處理首屏加載問題上能夠認識到是什麼在阻塞瀏覽器的加載。

其實對於script而言,最優的加載方式早就寫在各大論壇的各大博客上了,就是掛在HTML的最後,可是當咱們要作一些特殊操做的時候,忽然須要script不被放在頭部的樣式阻塞,或者並行加載一些依賴,這些知識就派上了用場。

說完這些,看完的同窗可能會發現文中頻繁出現event loop和任務的字眼,其實本質上HTML Parser就是跑在event loop上的一個任務,其實event loop的邏輯在HTML Standard中的規範至關複雜,它不只僅是調度執行JavaScript的任務,它基本調度了頁面中的全部任務。

個人下一篇文章應該會是根據HTML Standard的描述去解析event loop,其中會包括event loop調度模式,會包括原本中提到的任務暫停機制,還有你們應該感興趣的渲染時機的問題,其實本文沒有提到渲染的問題,但其實渲染可能出如今CSS加載完成後任意一個時機。

若是大夥以爲寫得還行,但願能點個贊,對下一篇感興趣能夠加個關注😁

例子下載:github.com/MinuteWong/…

相關文章
相關標籤/搜索