史上最詳細的經典面試題 從輸入URL到看到頁面發生了什麼?

虛竹子發表於2019-04-28

首先說明,本文很長,請泡一杯咖啡,抽出至少半個小時來慢慢回味。

URL的輸入到瀏覽器解析的一系列事件

很多大公司面試喜歡問這樣一道面試題,輸入URL到看見頁面發生了什麼?,今天我們來總結一下。 簡單來說,共有以下幾個過程

  • DNS解析
  • 發起TCP連線
  • 傳送HTTP請求
  • 伺服器處理請求並返回HTTP報文
  • 瀏覽器解析渲染頁面
  • 連線結束。

下面我們來看看具體的細節

DNS解析

DNS解析實際上就是尋找你所需要的資源的過程。假設你輸入www.baidu.com,而這個網址並不是百度的真實地址,網際網路中每一臺機器都有唯一標識的IP地址,這個才是關鍵,但是它不好記,亂七八糟一串數字誰記得住啊,所以就需要一個網址和IP地址的轉換,也就是DNS解析。下面看看具體的解析過程

具體解析

DNS解析其實是一個遞迴的過程

DNS解析
輸入www.google.com網址後,首先在本地的域名伺服器中查詢,沒找到去根域名伺服器查詢,沒有再去com頂級域名伺服器查詢,,如此的類推下去,直到找到IP地址,然後把它記錄在本地,供下次使用。大致過程就是.-> .com ->google.com. -> www.google.com.。 (你可能覺得我多寫 .,並木有,這個.對應的就是根域名伺服器,預設情況下所有的網址的最後一位都是.,既然是預設情況下,為了方便使用者,通常都會省略,瀏覽器在請求DNS的時候會自動加上)

DNS優化

既然已經懂得了解析的具體過程,我們可以看到上述一共經過了N個過程,每個過程有一定的消耗和時間的等待,因此我們得想辦法解決一下這個問題!

DNS快取

DNS存在著多級快取,從離瀏覽器的距離排序的話,有以下幾種: 瀏覽器快取,系統快取,路由器快取,IPS伺服器快取,根域名伺服器快取,頂級域名伺服器快取,主域名伺服器快取。

  • 在你的chrome瀏覽器中輸入:chrome://dns/,你可以看到chrome瀏覽器的DNS快取。

  • 系統快取主要存在/etc/hosts(Linux系統)中

DNS負載均衡

不知道你們有沒有注意這樣一件事,你訪問baidu.com的時候,每次響應的並非是同一個伺服器(IP地址不同),一般大公司都有成百上千臺伺服器來支撐訪問,假設只有一個伺服器,那它的效能和儲存量要多大才能支撐這樣大量的訪問呢?DNS可以返回一個合適的機器的IP給使用者,例如可以根據每臺機器的負載量,該機器離使用者地理位置的距離等等,這種過程就是DNS負載均衡

發起TCP連線

TCP提供一種可靠的傳輸,這個過程涉及到三次握手,四次揮手,下面我們詳細看看 TCP提供一種面向連線的,可靠的位元組流服務。 其首部的資料格式如下

TCP首部

欄位分析

  • 源埠:源埠和IP地址的作用是標識報文的返回地址。

  • 目的埠:埠指明接收方計算機上的應用程式介面。

