JavaScript 知識點(二)

瀏覽器

瀏覽器架構設計

進程和線程

一個進程就是一個程序的運行實例。javascript

詳細解釋就是,啓動一個程序的時候,操做系統會爲該程序建立一塊內存,用來存放代碼、運行中的數據和一個執行任務的主線程,咱們把這樣的一個運行環境叫進程。css

單線程與多線程的進程對比圖

​ 單線程與多線程的進程對比圖html

線程是依附於進程的,而進程中使用多線程並行處理能提高運算效率。java

進程和線程之間的關係有如下 4 個特色。node

  • 進程中的任意一線程執行出錯,都會致使整個進程的崩潰。webpack

  • 線程之間共享進程中的數據。web

    • ​ 線程之間共享進程中的數據示意圖算法

      從上圖能夠看出,線程 一、線程 二、線程 3 分別把執行的結果寫入 A、B、C 中,而後線程 2 繼續從 A、B、C 中讀取數據,用來顯示執行結果。chrome

  • 當一個進程關閉以後,操做系統會回收進程所佔用的內存。promise

    • 當一個進程退出時,操做系統會回收該進程所申請的全部資源;即便其中任意線程由於操做不當致使內存泄漏,當進程退出時,這些內存也會被正確回收。好比以前的 IE 瀏覽器,支持不少插件,而這些插件很容易致使內存泄漏,這意味着只要瀏覽器開着,內存佔用就有可能會愈來愈多,可是當關閉瀏覽器進程時,這些內存就都會被系統回收掉。
  • 進程之間的內容相互隔離。

    • 進程隔離是爲保護操做系統中進程互不干擾的技術,每個進程只能訪問本身佔有的數據。
    • 若是進程之間須要進行數據的通訊,這時候,就須要使用用於進程間通訊(IPC)的機制了。
單進程瀏覽器時代

單進程瀏覽器是指瀏覽器的全部功能模塊都是運行在同一個進程裏

如此多的功能模塊運行在一個進程裏,是致使單進程瀏覽器不穩定、不流暢和不安全的一個主要因素。

  • 不穩定
    • 早期瀏覽器須要藉助於插件來實現諸如 Web 視頻、Web 遊戲等各類強大的功能,可是插件是最容易出問題的模塊,而且還運行在瀏覽器進程之中,因此一個插件的意外崩潰會引發整個瀏覽器的崩潰。
    • 除了插件以外,渲染引擎模塊也是不穩定的,一般一些複雜的 JavaScript 代碼就有可能引發渲染引擎模塊的崩潰。和插件同樣,渲染引擎的崩潰也會致使整個瀏覽器的崩潰。
  • 不流暢
    • 頁面的內存泄漏
    • 同一時刻只能有一個模塊能夠執行。
    • 全部頁面的渲染模塊、JavaScript 執行環境以及插件都是運行在同一個線程中的。
  • 不安全
多進程瀏覽器時代

​ chrome 進程架構

​ Chrome 的頁面是運行在單獨的渲染進程中的,同時頁面裏的插件也是運行在單獨的插件進程之中,而進程之間是經過 IPC 機制進行通訊(如圖中虛線部分)。

經過單獨進程模式來解決單進程瀏覽器碰到 不穩定 不流暢的問題,經過sandbox(安全沙箱)來解決安全問題。

Chrome 把插件進程和渲染進程鎖在沙箱裏面,這樣即便在渲染進程或者插件進程裏面執行了惡意程序,惡意程序也沒法突破沙箱去獲取系統權限。

現代瀏覽器

​ 從圖中能夠看出,最新的 Chrome 瀏覽器包括:1 個瀏覽器(Browser)主進程、1 個 GPU 進程、1 個網絡(NetWork)進程、多個渲染進程和多個插件進程。

  • 瀏覽器進程: 主要負責界面顯示、用戶交互、子進程管理,同時提供存儲等功能。
  • 渲染進程: 核心任務是將 HTML、CSS 和 JavaScript 轉換爲用戶能夠與之交互的網頁,排版引擎 Blink 和 JavaScript 引擎 V8 都是運行在該進程中,默認狀況下,Chrome 會爲每一個 Tab 標籤建立一個渲染進程。出於安全考慮,渲染進程都是運行在沙箱模式下。
  • GPU 進程: GPU 的使用初衷是爲了實現 3D CSS 的效果,只是隨後網頁、Chrome 的 UI 界面都選擇採用 GPU 來繪製,這使得 GPU 成爲瀏覽器廣泛的需求。最後,Chrome 在其多進程架構上也引入了 GPU 進程。
  • 網絡進程: 主要負責頁面的網絡資源加載,以前是做爲一個模塊運行在瀏覽器進程裏面的,直至最近才獨立出來,成爲一個單獨的進程。
  • 插件進程: 主要是負責插件的運行,因插件易崩潰,因此須要經過插件進程來隔離,以保證插件進程崩潰不會對瀏覽器和頁面形成影響。

打開 1 個頁面至少須要 1 個網絡進程、1 個瀏覽器進程、1 個 GPU 進程以及 1 個渲染進程,共 4 個;若是打開的頁面有運行插件的話,還須要再加上 1 個插件進程。

負面問題

  • 更高的資源佔用。由於每一個進程都會包含公共基礎結構的副本(如 JavaScript 運行環境),這就意味着瀏覽器會消耗更多的內存資源。
  • 更復雜的體系架構。瀏覽器各模塊之間耦合性高、擴展性差等問題,會致使如今的架構已經很難適應新的需求了。
將來面向服務的架構

爲了解決這些問題,在 2016 年,Chrome 官方團隊使用**"面向服務的架構"**(Services Oriented Architecture,簡稱 SOA)的思想設計了新的 Chrome 架構。

也就是說 Chrome 總體架構會朝向現代操做系統所採用的「面向服務的架構」 方向發展,原來的各類模塊會被重構成獨立的服務(Service),每一個服務(Service)均可以在獨立的進程中運行,訪問服務(Service)必須使用定義好的接口,經過 IPC 來通訊,從而構建一個更內聚、鬆耦合、易於維護和擴展的系統,更好實現 Chrome 簡單、穩定、高速、安全的目標。

​ Chrome「面向服務的架構」進程模型圖

同時 Chrome 還提供靈活的彈性架構,在強大性能設備上會以多進程的方式運行基礎服務,可是若是在資源受限的設備上(以下圖),Chrome 會將不少服務整合到一個進程中,從而節省內存佔用。

​ 在資源不足的設備上,將服務合併到瀏覽器進程中

網絡協議

IP

數據包要在互聯網上進行傳輸,就要符合網際協議(Internet Protocol,簡稱 IP)標準。

計算機的地址就稱爲 IP 地址,訪問任何網站實際上只是你的計算機向另一臺計算機請求信息。

UDP:把數據包送達應用程序

無需創建鏈接就能夠發送封裝的 IP 數據報的方法

UDP 協議基本上是IP協議與上層協議的接口。UDP協議適用端口分別運行在同一臺設備上的多個應用程序。

IP 是很是底層的協議,只負責把數據包傳送到對方電腦,可是對方電腦並不知道把數據包交給哪一個程序,是交給瀏覽器仍是交給xxx?所以,須要基於 IP 之上開發能和應用打交道的協議,最多見的是「用戶數據包協議(User Datagram Protocol)」,簡稱 UDP。

UDP 中一個最重要的信息是端口號,端口號其實就是一個數字,每一個想訪問網絡的程序都須要綁定一個端口號。經過端口號 UDP 就能把指定的數據包發送給指定的程序了,因此 IP 經過 IP 地址信息把數據包發送給指定的電腦,而 UDP 經過端口號把數據包分發給正確的程序。和 IP 頭同樣,端口號會被裝進 UDP 頭裏面,UDP 頭再和原始數據包合併組成新的 UDP 數據包。UDP 頭中除了目的端口,還有源端口號等信息。

​ 簡化的 UDP 網絡四層傳輸模型

在使用 UDP 發送數據時,有各類因素會致使數據包出錯,雖然 UDP 能夠校驗數據是否正確,可是對於錯誤的數據包,UDP 並不提供重發機制,只是丟棄當前的包,並且 UDP 在發送以後也沒法知道是否能達到目的地。

雖然說 UDP 不能保證數據可靠性,可是傳輸速度卻很是快,因此 UDP 會應用在一些關注速度、但不那麼嚴格要求數據完整性的領域,如在線視頻、互動遊戲等。

TCP: 把數據完整地送達應用程序

對於瀏覽器請求,或者郵件這類要求數據傳輸可靠性(reliability)的應用,若是使用 UDP 來傳輸會存在兩個問題:

  • 數據包在傳輸過程當中容易丟失;
  • 大文件會被拆分紅不少小的數據包來傳輸,這些小的數據包會通過不一樣的路由,並在不一樣的時間到達接收端,而 UDP 協議並不知道如何組裝這些數據包,從而把這些數據包還原成完整的文件。

基於這兩個問題,咱們引入 TCP 了。TCP(Transmission Control Protocol,傳輸控制協議)是一種面向鏈接的、可靠的、基於字節流的傳輸層通訊協議。相對於 UDP,TCP 有下面兩個特色:

  • 對於數據包丟失的狀況,TCP 提供重傳機制;
  • TCP 引入了數據包排序機制,用來保證把亂序的數據包組合成一個完整的文件。

和 UDP 頭同樣,TCP 頭除了包含了目標端口和本機端口號外,還提供了用於排序的序列號,以便接收端經過序號來重排數據包

​ 簡化的 TCP 網絡四層傳輸模型

TCP 單個數據包的傳輸流程和 UDP 流程差很少,不一樣的地方在於,經過 TCP 頭的信息保證了一塊大的數據傳輸的完整性。

一個完整的 TCP 鏈接的生命週期包括了「創建鏈接」「傳輸數據」和「斷開鏈接」三個階段。

​ 一個 TCP 鏈接的生命週期

  • 首先,創建鏈接階段。這個階段是經過「三次握手」來創建客戶端和服務器之間的鏈接。TCP 提供面向鏈接的通訊傳輸。面向鏈接是指在數據通訊開始以前先作好兩端之間的準備工做。所謂三次握手,是指在創建一個 TCP 鏈接時,客戶端和服務器總共要發送三個數據包以確認鏈接的創建。
  • 其次,傳輸數據階段。 在該階段,接收端須要對每一個數據包進行確認操做,也就是接收端在接收到數據包以後,須要發送確認數據包給發送端。因此當發送端發送了一個數據包以後,在規定時間內沒有接收到接收端反饋的確認消息,則判斷爲數據包丟失,並觸發發送端的重發機制。一樣,一個大的文件在傳輸過程當中會被拆分紅不少小的數據包,這些數據包到達接收端後,接收端會按照 TCP 頭中的序號爲其排序,從而保證組成完整的數據。
  • **最後,斷開鏈接階段。**數據傳輸完畢以後,就要終止鏈接了,涉及到最後一個階段「四次揮手」來保證雙方都能斷開鏈接。

TCP 爲了保證數據傳輸的可靠性,犧牲了數據包的傳輸速度,由於「三次握手」和「數據包校驗機制」等把傳輸過程當中的數據包的數量提升了一倍。

http和TCP協議

