窮追猛打,阿里二面問了我30分鐘從URL輸入到渲染...

前端私教年年發表於2022-03-16

當面試官問出這個題後,大部分人聽到都是內心竊喜:早就背下這篇八股文。

但是稍等,下面幾個問題你能答出來嗎:

  1. 瀏覽器對URL為什麼要解析?URL引數用的是什麼字元編碼?那encodeURI和encodeURIComponent有什麼區別?
  2. 瀏覽器快取的disk cache和memory cache是什麼?
  3. 預載入prefetch、preload有什麼差別?
  4. JS指令碼的async和defer有什麼區別?
  5. TCP握手為什麼要三次,揮手為什麼要四次?
  6. HTTPS的握手有了解過嗎?

同樣的問題,可以拿來招聘P5也可以是P7,只是深度不同。所以我重新整理了一遍整個流程,本文較長,建議先收藏。

概述

在進入正題之前,先簡單瞭解一下瀏覽器的架構作為前置知識。瀏覽器是多程式的工作的,“從URL輸入到渲染”會主要涉及到的,是瀏覽器程式、網路程式和渲染程式這三個:

  1. 瀏覽器程式負責處理、響應使用者互動,比如點選、滾動;
  2. 網路程式負責處理資料的請求,提供下載功能;
  3. 渲染程式負責將獲取到的HTML、CSS、JS處理成可以看見、可以互動的頁面;

“從URL輸入到頁面渲染”整個過程可以分成網路請求和瀏覽器渲染兩個部分,分別由網路程式和渲染程式去處理。

網路請求

網路請求部分進行了這幾項工作:

  1. URL的解析
  2. 檢查資源快取
  3. DNS解析
  4. 建立TCP連線
  5. TLS協商金鑰
  6. 傳送請求&接收響應
  7. 關閉TCP連線

接下來會一一展開。

URL解析

瀏覽器首先會判斷輸入的內容是一個URL還是搜尋關鍵字。

如果是URL,會把不完整的URL合成完整的URL。一個完整的URL應該是:協議+主機+埠+路徑[+引數][+錨點]。比如我們在位址列輸入www.baidu.com,瀏覽器最終會將其拼接成https://www.baidu.com/,預設使用443埠。

如果是搜尋關鍵字,會將其拼接到預設搜尋引擎的引數部分去搜尋。這個流程需要對輸入的不安全字元編碼進行轉義(安全字元指的是數字、英文和少數符號)。因為URL的引數是不能有中文的,也不能有一些特殊字元,比如= ? &,否則當我搜尋1+1=2,假如不加以轉義,url會是/search?q=1+1=2&source=chrome,和URL本身的分隔符=產生了歧義。

URL對非安全字元轉義時,使用的編碼叫百分號編碼,因為它使用百分號加上兩位的16進位制數表示。這兩位16進位制數來自UTF-8編碼,將每一箇中文轉換成3個位元組,比如我在google位址列輸入“中文”,url會變成/search?q=%E4%B8%AD%E6%96%87,一共6個位元組。

我們在寫程式碼時經常會用的encodeURIencodeURIComponent正是起這個作用的,它們的規則基本一樣,只是= ? & ; /這類URI組成符號,這些在encodeURI中不會被編碼,但在encodeURIComponent中統統會。因為encodeURI是編碼整個URL,而encodeURIComponent編碼的是引數部分,需要更加嚴格把關。

檢查快取

檢查快取一定是在發起真正的請求之前進行的,只有這樣快取的機制才會生效。如果發現有對應的快取資源,則去檢查快取的有效期。

  1. 在有效期內的快取資源直接使用,稱之為強快取,從chrome網路皮膚看到這類請求直接返回200,size是memory cache或者disk cachememory cache是指從資源從記憶體中被取出,disk cache是指從磁碟中被取出;從記憶體中讀取比從磁碟中快很多,但資源能不能分配到記憶體要取決於當下的系統狀態。通常來說,重新整理頁面會使用記憶體快取,關閉後重新開啟會使用磁碟快取。
  2. 超過有效期的,則攜帶快取的資源標識向服務端發起請求,校驗是否能繼續使用,如果服務端告訴我們,可以繼續使用本地儲存,則返回304,並且不攜帶資料;如果服務端告訴我們需要用更新的資源,則返回200,並且攜帶更新後的資源和資源標識快取到本地,方便下一次使用。