TCP報頭中的源埠號和目的埠號同IP資料包中的源IP與目的IP唯一確定一條TCP連線。

  • 序號:是TCP可靠傳輸的關鍵部分。序號是該報文段傳送的資料組的第一個位元組的序號。在TCP傳送的流中,每一個位元組都有一個序號。比如一個報文段的序號為300,報文段資料部分共有100位元組,則下一個報文段的序號為400。所以序號確保了TCP傳輸的有序性。

  • 確認號:即ACK,指明下一個期待收到的位元組序號,表明該序號之前的所有資料已經正確無誤的收到。確認號只有當ACK標誌為1時才有效。比如建立連線時,SYN報文的ACK標誌位為0。

  • 首部長度/資料偏移:佔4位,它指出TCP報文的資料距離TCP報文段的起始處有多遠。由於首部可能含有可選項內容,因此TCP報頭的長度是不確定的,報頭不包含任何任選欄位則長度為20位元組,4位首部長度欄位所能表示的最大值為1111,轉化為10進製為15,15*32/8=60,故報頭最大長度為60位元組。首部長度也叫資料偏移,是因為首部長度實際上指示了資料區在報文段中的起始偏移值。

  • 保留:佔6位,保留今後使用,但目前應都位0。

  • 控制位:URG ACK PSH RST SYN FIN,共6個,每一個標誌位表示一個控制功能。

    • 緊急URG:當URG=1,表明緊急指標欄位有效。告訴系統此報文段中有緊急資料

    • 確認ACK:僅當ACK=1時,確認號欄位才有效。TCP規定,在連線建立後所有報文的傳輸都必須把ACK置1。

    • 推送PSH:當兩個應用程式進行互動式通訊時,有時在一端的應用程式希望在鍵入一個命令後立即就能收到對方的響應,這時候就將PSH=1。

    • 復位RST:當RST=1,表明TCP連線中出現嚴重差錯,必須釋放連線,然後再重新建立連線。

    • 同步SYN:在連線建立時用來同步序號。當SYN=1,ACK=0,表明是連線請求報文,若同意連線,則響應報文中應該使SYN=1,ACK=1。

    • 終止FIN:用來釋放連線。當FIN=1,表明此報文的傳送方的資料已經傳送完畢,並且要求釋放。

  • 視窗:滑動視窗大小,用來告知傳送端接受端的快取大小,以此控制傳送端傳送資料的速率,從而達到流量控制。視窗大小時一個16bit欄位,因而視窗大小最大為65535。

  • 校驗和:奇偶校驗,此校驗和是對整個的 TCP 報文段,包括 TCP 頭部和 TCP 資料,以 16 位字進行計算所得。由傳送端計算和儲存,並由接收端進行驗證。

  • 緊急指標:只有當 URG 標誌置 1 時緊急指標才有效。緊急指標是一個正的偏移量,和順序號欄位中的值相加表示緊急資料最後一個位元組的序號。 TCP 的緊急方式是傳送端向另一端傳送緊急資料的一種方式。

  • 選項和填充:最常見的可選欄位是最長報文大小,又稱為MSS(Maximum Segment Size),每個連線方通常都在通訊的第一個報文段(為建立連線而設定SYN標誌為1的那個段)中指明這個選項,它表示本端所能接受的最大報文段的長度。選項長度不一定是32位的整數倍,所以要加填充位,即在這個欄位中加入額外的零,以保證TCP頭是32的整數倍。

  • 資料部分: TCP 報文段中的資料部分是可選的。在一個連線建立和一個連線終止時,雙方交換的報文段僅有 TCP 首部。如果一方沒有資料要傳送,也使用沒有任何資料的首部來確認收到的資料。在處理超時的許多情況中,也會傳送不帶任何資料的報文段。

需要注意的是: (A)不要將確認序號Ack與標誌位中的ACK搞混了。 (B)確認方Ack=發起方Req+1,兩端配對。

三次握手

第一次握手:

客戶端傳送syn包(Seq=x)到伺服器,並進入SYN_SEND狀態,等待伺服器確認;

第二次握手:

伺服器收到syn包,必須確認客戶的SYN(ack=x+1),同時自己也傳送一個SYN包(Seq=y),即SYN+ACK包,此時伺服器進入SYN_RECV狀態;

第三次握手:

客戶端收到伺服器的SYN+ACK包,向伺服器傳送確認包ACK(ack=y+1),此包傳送完畢,客戶端和伺服器進入ESTABLISHED狀態,完成三次握手。

握手過程中傳送的包裡不包含資料,三次握手完畢後,客戶端與伺服器才正式開始傳送資料。理想狀態下,TCP連線一旦建立,在通訊雙方中的任何一方主動關閉連線之前,TCP 連線都將被一直保持下去。

三次握手

為什麼會採用三次握手,若採用二次握手可以嗎? 四次呢?

建立連線的過程是利用客戶伺服器模式,假設主機A為客戶端,主機B為伺服器端。

採用三次握手是為了防止失效的連線請求報文段突然又傳送到主機B,因而產生錯誤。失效的連線請求報文段是指:主機A發出的連線請求沒有收到主機B的確認,於是經過一段時間後,主機A又重新向主機B傳送連線請求,且建立成功,順序完成資料傳輸。考慮這樣一種特殊情況,主機A第一次傳送的連線請求並沒有丟失,而是因為網路節點導致延遲達到主機B,主機B以為是主機A又發起的新連線,於是主機B同意連線,並向主機A發回確認,但是此時主機A根本不會理會,主機B就一直在等待主機A傳送資料,導致主機B的資源浪費。