HTTP協議和TCP協議都是TCP/IP協議簇的子集。

HTTP協議屬於應用層,TCP協議屬於傳輸層,HTTP協議位於TCP協議的上層。

請求方要發送的數據包,在應用層加上HTTP頭之後會交給傳輸層的TCP協議處理,應答方接收到的數據包,在傳輸層拆掉TCP頭之後交給應用層的HTTP協議處理。創建 TCP 鏈接後會順序收發數據,請求方和應答方都必須依據 HTTP 規範構建和解析HTTP報文。

tcp協議是傳輸協議,如何運輸,運輸內容就是http協議中的報文。

HTTP協議請求流程
  • 構建請求

    • 首先,瀏覽器構建請求行信息(以下所示),構建好後,瀏覽器準備發起網絡請求。
  • 查找緩存

    • 在真正發起網絡請求以前,瀏覽器會先在瀏覽器緩存中查詢是否有要請求的文件。其中,瀏覽器緩存是一種在本地保存資源副本,以供下次請求時直接使用的技術。
  • 準備 IP 地址和端口

    • 瀏覽器使用 HTTP 協議做爲應用層協議,用來封裝請求的文本信息;並使用 TCP/IP 做傳輸層協議將它發到網絡上,因此在 HTTP 工做開始以前,瀏覽器須要經過 TCP 與服務器創建鏈接。也就是說 HTTP 的內容是經過 TCP 的傳輸數據階段來實現的

    • ​ TCP 和 HTTP 的關係示意圖

    • 把域名和 IP 地址作一一映射關係。這套域名映射爲 IP 的系統就叫作「域名系統

    • 第一步瀏覽器會請求 DNS 返回域名對應的 IP。 (包含DNS 數據緩存服務)

    • 等待 TCP 隊列。(同一個域名同時最多隻能創建 6 個 TCP 鏈接,若是在同一個域名下同時有 10 個請求發生,那麼其中 4 個請求會進入排隊等待狀態,直至進行中的請求完成。)

    • 創建 TCP 鏈接

    • 發送 HTTP 請求

      • ​ HTTP 請求數據格式

      • 首先瀏覽器會向服務器發送請求行,它包括了請求方法、請求 URI(Uniform Resource Identifier)和 HTTP 版本協議。

  • 服務器端處理 HTTP 請求流程

    • 返回請求

      • ​ 服務器返回內容

    • 斷開鏈接

      • 一般狀況下,一旦服務器向客戶端返回了請求數據,它就要關閉 TCP 鏈接。不過若是瀏覽器或者服務器在其頭信息中加入了:Connection:Keep-Alive

        那麼 TCP 鏈接在發送後將仍然保持打開狀態,這樣瀏覽器就能夠繼續經過同一個 TCP 鏈接發送請求。保持 TCP 鏈接能夠省去下次請求時須要創建鏈接的時間,提高資源加載速度。好比,一個 Web 頁面中內嵌的圖片就都來自同一個 Web 站點,若是初始化了一個持久鏈接,你就能夠複用該鏈接,以請求其餘資源,而不須要從新再創建新的 TCP 鏈接。

    • 重定向

      • 服務器返回302時會進行重定向
  • 緩存

    • ​ 緩存查找流程示意圖

  • 總結圖

    • ​ HTTP 請求流程示意圖

從輸入URL到頁面展現,這中間發生了什麼?

​ 從輸入 URL 到頁面展現完整流程示意圖

大體步驟

  • 首先,用戶從瀏覽器進程裏輸入請求信息;
  • 而後,網絡進程發起 URL 請求;
  • 服務器響應 URL 請求以後,瀏覽器進程就又要開始準備渲染進程了;
  • 渲染進程準備好以後,須要先向渲染進程提交頁面數據,咱們稱之爲提交文檔階段;
  • 渲染進程接收完文檔信息以後,便開始解析頁面和加載子資源,完成頁面的渲染。

