這是一道非常經典的題目,相信你被面試或者面試別人有非常大的概率接觸過,也可能只是其中某一部分進行提問。這道題涵蓋的知識點非常多,考察得比較全面,網上一搜也有成百上千篇文章,不同的人有不同的見解,然而大部分都是千篇一律。如果你沒有深入透徹系統性地研究過,光靠死記硬背,面試官稍稍針對某一點提問,或者換成另外一種方式提問,就有可能露出破綻。仔細想想,學習積累到了一定階段,也該憑技術儲備對知識體系進行一遍全面的梳理總結。
開放性的題目,沒有固定的答案,涉及計算機圖形學、操作系統、編譯原理、計算機網絡、通信原理、分佈式系統、瀏覽器原理等多個不同的學科、領域。但無論從哪個領域入手,軟件角度或硬件角度,鋪開來講都可以是長篇大論。如果你專精某個學科領域多年,那你在這一方面肯定比我有更深厚的經驗、更獨特的見解,歡迎指點。
從我的角度來看,在題意不夠明確、缺少情景和限定條件的情況下,沒法直接作答。在計算機越來越複雜的今天,任何一個條件的變化與組合,都有可能產生千千萬萬種可能,打破常規。對題目本身而言,就會包括但不僅限於以下幾種條件:
如果請求的是靜態資源,那麼流量有可能到達 CDN 服務器;如果請求的是動態資源,那麼情況更加複雜,流量可能依次經過代理/網關、Web 服務器、應用服務器、數據庫。圖 1 爲阿里雲 SLB(Server Load Balancer,負載均衡)高可用部署示意圖,它不同於傳統的主備切換模式過於依賴單機處理能力,來自公網的請求通過上層交換機的 ECMP(Equal-cost multi-path routing,等價多路徑路由)將流量轉發給 LVS 集羣(四層 SLB),對於 TCP/UDP 請求,LVS 集羣直接轉發給後端 ECS 集羣,對於 HTTP 請求,則轉發給 Tengine 集羣(七層 SLB),由 Tengine 集羣再轉發給後端 ECS 集羣,集羣之間通過實現會話同步、健康檢查等機制來保證高可用。
圖 1:阿里雲負載均衡服務
隨着業務規模的不斷擴大,爲了承載千萬級甚至億級流量及海量存儲,對系統的多機房容災、自由擴容的要求越來越高,系統還可能演進爲異地多活架構(圖 2)。與傳統的災備設計不同的是,多個數據中心同時對外提供服務,同時保證異地單元間數據庫數據的一致性和完整性(CAP 理論)。整個系統架構分爲流量層、應用層、數據層,利用 DNS 技術實現 GSLB(Global Server Load Balance,全局負載均衡),實現用戶就近訪問。如果某個地域的系統發生整體故障,則把所有流量請求切換到另一個地域,來滿足異地容災,這也類似於餓了麼現階段整體架構方案。
圖 2:阿里雲異地多活解決方案
但是,不同於動態資源,考慮部署成本和流量成本,靜態資源一般是通過 CDN,利用中間服務器作緩存,如果沒有命中緩存,再回源到 OSS(Object Storage Service,阿里雲對象存儲服務)或者私有服務器。
因此,現實世界的情況是千變萬化的,你也很難想象一個 GET 請求甚至觸發銀行的轉賬操作,一切取決於人爲實現。重新回到本文的主題,我們排除一切特殊條件,把問題簡化一下,如果僅僅考慮:
這個過程如下:
首先,瀏覽器向本地 DNS 服務器發起請求,由於本地 DNS 服務器沒有緩存不能直接將域名轉換爲 IP 地址,需要採用遞歸或者迭代查詢的方式(圖 3)依次向根域名服務器、頂級域名服務器、權威域名服務器發起查詢請求,直至找到一個或一組 IP 地址,返回給瀏覽器。一般本地 DNS 地址由 ISP(Internet Service Provider,互聯網服務提供商)通過 DHCP 協議動態分配,我們仍可以手動把它修改爲公共 DNS,比如 Google 提供的 8.8.8.8,國內的 114.114.114.114,它們分佈在不同的地理位置上,藉助 Anycast 技術,將請求路由到離用戶最近的 DNS 服務器上。爲了讓 DNS 解析更加精確,客戶端還需在請求包裏帶上自己的源 IP 地址,否則類似 GSLB 的 DNS 服務器不能夠精準地匹配判斷離用戶最近的目標 IP 地址。
圖 3:DNS 查詢過程
由於 HTTP 是基於更易於閱讀的文本格式,除了在瀏覽器直接發起 HTTP 之外,你也可以用命令行 telnet
來與服務器指定端口建立 TCP 連接,按照協議規定的格式,來發送請求頭和請求實體。另外,如果想查看詳細具體的封包內容,可以使用網絡封包分析工具 Wireshark 或命令行 tcpdump
,來捕獲某一塊網卡上的數據包。在上一步我們通過 DNS 解析拿到服務器 IP 地址後,瀏覽器再通過系統調用 Socket 接口與服務器 443 端口進行通信,整個過程可以分解爲建立連接、發送 HTTP 請求、返回 HTTP 響應、維持連接、釋放連接五個部分(圖 4)。圖中所示箭頭有可能代表一個 TCP 報文段,也有可能代表一個完整的應用層報文,在實際傳輸過程中,會被組合爲一個或分片爲多個 TCP 報文段。
圖 4:HTTP 請求過程
建立起安全的加密信道後,瀏覽器開始發送 HTTP 請求,一個請求報文由請求行、請求頭、空行、實體(Get 請求沒有)組成。請求頭由通用首部、請求首部、實體首部、擴展首部組成。其中,通用首部表示無論是請求報文還是響應報文都可以使用,比如 Date;請求首部表示只有在請求報文中才有意義,分爲 Accept 首部、條件請求首部、安全請求首部和代理請求首部這四類;實體首部作用於實體內容,分爲內容首部和緩存首部這兩類;擴展首部表示用戶自定義的首部,通過 X-
前綴來添加。另外需要注意的是,HTTP 請求頭是不區分大小寫的,它基於 ASCII 進行編碼,而實體可以基於其它編碼方式,由 Content-Type
決定。
服務器接受並處理完請求,返回 HTTP 響應,一個響應報文格式基本等同於請求報文,由響應行、響應頭、空行、實體組成。區別於請求頭,響應頭有自己的響應首部集,比如 Vary、Set-Cookie,其它的通用首部、實體首部、擴展首部則共用。此外,瀏覽器和服務器必須保證 HTTP 的傳輸順序,各自維護的隊列中請求/響應順序必須一一對應,否則會出現亂序而出錯的情況。
完成一次 HTTP 請求後,服務器並不是馬上斷開與客戶端的連接。在 HTTP/1.1 中,Connection: keep-alive
是默認啓用的,表示持久連接,以便處理不久後到來的新請求,無需重新建立連接而增加慢啓動開銷,提高網絡的吞吐能力。在反向代理軟件 Nginx 中,持久連接超時時間默認值爲 75 秒,如果 75 秒內沒有新到達的請求,則斷開與客戶端的連接。同時,瀏覽器每隔 45 秒會向服務器發送 TCP keep-alive 探測包,來判斷 TCP 連接狀況,如果沒有收到 ACK 應答,則主動斷開與服務器的連接。注意,HTTP keep-alive 和 TCP keep-alive 雖然都是一種保活機制,但是它們完全不相同,一個作用於應用層,一個作用於傳輸層。
現代瀏覽器是一個及其龐大的大型軟件,在某種程度上甚至不亞於一個操作系統,它由多媒體支持、圖形顯示、GPU 渲染、進程管理、內存管理、沙箱機制、存儲系統、網絡管理等大大小小數百個組件組成。雖然開發者在開發 Web 應用時,無需關心底層實現細節,只需將頁面代碼交付於瀏覽器計算,就可以展示出豐富的內容。但頁面性能不僅僅關乎瀏覽器的實現方式,更取決於開發者的水平,對工具的熟悉程度,代碼優化是無止盡的。顯然,瞭解瀏覽器的基本原理,瞭解 W3C 技術標準,瞭解網絡協議,對設計、開發一個高性能 Web 應用幫助非常大。
當我們在使用 Chrome 瀏覽器時,其背後的引擎是 Google 開源的 Chromium 項目,而 Chromium 的內核則是渲染引擎 Blink(基於 Webkit)和 JavaScript 引擎 V8。在闡述瀏覽器解析 HTML 文件之前,先簡單介紹一下 Chromium 的多進程多線程架構(圖 5),它包括多個進程:
而每個進程包括若干個線程:
圖 5:Chromium 多進程多線程架構
Chromium 支持多種不同的方式管理 Renderer 進程,不僅僅是每一個開啓的 Tab 頁面,iframe 頁面也包括在內,每個 Renderer 進程是一個獨立的沙箱,相互之間隔離不受影響。
當 Renderer 進程需要訪問網絡請求模塊(XHR、Fetch),以及訪問存儲系統(同步 Local Storage、同步 Cookie、異步 Cookie Store)時,則調用 RenderProcess 全局對象通過 IO 線程與 Browser 進程中的 RenderProcessHost 對象建立 IPC 信道,底層通過 socketpair 來實現。正由於這種機制,Chromium 可以更好地統一管理資源、調度資源,有效地減少網絡、性能開銷。
頁面的解析工作是在 Renderer 進程中進行的,Renderer 進程通過在主線程中持有的 Blink 實例邊接收邊解析 HTML 內容(圖 6),每次從網絡緩衝區中讀取 8KB 以內的數據。瀏覽器自上而下逐行解析 HTML 內容,經過詞法分析、語法分析,構建 DOM 樹。當遇到外部 CSS 鏈接時,主線程調用網絡請求模塊異步獲取資源,不阻塞而繼續構建 DOM 樹。當 CSS 下載完畢後,主線程在合適的時機解析 CSS 內容,經過詞法分析、語法分析,構建 CSSOM 樹。瀏覽器結合 DOM 樹和 CSSOM 樹構建 Render 樹,並計算佈局屬性,每個 Node 的幾何屬性和在座標系中的位置,最後進行繪製展示在屏幕上。當遇到外部 JS 鏈接時,主線程調用網絡請求模塊異步獲取資源,由於 JS 可能會修改 DOM 樹和 CSSOM 樹而造成迴流和重繪,此時 DOM 樹的構建是處於阻塞狀態的。但主線程並不會掛起,瀏覽器會使用一個輕量級的掃描器去發現後續需要下載的外部資源,提前發起網絡請求,而腳本內部的資源不會識別,比如 document.write
。當 JS 下載完畢後,瀏覽器調用 V8 引擎在 Script Streamer 線程中解析、編譯 JS 內容,並在主線程中執行(圖 7)。
圖 6:Webkit 主流程
圖 7:V8 解釋流程,Chrome 66 以前對比 Chrome 66
當 DOM 樹構建完畢後,還需經過好幾次轉換,它們有多種中間表示(圖 8)。首先計算佈局、繪圖樣式,轉換爲 RenderObject 樹(也叫 Render 樹)。再轉換爲 RenderLayer 樹,當 RenderObject 擁有同一個座標系(比如 canvas、absolute)時,它們會合併爲一個 RenderLayer,這一步由 CPU 負責合成。接着轉換爲 GraphicsLayer 樹,當 RenderLayer 滿足合成層條件(比如 transform,熟知的硬件加速)時,會有自己的 GraphicsLayer,否則與父節點合併,這一步同樣由 CPU 負責合成。最後,每個 GraphicsLayer 都有一個 GraphicsContext 對象,負責將層繪製成位圖作爲紋理上傳給 GPU,由 GPU 負責合成多個紋理,最終顯示在屏幕上。
圖 8:從 DOM 樹到 GraphicsLayer 樹的轉換
另外,爲了提升渲染性能效率,瀏覽器會有專用的 Compositor 線程來負責層合成(圖 9),同時負責處理部分交互事件(比如滾動、觸摸),直接響應 UI 更新而不阻塞主線程。主線程把 RenderLayer 樹同步給 Compositor 線程,由它開啓多個 Rasterizer 線程,進行光柵化處理,在可視區域以瓦片爲單位把頂點數據轉換爲片元,最後交付給 GPU 進行最終合成渲染。
圖 9:Chromium 多線程渲染
頁面從發起請求開始,結束於跳轉、刷新或關閉,會經過多次狀態變化和事件通知,因此瞭解整個過程的生命週期非常有必要。瀏覽器提供了 Navigation Timing 和 Resource Timing 兩種 API 來記錄每一個資源的事件發生時間點,你可以用它來收集 RUM(Real User Monitoring,真實用戶監控)數據,發送給後端監控服務,綜合分析頁面性能來不斷改善用戶體驗。圖 10 表示 HTML 資源加載的事件記錄全過程,而中間黃色部分表示其它資源(CSS、JS、IMG、XHR)加載事件記錄過程,它們都可以通過調用 window.performance.getEntries()
來獲取具體指標數據。
圖 10:頁面加載事件記錄流程
衡量一個頁面性能的方式有很多,但能給用戶帶來直接感受的是頁面何時渲染完成、何時可交互、何時加載完成。其中,有兩個非常重要的生命週期事件,DOMContentLoaded 事件表示 DOM 樹構建完畢,可以安全地訪問 DOM 樹所有 Node 節點、綁定事件等等;load 事件表示所有資源都加載完畢,圖片、背景、內容都已經完成渲染,頁面處於可交互狀態。但是迄今爲止瀏覽器並不能像 Android 和 iOS app 一樣完全掌控應用的狀態,在前後臺切換的時候,重新分配資源,合理地利用內存。實際上,現代瀏覽器都已經在做這方面的相關優化,並且自 Chrome 68 以後提供了Page Lifecycle API,定義了全新的瀏覽器生命週期(圖 11),讓開發者可以構建更出色的應用。
圖 11:新版頁面生命週期
現在,你可以通過給 window
和 document
綁定上所有生命週期監聽事件(圖 12),來監測頁面切換、用戶交互行爲所觸發的狀態變化過程。不過,開發者只能感知事件在何時發生,不能直接獲取某一刻的頁面狀態(圖 11 中的 STATE)。即使如此,利用這個 API,也可以讓腳本在合適的時機執行某項任務或進行界面 UI 反饋。
圖 12:生命週期監聽事件
篇幅有限,本文並不是一篇大而全的面試寶典,只涉及其中一些關鍵路徑和知識點,開篇也提到問題本身沒有標準答案。文中也有很多沒有深入展開分析的地方,一是過於偏離主題,二是個人能力有限,比如編碼規則、加密算法、摘要算法、路由算法、壓縮算法、協議標準、緩存機制、V8 原理、GPU 原理等等。如果想透徹理解每一個部分的技術細節和原理,可以結合參考其它書籍、資料和源碼。
⚠️最後,注意防脫。