DNS解析

如果沒有成功使用本地快取,則需要發起網路請求了。首先要做的是DNS解析。

會依次搜尋:

  1. 瀏覽器的DNS快取;
  2. 作業系統的DNS快取;
  3. 路由器的DNS快取;
  4. 向服務商的DNS伺服器查詢;
  5. 向全球13臺根域名伺服器查詢;

為了節省時間,可以在HTML頭部去做DNS的預解析:

<link rel="dns-prefetch" href="http://www.baidu.com" />
為了保證響應的及時,DNS解析使用的是UDP協議

建立TCP連線

我們傳送的請求是基於TCP協議的,所以要先進行連線建立。建立連線的通訊是打電話,雙方都線上;無連線的通訊是發簡訊,傳送方不管接收方,自己說自己的。

這個確認接收方線上的過程就是通過TCP的三次握手完成的。

  1. 客戶端傳送建立連線請求;
  2. 服務端傳送建立連線確認,此時服務端為該TCP連線分配資源;
  3. 客戶端傳送建立連線確認的確認,此時客戶端為該TCP連線分配資源;

為什麼要三次握手才算建立連線完成?

可以先假設建立連線只要兩次會發生什麼。把上面的狀態圖稍加修改,看起來一切正常。


但假如這時服務端收到一個失效的建立連線請求,我們會發現服務端的資源被浪費了——此時客戶端並沒有想給它傳送資料,但它卻準備好了記憶體等資源一直等待著。

所以說,三次握手是為了保證客戶端存活,防止服務端在收到失效的超時請求造成資源浪費。

協商加密金鑰——TLS握手

為了保障通訊的安全,我們使用的是HTTPS協議,其中的S指的就是TLS。TLS使用的是一種非對稱+對稱的方式進行加密。

對稱加密就是兩邊擁有相同的祕鑰,兩邊都知道如何將密文加密解密。這種加密方式速度很快,但是問題在於如何讓雙方知道祕鑰。因為
傳輸資料都是走的網路,如果將祕鑰通過網路的方式傳遞的話,祕鑰被截獲,就失去了加密的意義。

非對稱加密,每個人都有一把公鑰和私鑰,公鑰所有人都可以知道,私鑰只有自己知道,將資料用公鑰加密,解密必須使用私鑰。這種加密方式就可以完美解決對稱加密存在的問題,缺點是速度很慢。

我們採取非對稱加密的方式協商出一個對稱金鑰,這個金鑰只有傳送方和接收方知道的金鑰,流程如下:

  1. 客戶端傳送一個隨機值以及需要的協議和加密方式;
  2. 服務端收到客戶端的隨機值,傳送自己的數字證照,附加上自己產生一個隨機值,並根據客戶端需求的協議和加密方式使用對應的方式;
  3. 客戶端收到服務端的證照並驗證是否有效,驗證通過會再生成一個隨機值,通過服務端證照的公鑰去加密這個隨機值併傳送給服務端;
  4. 服務端收到加密過的隨機值並使用私鑰解密獲得第三個隨機值,這時候兩端都擁有了三個隨機值,可以通過這三個隨機值按照之前約定的加密方式生成金鑰,接下來的通訊就可以通過該對稱金鑰來加密解密;

通過以上步驟可知,在TLS握手階段,兩端使用非對稱加密的方式來通訊,但是因為非對稱加密損耗的效能比對稱加密大,所以在正式傳輸資料時,兩端使用對稱加密的方式。

傳送請求&接收響應

HTTP的預設埠是80,HTTPS的預設埠是443。

請求的基本組成是請求行+請求頭+請求體

POST /hello HTTP/1.1
User-Agent: curl/7.16.3 libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
Host: www.example.com
Accept-Language: en, mi

name=niannian

響應的基本組成是響應行+響應頭+響應體

HTTP/1.1 200 OK
Content-Type:application/json
Server:apache

{password:'123'}

關閉TCP連線