採用兩次握手不行,原因就是上面說的失效的連線請求的特殊情況。而在三次握手中, client和server都有一個發syn和收ack的過程, 雙方都是發後能收, 表明通訊則準備工作OK.

為什麼不是四次握手呢? 大家應該知道通訊中著名的藍軍紅軍約定, 這個例子說明, 通訊不可能100%可靠, 而上面的三次握手已經做好了通訊的準備工作, 再增加握手, 並不能顯著提高可靠性, 而且也沒有必要。

四次揮手

資料傳輸完畢後,雙方都可釋放連線。最開始的時候,客戶端和伺服器都是處於ESTABLISHED狀態,假設客戶端主動關閉,伺服器被動關閉。

四次揮手

第一次揮手:

客戶端傳送一個FIN,用來關閉客戶端到伺服器的資料傳送,也就是客戶端告訴伺服器:我已經不 會再給你發資料了(當然,在fin包之前傳送出去的資料,如果沒有收到對應的ack確認報文,客戶端依然會重發這些資料),但是,此時客戶端還可 以接受資料。 FIN=1,其序列號為seq=u(等於前面已經傳送過來的資料的最後一個位元組的序號加1),此時,客戶端進入FIN-WAIT-1(終止等待1)狀態。 TCP規定,FIN報文段即使不攜帶資料,也要消耗一個序號。

第二次揮手:

伺服器收到FIN包後,傳送一個ACK給對方並且帶上自己的序列號seq,確認序號為收到序號+1(與SYN相同,一個FIN佔用一個序號)。此時,服務端就進入了CLOSE-WAIT(關閉等待)狀態。TCP伺服器通知高層的應用程式,客戶端向伺服器的方向就釋放了,這時候處於半關閉狀態,即客戶端已經沒有資料要傳送了,但是伺服器若傳送資料,客戶端依然要接受。這個狀態還要持續一段時間,也就是整個CLOSE-WAIT狀態持續的時間。

此時,客戶端就進入FIN-WAIT-2(終止等待2)狀態,等待伺服器傳送連線釋放報文(在這之前還需要接受伺服器傳送的最後的資料)。

第三次揮手:

伺服器傳送一個FIN,用來關閉伺服器到客戶端的資料傳送,也就是告訴客戶端,我的資料也傳送完了,不會再給你發資料了。由於在半關閉狀態,伺服器很可能又傳送了一些資料,假定此時的序列號為seq=w,此時,伺服器就進入了LAST-ACK(最後確認)狀態,等待客戶端的確認。

第四次揮手:

主動關閉方收到FIN後,傳送一個ACK給被動關閉方,確認序號為收到序號+1,此時,客戶端就進入了TIME-WAIT(時間等待)狀態。注意此時TCP連線還沒有釋放,必須經過2∗MSL(最長報文段壽命)的時間後,當客戶端撤銷相應的TCB後,才進入CLOSED狀態。

伺服器只要收到了客戶端發出的確認,立即進入CLOSED狀態。同樣,撤銷TCB後,就結束了這次的TCP連線。可以看到,伺服器結束TCP連線的時間要比客戶端早一些。

至此,完成四次揮手。

為什麼客戶端最後還要等待2MSL?

MSL(Maximum Segment Lifetime),TCP允許不同的實現可以設定不同的MSL值。

第一,保證客戶端傳送的最後一個ACK報文能夠到達伺服器,因為這個ACK報文可能丟失,站在伺服器的角度看來,我已經傳送了FIN+ACK報文請求斷開了,客戶端還沒有給我回應,應該是我傳送的請求斷開報文它沒有收到,於是伺服器又會重新傳送一次,而客戶端就能在這個2MSL時間段內收到這個重傳的報文,接著給出迴應報文,並且會重啟2MSL計時器。
第二,防止類似與“三次握手”中提到了的“已經失效的連線請求報文段”出現在本連線中。客戶端傳送完最後一個確認報文後,在這個2MSL時間中,就可以使本連線持續的時間內所產生的所有報文段都從網路中消失。這樣新的連線中不會出現舊連線的請求報文。

