從URL到頁面
一個老生常談的問題,從輸入url到頁面渲染完成之間發生了什麼?
在這個過程中包括以下2大部分:
- 1.http請求響應
- 2.渲染
1.http請求響應
先來提三個問題:
1.當輸入url後,瀏覽器如何包裝發起請求?
2.在發出請求--接到響應之間發生了什麼?
3.當返回請求結果後,瀏覽器如何解析結果?
1.1 請求
1.1.1 GET請求包裝
1.為了知道瀏覽器是如何包裝http請求的,使用nodejs搭建伺服器
const http = require('http'); const server = http.createServer((req,res) => { if(req.url === '/'){ res.end('hello') } }); server.listen(8005,() => { console.log('server listen on http://localhost:8005') });
2.伺服器搭建好了,需要知道瀏覽器到底包裝了什麼資訊,直接看控制檯:
Request URL: http://localhost:8005/ Request Method: GET Status Code: 200 OK Remote Address: [::1]:8005 Referrer Policy: no-referrer-when-downgrade Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 Cache-Control: max-age=0 Connection: keep-alive Host: localhost:8005 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36
1.1.2 POST請求包裝
這些是瀏覽器自動包裝過後的請求,包括請求行,請求頭和請求主體,瀏覽器預設傳送的是GET請求,如果需要指定POST請求,可以寫個表單來驗證一下,大概意思是瀏覽器發起post請求,服務端接收到後返回success,瀏覽器端顯示返回的內容
//index.html <!DOCTYPE HTML> <html> <body> <form> <input type="text" id="val"/> </form> <button id="button">submit</button> <div id="item"></div> <script> var val = document.getElementById('val'); var button = document.getElementById('button'); var item = document.getElementById('item'); button.addEventListener('click',function(){ var oAjax = new XMLHttpRequest(); oAjax.open('POST', 'http://localhost:8005', false); oAjax.setRequestHeader("Content-type", "application/*"); var data = { value:val.value }; oAjax.onreadystatechange = function() { if (oAjax.readyState == 4 && oAjax.status == 200) { item.innerHTML = oAjax.responseText; } else { console.log(oAjax); } }; oAjax.send(JSON.stringify(data)); }) </script> </body> </html>
這樣寫的時候,由於html檔案的協議是file,所以為了解決跨域問題,需要服務端進行設定
const http = require('http'); const server = http.createServer((req,res) => { if(req.url === '/'){ res.setHeader("Access-Control-Allow-Origin", "*") res.setHeader("Access-Control-Allow-methods", "GET, POST, OPTIONS, PUT, DELETE") res.setHeader("Access-Control-Allow-Headers","*") res.setHeader("Content-type","application/plain") res.end('success!!!') } }); server.listen(8005,() => { console.log('server listen on http://localhost:8005') });
這樣一次post請求就成功了,來看看瀏覽器預設包裝了什麼資訊
Request URL: http://localhost:8005/ Request Method: POST Status Code: 200 OK Remote Address: [::1]:8005 //自動使用https協議 Referrer Policy: no-referrer-when-downgrade Content-type: application/* Origin: null User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36
這些資訊有的是我們自己在後端寫的,有的是瀏覽器自動新增的
1.2 過程
1.2.1 整體流程
前面已經知道了瀏覽器在發起GET或者POST請求的時候會自動的新增的欄位,那瀏覽器在傳送請求後到接收到服務端傳來的資料前這段時間發生了什麼?
網上看到大家的回答大部分都是:
- 1.接收 URL,並拆分成協議,網路地址,資源路徑
- 2.與快取進行比對,如果請求的物件在快取中,則直接進行第9步
- 3.檢查域名是否在本地的 host 的檔案中,在則直接返回 IP 地址,不在則向 DNS 伺服器請求,直到查詢到 IP 地址
- 4.瀏覽器向伺服器發起一個 TCP 連線
- 5.瀏覽器通過 TCP 連線向伺服器發起 HTTP 請求,HTTP 三次握手,HTTPS 握手過程則複雜得多
- 6.瀏覽器接受 HTTP 響應,這時候它能關閉 TCP 連線也能為另一個連線保留。
- 7.檢查 HTTP header 裡的狀態碼,並做出不同的處理方式。比如:錯誤(4XX、5XX),重定向(3XX),授權請求(2XX)
- 8.如果是可以快取的,這個響應則會被儲存起來
- 9.瀏覽器進行解碼響應,並決定如何處理該響應(比如HTML頁面,影象,聲音等等)
- 10.瀏覽器渲染響應,或者為不能識別的型別提供下載的提示框
1.2.2 域名解析流程
這樣的回答確實把相關的流程說了一遍,但是DNS是如何把域名解析成IP的?這個過程可以被觀察到麼?三次握手又是什麼意思?
為了看到域名解析的過程,我們可以使用Nslookup,它是由微軟釋出用於對DNS伺服器進行檢測和排錯的命令列工具
比如可以看一下,https://www.baidu.com它的IP是什麼,nslookup https://www.baidu.com
我在檢視的時候一直報延時錯誤,只好從網上引用一張圖來說明一下了
其中server代表本地地址ip,下面那個address是百度的ip
通過這樣的方式就能看到具體域名解析的過程
1.2.3 三次握手流程
接下來是三次握手,當域名轉化成IP後,瀏覽器沿著ip找到伺服器,進行三次握手:
- 第一次握手:客戶端的應用程序主動開啟,並向客戶端發出請求報文段。其首部中:SYN=1,seq=x。
- 第二次握手:伺服器應用程序被動開啟。若同意客戶端的請求,則發回確認報文,其首部中:SYN=1,ACK=1,ack=x+1,seq=y
- 第三次握手:客戶端收到確認報文之後,通知上層應用程序連線已建立,並向伺服器發出確認報文,其首部:ACK=1,ack=y+1。當伺服器收到客戶端的確認報文之後,也通知其上層應用程序連線已建立
看到這裡,有個問題,前兩次握手已經把客戶端和服務端聯絡在一起了,那為什麼還要第三次握手?
如果是兩次握手,當A想要建立連線時傳送一個SYN,然後等待ACK,結果這個SYN因為網路問題沒有及時到達B,所以A在一段時間內沒收到ACK後,在傳送一個SYN,B也成功收到,然後A也收到ACK,這時A傳送的第一個SYN終於到了B,對於B來說這是一個新連線請求,然後B又為這個連線申請資源,返回ACK,然而這個SYN是個無效的請求,A收到這個SYN的ACK後也並不會理會它,而B卻不知道,B會一直為這個連線維持著資源,造成資源的浪費,但如果是三次握手,如果第三次握手遲遲不來,伺服器便會認為這個SYN是無效的,釋放相關資源
1.3 響應
成功發起請求並完整走完了上述流程,瀏覽器能獲得伺服器發來的資料,那這些資料被放在哪裡,它是如何被瀏覽器處理的?
其實這個問題很簡單,在前面成功發起http請求後,服務端會有一個響應,這裡面規定了各種檔案格式
Access-Control-Allow-Headers: * Access-Control-Allow-methods: GET, POST, OPTIONS, PUT, DELETE Access-Control-Allow-Origin: * Connection: keep-alive Content-Length: 10 Content-type: application/plain Date: Wed, 08 May 2019 07:12:14 GMT
2.渲染
2.1 整體流程
資料請求回來以後,瀏覽器是如何把資料轉化成頁面的呢?這個過程就涉及到了DOM樹,CSSOM樹,render樹的生成和頁面的繪製,先來貼圖看看整體流程:
在構建DOM樹的時候,遇到 js 和 CSS元素,HTML解析器就換將控制權轉讓給JS解析器或者是CSS解析器。開始構建CSSOM,在構建CSSOM樹的時候,解析是從右向左進行的,DOM樹構建完之後和CSSOM合成一棵render tree
有了Render Tree,瀏覽器已經能知道網頁中有哪些節點、各個節點的CSS定義以及他們的從屬關係。下一步操作稱之為Layout,顧名思義就是計算出每個節點在螢幕中的位置
Layout後,瀏覽器已經知道了哪些節點要顯示(which nodes are visible)、每個節點的CSS屬性是什麼(their computed styles)、每個節點在螢幕中的位置是哪裡(geometry)。就進入了最後一步:Painting,按照算出來的規則,通過顯示卡,把內容畫到螢幕上,HTML預設是流式佈局的,CSS和js會打破這種佈局,改變DOM的外觀樣式以及大小和位置,當尺寸改變時會reflow,也就是重新繪製,比如table佈局整體尺寸改變,頁面就需要重繪,但當非尺寸改變時,會進行replaint
通過這個分析知道了DOM樹的生成過程中可能會被CSS和JS的載入執行阻塞,所以平時寫CSS時,儘量用id和class,千萬不要過渡層疊,儘量減少會造成reflow的操作,把JS程式碼放到頁面底部,且JavaScript 應儘量少影響 DOM 的構建
2.2 底層原始碼
這樣說一遍,還是在很表面的層次在說渲染這件事,那有沒有更深層次的理解呢?可以通過看瀏覽器原始碼來進行分析:
大致分為三個步驟:
1.HTMLDocumentParser負責解析html文字為tokens 2.HTMLTreeBuilder對這些tokens分類處理 3.HTMLConstructionSite呼叫不同的函式構建DOM樹
接下來使用這個html文件來說明DOM樹的構建過程:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <div> <h1 class="title">demo</h1> <input value="hello"> </div> </body> </html>
2.2.1生成tokens
首先是>>>HTMLDocumentParser負責解析html文字為tokens
void DocumentLoader::commitData(const char* bytes, size_t length) { ensureWriter(m_response.mimeType()); if (length) m_dataReceived = true; m_writer->addData(bytes, length);//內部呼叫HTMLDocumentParser }
構建出來的token是包含頁面元素的資訊表:
tagName: html|type: DOCTYPE|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: html|type: startTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: head|type: startTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: meta|type: startTag|attr:charset=utf-8 |text: " tagName:|type: Character |attr:|text: \n" tagName: head|type: EndTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: body|type: startTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: div|type: startTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: h1|type: startTag|attr:class=title|text: " tagName:|type: Character |attr:|text: demo" tagName: h1|type: EndTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: input |type: startTag|attr:value=hello|text: " tagName:|type: Character |attr:|text: \n" tagName: div|type: EndTag|attr:|text: " tagName:|type: Character |attr:|text:\n" tagName: body|type: EndTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName: html|type: EndTag|attr:|text: " tagName:|type: Character |attr:|text: \n" tagName:|type: EndOfFile |attr:|text: "
2.2.2tokens分類
接著是>>>>>HTMLTreeBuilder對這些tokens分類處理
void HTMLTreeBuilder::processToken(AtomicHTMLToken* token) { if (token->type() == HTMLToken::Character) { processCharacter(token); return; } switch (token->type()) { case HTMLToken::DOCTYPE: processDoctypeToken(token); break; case HTMLToken::StartTag: processStartTag(token); break; case HTMLToken::EndTag: processEndTag(token); break; //othercode } }
2.2.3 構建DOM樹
最後,最關鍵的就是HTMLConstructionSite呼叫不同的函式構建DOM樹,它根據不同的節點型別進行不同的處理
1.DOCTYPE的處理
// tagName不是html,那麼文件型別將會是怪異模式 if (name != "html" ) { setCompatibilityMode(Document::QuirksMode); return; }
// html4寫法,文件型別是有限怪異模式 if (!systemId.isEmpty() && publicId.startsWith("-//W3C//DTD HTML 4.01 Transitional//", TextCaseASCIIInsensitive))) { setCompatibilityMode(Document::LimitedQuirksMode); return; }
// h5的寫法,標準模式 setCompatibilityMode(Document::NoQuirksMode);
不同的模式會造成什麼影響?
// There are three possible compatibility modes: // Quirks - quirks mode emulates WinIE and NS4. CSS parsing is also relaxed in // this mode, e.g., unit types can be omitted from numbers. // Limited Quirks - This mode is identical to no-quirks mode except for its // treatment of line-height in the inline box model. // No Quirks - no quirks apply. Web pages will obey the specifications to the // letter. //怪異模式會模擬IE,同時CSS解析會比較寬鬆,例如數字單位可以省略, //有限怪異模式和標準模式的唯一區別在於在於對inline元素的行高處理不一樣 //標準模式將會讓頁面遵守文件規定
2.開標籤的處理
首先是<html>標籤,處理這個標籤的任務應該是例項化一個HTMLHtmlElement元素,然後把它的父元素指向document
HTMLConstructionSite::HTMLConstructionSite( Document& document) : m_document(&document), m_attachmentRoot(document)) { }
void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomicHTMLToken* token) { HTMLHtmlElement* element = HTMLHtmlElement::create(*m_document);//建立一個html結點 attachLater(m_attachmentRoot, element);//加到一個任務佇列裡面 m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element, token));//壓到一個棧裡面,這個棧存放了未遇到閉標籤的所有開標籤 executeQueuedTasks();//執行佇列裡面的任務 }
//建立一個task void HTMLConstructionSite::attachLater(ContainerNode* parent,Node* child, bool selfClosing) { HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert); task.parent = parent; task.child = child; task.selfClosing = selfClosing; // Add as a sibling of the parent if we have reached the maximum depth // allowed. if (m_openElements.stackDepth() > maximumHTMLParserDOMTreeDepth && task.parent->parentNode()) task.parent = task.parent->parentNode(); queueTask(task); }
//executeQueuedTasks根據task的型別執行不同的操作 void ContainerNode::parserAppendChild(Node* newChild) { if (!checkParserAcceptChild(*newChild)) return; AdoptAndAppendChild()(*this, *newChild, nullptr); } notifyNodeInserted(*newChild, ChildrenChangeSourceParser); }
//建立起html結點的父子兄弟關係 void ContainerNode::appendChildCommon(Node& child) { child.setParentOrShadowHostNode(this);//設定子元素的父結點,也就是會把html結點的父結點指向document if (m_lastChild) { //子元素的previousSibling指向老的lastChild,老的lastChild的nexSibling指向它 child.setPreviousSibling(m_lastChild); m_lastChild->setNextSibling(&child); } else { //如果沒有lastChild,會將這個子元素作為firstChild setFirstChild(&child); } //子元素設定為當前ContainerNode(即document)的lastChild setLastChild(&child); }
每當遇到一個開標籤時,就把它壓起來,下一次再遇到一個開標籤時,它的父元素就是上一個開標籤,藉助一個棧建立起了父子關係
3.閉標籤的處理
第一個閉標籤是head標籤,它會把開的head標籤pop出來,棧裡面就剩下html元素了,所以當再遇到body時,html元素就是body的父元素了
m_tree.openElements()->popUntilPopped(token->name());
至此,一個url到頁面的過程差不多就完成了,寫這篇參考了很多文章,連結貼在下面,大家可以去看看:
1.簡述TCP連線的建立與釋放(三次握手、四次揮手): https://www.cnblogs.com/zhuwq...
2.從輸入 URL 到頁面載入完成發生了什麼事: https://segmentfault.com/a/11...
3.十分鐘讀懂瀏覽器渲染流程: https://segmentfault.com/a/11...
4.從Chrome原始碼看瀏覽器如何構建DOM樹 : https://zhuanlan.zhihu.com/p/...