詳細步驟

  • 用戶輸入

    • 若是判斷輸入內容符合 URL 規則,好比輸入的是 time.geekbang.org,那麼地址欄會根據規則,把這段內容加上協議,合成爲完整的 URL
  • URL 請求過程

    • 瀏覽器進程會經過進程間通訊(IPC)把 URL 請求發送至網絡進程,網絡進程接收到 URL 請求後,會在這裏發起真正的 URL 請求流程。

      • 首先,網絡進程會查找本地緩存是否緩存了該資源。若是有緩存資源,那麼直接返回資源給瀏覽器進程;若是在緩存中沒有查找到資源,那麼直接進入網絡請求流程。這請求前的第一步是要進行 DNS 解析,以獲取請求域名的服務器 IP 地址。若是請求協議是 HTTPS,那麼還須要創建 TLS 鏈接

      • ​ https和http區別

      • 接下來就是利用 IP 地址和服務器創建 TCP 鏈接。鏈接創建以後,瀏覽器端會構建請求行、請求頭等信息,並把和該域名相關的 Cookie 等數據附加到請求頭中,而後向服務器發送構建的請求信息。

    • 重定向

      • 在接收到服務器返回的響應頭後,網絡進程開始解析響應頭,若是發現返回的狀態碼是 301 或者 302,那麼說明服務器須要瀏覽器重定向到其餘 URL。這時網絡進程會從響應頭的 Location 字段裏面讀取重定向的地址,而後再發起新的 HTTP 或者 HTTPS 請求,一切又重頭開始了。
    • 響應數據類型處理

      • 在處理了跳轉信息以後,咱們繼續導航流程的分析。URL 請求的數據類型,有時候是一個下載類型,有時候是正常的 HTML 頁面。
      • Content-Type 是 HTTP 頭中一個很是重要的字段, 它告訴瀏覽器服務器返回的響應體數據是什麼類型,而後瀏覽器會根據 Content-Type 的值來決定如何顯示響應體的內容。
  • 準備渲染進程

    • Chrome 的默認策略是,每一個標籤對應一個渲染進程。但若是從一個頁面打開了另外一個新頁面,而新頁面和當前頁面屬於同一站點的話,那麼新頁面會複用父頁面的渲染進程。官方把這個默認策略叫 process-per-site-instance。
  • 提交文檔

    • 這裏的「文檔」是指 URL 請求的響應體數據。至關於響應數據
    • 「提交文檔」的消息是由瀏覽器進程發出的,渲染進程接收到「提交文檔」的消息後,會和網絡進程創建傳輸數據的「管道」。
    • 等文檔數據傳輸完成以後,渲染進程會返回「確認提交」的消息給瀏覽器進程。
    • 瀏覽器進程在收到「確認提交」的消息後,會更新瀏覽器界面狀態,包括了安全狀態、地址欄的 URL、前進後退的歷史狀態,並更新 Web 頁面。
    • 更新內容以下圖所示:
  • 渲染階段

    一旦文檔被提交,渲染進程便開始頁面解析和子資源加載了

    • 構建 DOM 樹

    • 樣式計算(Recalculate Style)

      • 經過 link 引用的外部 CSS 文件
      • 經過 link 引用的外部 CSS 文件
      • 元素的 style 屬性內嵌的 CSS

      樣式計算的目的是爲了計算出 DOM 節點中每一個元素的具體樣式,這個階段大致可分爲三步來完成。

      • 把 CSS 轉換爲瀏覽器可以理解的結構

        • 當渲染引擎接收到 CSS 文本時,會執行一個轉換操做,將 CSS 文本轉換爲瀏覽器能夠理解的結構——styleSheets。

        • ​ styleSheets

      • 轉換樣式表中的屬性值,使其標準化

        CSS 文本中有不少屬性值,如 2em、blue、bold,這些類型數值不容易被渲染引擎理解,因此須要將全部值轉換爲渲染引擎容易理解的、標準化的計算值,這個過程就是屬性值標準化。

        • ​ 標準化屬性值

      • 計算出 DOM 樹中每一個節點的具體樣式

        • CSS 的繼承規則

          • body { font-size: 20px }
            p {color:blue;}
            span {display: none}
            div {font-weight: bold;color:red}
            div p {color:green;}
            複製代碼
          • ​ 計算後 DOM 的樣式

        • 層疊規則

          css層疊

          層疊是 CSS 的一個基本特徵,它是一個定義瞭如何合併來自多個源的屬性值的算法。

        樣式計算階段的目的是爲了計算出 DOM 節點中每一個元素的具體樣式,在計算過程當中須要遵照 CSS 的繼承和層疊兩個規則。這個階段最終輸出的內容是每一個 DOM 節點的樣式,並被保存在 ComputedStyle 的結構內。

    • 佈局

      計算出 DOM 樹中可見元素的幾何位置,咱們把這個計算過程叫作佈局。

      Chrome 在佈局階段須要完成兩個任務:建立佈局樹和佈局計算。

      • 建立佈局樹

        • ​ 佈局樹構造過程示意圖

        • 爲了構建佈局樹,瀏覽器大致上完成了下面這些工做:

          • 遍歷 DOM 樹中的全部可見節點,並把這些節點加到佈局中;
          • 而不可見的節點會被佈局樹忽略掉,如 head 標籤下面的所有內容,再好比 body.p.span 這個元素,由於它的屬性包含 dispaly:none,因此這個元素也沒有被包進佈局樹。
      • 佈局計算

        • 在執行佈局操做的時候,會把佈局運算的結果從新寫回佈局樹中,因此佈局樹既是輸入內容也是輸出內容。

          這是佈局階段一個不合理的地方,由於在佈局階段並無清晰地將輸入內容和輸出內容區分開來。針對這個問題,Chrome 團隊正在重構佈局代碼,下一代佈局系統叫 LayoutNG,試圖更清晰地分離輸入和輸出,從而讓新設計的佈局算法更加簡單。

        • 若是下載 CSS 文件阻塞了,會阻塞 DOM 樹的合成嗎?會阻塞頁面的顯示嗎?

    • 分層

      頁面中有不少複雜的效果,如一些複雜的 3D 變換、頁面滾動,或者使用 z-indexing 作 z 軸排序等,爲了更加方便地實現這些效果,渲染引擎還須要爲特定的節點生成專用的圖層,並生成一棵對應的圖層樹(LayerTree)

      ​ 佈局樹和圖層樹關係示意圖

      一般狀況下,並非佈局樹的每一個節點都包含一個圖層,若是一個節點沒有對應的層,那麼這個節點就從屬於父節點的圖層

      那麼須要知足什麼條件,渲染引擎纔會爲特定的節點建立新的層呢?

      一般知足下面兩點中任意一點的元素就能夠被提高爲單獨的一個圖層。

      • 擁有層疊上下文屬性的元素會被提高爲單獨的一層。

        • 層疊上下文

        • 根元素 (HTML)

        • z-index值不爲auto的絕對/相對定位元素

        • 固定(fixed) / 沾滯(sticky)定位(沾滯定位適配全部移動設備上的瀏覽器,但老的桌面瀏覽器不支持)

        • z-index值不爲auto的flex(flexbox)子項 (flex item),即:父元素display: flex|inline-flex

        • z-index值不爲auto的grid(grid)子項,即:父元素display:grid

        • opacity屬性值小於1的元素

        • mix-blend-mode屬性值不爲normal的元素。

        • 如下任意屬性值不爲none的元素:

        • isolation屬性被設置爲isolate的元素。

        • -webkit-overflow-scrolling屬性被設置爲touch的元素

        • will-change中指定了任意CSS屬性(參考這篇文章

        • contain屬性值爲layoutpaint,或者綜合值(好比contain: strictcontain: content)。

          在層疊上下文中,其子元素一樣也按照上面解釋的規則進行層疊。 特別值得一提的是,其子元素的z-index值只在父級層疊上下文中有意義。子級層疊上下文被自動視爲父級層疊上下文的一個獨立單元。

          總結:

          • 層疊上下文能夠包含在其餘層疊上下文中,而且一塊兒建立一個有層級的層疊上下文。
          • 每一個層疊上下文徹底獨立於它的兄弟元素:當處理層疊時只考慮子元素。
          • 每一個層疊上下文是自包含的:當元素的內容發生層疊後,整個該元素將會在父層疊上下文中按順序進行層疊。
      • 須要剪裁(clip)的地方也會被建立爲圖層。

    • 圖層繪製

      • 在完成圖層樹的構建以後,渲染引擎會對圖層樹中的每一個圖層進行繪製。

      • 渲染引擎實現圖層的繪製與之相似,會把一個圖層的繪製拆分紅不少小的繪製指令,而後再把這些指令按照順序組成一個待繪製列表,以下圖所示:

        ​ 繪製列表

        從圖中能夠看出,繪製列表中的指令其實很是簡單,就是讓其執行一個簡單的繪製操做,好比繪製粉色矩形或者黑色的線等。而繪製一個元素一般須要好幾條繪製指令,由於每一個元素的背景、前景、邊框都須要單獨的指令去繪製。因此在圖層繪製階段,輸出的內容就是這些待繪製列表。

    • 柵格化操做(raster)

      • 繪製列表只是用來記錄繪製順序和繪製指令的列表,而實際上繪製操做是由渲染引擎中的合成線程來完成的。你能夠結合下圖來看下渲染主線程和合成線程之間的關係:

        渲染進程中的合成線程和主線程

      • 如上圖所示,當圖層的繪製列表準備好以後,主線程會把該繪製列表提交(commit)給合成線程

      • 合成線程

        一般一個頁面可能很大,可是用戶只能看到其中的一部分,咱們把用戶能夠看到的這個部分叫作視口(viewport)。

        在有些狀況下,有的圖層能夠很大,好比有的頁面你使用滾動條要滾動很久才能滾動到底部,可是經過視口,用戶只能看到頁面的很小一部分,因此在這種狀況下,要繪製出全部圖層內容的話,就會產生太大的開銷,並且也沒有必要。

        基於這個緣由,合成線程會將圖層劃分爲圖塊(tile)

        • 合成線程會將圖層劃分爲圖塊。這些圖塊的大小一般是 256x256 或者 512x512,以下圖所示:

          圖層被劃分爲圖塊示意圖

          • 而後合成線程會按照視口附近的圖塊來優先生成位圖,實際生成位圖的操做是由柵格化來執行的。**所謂柵格化,是指將圖塊轉換爲位圖。**而圖塊是柵格化執行的最小單位。渲染進程維護了一個柵格化的線程池,全部的圖塊柵格化都是在線程池內執行的,運行方式以下圖所示:

            一般,柵格化過程都會使用 GPU 來加速生成,使用 GPU 生成位圖的過程叫快速柵格化,或者 GPU 柵格化,生成的位圖被保存在 GPU 內存中。

            從圖中能夠看出,渲染進程把生成圖塊的指令發送給 GPU,而後在 GPU 中執行生成圖塊的位圖,並保存在 GPU 的內存中。

    • 合成和顯示

      • 一旦全部圖塊都被光柵化,合成線程就會生成一個繪製圖塊的命令——「DrawQuad」,而後將該命令提交給瀏覽器進程。
      • 瀏覽器進程裏面有一個叫 viz 的組件,用來接收合成線程發過來的 DrawQuad 命令,而後根據 DrawQuad 命令,將其頁面內容繪製到內存中,最後再將內存顯示在屏幕上。
    • 總結

      • 完整的渲染流水線示意圖

      • 結合上圖,一個完整的渲染流程大體可總結爲以下:

        • 渲染進程將 HTML 內容轉換爲可以讀懂的 DOM 樹結構。
        • 渲染引擎將 CSS 樣式錶轉化爲瀏覽器能夠理解的 styleSheets,計算出 DOM 節點的樣式。
        • 建立佈局樹,並計算元素的佈局信息。
        • 對佈局樹進行分層,並生成分層樹。
        • 爲每一個圖層生成繪製列表,並將其提交到合成線程。
        • 合成線程將圖層分紅圖塊,並在光柵化線程池中將圖塊轉換成位圖。
        • 合成線程發送繪製圖塊命令 DrawQuad 給瀏覽器進程。
        • 瀏覽器進程根據 DrawQuad 消息生成頁面,並顯示到顯示器上。
  • 相關概念

    • 「重排」

      • 更新了元素的幾何屬性(重排)

      • ​ 更新元素的幾何屬性

        從上圖能夠看出,若是你經過 JavaScript 或者 CSS 修改元素的幾何位置屬性,例如改變元素的寬度、高度等,那麼瀏覽器會觸發從新佈局,解析以後的一系列子階段,這個過程就叫重排。無疑,重排鬚要更新完整的渲染流水線,因此開銷也是最大的。

    • 」重繪「

      • ​ 更新元素背景

        從圖中能夠看出,若是修改了元素的背景顏色,那麼佈局階段將不會被執行,由於並無引發幾何位置的變換,因此就直接進入了繪製階段,而後執行以後的一系列子階段,這個過程就叫重繪。相較於重排操做,重繪省去了佈局和分層階段,因此執行效率會比重排操做要高一些。

    • "合成"

      • 若是你更改一個既不要佈局也不要繪製的屬性,會發生什麼變化呢?渲染引擎將跳過佈局和繪製,只執行後續的合成操做,咱們把這個過程叫作合成。具體流程參考下圖:

        ​ 避開重排和重繪

        在上圖中,咱們使用了 CSS 的 transform 來實現動畫效果,這能夠避開重排和重繪階段,直接在非主線程上執行合成動畫操做。這樣的效率是最高的,由於是在非主線程上合成,並無佔用主線程的資源,另外也避開了佈局和繪製兩個子階段,因此相對於重繪和重排,合成能大大提高繪製效率。

javascript 引擎工做原理

執行上下文

變量提高(Hoisting)

所謂的變量提高,是指在 JavaScript 代碼執行過程當中,JavaScript 引擎把變量的聲明部分和函數的聲明部分提高到代碼開頭的「行爲」。變量被提高後,會給變量設置默認值,這個默認值就是咱們熟悉的 undefined。

實際上變量和函數聲明在代碼裏的位置是不會改變的,並且是在編譯階段被 JavaScript 引擎放入內存中。

做用域(scope)

做用域是指在程序中定義變量的區域,該位置決定了變量的生命週期。通俗地理解,做用域就是變量與函數的可訪問範圍,即做用域控制着變量和函數的可見性和生命週期。

在 ES6 以前,ES 的做用域只有兩種:全局做用域和函數做用域。

  • 全局做用域中的對象在代碼中的任何地方都能訪問,其生命週期伴隨着頁面的生命週期。
  • 函數做用域就是在函數內部定義的變量或者函數,而且定義的變量或者函數只能在函數內部被訪問。函數執行結束以後,函數內部定義的變量會被銷燬。

變量提高所帶來的問題

  1. 變量容易在不被察覺的狀況下被覆蓋掉
  2. 穿透 if for循環

ES6 中的letconst

做用域塊內聲明的變量不影響塊外面的變量。

JavaScript 是如何支持塊級做用域的?

function foo(){
  var a = 1 
  let b = 2 
  { 
    let b = 3 
    var c = 4 
    let d = 5
    console.log(a)
    console.log(b) 
  }
  console.log(b) 
  console.log(c) 
  console.log(d)
}

foo()

複製代碼

第一步是編譯並建立執行上下文:

  • 函數內部經過 var 聲明的變量,在編譯階段全都被存放到變量環境裏面了。
  • 經過 let 聲明的變量,在編譯階段會被存放到詞法環境(Lexical Environment)中。
  • 在函數的做用域內部,經過 let 聲明的變量並無被存放到詞法環境中。

第二步繼續執行代碼

當執行到代碼塊裏面時,變量環境中 a 的值已經被設置成了 1,詞法環境中 b 的值已經被設置成了 2,這時候函數的執行上下文就以下圖所示:

​ 執行 foo 函數內部做用域塊時的執行上下文

當進入函數的做用域塊時,做用域塊中經過 let 聲明的變量,會被存放在詞法環境的一個單獨的區域中,這個區域中的變量並不影響做用域塊外面的變量,好比在做用域外面聲明瞭變量 b,在該做用域塊內部也聲明瞭變量 b,當執行到做用域內部時,它們都是獨立的存在。

其實,在詞法環境內部,維護了一個小型棧結構,棧底是函數最外層的變量,進入一個做用域塊後,就會把該做用域塊內部的變量壓到棧頂;看成用域執行完成以後,該做用域的信息就會從棧頂彈出,這就是詞法環境的結構。須要注意下,我這裏所講的變量是指經過 let 或者 const 聲明的變量。

當執行到做用域塊中的console.log(a)這行代碼時,就須要在詞法環境和變量環境中查找變量 a 的值了,具體查找方式是:沿着詞法環境的棧頂向下查詢,若是在詞法環境中的某個塊中查找到了,就直接返回給 JavaScript 引擎,若是沒有查找到,那麼繼續在變量環境中查找。

這樣一個變量查找過程就完成了:

看成用域塊執行結束以後,其內部定義的變量就會從詞法環境的棧頂彈出,最終執行上下文以下圖所示:

​ 做用域執行完成示意圖

做用域鏈&閉包

function bar() { 
  console.log(myName)
}

function foo() { 
  var myName = "極客邦" 
  bar()
}
var myName = "極客時間"
foo()
複製代碼

在每一個執行上下文的變量環境中,都包含了一個外部引用,用來指向外部的執行上下文,咱們把這個外部引用稱爲 outer。

當一段代碼使用了一個變量時,JavaScript 引擎首先會在「當前的執行上下文」中查找該變量,

好比上面那段代碼在查找 myName 變量時,若是在當前的變量環境中沒有查找到,那麼 JavaScript 引擎會繼續在 outer 所指向的執行上下文中查找。爲了直觀理解,你能夠看下面這張圖:

從圖中能夠看出,bar 函數和 foo 函數的 outer 都是指向全局上下文的,這也就意味着若是在 bar 函數或者 foo 函數中使用了外部變量,那麼 JavaScript 引擎會去全局執行上下文中查找。咱們把這個查找的鏈條就稱爲做用域鏈

詞法做用域

詞法做用域就是指做用域是由代碼中函數聲明的位置來決定的,因此詞法做用域是靜態的做用域,經過它就可以預測代碼在執行過程當中如何查找標識符。

從圖中能夠看出,詞法做用域就是根據代碼的位置來決定的,其中 main 函數包含了 bar 函數,bar 函數中包含了 foo 函數,由於 JavaScript 做用域鏈是由詞法做用域決定的,因此整個詞法做用域鏈的順序是:

foo 函數做用域—>bar 函數做用域—>main 函數做用域—> 全局做用域。

詞法做用域是代碼階段就決定好的,和函數是怎麼調用的沒有關係。

塊級做用域中的變量查找

function bar() {
 var myName = "極客世界" 
 let test1 = 100 
 if (1) { 
   let myName = "Chrome瀏覽器" 
   console.log(test) 
 }
}
function foo() {
  var myName = "極客邦"
  let test = 2 
  { 
    let test = 3 bar() 
  }
}
var myName = "極客時間"
let myAge = 10
let test = 1
foo()
複製代碼

​ 塊級做用域中是如何查找變量的

閉包

在 JavaScript 中,根據詞法做用域的規則,內部函數老是能夠訪問其外部函數中聲明的變量,當經過調用一個外部函數返回一個內部函數後,即便該外部函數已經執行結束了,可是內部函數引用外部函數的變量依然保存在內存中,咱們就把這些變量的集合稱爲閉包。

閉包是怎麼回收的

原則:若是該閉包會一直使用,那麼它能夠做爲全局變量而存在;但若是使用頻率不高,並且佔用內存又比較大的話,那就儘可能讓它成爲一個局部變量。

This

  • 使用對象來調用其內部的一個方法,該方法的 this 是指向對象自己的。
  • 在全局環境中調用一個函數,函數內部的 this 指向的是全局變量 window。
  • 經過一個對象來調用其內部的一個方法,該方法的執行上下文中的 this 指向對象自己。
  • 普通函數中的 this 默認指向全局對象 window
  • 當函數被正常調用時,在嚴格模式下,this 值是 undefined,非嚴格模式下 this 指向的是全局對象 window;
  • 當函數做爲對象的方法調用時,函數中的 this 就是該對象;
  • 嵌套函數中的 this 不會繼承外層函數的 this 值。

箭頭函數

ES6 中的箭頭函數並不會建立其自身的執行上下文,因此箭頭函數中的 this 取決於它的外部函數。

**New **

var tempObj = {} 
CreateObj.call(tempObj) 
return tempObj
複製代碼

JavaScript 代碼的執行流程

​ JavaScript 的執行流程圖

  • 編譯階段

    • ​ JavaScript 執行流程細化圖

    • 第 1 行和第 2 行,因爲這兩行代碼不是聲明操做,因此 JavaScript 引擎不會作任何處理;

    • 第 3 行,因爲這行是通過 var 聲明的,所以 JavaScript 引擎將在環境對象中建立一個名爲 myname 的屬性,並使用 undefined 對其初始化;

    • 第 4 行,JavaScript 引擎發現了一個經過 function 定義的函數,因此它將函數定義存儲到堆 (HEAP)中,並在環境對象中建立一個 showName 的屬性,而後將該屬性值指向堆中函數的位置

    • 執行上下文是 JavaScript 執行一段代碼時的運行環境

      執行上下文

    • 當 JavaScript 執行全局代碼的時候,會編譯全局代碼並建立全局執行上下文,並且在整個頁面的生存週期內,全局執行上下文只有一份。

    • 當調用一個函數的時候,函數體內的代碼會被編譯,並建立函數執行上下文,通常狀況下,函數執行結束以後,建立的函數執行上下文會被銷燬。

    • 當使用 eval 函數的時候,eval 的代碼也會被編譯,並建立執行上下文。

      調用棧

      調用棧就是用來管理函數調用關係的一種數據結構

      什麼是函數調用

      var a = 2
      function add(){
        var b = 10
        return a+b
      }
      add()
      複製代碼

      在執行到函數 add() 以前,JavaScript 引擎會爲上面這段代碼建立全局執行上下文,包含了聲明的函數和變量,你能夠參考下圖:

      ​ 全局執行上下文

    • 從圖中能夠看出,代碼中全局變量和函數都保存在全局上下文的變量環境中。

      執行上下文準備好以後,便開始執行全局代碼,當執行到 add 這兒時,JavaScript 判斷這是一個函數調用,那麼將執行如下操做:

      • 首先,從全局執行上下文中,取出 add 函數代碼。

      • 其次,對 add 函數的這段代碼進行編譯,並建立該函數的執行上下文和可執行代碼。

      • 最後,執行代碼,輸出結果。

        完整流程你能夠參考下圖:

        就這樣,當執行到 add 函數的時候,咱們就有了兩個執行上下文了——全局執行上下文和 add 函數的執行上下文。

        也就是說在執行 JavaScript 時,可能會存在多個執行上下文,JavaScript 引擎是經過一種叫棧的數據結構來管理的上下文的。

      什麼是棧

      關於棧,你能夠結合這麼一個貼切的例子來理解,一條單車道的單行線,一端被堵住了,而另外一端入口處沒有任何提示信息,堵住以後就只能後進去的車子先出來,這時這個堵住的單行線就能夠被看做是一個棧容器,車子開進單行線的操做叫作入棧,車子倒出去的操做叫作出棧。

      在車流量較大的場景中,就會發生反覆的入棧、棧滿、出棧、空棧和再次入棧,一直循環。

      因此,棧就是相似於一端被堵住的單行線,車子相似於棧中的元素,棧中的元素知足後進先出的特色。你能夠參看下圖:

      什麼是 JavaScript 的調用棧

      JavaScript 引擎正是利用棧的這種結構來管理執行上下文的。在執行上下文建立好後,JavaScript 引擎會將執行上下文壓入棧中,一般把這種用來管理執行上下文的棧稱爲執行上下文棧,又稱調用棧

      var a = 2
      function add(b,c){ 
        return b+c
      }
      function addAll(b,c){
        var d = 10
        result = add(b,c)
        return a+result+d
      }
      addAll(3,6)
      複製代碼

      第一步,建立全局上下文,並將其壓入棧底。以下圖所示:

      全局執行上下文壓入到調用棧後,JavaScript 引擎便開始執行全局代碼了。首先會執行 a=2 的賦值操做,執行該語句會將全局上下文變量環境中 a 的值設置爲 2。設置後的全局上下文的狀態以下圖所示:

      第二步 調用 addAll 函數

      當調用該函數時,JavaScript 引擎會編譯該函數,併爲其建立一個執行上下文,最後還將該函數的執行上下文壓入棧中,以下圖所示:

      addAll 函數的執行上下文建立好以後,便進入了函數代碼的執行階段了,這裏先執行的是 d=10 的賦值操做,執行語句會將 addAll 函數執行上下文中的 d 由 undefined 變成了 10。

      第三步,當執行到 add 函數調用語句時,一樣會爲其建立執行上下文,並將其壓入調用棧,以下圖所示:

      當 add 函數返回時,該函數的執行上下文就會從棧頂彈出,並將 result 的值設置爲 add 函數的返回值,也就是 9。以下圖所示:

      緊接着 addAll 執行最後一個相加操做後並返回,addAll 的執行上下文也會從棧頂部彈出,此時調用棧中就只剩下全局上下文了。最終以下圖所示:

      至此,整個 JavaScript 流程執行結束了。

      在開發中,如何利用好調用棧

  • 棧溢出(Stack Overflow)

    • 調用棧是有大小的,當入棧的執行上下文超過必定數目,JavaScript 引擎就會報錯,咱們把這種錯誤叫作棧溢出

    • 遞歸代碼例子:

    • function division(a,b){ 
        return division(a,b)
      }
      console.log(division(1,2))
      複製代碼

  • 執行階段

    • 當執行到 showName 函數時,JavaScript 引擎便開始在變量環境對象中查找該函數,因爲變量環境對象中存在該函數的引用,因此 JavaScript 引擎便開始執行該函數,並輸出「函數 showName 被執行」結果。

    • 接下來打印「myname」信息,JavaScript 引擎繼續在變量環境對象中查找該對象,因爲變量環境存在 myname 變量,而且其值爲 undefined,因此這時候就輸出 undefined。

    • 接下來執行第 3 行,把「極客時間」賦給 myname 變量,賦值後變量環境中的 myname 屬性值改變爲「極客時間」,變量環境以下所示:

    • VariableEnvironment: 
      			myname -> "極客時間", 
            showName -> function : {console.log(myname)
      複製代碼

頁面工做原理

棧空間和堆空間

內存空間

​ 對象類型(引用類型)是「堆」來存儲

原始類型的數據值都是直接保存在「棧」中的,引用類型的值是存放在「堆」中的

​ 調用棧中切換執行上下文狀態

一般狀況下,棧空間都不會設置太大,主要用來存放一些原始類型的小數據。

堆空間很大,能存放不少大的數據,不過缺點是分配內存和回收內存都會佔用必定的時間。

原始類型的賦值會完整複製變量值,而引用類型的賦值是複製引用地址。

閉包在堆中的存儲
function foo() { 
  var myName = "aaa" 
  let test1 = 1 
  const test2 = 2 
  var innerBar = { 
    setName:function(newName){ 
      myName = newName 
    }, 
    getName:function(){ 
      console.log(test1) 
      return myName 
    } 
  }
  return innerBar
}
var bar = foo()
bar.setName("bbbb")
bar.getName()
console.log(bar.getName())
複製代碼
  1. 當 JavaScript 引擎執行到 foo 函數時,首先會編譯,並建立一個空執行上下文。
  2. 在編譯過程當中,遇到內部函數 setName,JavaScript 引擎還要對內部函數作一次快速的詞法掃描,發現該內部函數引用了 foo 函數中的 myName 變量,因爲是內部函數引用了外部函數的變量,因此 JavaScript 引擎判斷這是一個閉包,因而在堆空間建立換一個「closure(foo)」的對象(這是一個內部對象,JavaScript 是沒法訪問的),用來保存 myName 變量。
  3. 接着繼續掃描到 getName 方法時,發現該函數內部還引用變量 test1,因而 JavaScript 引擎又將 test1 添加到「closure(foo)」對象中。這時候堆中的「closure(foo)」對象中就包含了 myName 和 test1 兩個變量了。
  4. 因爲 test2 並無被內部函數引用,因此 test2 依然保存在調用棧中。

當執行到 foo 函數時,閉包就產生了;當 foo 函數執行結束以後,返回的 getName 和 setName 方法都引用「clourse(foo)」對象,因此即便 foo 函數退出了,「clourse(foo)」依然被其內部的 getName 和 setName 方法引用。因此在下次調用bar.setName或者bar.getName時,建立的執行上下文中就包含了「clourse(foo)」

深拷貝

function deepCopy(o1, o2){
        // 取出第一個對象的每個屬性
        for(var key in o1){
            // 取出第一個對象當前屬性對應的值
            var item = o1[key]; // dog
            // 判斷當前的值是不是引用類型
            // 若是是引用類型, 咱們就從新開闢一塊存儲空間
            if(item instanceof Object){
                var temp = new Object();
                /* {name: "wc",age: "3"} */
               deepCopy(item, temp);   //遞歸
                o2[key] = temp;
            }else{
                // 基本數據類型
                o2[key] = o1[key];
            }
        }
    }
複製代碼
js中的垃圾回收

調用棧中的數據是如何回收的

function foo(){ 
  var a = 1 
  var b = {name:"極客邦"} 
  function showName(){ 
    var c = "極客時間" 
    var d = {name:"極客時間"} 
  }
  showName()
}
foo()
複製代碼

當執行到第 6 行代碼時,其調用棧和堆空間狀態圖以下所示:

​ 執行到 showName 函數時的內存模型

從圖中能夠看出,原始類型的數據被分配到棧中,引用類型的數據會被分配到堆中。當 foo 函數執行結束以後,foo 函數的執行上下文會從堆中被銷燬掉,那麼它是怎麼被銷燬的呢?下面咱們就來分析一下。

若是執行到 showName 函數時,那麼 JavaScript 引擎會建立 showName 函數的執行上下文,並將 showName 函數的執行上下文壓入到調用棧中,最終執行到 showName 函數時,其調用棧就如上圖所示。與此同時,還有一個記錄當前執行狀態的指針(稱爲 ESP),指向調用棧中 showName 函數的執行上下文,表示當前正在執行 showName 函數。

當 showName 函數執行完成以後,函數執行流程就進入了 foo 函數,那這時就須要銷燬 showName 函數的執行上下文了。ESP 這時候就幫上忙了,JavaScript 會將 ESP 下移到 foo 函數的執行上下文,這個下移操做就是銷燬 showName 函數執行上下文的過程。

當 showName 函數執行結束以後,ESP 向下移動到 foo 函數的執行上下文中,上面 showName 的執行上下文雖然保存在棧內存中,可是已是無效內存了。好比當 foo 函數再次調用另一個函數時,這塊內容會被直接覆蓋掉,用來存放另一個函數的執行上下文。

當一個函數執行結束以後,JavaScript 引擎會經過向下移動 ESP 來銷燬該函數保存在棧中的執行上下文。

堆中的數據是如何回收的

當上面那段代碼的 foo 函數執行結束以後,ESP 應該是指向全局執行上下文的,那這樣的話,showName 函數和 foo 函數的執行上下文就處於無效狀態了,不過保存在堆中的兩個對象依然佔用着空間,以下圖所示:

​ foo 函數執行結束後的內存狀態

1003 和 1050 這兩塊內存依然被佔用。要回收堆中的垃圾數據,就須要用到 JavaScript 中的垃圾回收器了。

代際假說(The Generational Hypothesis)和分代收集

代際假說有如下兩個特色:

  • 大部分對象在內存中存在的時間很短,簡單來講,就是不少對象一經分配內存,很快就變得不可訪問
  • 不死的對象,會活得更久。

在 V8 中會把堆分爲新生代和老生代兩個區域,新生代中存放的是生存時間短的對象,老生代中存放的生存時間久的對象。

新生區一般只支持 1~8M 的容量,而老生區支持的容量就大不少了。對於這兩塊區域,V8 分別使用兩個不一樣的垃圾回收器,以便更高效地實施垃圾回收。

  • 副垃圾回收器,主要負責新生代的垃圾回收。
  • 主垃圾回收器,主要負責老生代的垃圾回收。

垃圾回收器的工做流程

不論什麼類型的垃圾回收器,它們都有一套共同的執行流程。

  • 第一步是標記空間中活動對象和非活動對象。所謂活動對象就是還在使用的對象,非活動對象就是能夠進行垃圾回收的對象。
  • 第二步是回收非活動對象所佔據的內存。其實就是在全部的標記完成以後,統一清理內存中全部被標記爲可回收的對象。
  • 第三步是作內存整理。通常來講,頻繁回收對象後,內存中就會存在大量不連續空間,咱們把這些不連續的內存空間稱爲內存碎片。當內存中出現了大量的內存碎片以後,若是須要分配較大連續內存的時候,就有可能出現內存不足的狀況。因此最後一步須要整理這些內存碎片,但這步實際上是可選的,由於有的垃圾回收器不會產生內存碎片,好比接下來咱們要介紹的副垃圾回收器。

副垃圾回收器

副垃圾回收器主要負責新生區的垃圾回收。而一般狀況下,大多數小的對象都會被分配到新生區,因此說這個區域雖然不大,可是垃圾回收仍是比較頻繁的。

新生代中用 Scavenge 算法來處理。所謂 Scavenge 算法,是把新生代空間對半劃分爲兩個區域,一半是對象區域,一半是空閒區域,以下圖所示:

新加入的對象都會存放到對象區域,當對象區域快被寫滿時,就須要執行一次垃圾清理操做。

在垃圾回收過程當中,首先要對對象區域中的垃圾作標記;標記完成以後,就進入垃圾清理階段,副垃圾回收器會把這些存活的對象複製到空閒區域中,同時它還會把這些對象有序地排列起來,因此這個複製過程,也就至關於完成了內存整理操做,複製後空閒區域就沒有內存碎片了。

完成複製後,對象區域與空閒區域進行角色翻轉,也就是原來的對象區域變成空閒區域,原來的空閒區域變成了對象區域。這樣就完成了垃圾對象的回收操做,同時這種角色翻轉的操做還能讓新生代中的這兩塊區域無限重複使用下去。

因爲新生代中採用的 Scavenge 算法,因此每次執行清理操做時,都須要將存活的對象從對象區域複製到空閒區域。但複製操做須要時間成本,若是新生區空間設置得太大了,那麼每次清理的時間就會太久,因此爲了執行效率,通常新生區的空間會被設置得比較小。

也正是由於新生區的空間不大,因此很容易被存活的對象裝滿整個區域。爲了解決這個問題,JavaScript 引擎採用了對象晉升策略也就是通過兩次垃圾回收依然還存活的對象,會被移動到老生區中。

主垃圾回收器

主垃圾回收器主要負責老生區中的垃圾回收。除了新生區中晉升的對象,一些大的對象會直接被分配到老生區。所以老生區中的對象有兩個特色,一個是對象佔用空間大,另外一個是對象存活時間長。

因爲老生區的對象比較大,若要在老生區中使用 Scavenge 算法進行垃圾回收,複製這些大的對象將會花費比較多的時間,從而致使回收執行效率不高,同時還會浪費一半的空間。於是,主垃圾回收器是採用標記 - 清除(Mark-Sweep)的算法進行垃圾回收的。下面咱們來看看該算法是如何工做的。

首先是標記過程階段。標記階段就是從一組根元素開始,遞歸遍歷這組根元素,在這個遍歷過程當中,能到達的元素稱爲活動對象,沒有到達的元素就能夠判斷爲垃圾數據。

當 showName 函數執行結束以後,ESP 向下移動,指向了 foo 函數的執行上下文,這時候若是遍歷調用棧,是不會找到引用 1003 地址的變量,也就意味着 1003 這塊數據爲垃圾數據,被標記爲紅色。因爲 1050 這塊數據被變量 b 引用了,因此這塊數據會被標記爲活動對象。這就是大體的標記過程。

接下來就是垃圾的清除過程。它和副垃圾回收器的垃圾清除過程徹底不一樣,你能夠理解這個過程是清除掉紅色標記數據的過程,可參考下圖大體理解下其清除過程:

上面的標記過程和清除過程就是標記 - 清除算法,不過對一塊內存屢次執行標記 - 清除算法後,會產生大量不連續的內存碎片。而碎片過多會致使大對象沒法分配到足夠的連續內存,因而又產生了另一種算法——標記 - 整理(Mark-Compact)

這個標記過程仍然與標記 - 清除算法裏的是同樣的,但後續步驟不是直接對可回收對象進行清理,而是讓全部存活的對象都向一端移動,而後直接清理掉端邊界之外的內存。你能夠參考下圖:

​ 標記整理過程

全停頓

因爲 JavaScript 是運行在主線程之上的,一旦執行垃圾回收算法,都須要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢後再恢復腳本執行。咱們把這種行爲叫作全停頓(Stop-The-World)

好比堆中的數據有 1.5GB,V8 實現一次完整的垃圾回收須要 1 秒以上的時間,這也是因爲垃圾回收而引發 JavaScript 線程暫停執行的時間,如果這樣的時間花銷,那麼應用的性能和響應能力都會直線降低。主垃圾回收器執行一次完整的垃圾回收流程以下圖所示:

​ 全停頓

在 V8 新生代的垃圾回收中,因其空間較小,且存活對象較少,因此全停頓的影響不大,但老生代就不同了。若是在執行垃圾回收的過程當中,佔用主線程時間太久,就像上面圖片展現的那樣,花費了 200 毫秒,在這 200 毫秒內,主線程是不能作其餘事情的。好比頁面正在執行一個 JavaScript 動畫,由於垃圾回收器在工做,就會致使這個動畫在這 200 毫秒內沒法執行的,這將會形成頁面的卡頓現象。

爲了下降老生代的垃圾回收而形成的卡頓,V8 將標記過程分爲一個個的子標記過程,同時讓垃圾回收標記和 JavaScript 應用邏輯交替進行,直到標記階段完成,咱們把這個算法稱爲增量標記(Incremental Marking)算法。以下圖所示:

使用增量標記算法,能夠把一個完整的垃圾回收任務拆分爲不少小的任務,這些小的任務執行時間比較短,能夠穿插在其餘的 JavaScript 任務中間執行,這樣當執行上述動畫效果時,就不會讓用戶由於垃圾回收任務而感覺到頁面的卡頓了。


V8引擎

編譯器和解釋器

編譯型語言在程序執行以前,須要通過編譯器的編譯過程,而且編譯以後會直接保留機器能讀懂的二進制文件,這樣每次運行程序時,均可以直接運行該二進制文件,而不須要再次從新編譯了。好比 C/C++、GO 等都是編譯型語言。而由解釋型語言編寫的程序,在每次運行時都須要經過解釋器對程序進行動態解釋和執行。好比 Python、JavaScript 等都屬於解釋型語言。

​ 編譯器和解釋器「翻譯」代碼

從圖中你能夠看出這兩者的執行流程,大體可闡述爲以下:

  1. 在編譯型語言的編譯過程當中,編譯器首先會依次對源代碼進行詞法分析、語法分析,生成抽象語法樹(AST),而後是優化代碼,最後再生成處理器可以理解的機器碼。若是編譯成功,將會生成一個可執行的文件。但若是編譯過程發生了語法或者其餘的錯誤,那麼編譯器就會拋出異常,最後的二進制文件也不會生成成功。
  2. 在解釋型語言的解釋過程當中,一樣解釋器也會對源代碼進行詞法分析、語法分析,並生成抽象語法樹(AST),不過它會再基於抽象語法樹生成字節碼,最後再根據字節碼來執行程序、輸出結果。

V8 是如何執行一段 JavaScript 代碼的

​ V8 執行一段代碼流程圖

從圖中能夠清楚地看到,V8 在執行過程當中既有解釋器 Ignition,又有編譯器 TurboFan,那麼它們是如何配合去執行一段 JavaScript 代碼的呢? 下面咱們就按照上圖來一一分解其執行流程。

1. 生成抽象語法樹(AST)和執行上下文

將源代碼轉換爲抽象語法樹,並生成執行上下文

AST

var myName = "極客時間"
function foo(){ return 23;}
myName = "geektime"
foo()
複製代碼

​ 抽象語法樹(AST)結構

Babel 的工做原理就是先將 ES6 源碼轉換爲 AST,而後再將 ES6 語法的 AST 轉換爲 ES5 語法的 AST,最後利用 ES5 的 AST 生成 JavaScript 源代碼。 ESLint 其檢測流程也是須要將源碼轉換爲 AST,而後再利用 AST 來檢查代碼規範化的問題。

生成 AST 須要通過兩個階段。

第一階段是分詞(tokenize),又稱爲詞法分析,其做用是將一行行的源碼拆解成一個個 token。所謂 token,指的是語法上不可能再分的、最小的單個字符或字符串。你能夠參考下圖來更好地理解什麼 token。

​ 分解 token 示意圖

從圖中能夠看出,經過var myName = 「極客時間」簡單地定義了一個變量,其中關鍵字「var」、標識符「myName」 、賦值運算符「=」、字符串「極客時間」四個都是 token,並且它們表明的屬性還不同。

第二階段是解析(parse),又稱爲語法分析,其做用是將上一步生成的 token 數據,根據語法規則轉爲 AST。若是源碼符合語法規則,這一步就會順利完成。但若是源碼存在語法錯誤,這一步就會終止,並拋出一個「語法錯誤」。

有了AST後,v8就會生成該段代碼的執行上下文。

2. 生成字節碼

有了 AST 和執行上下文後,那接下來的第二步,解釋器 Ignition 就登場了,它會根據 AST 生成字節碼,並解釋執行字節碼。

其實一開始 V8 並無字節碼,而是直接將 AST 轉換爲機器碼,因爲執行機器碼的效率是很是高效的,因此這種方式在發佈後的一段時間內運行效果是很是好的。可是隨着 Chrome 在手機上的普遍普及,特別是運行在 512M 內存的手機上,內存佔用問題也暴露出來了,由於 V8 須要消耗大量的內存來存放轉換後的機器碼。爲了解決內存佔用問題,V8 團隊大幅重構了引擎架構,引入字節碼,而且拋棄了以前的編譯器,最終花了將進四年的時間,實現瞭如今的這套架構。

字節碼就是介於 AST 和機器碼之間的一種代碼。可是與特定類型的機器碼無關,字節碼須要經過解釋器將其轉換爲機器碼後才能執行。

​ 字節碼和機器碼佔用空間對比

  1. 執行代碼

生成字節碼以後,接下來就要進入執行階段了。

一般,若是有一段第一次執行的字節碼,解釋器 Ignition 會逐條解釋執行。在執行字節碼的過程當中,若是發現有熱點代碼(HotSpot),好比一段代碼被重複執行屢次,這種就稱爲熱點代碼

那麼後臺的編譯器 TurboFan 就會把該段熱點的字節碼編譯爲高效的機器碼,而後當再次執行這段被優化的代碼時,只須要執行編譯後的機器碼就能夠了,這樣就大大提高了代碼的執行效率。

其實字節碼配合解釋器和編譯器是最近一段時間很火的技術,好比 Java 和 Python 的虛擬機也都是基於這種技術實現的,咱們把這種技術稱爲即時編譯(JIT)

具體到 V8,就是指解釋器 Ignition 在解釋執行字節碼的同時,收集代碼信息,當它發現某一部分代碼變熱了以後,TurboFan 編譯器便閃亮登場,把熱點的字節碼轉換爲機器碼,並把轉換後的機器碼保存起來,以備下次使用。

瀏覽器中的頁面循環系統

每一個渲染進程都有一個主線程,而且主線程很是繁忙,既要處理 DOM,又要計算樣式,還要處理佈局,同時還須要處理 JavaScript 任務以及各類輸入事件。要讓這麼多不一樣類型的任務在主線程中有條不紊地執行,這就須要一個系統來統籌調度這些任務,這個統籌調度系統就是消息隊列和事件循環系統。

要想在線程運行過程當中,能接收並執行新的任務,就須要採用事件循環機制。

消息隊列是一種數據結構,能夠存放要執行的任務。它符合隊列「先進先出」的特色,也就是說要添加任務的話,添加到隊列的尾部;要取出任務的話,從隊列頭部去取。

​ 隊列 + 循環

因爲是多個線程操做同一個消息隊列,因此在添加任務和取出任務時還會加上一個同步鎖。

​ 跨進程發送消息

渲染進程專門有一個 IO 線程用來接收其餘進程傳進來的消息,接收到消息以後,會將這些消息組裝成任務發送給渲染主線程

一般咱們把消息隊列中的任務稱爲宏任務

每一個宏任務中都包含了一個微任務隊列

微任務

異步回調的概念,其主要有兩種方式。

  • 第一種是把異步回調函數封裝成一個宏任務,添加到消息隊列尾部,當循環系統執行到該任務的時候執行回調函數
  • 第二種方式的執行時機是在主函數執行結束以後、當前宏任務結束以前執行回調函數,這一般都是以微任務形式體現的。

微任務就是一個須要異步執行的函數,執行時機是在主函數執行結束以後、當前宏任務結束以前。

當 JavaScript 執行一段腳本的時候,V8 會爲其建立一個全局執行上下文,在建立全局執行上下文的同時,V8 引擎也會在內部建立一個微任務隊列。

每一個宏任務都關聯了一個微任務隊列。

在現代瀏覽器裏面,產生微任務有兩種方式。

  • MutationObserver 監控某個 DOM 節點,而後再經過 JavaScript 來修改這個節點,或者爲這個節點添加、刪除部分子節點,當 DOM 節點發生變化時,就會產生 DOM 變化記錄的微任務。
  • 使用 Promise,當調用 Promise.resolve() 或者 Promise.reject() 的時候,也會產生微任務。

微任務隊列是什麼時候被執行的

  • 在當前宏任務中的 JavaScript 快執行完成時,也就在 JavaScript 引擎準備退出全局執行上下文並清空調用棧的時候,JavaScript 引擎會檢查全局執行上下文中的微任務隊列,而後按照順序執行隊列中的微任務。WHATWG 把執行微任務的時間點稱爲檢查點
  • 若是在執行微任務的過程當中,產生了新的微任務,一樣會將該微任務添加到微任務隊列中,V8 引擎一直循環執行微任務隊列中的任務,直到隊列爲空纔算執行結束。也就是說在執行微任務過程當中產生的新的微任務並不會推遲到下個宏任務中執行,而是在當前的宏任務中繼續執行。

​ 微任務添加和執行流程示意圖

結論

  • 微任務和宏任務是綁定的,每一個宏任務在執行時,會建立本身的微任務隊列。
  • 微任務的執行時長會影響到當前宏任務的時長。
  • 在一個宏任務中,分別建立一個用於回調的宏任務和微任務,不管什麼狀況下,微任務都早於宏任務執行

setTimeout

  • setTimeout在被使用時會被推入 延遲隊列, 延遲隊列是個小頂堆,會根據時間將要執行的回調推入堆頂,宏任務結束後會去執行堆頂的內容

  • 若是 setTimeout 存在嵌套調用,那麼系統會設置最短期間隔爲 4 毫秒(系統會認爲阻塞)v8源碼定義4ms出

  • 未激活的頁面,setTimeout 執行最小間隔是 1000 毫秒

  • 延時執行時間有最大值 Chrome、Safari、Firefox 都是以 32 個 bit 來存儲延時值的,32bit 最大隻能存放的數字是 2147483647 毫秒

  • 使用 setTimeout 設置的回調函數中的 this 不必定指向當前環境

requestAnimationFrame

使用 requestAnimationFrame 不須要設置具體的時間,由系統來決定回調函數的執行時間,requestAnimationFrame 裏面的回調函數是在頁面刷新以前執行,它跟着屏幕的刷新頻率走,保證每一個刷新間隔只執行一次,內若是頁面未激活的話,requestAnimationFrame 也會中止渲染,這樣既能夠保證頁面的流暢性,又能節省主線程執行函數的開銷

Promise
  • Promise 實現了回調函數的延時綁定。

  • 須要將回調函數 onResolve 的返回值穿透到最外層。

模擬promise

function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
     //模擬實現resolve和then,暫不支持rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    function resolve(value) {
          //setTimeout(()=>{ // 使用微任務延遲綁定
            onResolve_(value)
           // },0)
    }
    executor(resolve, null);
}
複製代碼
async await