為什麼建立連線是三次握手,關閉連線確是四次揮手呢?

建立連線的時候, 伺服器在LISTEN狀態下,收到建立連線請求的SYN報文後,把ACK和SYN放在一個報文裡傳送給客戶端。 而關閉連線時,伺服器收到對方的FIN報文時,僅僅表示對方不再傳送資料了但是還能接收資料,而自己也未必全部資料都傳送給對方了,所以己方可以立即關閉,也可以傳送一些資料給對方後,再傳送FIN報文給對方來表示同意現在關閉連線,因此,己方ACK和FIN一般都會分開傳送,從而導致多了一次。

傳送HTTP請求

首先科補一個小知識,HTTP的埠為80/8080,而HTTPS的埠為443

傳送HTTP請求的過程就是構建HTTP請求報文並通過TCP協議中傳送到伺服器指定埠 請求報文由請求行請求抱頭請求正文組成。

請求行

請求行的格式為Method Request-URL HTTP-Version CRLF eg: GET index.html HTTP/1.1 常用的方法有: GET,POST, PUT, DELETE, OPTIONS, HEAD

常見的請求方法區別

這裡主要展示POSTGET的區別

常見的區別

  • GET在瀏覽器回退時是無害的,而POST會再次提交請求。
  • GET產生的URL地址可以被Bookmark,而POST不可以。
  • GET請求會被瀏覽器主動cache,而POST不會,除非手動設定。
  • GET請求只能進行url編碼,而POST支援多種編碼方式。
  • GET請求引數會被完整保留在瀏覽器歷史記錄裡,而POST中的引數不會被保留。
  • GET請求在URL中傳送的引數是有長度限制的,而POST麼有。
  • 對引數的資料型別,GET只接受ASCII字元,而POST沒有限制。
  • GET比POST更不安全,因為引數直接暴露在URL上,所以不能用來傳遞敏感資訊。
  • GET引數通過URL傳遞,POST放在Request body中。

注意一點你也可以在GET裡面藏body,POST裡面帶引數

重點區別

GET會產生一個TCP資料包,而POST會產生兩個TCP資料包。

詳細的說就是:

  • 對於GET方式的請求,瀏覽器會把http header和data一併傳送出去,伺服器響應200(返回資料);

  • 而對於POST,瀏覽器先傳送header,伺服器響應100 continue,瀏覽器再傳送data,伺服器響應200 ok(返回資料)。

注意一點,並不是所有的瀏覽器都會傳送兩次資料包,Firefox就傳送一次

請求報頭

請求報頭允許客戶端向伺服器傳遞請求的附加資訊和客戶端自身的資訊。

請求報頭
從圖中可以看出,請求報頭中使用了Accept, Accept-Encoding, Accept-Language, Cache-Control, Connection, Cookie等欄位。Accept用於指定客戶端用於接受哪些型別的資訊,Accept-Encoding與Accept類似,它用於指定接受的編碼方式。Connection設定為Keep-alive用於告訴客戶端本次HTTP請求結束之後並不需要關閉TCP連線,這樣可以使下次HTTP請求使用相同的TCP通道,節省TCP連線建立的時間。

請求正文

當使用POST, PUT等方法時,通常需要客戶端向伺服器傳遞資料。這些資料就儲存在請求正文中。在請求包頭中有一些與請求正文相關的資訊,例如: 現在的Web應用通常採用Rest架構,請求的資料格式一般為json。這時就需要設定Content-Type: application/json

更重要的事情-HTTP快取

HTTP屬於客戶端快取,我們常認為瀏覽器有一個快取資料庫,用來儲存一些靜態檔案,下面我們分為以下幾個方面來簡單介紹HTTP快取

  • 快取的規則
  • 快取的方案
  • 快取的優點
  • 不同重新整理的請求執行過程

快取的規則

快取規則分為強制快取協商快取

強制快取

當快取資料庫中有客戶端需要的資料,客戶端直接將資料從其中拿出來使用(如果資料未失效),當快取伺服器沒有需要的資料時,客戶端才會向服務端請求。

強制快取

協商快取

又稱對比快取。客戶端會先從快取資料庫拿到一個快取的標識,然後向服務端驗證標識是否失效,如果沒有失效服務端會返回304,這樣客戶端可以直接去快取資料庫拿出資料,如果失效,服務端會返回新的資料