等資料傳輸完畢,就要關閉TCP連線了。關閉連線的主動方可以是客戶端,也可以是服務端,這裡以客戶端為例,整個過程有四次握手:

  1. 客戶端請求釋放連線,僅表示客戶端不再傳送資料了;
  2. 服務端確認連線釋放,但這時可能還有資料需要處理和傳送;
  3. 服務端請求釋放連線,服務端這時不再需要傳送資料時;
  4. 客戶端確認連線釋放;

為什麼要有四次揮手

TCP 是可以雙向傳輸資料的,每個方向都需要一個請求和一個確認。因為在第二次握手結束後,服務端還有資料傳輸,所以沒有辦法把第二次確認和第三次合併。

主動方為什麼會等待2MSL

客戶端在傳送完第四次的確認報文段後會等待2MSL才正真關閉連線,MSL是指資料包在網路中最大的生存時間。目的是確保服務端收到了這個確認報文段,

假設服務端沒有收到第四次握手的報文,試想一下會發生什麼?在客戶端傳送第四次握手的資料包後,服務端首先會等待,在1個MSL後,它發現超過了網路中資料包的最大生存時間,但是自己還沒有收到資料包,於是服務端認為這個資料包已經丟失了,它決定把第三次握手的資料包重新給客戶端傳送一次,這個資料包最多花費一個MSL會到達客戶端。

一來一去,一共是2MSL,所以客戶端在傳送完第四次握手資料包後,等待2MSL是一種兜底機制,如果在2MSL內沒有收到其他報文段,客戶端則認為服務端已經成功接受到第四次揮手,連線正式關閉。

瀏覽器渲染

上面講完了網路請求部分,現在瀏覽器拿到了資料,剩下需要渲染程式工作了。瀏覽器渲染主要完成了一下幾個工作:

  1. 構建DOM樹;
  2. 樣式計算;
  3. 佈局定位;
  4. 圖層分層;
  5. 圖層繪製;
  6. 顯示;

構建DOM樹

HTML檔案的結構沒法被瀏覽器理解,所以先要把HTML中的標籤變成一個可以給JS使用的結構。

在控制檯可以嘗試列印document,這就是解析出來的DOM樹。

樣式計算

CSS檔案一樣沒法被瀏覽器直接理解,所以首先把CSS解析成樣式表。
這三類樣式都會被解析:

  • 通過 link 引用的外部 CSS 檔案
  • <style>標籤內的樣式
  • 元素的 style 屬性內嵌的 CSS

在控制檯列印document.styleSheets,這就是解析出的樣式表。

利用這份樣式表,我們可以計算出DOM樹中每個節點的樣式。之所以叫計算,是因為每個元素要繼承其父元素的屬性。

<style>
    span {
        color: red
    }
    div {
        font-size: 30px
    }
</style>
<div>
    <span>年年</span>
</div>

比如上面的年年,不僅要接受span設定的樣式,還要繼承div設定的。

DOM樹中的節點有了樣式,現在被叫做渲染樹。

為什麼要把CSS放在頭部,js放在body的尾部

在解析HTML的過程中,遇到需要載入的資源特點如下:

  • CSS資源非同步下載,下載和解析都不會阻塞構建dom樹<link href='./style.css' rel='stylesheet'/>
  • JS資源同步下載,下載和執行都會阻塞構建dom樹<script src='./index.js'/>

因為這樣的特性,往往推薦將CSS樣式表放在head頭部,js檔案放在body尾部,使得渲染能儘早開始。

CSS會阻塞HTML解析嗎

上文提到頁面渲染是渲染程式的任務,這個渲染程式中又細分為GUI渲染執行緒和JS執行緒。

解析HTML生成DOM樹,解析CSS生成樣式表以及後面去生成佈局樹、圖層樹都是由GUI渲染執行緒去完成的,這個執行緒可以一邊解析HTML,一邊解析CSS,這兩個是不會衝突的,所以也提倡把CSS在頭部引入。

但是在JS執行緒執行時,GUI渲染執行緒沒有辦法去解析HTML,這是因為JS可以操作DOM,如果兩者同時進行可能引起衝突。如果這時JS去修改了樣式,那此時CSS的解析和JS的執行也沒法同時進行了,會先等CSS解析完成,再去執行JS,最後再去解析HTML。