async/await. 提供了在不阻塞主線程的狀況下使用同步代碼實現異步訪問資源的能力。

生成器

HTML CSS

優化network

​ 單個文件請求的時間線

Queuing

當瀏覽器發起一個請求的時候,會有不少緣由致使該請求不能被當即執行,而是須要排隊等待。致使請求處於排隊狀態的緣由有不少。

  • 首先,頁面中的資源是有優先級的,好比 CSS、HTML、JavaScript 等都是頁面中的核心文件,因此優先級最高;而圖片、視頻、音頻這類資源就不是核心資源,優先級就比較低。一般當後者遇到前者時,就須要「讓路」,進入待排隊狀態。
  • 其次,咱們前面也提到過,瀏覽器會爲每一個域名最多維護 6 個 TCP 鏈接,若是發起一個 HTTP 請求時,這 6 個 TCP 鏈接都處於忙碌狀態,那麼這個請求就會處於排隊狀態。
  • 網絡進程在爲數據分配磁盤空間時,新的 HTTP 請求也須要短暫地等待磁盤分配結束。

優化:

1. 排隊(Queuing)時間太久

排隊時間太久,大機率是由瀏覽器爲每一個域名最多維護 6 個鏈接致使的。那麼基於這個緣由,你就可讓 1 個站點下面的資源放在多個域名下面,好比放到 3 個域名下面,這樣就能夠同時支持 18 個鏈接了,這種方案稱爲域名分片技術。除了域名分片技術外,把站點升級到 HTTP2,由於 HTTP2 已經沒有每一個域名最多維護 6 個 TCP 鏈接的限制了。