協商快取

強制快取的優先順序高於協商快取,若兩種快取皆存在,且強制快取命中目標,則協商快取不再驗證標識。

快取的方案

上面的內容讓我們大概瞭解了快取機制是怎樣執行的,但是,伺服器是如何判斷快取是否失效呢?我們知道瀏覽器和伺服器進行互動的時候會傳送一些請求資料和響應資料,我們稱之為HTTP報文。報文中包含首部header和主體部分body。與快取相關的規則資訊就包含在header中。boby中的內容是HTTP請求真正要傳輸的部分。舉個HTTP報文header部分的例子如下:

報頭
我們依舊分為強制快取和協商快取來分析。

強制快取

對於強制快取,伺服器響應的header中會用兩個欄位來表明——Expires和Cache-Control。

Expires

Exprires的值為服務端返回的資料到期時間。當再次請求時的請求時間小於返回的此時間,則直接使用快取資料。但由於服務端時間和客戶端時間可能有誤差,這也將導致快取命中的誤差,另一方面,Expires是HTTP1.0的產物,故現在大多數使用Cache-Control替代。

Cache-Control

Cache-Control有很多屬性,不同的屬性代表的意義也不同。

  • private:客戶端可以快取
  • public:客戶端和代理伺服器都可以快取
  • max-age=t:快取內容將在t秒後失效
  • no-cache:需要使用協商快取來驗證快取資料
  • no-store:所有內容都不會快取。
協商快取

協商快取需要進行對比判斷是否可以使用快取。瀏覽器第一次請求資料時,伺服器會將快取標識與資料一起響應給客戶端,客戶端將它們備份至快取中。再次請求時,客戶端會將快取中的標識傳送給伺服器,伺服器根據此標識判斷。若未失效,返回304狀態碼,瀏覽器拿到此狀態碼就可以直接使用快取資料了。

對於協商快取來說,快取標識我們需要著重理解一下,下面我們將著重介紹它的兩種快取方案。

Last-Modified