從這個角度來看,CSS有可能阻塞HTML的解析。

預載入掃描器是什麼

上面提到的外鏈資源,不論是同步載入JS還是非同步載入CSS、圖片等,都要到HTML解析到這個標籤才能開始,這似乎不是一種很好的方式。實際上,從2008年開始,瀏覽器開始逐步實現了預載入掃描器:在拿到HTML文件的時候,先掃描整個文件,把CSS、JS、圖片和web字型等提前下載。

js指令碼引入時async和defer有什麼差別

預載入掃描器解決了JS同步載入阻塞HTML解析的問題,但是我們還沒有解決JS執行阻塞HTML解析的問題。所有有了async和defer屬性。

  • 沒有 defer 或 async,瀏覽器會立即載入並執行指定的指令碼
  • async 屬性表示非同步執行引入的 JavaScript,經載入好,就會開始執行
  • defer 屬性表示延遲到DOM解析完成,再執行引入的 JS

在載入多個JS指令碼的時候,async是無順序的執行,而defer是有順序的執行

preload、prefetch有什麼區別

之前提到過預載入掃描器,它能提前載入頁面需要的資源,但這一功能只對特定寫法的外鏈生效,並且我們沒有辦法按照自己的想法給重要的資源一個更高的優先順序,所以有了preload和prefetch。

  1. preload:以高優先順序為當前頁面載入資源;
  2. prefetch:以低優先順序為後面的頁面載入未來需要的資源,只會在空閒時才去載入;

無論是preload還是prefetch,都只會載入,不會執行,如果預載入的資源被伺服器設定了可以快取cache-control那麼會進入磁碟,反之只會被儲存在記憶體中。

具體使用如下:

<head>
    <!-- 檔案載入 -->
    <link rel="preload" href="main.js" as="script">
    <link rel="prefetch" href="news.js" as="script">
</head>

<body>
    <h1>hello world!</h1>
    <!-- 檔案檔案執行 -->
    <script src="main.js" defer></script>
</body>

為了保證資源正確被預載入,使用時需要注意:

  1. preload的資源應該在當前頁面立即使用,如果不加上script標籤執行預載入的資源,控制檯中會顯示警告,提示預載入的資源在當前頁面沒有被引用;
  2. prefetch的目的是取未來會使用的資源,所以當使用者從A頁面跳轉到B頁面時,進行中的preload的資源會被中斷,而prefetch不會;
  3. 使用preload時,應配合as屬性,表示該資源的優先順序,使用 as="style" 屬性將獲得最高的優先順序,as ="script"將獲得低優先順序或中優先順序,其他可以取的值有font/image/audio/video
  4. preload字型時要加上crossorigin屬性,即使沒有跨域,否則會重複載入:

    <link rel="preload href="font.woff" as="font" crossorigin>

此外,這兩種預載入資源不僅可以通過HTML標籤設定,還可以通過js設定

var res = document.createElement("link"); 
res.rel = "preload"; 
res.as = "style"; 
res.href = "css/mystyles.css"; 
document.head.appendChild(res); 

以及 HTTP 響應頭:

Link: </uploads/images/pic.png>; rel=prefetch

佈局定位

上面詳細的講述了HTML和CSS載入、解析過程,現在我們的渲染樹中的節點有了樣式,但是不知道要畫在哪個位置。所以還需要另外一顆佈局樹確定元素的幾何定位。

佈局樹只取渲染樹中的可見元素,意味著head標籤,display:none的元素不會被新增。

圖層分層

現在我們有了佈局樹,但依舊不能直接開始繪製,在此之前需要分層,生成一棵對應的圖層樹。瀏覽器的頁面實際上被分成了很多圖層,這些圖層疊加後合成了最終的頁面。

因為頁面中有很多複雜的效果,如一些複雜的 3D 變換、頁面滾動,或者使用 z-index 做 z 軸排序等,我們希望能更加方便地實現這些效果。

並不是佈局樹的每個節點都能生成一個圖層,如果一個節點沒有自己的層,那麼這個節點就從屬於父節點的圖層