2.第一字節時間(TTFB)時間太久

  • 服務器生成頁面數據的時間太久

  • 網絡的緣由

  • 發送請求頭時帶上了多餘的用戶信息

3.Content Download 時間太久

若是單個請求的 Content Download 花費了大量時間,有多是字節數太多的緣由致使的。這時候你就須要減小文件大小,好比壓縮、去掉源碼中沒必要要的註釋等方法。

Stalled

等待排隊完成以後,就要進入發起鏈接的狀態了。不過在發起鏈接以前,還有一些緣由可能致使鏈接過程被推遲,這個推遲就表如今面板中的 Stalled 上

Proxy Negotiation

若是你使用了代理服務器,還會增長一個 Proxy Negotiation 階段,也就是代理協商階段,它表示代理服務器鏈接協商所用的時間

**Initial connection/SSL **

服務器創建鏈接的階段,這包括了創建 TCP 鏈接所花費的時間,若是你使用了 HTTPS 協議,那麼還須要一個額外的 SSL 握手時間,這個過程主要是用來協商一些加密信息。

Request sent

和服務器創建好鏈接以後,網絡進程會準備請求數據,並將其發送給網絡

Waiting (TTFB)

等待接收服務器第一個字節的數據

一般也稱爲「第一字節時間」,TTFB 時間越短,就說明服務器響應越快。