Last-Modified:伺服器在響應請求時,會告訴瀏覽器資源的最後修改時間。

  • if-Modified-Since:瀏覽器再次請求伺服器的時候,請求頭會包含此欄位,後面跟著在快取中獲得的最後修改時間。服務端收到此請求頭髮現有if-Modified-Since,則與被請求資源的最後修改時間進行對比,如果一致則返回304和響應報文頭,瀏覽器只需要從快取中獲取資訊即可。 從字面上看,就是說:從某個時間節點算起,是否檔案被修改了

    • 如果真的被修改:那麼開始傳輸響應一個整體,伺服器返回:200 OK
    • 如果沒有被修改:那麼只需傳輸響應header,伺服器返回:304 Not Modified
  • if-Unmodified-Since:從字面上看, 就是說: 從某個時間點算起, 是否檔案沒有被修改

    • 如果沒有被修改:則開始`繼續'傳送檔案: 伺服器返回: 200 OK
    • 如果檔案被修改:則不傳輸,伺服器返回: 412 Precondition failed (預處理錯誤)

這兩個的區別是一個是修改了才下載一個是沒修改才下載。

Last-Modified 說好卻也不是特別好,因為如果在伺服器上,一個資源被修改了,但其實際內容根本沒發生改變,會因為Last-Modified時間匹配不上而返回了整個實體給客戶端(即使客戶端快取裡有個一模一樣的資源)。為了解決這個問題,HTTP1.1推出了Etag。

Etag

Etag:伺服器響應請求時,通過此欄位告訴瀏覽器當前資源在伺服器生成的唯一標識(生成規則由伺服器決定)

  • If-None-Match:再次請求伺服器時,瀏覽器的請求報文頭部會包含此欄位,後面的值為在快取中獲取的標識。伺服器接收到次報文後發現If-None-Match則與被請求資源的唯一標識進行對比。

    • 不同,說明資源被改動過,則響應整個資源內容,返回狀態碼200。
    • 相同,說明資源無心修改,則響應header,瀏覽器直接從快取中獲取資料資訊。返回狀態碼304.

但是實際應用中由於Etag的計算是使用演算法來得出的,而演算法會佔用服務端計算的資源,所有服務端的資源都是寶貴的,所以就很少使用Etag了。

快取的優點

  • 減少了冗餘的資料傳遞,節省寬頻流量
  • 減少了伺服器的負擔,大大提高了網站效能
  • 加快了客戶端載入網頁的速度 這也正是HTTP快取屬於客戶端快取的原因。

不同重新整理的請求執行過程

瀏覽器位址列中寫入URL,回車
  • 瀏覽器發現快取中有這個檔案了,不用繼續請求了,直接去快取拿。(最快)
F5
  • F5就是告訴瀏覽器,別偷懶,好歹去伺服器看看這個檔案是否有過期了。於是瀏覽器就戰戰兢兢的傳送一個請求帶上If-Modify-since。
Ctrl+F5
  • 告訴瀏覽器,你先把你快取中的這個檔案給我刪了,然後再去伺服器請求個完整的資原始檔下來。於是客戶端就完成了強行更新的操作.

伺服器處理請求並返回HTTP報文

它會對TCP連線進行處理,對HTTP協議進行解析,並按照報文格式進一步封裝成HTTP Request物件,供上層使用。這一部分工作一般是由Web伺服器去進行,我使用過的Web伺服器有Tomcat, Nginx和Apache等等 HTTP報文也分成三份,狀態碼響應報頭響應報文

狀態碼

狀態碼是由3位陣列成,第一個數字定義了響應的類別,且有五種可能取值:

  • 1xx:指示資訊–表示請求已接收,繼續處理。

  • 2xx:成功–表示請求已被成功接收、理解、接受。

  • 3xx:重定向–要完成請求必須進行更進一步的操作。

  • 4xx:客戶端錯誤–請求有語法錯誤或請求無法實現。

  • 5xx:伺服器端錯誤–伺服器未能實現合法的請求。 平時遇到比較常見的狀態碼有:200, 204, 301, 302, 304, 400, 401, 403, 404, 422, 500

常見狀態碼區別

200 成功

請求成功,通常伺服器提供了需要的資源。

204 無內容

伺服器成功處理了請求,但沒有返回任何內容。

301 永久移動

請求的網頁已永久移動到新位置。 伺服器返回此響應(對 GET 或 HEAD 請求的響應)時,會自動將請求者轉到新位置。

302 臨時移動

伺服器目前從不同位置的網頁響應請求,但請求者應繼續使用原有位置來進行以後的請求。

304 未修改

自從上次請求後,請求的網頁未修改過。 伺服器返回此響應時,不會返回網頁內容。

400 錯誤請求

伺服器不理解請求的語法。

401 未授權

請求要求身份驗證。 對於需要登入的網頁,伺服器可能返回此響應。

403 禁止

伺服器拒絕請求。

404 未找到

伺服器找不到請求的網頁。

422 無法處理

請求格式正確,但是由於含有語義錯誤,無法響應

500 伺服器內部錯誤

伺服器遇到錯誤,無法完成請求。

響應報頭

常見的響應報頭欄位有: Server, Connection...。

響應報文

你從伺服器請求的HTML,CSS,JS檔案就放在這裡面

瀏覽器解析渲染頁面

瀏覽器解析頁面
這個圖就是Webkit解析渲染頁面的過程。

  • 解析HTML形成DOM樹
  • 解析CSS形成CSSOM 樹
  • 合併DOM樹和CSSOM樹形成渲染樹
  • 瀏覽器開始渲染並繪製頁面 這個過程涉及兩個比較重要的概念迴流重繪,DOM結點都是以盒模型形式存在,需要瀏覽器去計算位置和寬度等,這個過程就是迴流。等到頁面的寬高,大小,顏色等屬性確定下來後,瀏覽器開始繪製內容,這個過程叫做重繪。瀏覽器剛開啟頁面一定要經過這兩個過程的,但是這個過程非常非常非常消耗效能,所以我們應該儘量減少頁面的迴流和重繪

效能優化之迴流重繪

迴流

當Render Tree中部分或全部元素的尺寸、結構、或某些屬性發生改變時,瀏覽器重新渲染部分或全部文件的過程稱為迴流。

會導致迴流的操作:

  • 頁面首次渲染
  • 瀏覽器視窗大小發生改變
  • 元素尺寸或位置發生改變
  • 元素內容變化(文字數量或圖片大小等等)
  • 元素字型大小變化
  • 新增或者刪除可見的DOM元素
  • 啟用CSS偽類(例如::hover)
  • 查詢某些屬性或呼叫某些方法

一些常用且會導致迴流的屬性和方法:

  • clientWidth、clientHeight、clientTop、clientLeft
  • offsetWidth、offsetHeight、offsetTop、offsetLeft
  • scrollWidth、scrollHeight、scrollTop、scrollLeft
  • scrollIntoView()、scrollIntoViewIfNeeded()
  • getComputedStyle()
  • getBoundingClientRect()
  • scrollTo()

重繪

當頁面中元素樣式的改變並不影響它在文件流中的位置時(例如:color、background-color、visibility等),瀏覽器會將新樣式賦予給元素並重新繪製它,這個過程稱為重繪。

優化

CSS
  • 避免使用table佈局。
  • 儘可能在DOM樹的最末端改變class。
  • 避免設定多層內聯樣式。
  • 將動畫效果應用到position屬性為absolute或fixed的元素上。
  • 避免使用CSS表示式(例如:calc())。
JavaScript
  • 避免頻繁操作樣式,最好一次性重寫style屬性,或者將樣式列表定義為class並一次性更改class屬性。
  • 避免頻繁操作DOM,建立一個documentFragment,在它上面應用所有DOM操作,最後再把它新增到文件中。
  • 也可以先為元素設定display: none,操作結束後再把它顯示出來。因為在display屬性為none的元素上進行的DOM操作不會引發迴流和重繪。
  • 避免頻繁讀取會引發迴流/重繪的屬性,如果確實需要多次使用,就用一個變數快取起來。
  • 對具有複雜動畫的元素使用絕對定位,使它脫離文件流,否則會引起父元素及後續元素頻繁迴流。

JS的解析

JS的解析是由瀏覽器的JS引擎完成的。由於JavaScript是單程式執行,也就是說一個時間只能幹一件事,幹這件事情時其他事情都有排隊,但是有些人物比較耗時(例如IO操作),所以將任務分為同步任務非同步任務,所有的同步任務放在主執行緒上執行,形成執行棧,而非同步任務等待,當執行棧被清空時才去看看非同步任務有沒有東西要搞,有再提取到主執行緒執行,這樣往復迴圈(冤冤相報何時了,阿彌陀佛),就形成了Event Loop事件迴圈,下面來看看大人物

Event Loop

先看一段程式碼

setTimeout(function(){
    console.log('定時器開始啦')
});

new Promise(function(resolve){
    console.log('馬上執行for迴圈啦');
    for(var i = 0; i < 10000; i++){
        i == 99 && resolve();
    }
}).then(function(){
    console.log('執行then函式啦')
});

console.log('程式碼執行結束');
複製程式碼

結果我想大家都應該知道。主要來介紹JavaScript的解析,至於Promise等下一節再說

JavaScript

JavaScript是一門單執行緒語言,儘管H5中提出了Web-Worker,能夠模擬實現多執行緒,但本質上還是單執行緒,說它是多執行緒就是扯淡。

事件迴圈

既然是單執行緒,每個事件的執行就要有順序,比如你去銀行取錢,前面的人在進行,後面的就得等待,要是前面的人弄個一兩個小時,估計後面的人都瘋了,因此,瀏覽器的JS引擎處理JavaScript時分為同步任務非同步任務

事件執行
這張圖我們可以清楚看到

  • 同步和非同步任務分別進入不同的執行"場所",同步的進入主執行緒,非同步的進入Event Table並註冊函式。
  • 當指定的事情完成時,Event Table會將這個函式移入Event Queue。
  • 主執行緒內的任務執行完畢為空,會去Event Queue讀取對應的函式,進入主執行緒執行。
  • 上述過程會不斷重複,也就是常說的Event Loop(事件迴圈)。

js引擎存在monitoring process程式,會持續不斷的檢查主執行緒執行棧是否為空,一旦為空,就會去Event Queue那裡檢查是否有等待被呼叫的函式。 估計看完這些你對事件迴圈有一定的瞭解,但是事實上我們看對的沒這麼簡單,通常我們會看到Promise,setTimeout,process.nextTick(),這個時候你和我就懵逼。

除了同步任務和非同步任務,我們還分為巨集任務和微任務,常見的有以下幾種

  • macro-task(巨集任務):包括整體程式碼script,setTimeout,setInterval
  • micro-task(微任務):Promise,process.nextTick 不同任務會進入不同的任務佇列來執行。 JS引擎開始工作後,先在巨集任務中開始第一次迴圈(script裡面先執行,不過我喜歡把它拎出來,直接稱其進入執行棧),當主執行緒執行棧全部任務被清空後去微任務看看,如果有等待執行的任務,執行全部的微任務(其實將其回撥函式推入執行棧來執行),再去巨集任務找最先進入佇列的任務執行,執行這個任務後再去主執行緒執行任務(例如執行```console.log("hello world")這種任務),執行棧被清空後再去微任務,這樣往復迴圈(冤冤相報何時了)