通常滿足下面兩點中任意一點的元素就可以被提升為單獨的一個圖層。

1、擁有層疊上下文屬性的元素會被提升為單獨的一層:明確定位屬性position的元素、定義透明屬性opacity的元素、使用 CSS 濾鏡filter的元素等,都擁有層疊上下文屬性。

2、需要剪裁(clip)的地方也會被建立為圖層overflow

在chrome的開發者工具:更多選項-更多工具-Layers可以看到圖層的分層情況。

圖層繪製

在完成圖層樹的構建之後,接下來終於到對每個圖層進行繪製。
首先會把圖層拆解成一個一個的繪製指令,排布成一個繪製列表,在上文提到的開發者工具的Layers皮膚中,點選detail中的profiler可以看到繪製列表。

至此,渲染程式中的主執行緒——GUI渲染執行緒已經完成了它所有任務,接下來交給渲染程式中的合成現成。

合成執行緒接下來會把視口拆分成圖快,把圖塊轉換成點陣圖。

至此,渲染程式的工作全部完成,接下來會把生成的點陣圖還給瀏覽器程式,最後在頁面上顯示。

效能優化,還可以做些什麼

本篇不專講效能優化,只是在這個命題下補充一些常見手段。

預解析、預渲染

除了上文提到的使用preload、prefetch去提前載入,還可以使用DNS PrefetchPrerenderPreconnect

  1. DNS Prefetch:DNS 預解析;

     <link rel="dns-prefetch" href="//fonts.googleapis.com">
  2. preconnect:在一個 HTTP 請求正式發給伺服器前預先執行一些操作,這包括 DNS 解析,TLS 協商,TCP 握手;

    <link href="https://cdn.domain.com" rel="preconnect" crossorigin>

  3. Prerender:獲取下個頁面所有的資源,在空閒時渲染整個頁面;

    <link rel="prerender" href="https://www.keycdn.com">

    減少迴流和重繪

迴流是指瀏覽器需要重新計算樣式、佈局定位、分層和繪製,迴流又被叫重排;

觸發迴流的操作:

  • 新增或刪除可見的DOM元素
  • 元素的位置發生變化
  • 元素的尺寸發生變化
  • 瀏覽器的視窗尺寸變化

重繪是隻重新畫素繪製,當元素樣式的改變不影響佈局時觸發。

迴流=計算樣式+佈局+分層+繪製;重繪=繪製。故迴流對效能的影響更大

所以應該儘量避免迴流和重繪。比如利用GPU加速來實現樣式修改,transform/opacity/filters這些屬性的修改都不是在主執行緒完成的,不會重繪,更不會迴流。

結語

把“URL輸入到渲染”整個過程講完,回到開頭幾個比較刁鑽的問題,在文中都不難找到答案:

  1. 瀏覽器將輸入內容解析後,拼接成完整的URL,其中的引數使用的是UTF-8編碼,也就是我們開發時會常用的encodeURI和encodeURIComponent兩個函式,其中encodeURI是對完整URL編碼,encodeURIComponent是對URL引數部分編碼,要求會更嚴格;
  2. 瀏覽器快取的disk cache和memory cache分別是從磁碟讀取和從記憶體中讀取,通常重新整理頁面會直接從記憶體讀,而關閉tab後重新開啟是從磁碟讀;
  3. 預載入prefetch是在空閒時間,以低優先順序載入後續頁面用到的資源;而preload是以高優先順序提前載入當前頁面需要的資源;
  4. 指令碼的async是指非同步載入,完成載入立刻執行,defer是非同步載入,完成HTML解析後再執行;
  5. TCP握手需要三次的三次是為了保證客戶端的存活,防止服務端資源的浪費,揮手要四次是因為TCP是雙工通訊,每一個方向的連線釋放、應答各需要一次;
  6. HTTPS的握手是為了協商出一個對稱金鑰,雙方一共傳送三個隨機數,利用這三個隨機數計算出只有雙方知道的金鑰,正式通訊的內容都是用這個金鑰進行加密的;

如果這篇文章對你有幫助,幫我點個讚唄~這對我很重要

相關文章