Content Download

接收到第一個字節以後,進入陸續接收完整數據的階段

從第一字節時間到接收到所有響應數據所用的時間。

頁面加載性能

DOM 樹如何生成

在渲染引擎內部,有一個叫 HTML 解析器(HTMLParser)的模塊

HTML 解析器是等整個 HTML 文檔加載完成以後開始解析的,仍是隨着 HTML 文檔邊加載邊解析的?

網絡進程加載了多少數據,HTML 解析器便解析多少數據。

網絡進程接收到響應頭以後,會根據響應頭中的 content-type 字段來判斷文件的類型,好比 content-type 的值是「text/html」,那麼瀏覽器就會判斷這是一個 HTML 類型的文件,而後爲該請求選擇或者建立一個渲染進程。渲染進程準備好以後,網絡進程和渲染進程之間會創建一個共享數據的管道,網絡進程接收到數據後就往這個管道里面放,而渲染進程則從管道的另一端不斷地讀取數據,並同時將讀取的數據「喂」給 HTML 解析器。你能夠把這個管道想象成一個「水管」,網絡進程接收到的字節流像水同樣倒進這個「水管」,而「水管」的另一端是渲染進程的 HTML 解析器,它會動態接收字節流,並將其解析爲 DOM。

第一個階段,經過分詞器將字節流轉換爲 Token。