Tip:微任務會全部執行,而巨集任務會一個一個來執行

下面來看一段程式碼

setTimeout(function() {
    console.log('setTimeout');
})

new Promise(function(resolve) {
    console.log('promise');
}).then(function() {
    console.log('then');
})

console.log('console');
複製程式碼

我們看看它的執行情況

  • 第一輪
    • 這段程式碼進入主執行緒
    • 遇到setTimeout,將其回撥函式註冊後分發到巨集任務
  • 第二輪
    • 遇到Promise,new Promise立即執行(這個不解釋,想了解的我後續文章會介紹),輸出promise,遇到then,將其分發到微任務
  • 第三輪
    • 遇到console.log("console"),直接輸出console
  • 第四輪
    • 主執行緒執行棧已經清空,先去微任務看看,執行then函式,輸出then
  • 第五輪
    • 微任務執行完了,看看巨集任務,有個setTimeout,輸出setTimeout,整體執行完畢。 具體的執行過程大致就是這樣,可能我有疏忽的地方,還望指正。
      事件迴圈
      再來看看一段複雜的程式碼
console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})
複製程式碼

我們來分析一下

  • 整體script進入主執行緒,遇到console.log('1'),直接輸出
  • 遇到setTimeout,將其回撥函式分發到巨集任務事件佇列,暫時標記為setTimeout1
  • 遇到process.nextTick(),將其回撥函式分發到微任務事件佇列,標記為process.nextTick1(這個地方有點出入,我一般認為```process.nextTick()推入主執行緒執行棧棧底,作為執行棧最後一個任務執行)
  • 遇到Promise,立即執行,輸出7,then函式分發的微任務事件佇列,標記為Promise1。
  • 遇到setTimeout,將其回撥函式分發到微任務事件佇列,標記為setTimeout2。
  • 現在已經輸出了1,7,巨集任務和微任務的事件佇列 情況如下
    佇列
    我們接著來看
  • 現在主執行緒執行棧被清空,去微任務看看,發現有兩個事件等待,由於佇列是先進先出,執行process.nextTick1,輸出6,接著執行Promise1,輸出8

至此,第一輪迴圈已經結束,輸出了1,7,6,8,接下來執行第二輪迴圈 ,先從巨集任務的setTimeout1開始

  • 遇到console.log('2'),執行輸出。
  • 遇到process.nextTick(),將其回撥函式分發到微任務,標記為process.nextTick2,又遇到 Promise,立即執行,輸出4,將then函式推入微任務事件佇列,標記為Promise2
  • 到此巨集任務的一個任務執行完畢,輸出了2,4,來看看事件佇列
    佇列2
  • 去微任務看看,我們先處理process.nextTick2,輸出3,接著再來執行Promise2,輸出5。 第二輪迴圈執行完畢。現在一共輸出了1,7,6,8,2,4,3,5
  • setTimeout2開始第三輪迴圈 ,先直接輸出9,遇到process.nextTick(),將其回撥函式分發到微任務事件佇列,標記為process.nextTick3,又遇到噁心的Promise,立即執行輸出11,將then函式分發到微任務,標記為Promise3。
  • 執行微任務,看看還有撒子事件
    佇列3
    居然還有事件,能咋辦,接著執行唄,輸出10,12。 至此,全部任務執行完畢,輸出順序為1,7,6,8,2,4,3,5,9,11,10,12.

注意,這段程式碼執行結果可能與node等環境不同而發生變化。

我想說的也說完了,不知道您懂了嘛

總結

這篇文章由一個簡單的問題扯出了很多前端工程師必學也是很重要的東西,但是由於我本人水平較低,很多地方都是一筆帶過,甚至有些地方還有錯誤,望各位同仁指正批評。

相關文章