第二個和第三個階段是同步進行的,須要將 Token 解析爲 DOM 節點,並將 DOM 節點添加到 DOM 樹中。

HTML 解析器維護了一個 Token 棧結構

​ 生成的 Token 示意圖

該 Token 棧主要用來計算節點之間的父子關係,在第一個階段中生成的 Token 會被按照順序壓到這個棧中。具體的處理規則以下所示:

  • 若是壓入到棧中的是 StartTag Token,HTML 解析器會爲該 Token 建立一個 DOM 節點,而後將該節點加入到 DOM 樹中,它的父節點就是棧中相鄰的那個元素生成的節點。
  • 若是分詞器解析出來是文本 Token,那麼會生成一個文本節點,而後將該節點加入到 DOM 樹中,文本 Token 是不須要壓入到棧中,它的父節點就是當前棧頂 Token 所對應的 DOM 節點。
  • 若是分詞器解析出來的是 EndTag 標籤,好比是 EndTag div,HTML 解析器會查看 Token 棧頂的元素是不是 StarTag div,若是是,就將 StartTag div 從棧中彈出,表示該 div 元素解析完成。

經過分詞器產生的新 Token 就這樣不停地壓棧和出棧,整個解析過程就這樣一直持續下去,直到分詞器將全部字節流分詞完成。

​ 元素彈出 Token 棧示意圖

​ 最終解析結果

預解析操做

當渲染引擎收到字節流以後,會開啓一個預解析線程,用來分析 HTML 文件中包含的 JavaScript、CSS 等相關文件,解析到相關文件以後,預解析線程會提早下載這些文件。

XSSAuditor

渲染引擎還有一個安全檢查模塊叫 XSSAuditor,是用來檢測詞法安全的。在分詞器解析出來 Token 以後,它會檢測這些模塊是否安全,好比是否引用了外部腳本,是否符合 CSP 規範,是否存在跨站點請求等。若是出現不符合規範的內容,XSSAuditor 會對該腳本或者下載任務進行攔截。

CSS 渲染

​ 含有 CSS 的頁面渲染流水線

首先是發起主頁面的請求,這個發起請求方多是渲染進程,也有多是瀏覽器進程,發起的請求被送到網絡進程中去執行。網絡進程接收到返回的 HTML 數據以後,將其發送給渲染進程,渲染進程會解析 HTML 數據並構建 DOM。這裏你須要特別注意下,請求 HTML 數據和構建 DOM 中間有一段空閒時間,這個空閒時間有可能成爲頁面渲染的瓶頸。

當渲染進程接收 HTML 文件字節流時,會先開啓一個預解析線程,若是遇到 JavaScript 文件或者 CSS 文件,那麼預解析線程會提早下載這些數據。對於上面的代碼,預解析線程會解析出來一個外部的 theme.css 文件,併發起 theme.css 的下載。這裏也有一個空閒時間須要你注意一下,就是在 DOM 構建結束以後、theme.css 文件還未下載完成的這段時間內,渲染流水線無事可作,由於下一步是合成佈局樹,而合成佈局樹須要 CSSOM 和 DOM,因此這裏須要等待 CSS 加載結束並解析成 CSSOM。

和 HTML 同樣,渲染引擎也是沒法直接理解 CSS 文件內容的,因此須要將其解析成渲染引擎可以理解的結構,這個結構就是** CSSOM**。和 DOM 同樣,CSSOM 也具備兩個做用,第一個是提供給 JavaScript 操做樣式表的能力,第二個是爲佈局樹的合成提供基礎的樣式信息。**

​ 含有 JavaScript 和 CSS 的頁面渲染流水線

在執行 JavaScript 腳本以前,若是頁面中包含了外部 CSS 文件的引用,或者經過 style 標籤內置了 CSS 內容,那麼渲染引擎還須要將這些內容轉換爲 CSSOM,由於 JavaScript 有修改 CSSOM 的能力,因此在執行 JavaScript 以前,還須要依賴 CSSOM。也就是說 CSS 在部分狀況下也會阻塞 DOM 的生成。

​ 含有 JavaScript 文件和 CSS 文件頁面的渲染流水線

從圖中能夠看出來,在接收到 HTML 數據以後的預解析過程當中,HTML 預解析器識別出來了有 CSS 文件和 JavaScript 文件須要下載,而後就同時發起這兩個文件的下載請求,須要注意的是,這兩個文件的下載過程是重疊的,因此下載時間按照最久的那個文件來算。

無論 CSS 文件和 JavaScript 文件誰先到達,都要先等到 CSS 文件下載完成並生成 CSSOM,而後再執行 JavaScript 腳本,最後再繼續構建 DOM,構建佈局樹,繪製頁面。

影響頁面展現的因素以及優化策略

一般狀況下的瓶頸主要體如今下載 CSS 文件、下載 JavaScript 文件和執行 JavaScript。

  • 經過內聯 JavaScript、內聯 CSS 來移除這兩種類型的文件下載,這樣獲取到 HTML 文件以後就能夠直接開始渲染流程了。
  • 但並非全部的場合都適合內聯,那麼還能夠儘可能減小文件大小,好比經過 webpack 等工具移除一些沒必要要的註釋,並壓縮 JavaScript 文件。
  • 還能夠將一些不須要在解析 HTML 階段使用的 JavaScript 標記上 sync 或者 defer。
  • 對於大的 CSS 文件,能夠經過媒體查詢屬性,將其拆分爲多個不一樣用途的 CSS 文件,這樣只有在特定的場景下才會加載特定的 CSS 文件。
分層(layer)和合成(合成線程)機制

顯示器是怎麼顯示圖像的

每一個顯示器都有固定的刷新頻率,一般是 60HZ,也就是每秒更新 60 張圖片,更新的圖片都來自於顯卡中一個叫前緩衝區的地方,顯示器所作的任務很簡單,就是每秒固定讀取 60 次前緩衝區中的圖像,並將讀取的圖像顯示到顯示器上。

顯卡

顯卡的職責就是合成新的圖像,並將圖像保存到後緩衝區中,一旦顯卡把合成的圖像寫到後緩衝區,系統就會讓後緩衝區和前緩衝區互換,這樣就能保證顯示器能讀取到最新顯卡合成的圖像。一般狀況下,顯卡的更新頻率和顯示器的刷新頻率是一致的。但有時候,在一些複雜的場景中,顯卡處理一張圖片的速度會變慢,這樣就會形成視覺上的卡頓。

幀 VS 幀率

咱們把渲染流水線生成的每一副圖片稱爲一幀,把渲染流水線每秒更新了多少幀稱爲幀率,好比滾動過程當中 1 秒更新了 60 幀,那麼幀率就是 60Hz(或者 60FPS)。

如何生成一幀圖像

看完前面的內容應該知道渲染的效率 重排 < 重繪 < 合成

這裏咱們詳解合成的方式生產一幀

分層和合成

在 Chrome 的渲染流水線中,分層體如今生成佈局樹以後,渲染引擎會根據佈局樹的特色將其轉換爲層樹(Layer Tree),層樹是渲染流水線後續流程的基礎結構。

合成操做是在合成線程上完成的,這也就意味着在執行合成操做時,是不會影響到主線程執行的。這就是爲何常常主線程卡住了,可是 CSS 動畫依然能執行的緣由。

分塊

若是說分層是從宏觀上提高了渲染效率,那麼分塊則是從微觀層面提高了渲染效率。

一般狀況下,頁面的內容都要比屏幕大得多,顯示一個頁面時,若是等待全部的圖層都生成完畢,再進行合成的話,會產生一些沒必要要的開銷,也會讓合成圖片的時間變得更久。

所以,合成線程會將每一個圖層分割爲大小固定的圖塊,而後優先繪製靠近視口的圖塊,這樣就能夠大大加速頁面的顯示速度。不過有時候, 即便只繪製那些優先級最高的圖塊,也要耗費很多的時間,由於涉及到一個很關鍵的因素——紋理上傳,這是由於從計算機內存上傳到 GPU 內存的操做會比較慢。

爲了解決這個問題,Chrome 又採起了一個策略:在首次合成圖塊的時候使用一個低分辨率的圖片

好比能夠是正常分辨率的一半,分辨率減小一半,紋理就減小了四分之三。在首次顯示頁面內容的時候,將這個低分辨率的圖片顯示出來,而後合成器繼續繪製正常比例的網頁內容,當正常比例的網頁內容繪製完成後,再替換掉當前顯示的低分辨率內容。這種方式儘管會讓用戶在開始時看到的是低分辨率的內容。

will-change

.box {
 will-change: transform, opacity;
}
複製代碼

這段代碼就是提早告訴渲染引擎 box 元素將要作幾何變換和透明度變換操做,這時候渲染引擎會將該元素單獨實現一幀,等這些變換髮生時,渲染引擎會經過合成線程直接去處理變換,這些變換並無涉及到主線程,這樣就大大提高了渲染的效率。這也是 CSS 動畫比 JavaScript 動畫高效的緣由。

虛擬dom

dom的缺陷

好比,咱們能夠調用document.body.appendChild(node)往 body 節點上添加一個元素,調用該 API 以後會引起一系列的連鎖反應。首先渲染引擎會將 node 節點添加到 body 節點之上,而後觸發樣式計算、佈局、繪製、柵格化、合成等任務,咱們把這一過程稱爲重排。除了重排以外,還有可能引發重繪或者合成操做,形象地理解就是「牽一髮而動全身」。另外,對於 DOM 的不當操做還有可能引起強制同步佈局和佈局抖動的問題,這些操做都會大大下降渲染效率。所以,對於 DOM 的操做咱們時刻都須要很是當心謹慎。

虛擬 DOM 特色

  • 將頁面改變的內容應用到虛擬 DOM 上,而不是直接應用到 DOM 上。
  • 變化被應用到虛擬 DOM 上時,虛擬 DOM 並不急着去渲染頁面,而僅僅是調整虛擬 DOM 的內部狀態,這樣操做虛擬 DOM 的代價就變得很是輕了。
  • 在虛擬 DOM 收集到足夠的改變時,再把這些變化一次性應用到真實的 DOM 上。

​ 虛擬 DOM 執行流程

虛擬 DOM 怎麼運行的

  • 建立階段。首先依據 JSX 和基礎數據建立出來虛擬 DOM,它反映了真實的 DOM 樹的結構。而後由虛擬 DOM 樹建立出真實 DOM 樹,真實的 DOM 樹生成完後,再觸發渲染流水線往屏幕輸出頁面。
  • 更新階段。若是數據發生了改變,那麼就須要根據新的數據建立一個新的虛擬 DOM 樹;而後 React 比較兩個樹,找出變化的地方,並把變化的地方一次性更新到真實的 DOM 樹上;最後渲染引擎更新渲染流水線,並生成新的頁面。

這裏咱們重點關注下比較過程,最開始的時候,比較兩個虛擬 DOM 的過程是在一個遞歸函數裏執行的,其核心算法是 reconciliation。一般狀況下,這個比較過程執行得很快,不過當虛擬 DOM 比較複雜的時候,執行比較函數就有可能佔據主線程比較久的時間,這樣就會致使其餘任務的等待,形成頁面卡頓。爲了解決這個問題,React 團隊重寫了 reconciliation 算法,新的算法稱爲 Fiber reconciler,以前老的算法稱爲 Stack reconciler。

雙緩存

在開發遊戲或者處理其餘圖像的過程當中,屏幕從前緩衝區讀取數據而後顯示。可是不少圖形操做都很複雜且須要大量的運算,好比一幅完整的畫面,可能須要計算屢次才能完成,若是每次計算完一部分圖像,就將其寫入緩衝區,那麼就會形成一個後果,那就是在顯示一個稍微複雜點的圖像的過程當中,你看到的頁面效果多是一部分一部分地顯示出來,所以在刷新頁面的過程當中,會讓用戶感覺到界面的閃爍。

而使用雙緩存,可讓你先將計算的中間結果存放在另外一個緩衝區中,等所有的計算結束,該緩衝區已經存儲了完整的圖形以後,再將該緩衝區的圖形數據一次性複製到顯示緩衝區,這樣就使得整個圖像的輸出很是穩定。

你能夠把虛擬 DOM 當作是 DOM 的一個 buffer,和圖形顯示同樣,它會在完成一次完整的操做以後,再把結果應用到 DOM 上,這樣就能減小一些沒必要要的更新,同時還能保證 DOM 的穩定輸出。

MVC 模式

​ MVC 基礎結構

其核心思想就是將數據和視圖分離

基於 MVC 又能衍生出不少其餘的模式,如 MVP、MVVM 等,不過萬變不離其宗,它們的基礎骨架都是基於 MVC 而來。

​ 基於 React 和 Redux 構建 MVC 模型

在該圖中,咱們能夠把虛擬 DOM 當作是 MVC 的視圖部分,其控制器和模型都是由 Redux 提供的。其具體實現過程以下:

  • 圖中的控制器是用來監控 DOM 的變化,一旦 DOM 發生變化,控制器便會通知模型,讓其更新數據;
  • 模型數據更新好以後,控制器會通知視圖,告訴它模型的數據發生了變化;
  • 視圖接收到更新消息以後,會根據模型所提供的數據來生成新的虛擬 DOM;
  • 新的虛擬 DOM 生成好以後,就須要與以前的虛擬 DOM 進行比較,找出變化的節點;
  • 比較出變化的節點以後,React 將變化的虛擬節點應用到 DOM 上,這樣就會觸發 DOM 節點的更新;
  • DOM 節點的變化又會觸發後續一系列渲染流水線的變化,從而實現頁面的更新。
webComponent
<!DOCTYPE html>
<html>


<body>
    <!-- 一:定義模板 二:定義內部CSS樣式 三:定義JavaScript行爲 -->
    <template id="geekbang-t">
        <style> p { background-color: brown; color: cornsilk } div { width: 200px; background-color: bisque; border: 3px solid chocolate; border-radius: 10px; } </style>
        <div>
            <p>time.geekbang.org</p>
            <p>time1.geekbang.org</p>
        </div>
        <script> function foo() { console.log('inner log') } </script>
    </template>
    <script> class GeekBang extends HTMLElement { constructor() { super() //獲取組件模板 const content = document.querySelector('#geekbang-t').content //建立影子DOM節點 const shadowDOM = this.attachShadow({ mode: 'open' }) //將模板添加到影子DOM上 shadowDOM.appendChild(content.cloneNode(true)) } } customElements.define('geek-bang', GeekBang) </script>


    <geek-bang></geek-bang>
    <div>
        <p>time.geekbang.org</p>
        <p>time1.geekbang.org</p>
    </div>
    <geek-bang></geek-bang>
</body>


</html>
複製代碼

影子 DOM 的做用是將模板中的內容與全局 DOM 和 CSS 進行隔離,這樣咱們就能夠實現元素和樣式的私有化了。你能夠把影子 DOM 當作是一個做用域,其內部的樣式和元素是不會影響到全局的樣式和元素的,而在全局環境下,要訪問影子 DOM 內部的樣式或者元素也是須要經過約定好的接口的。

Shadow dom 的javascript 腳本不會被隔離

HTTP

HTTP/0.9

  • 只有一個請求行,並無 HTTP 請求頭和請求體
  • 服務器也沒有返回頭信息。
  • 返回的文件內容是以 ASCII 字符流來傳輸的,由於都是 HTML 格式的文件,因此使用 ASCII 字節碼來傳輸是最合適的。

HTTP/1.0

​ HTTP/1.0 的請求流程

  • 新增了狀態碼
  • 提供了 Cache 機制
  • 加入了用戶代理
  • 增長了請求頭和返回頭(實現多種類型文件下載)

HTTP/1.1

  • 改進持久鏈接 (keep-alive 目前瀏覽器中對於同一個域名,默認容許同時創建 6 個 TCP 持久鏈接。)
  • 增長客戶端 Cookie
  • http請求cache
  • Chunk transfer 機制 (服務器會將數據分割成若干個任意大小的數據塊,每一個數據塊發送時會附上上個數據塊的長度,最後使用一個零長度的塊做爲發送數據完成的標誌。)

缺點

  1. 同時開啓了多條 TCP 鏈接,那麼這些鏈接會競爭固定的帶寬。

  2. tcp慢啓動

  3. HTTP/1.1 隊頭阻塞的問題(阻塞的請求)

HTTP/2

  • 多路複用 ,HTTP/2 的思路就是一個域名只使用一個 TCP 長鏈接來傳輸數據,這樣整個頁面資源的下載過程只須要一次慢啓動,同時也避免了多個 TCP 鏈接競爭帶寬所帶來的問題。
  • 並行請求
  • 能夠設置請求的優先級
  • 服務器推送
  • 頭部壓縮

​ HTTP/2 的多路複用

一個域名只使用一個 TCP 長鏈接和消除隊頭阻塞問題。

多路複用實現原理

​ HTTP/2 協議棧

  • 首先,瀏覽器準備好請求數據,包括了請求行、請求頭等信息,若是是 POST 方法,那麼還要有請求體。
  • 這些數據通過二進制分幀層處理以後,會被轉換爲一個個帶有請求 ID 編號的幀,經過協議棧將這些幀發送給服務器。
  • 服務器接收到全部幀以後,會將全部相同 ID 的幀合併爲一條完整的請求信息。
  • 而後服務器處理該條請求,並將處理的響應行、響應頭和響應體分別發送至二進制分幀層。
  • 一樣,二進制分幀層會將這些響應數據轉換爲一個個帶有請求 ID 編號的幀,通過協議棧發送給瀏覽器。
  • 瀏覽器接收到響應幀以後,會根據 ID 編號將幀的數據提交給對應的請求。

HTTP/3

tcp的隊頭阻塞

從一端發送給另一端的數據會被拆分爲一個個按照順序排列的數據包,這些數據包經過網絡傳輸到了接收端,接收端再按照順序將這些數據包組合成原始數據,這樣就完成了數據傳輸。

不過,若是在數據傳輸的過程當中,有一個數據由於網絡故障或者其餘緣由而丟包了,那麼整個 TCP 的鏈接就會處於暫停狀態,須要等待丟失的數據包被從新傳輸過來

在 TCP 傳輸過程當中,因爲單個數據包的丟失而形成的阻塞稱爲 TCP 上的隊頭阻塞

​ HTTP/2 多路複用

經過該圖,咱們知道在 HTTP/2 中,多個請求是跑在一個 TCP 管道中的,若是其中任意一路數據流中出現了丟包的狀況,那麼就會阻塞該 TCP 鏈接中的全部請求。這不一樣於 HTTP/1.1,使用 HTTP/1.1 時,瀏覽器爲每一個域名開啓了 6 個 TCP 鏈接,若是其中的 1 個 TCP 鏈接發生了隊頭阻塞,那麼其餘的 5 個鏈接依然能夠繼續傳輸數據。

因此隨着丟包率的增長,HTTP/2 的傳輸效率也會愈來愈差。有測試數據代表,當系統達到了 2% 的丟包率時,HTTP/1.1 的傳輸效率反而比 HTTP/2 表現得更好。

QUIC 協議

HTTP/3 選擇了一個折衷的方法——UDP 協議,基於 UDP 實現了相似於 TCP 的多路數據流、傳輸可靠性等功能,咱們把這套功能稱爲 QUIC 協議

​ HTTP/2 和 HTTP/3 協議棧

HTTP/3 中的 QUIC 協議集合瞭如下幾點功能。

  • 實現了相似 TCP 的流量控制、傳輸可靠性的功能。雖然 UDP 不提供可靠性的傳輸,但 QUIC 在 UDP 的基礎之上增長了一層來保證數據可靠性傳輸。它提供了數據包重傳、擁塞控制以及其餘一些 TCP 中存在的特性。
  • 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相較於早期版本 TLS1.3 有更多的優勢,其中最重要的一點是減小了握手所花費的 RTT 個數。
  • 實現了快速握手功能。因爲 QUIC 是基於 UDP 的,因此 QUIC 能夠實現使用 0-RTT 或者 1-RTT 來創建鏈接,這意味着 QUIC 能夠用最快的速度來發送和接收數據,這樣能夠大大提高首次打開頁面的速度。
  • 實現了 HTTP/2 中的多路複用功能。和 TCP 不一樣,QUIC 實現了在同一物理鏈接上能夠有多個獨立的邏輯數據流(以下圖)。實現了數據流的單獨傳輸,就解決了 TCP 中隊頭阻塞的問題。

​ QUIC 協議的多路複用

HTTP/3的困境

第一,從目前的狀況來看,服務器和瀏覽器端都沒有對 HTTP/3 提供比較完整的支持。Chrome 雖然在數年前就開始支持 Google 版本的 QUIC,可是這個版本的 QUIC 和官方的 QUIC 存在着很是大的差別。

第二,部署 HTTP/3 也存在着很是大的問題。由於系統內核對 UDP 的優化遠遠沒有達到 TCP 的優化程度,這也是阻礙 QUIC 的一個重要緣由。

第三,中間設備僵化的問題。這些設備對 UDP 的優化程度遠遠低於 TCP,據統計使用 QUIC 協議時,大約有 3%~7% 的丟包率。

相關文章
相關標籤/搜索