從渲染原理談前端性能優化

作者:李佳曉 原文:學而思網校技術團隊

前言

合格的開發者知道怎麼做,而優秀的開發者知道爲什麼這麼做。

這句話來自《web性能權威指南》,我一直很喜歡,而本文嘗試從瀏覽器渲染原理探討如何進行性能提升。
全文將從網絡通信以及頁面渲染兩個過程去探討瀏覽器的行爲及在此過程中我們可以針對那些點進行優化,有些的不足之處還請各位不吝雅正。

一、關於瀏覽器渲染的容易誤解點總結

關於瀏覽器渲染機制已經是老生常談,而且網上現有資料中有非常多的優秀資料對此進行闡述。遺憾的是網上的資料良莠不齊,經常在不同的文檔中對同一件事的描述出現了極大的差異。懷着嚴謹求學的態度經過大量資料的查閱和請教,將會在後文總結出一個完整的流程。

1、DOM樹的構建是文檔加載完成開始的?

DOM樹的構建是從接受到文檔開始的,先將字節轉化爲字符,然後字符轉化爲標記,接着標記構建dom樹。這個過程被分爲標記化和樹構建
而這是一個漸進的過程。爲達到更好的用戶體驗,呈現引擎會力求儘快將內容顯示在屏幕上。它不必等到整個 HTML 文檔解析完畢之後,就會開始構建呈現樹和設置佈局。在不斷接收和處理來自網絡的其餘內容的同時,呈現引擎會將部分內容解析並顯示出來。
參考文檔:
http://taligarsiel.com/Projec...

2、渲染樹是在DOM樹和CSS樣式樹構建完畢纔開始構建的嗎?

這三個過程在實際進行的時候又不是完全獨立,而是會有交叉。會造成一邊加載,一邊解析,一邊渲染的工作現象。
參考文檔:

http://www.jianshu.com/p/2d52...

3、css的標籤嵌套越多,越容易定位到元素

css的解析是自右至左逆向解析的,嵌套越多越增加瀏覽器的工作量,而不會越快。
因爲如果正向解析,例如「div div p em」,我們首先就要檢查當前元素到 html 的整條路徑,找到最上層的 div,再往下找,如果遇到不匹配就必須回到最上層那個 div,往下再去匹配選擇器中的第一個 div,回溯若干次才能確定匹配與否,效率很低。
逆向匹配則不同,如果當前的 DOM 元素是 div,而不是 selector 最後的 em,那隻要一步就能排除。只有在匹配時,纔會不斷向上找父節點進行驗證。
打個比如 p span.showing
你認爲從一個p元素下面找到所有的span元素並判斷是否有class showing快,還是找到所有的span元素判斷是否有class showing並且包括一個p父元素快
參考文檔:
http://www.imooc.com/code/4570

二、頁面渲染的完整流程

當瀏覽器拿到HTTP報文時呈現引擎將開始解析 HTML 文檔,並將各標記逐個轉化成“內容樹”上的 DOM 節點。同時也會解析外部 CSS 文件以及樣式元素中的樣式數據。HTML 中這些帶有視覺指令的樣式信息將用於創建另一個樹結構:呈現樹。瀏覽器將根據呈現樹進行佈局繪製。

以上就是頁面渲染的大致流程。那麼瀏覽器從用戶輸入網址之後到底做了什麼呢?以下將會進行一個完整的梳理。鑑於本文是前端向的所以梳理內容會有所偏重。而從輸入到呈現可以分爲兩個部分:網絡通信頁面渲染

我們首先來看網絡通信部分:

1、用戶輸入url並敲擊回車。

2、進行DNS解析。

如果用戶輸入的是ip地址則直接進入第三條。但去記錄毫無規律且冗長的ip地址顯然不是易事,所以通常都是輸入的域名,此時就會進行dns解析。所謂DNS(Domain Name System)指域名系統。因特網上作爲域名和IP地址相互映射的一個分佈式數據庫,能夠使用戶更方便的訪問互聯網,而不用去記住能夠被機器直接讀取的IP數串。通過主機名,最終得到該主機名對應的IP地址的過程叫做域名解析(或主機名解析)。這個過程如下所示:

瀏覽器會首先搜索瀏覽器自身的DNS緩存(緩存時間比較短,大概只有2分鐘左右,且只能容納1000條緩存)。

  • 如果瀏覽器自身緩存找不到則會查看系統的DNS緩存,如果找到且沒有過期則停止搜索解析到此結束.
  • 而如果本機沒有找到DNS緩存,則瀏覽器會發起一個DNS的系統調用,就會向本地配置的首選DNS服務器發起域名解析請求(通過的是UDP協議向DNS的53端口發起請求,這個請求是遞歸的請求,也就是運營商的DNS服務器必須得提供給我們該域名的IP地址),運營商的DNS服務器首先查找自身的緩存,找到對應的條目,且沒有過期,則解析成功。
  • 如果沒有找到對應的條目,則有運營商的DNS代我們的瀏覽器發起迭代DNS解析請求,它首先是會找根域的DNS的IP地址(這個DNS服務器都內置13臺根域的DNS的IP地址),找打根域的DNS地址,就會向其發起請求(請問www.xxxx.com這個域名的IP地址是多少啊?)
  • 根域發現這是一個頂級域com域的一個域名,於是就告訴運營商的DNS我不知道這個域名的IP地址,但是我知道com域的IP地址,你去找它去,於是運營商的DNS就得到了com域的IP地址,又向com域的IP地址發起了請求(請問www.xxxx.com這個域名的IP地址是多少?),com域這臺服務器告訴運營商的DNS我不知道www.xxxx.com這個域名的IP地址,但是我知道xxxx.com這個域的DNS地址,你去找它去,於是運營商的DNS又向linux178.com這個域名的DNS地址(這個一般就是由域名註冊商提供的,像萬網,新網等)發起請求(請問www.xxxx.com這個域名的IP地址是多少?),這個時候xxxx.com域的DNS服務器一查,誒,果真在我這裏,於是就把找到的結果發送給運營商的DNS服務器,這個時候運營商的DNS服務器就拿到了www.xxxx.com這個域名對應的IP地址,並返回給Windows系統內核,內核又把結果返回給瀏覽器,終於瀏覽器拿到了www.xxxx.com對應的IP地址,這次dns解析圓滿成功。

3、建立tcp連接

拿到域名對應的IP地址之後,User-Agent(一般是指瀏覽器)會以一個隨機端口(1024< 端口 < 65535)向服務器的WEB程序(常用的有httpd,nginx等)80端口發起TCP的連接請求。這個連接請求(原始的http請求經過TCP/IP4層模型的層層封包)到達服務器端後(這中間通過各種路由設備,局域網內除外),進入到網卡,然後是進入到內核的TCP/IP協議棧(用於識別該連接請求,解封包,一層一層的剝開),還有可能要經過Netfilter防火牆(屬於內核的模塊)的過濾,最終到達WEB程序,最終建立了TCP/IP的連接。

tcp建立連接和關閉連接均需要一個完善的確認機制,我們一般將連接稱爲三次握手,而連接關閉稱爲四次揮手。而不論是三次握手還是四次揮手都需要數據從客戶端到服務器的一次完整傳輸。將數據從客戶端到服務端經歷的一個完整時延包括:

  • 發送時延:把消息中的所有比特轉移到鏈路中需要的時間,是消息長度和鏈路速度的函數
  • 傳播時延:消息從發送端到接受端需要的時間,是信號傳播距離和速度的函數
  • 處理時延:處理分組首部,檢查位錯誤及確定分組目標所需的時間
  • 排隊時延:到來的分組排隊等待處理的時間以上的延遲總和就是客戶端到服務器的總延遲時間

以上的延遲總和就是客戶端到服務器的總延遲時間。因此每一次的連接建立和斷開都是有巨大代價的。因此去掉不必要的資源和資源合併(包括js及css資源合併、雪碧圖等)纔會成爲性能優化繞不開的方案。但是好消息是隨着協議的發展我們將對性能優化這個主題有着新的看法和思考。雖然還未到來,但也不遠了。如果你感到好奇那就接着往下看。

以下簡述下tcp建立連接的過程:

clipboard.png

  • 第一次握手:客戶端發送syn包(syn=x,x爲客戶端隨機序列號)的數據包到服務器,並進入SYN_SEND狀態,等待服務器確認;
  • 第二次握手:服務器收到syn包,必須確認客戶的SYN(ack=x+1),同時自己也發送一個SYN包(syn=y,y爲服務端生成的隨機序列號),即SYN+ACK包,此時服務器進入SYN_RECV狀態;
  • 第三次握手:客戶端收到服務器的SYN+ACK包,向服務器發送確認包ACK(ack=y+1)

此包發送完畢,客戶端和服務器進入ESTABLISHED狀態,完成三次握手。握手過程中傳送的包裏不包含數據,三次握手完畢後,客戶端與服務器才正式開始傳送數據。理想狀態下,TCP連接一旦建立,在通信雙方中的任何一方主動關閉連接之前,TCP連接都將被一直保持下去

這裏注意, 三次握手是不攜帶數據的,而是在握手完畢纔開始數據傳輸。因此如果每次數據請求都需要重新進行完整的tcp連接建立,通信時延的耗時是難以估量的!這也就是爲什麼我們總是能聽到資源合併減少請求次數的原因。

下面來看看HTTP如何在協議層面幫我們進行優化的:

HTTP1.0

在http1.0時代,每個TCP連接只能發送一個請求。發送數據完畢,連接就關閉,如果還要請求其他資源,就必須再新建一個連接。 TCP連接的新建成本很高,因爲需要客戶端和服務器三次握手,並且開始時發送速率較慢(TCP的擁塞控制開始時會啓動慢啓動算法)。在數據傳輸的開始只能發送少量包,並隨着網絡狀態良好(無擁塞)指數增長。但遇到擁塞又要重新從1個包開始進行傳輸。

以下圖爲例,慢啓動時第一次數據傳輸只能傳輸一組數據,得到確認後傳輸2組,每次翻倍,直到達到閾值16時開始啓用擁塞避免算法,既每次得到確認後數據包只增加一個。當發生網絡擁塞後,閾值減半重新開始慢啓動算法。

clipboard.png

因此爲避免tcp連接的三次握手耗時及慢啓動引起的發送速度慢的情況,應儘量減少tcp連接的次數。

而HTTP1.0每個數據請求都需要重新建立連接的特點使得HTTP 1.0版本的性能比較差。隨着網頁加載的外部資源越來越多,這個問題就愈發突出了。 爲了解決這個問題,有些瀏覽器在請求時,用了一個非標準的Connection字段。 Kepp-alive 一個可以複用的TCP連接就建立了,直到客戶端或服務器主動關閉連接。但是,這不是標準字段,不同實現的行爲可能不一致,因此不是根本的解決辦法。

HTTP1.1

http1.1(以下簡稱h1.1) 版的最大變化,就是引入了持久連接(persistent connection),即TCP連接默認不關閉,可以被多個請求複用,不用聲明Connection: keep-alive。 客戶端和服務器發現對方一段時間沒有活動,就可以主動關閉連接。不過,規範的做法是,客戶端在最後一個請求時,發送Connection: close,明確要求服務器關閉TCP連接。 目前,對於同一個域名,大多數瀏覽器允許同時建立6個持久連接。相比與http1.0,1.1的頁面性能有了巨大提升,因爲省去了很多tcp的握手揮手時間。下圖第一種是tcp建立後只能發一個請求的http1.0的通信狀態,而擁有了持久連接的h1.1則避免了tcp握手及慢啓動帶來的漫長時延。

clipboard.png

從圖中可以看到相比h1.0,h1.1的性能有所提升。然而雖然1.1版允許複用TCP連接,但是同一個TCP連接裏面,所有的數據通信是按次序進行的。服務器只有處理完一個迴應,纔會進行下一個迴應。要是前面的迴應特別慢,後面就會有許多請求排隊等着。這稱爲"隊頭堵塞"(Head-of-line blocking)。 爲了避免這個問題,只有三種方法:一是減少請求數,二是同時多開持久連接。這導致了很多的網頁優化技巧,比如合併腳本和樣式表、將圖片嵌入CSS代碼、域名分片(domain sharding)等等。如果HTTP協議能繼續優化,這些額外的工作是可以避免的。三是開啓pipelining,不過pipelining並不是救世主,它也存在不少缺陷:

    • pipelining只能適用於http1.1,一般來說,支持http1.1的server都要求支持pipelining
    • 只有冪等的請求(GET,HEAD)能使用pipelining,非冪等請求比如POST不能使用,因爲請求之間可能會存在先後依賴關係。
    • head of line blocking並沒有完全得到解決,server的response還是要求依次返回,遵循FIFO(first in first out)原則。也就是說如果請求1的response沒有回來,2,3,4,5的response也不會被送回來。
    • 絕大部分的http代理服務器不支持pipelining。 和不支持pipelining的老服務器協商有問題。 可能會導致新的隊首阻塞問題。

    鑑於以上種種原因,pipelining的支持度並不友好。可以看看chrome對pipelining的描述:

    https://www.chromium.org/deve...

    clipboard.png

    HTTP2

    2015年,HTTP/2 發佈。它不叫 HTTP/2.0,是因爲標準委員會不打算再發布子版本了,下一個新版本將是 HTTP/3。HTTP2將具有以下幾個主要特點:

    • 二進制協議 :HTTP/1.1 版的頭信息肯定是文本(ASCII編碼),數據體可以是文本,也可以是二進制。HTTP/2 則是一個徹底的二進制協議,頭信息和數據體都是二進制,並且統稱爲"幀"(frame):頭信息幀和數據幀。
    • 多工 :HTTP/2 複用TCP連接,在一個連接裏,客戶端和瀏覽器都可以同時發送多個請求或迴應,而且不用按照順序一一對應,這樣就避免了"隊頭堵塞"。
    • 數據流:因爲 HTTP/2 的數據包是不按順序發送的,同一個連接裏面連續的數據包,可能屬於不同的迴應。因此,必須要對數據包做標記,指出它屬於哪個迴應。 HTTP/2 將每個請求或迴應的所有數據包,稱爲一個數據流(stream)。每個數據流都有一個獨一無二的編號。數據包發送的時候,都必須標記數據流ID,用來區分它屬於哪個數據流。另外還規定,客戶端發出的數據流,ID一律爲奇數,服務器發出的,ID爲偶數。 數據流發送到一半的時候,客戶端和服務器都可以發送信號(RST_STREAM幀),取消這個數據流。1.1版取消數據流的唯一方法,就是關閉TCP連接。這就是說,HTTP/2 可以取消某一次請求,同時保證TCP連接還打開着,可以被其他請求使用。 客戶端還可以指定數據流的優先級。優先級越高,服務器就會越早迴應。
    • 頭信息壓縮: HTTP 協議不帶有狀態,每次請求都必須附上所有信息。所以,請求的很多字段都是重複的,比如Cookie和User Agent,一模一樣的內容,每次請求都必須附帶,這會浪費很多帶寬,也影響速度。 HTTP2對這一點做了優化,引入了頭信息壓縮機制(header compression)。一方面,頭信息使用gzip或compress壓縮後再發送;另一方面,客戶端和服務器同時維護一張頭信息表,所有字段都會存入這個表,生成一個索引號,以後就不發送同樣字段了,只發送索引號,這樣就提高速度了。
    • 服務器推送: HTTP/2 允許服務器未經請求,主動向客戶端發送資源,這叫做服務器推送(server push)。 常見場景是客戶端請求一個網頁,這個網頁裏面包含很多靜態資源。正常情況下,客戶端必須收到網頁後,解析HTML源碼,發現有靜態資源,再發出靜態資源請求。其實,服務器可以預期到客戶端請求網頁後,很可能會再請求靜態資源,所以就主動把這些靜態資源隨着網頁一起發給客戶端了。

    就這幾個點我們分別討論一下:
    就多工來看:雖然http1.1支持了pipelining,但是仍然會有隊首阻塞問題,如果瀏覽器同時發出http請求請求和css,服務器端處理css請求耗時20ms,但是因爲先請求資源是html,此時的css儘管已經處理好了但仍不能返回,而需要等待html處理好一起返回,此時的客戶端就處於盲等狀態,而事實上如果服務器先處理好css就先返回css的話,瀏覽器就可以開始解析css了。而多工的出現就解決了http之前版本協議的問題,極大的提升了頁面性能。縮短了通信時間。我們來看看有了多工之後有那些影響:

    • 無需進行資源分片:爲了避免請求tcp連接耗時長的和初始發送速率低的問題,瀏覽器允許同時打開多個tcp連接讓資源同時請求。但是爲了避免服務器壓力,一般針對一個域名會有最大併發數的限制,一般來說是6個。允許一個頁面同時對相同域名打開6個tcp連接。爲了繞過最大併發數的限制,會將資源分佈在不同的域名下,避免資源在超過併發數後需要等待才能開始請求。而有了http2,可以同步請求資源,資源分片這種方式就可以不再使用。
    • 資源合併:資源合併會不利於緩存機制,因爲單文件修改會影響整個資源包。而且單文件過大對於 HTTP/2 的傳輸不好,儘量做到細粒化更有利於 HTTP/2 傳輸。而且內置資源也是同理,將資源以base64的形式放進代碼中不利於緩存。且編碼後的圖片資源大小是要超過圖片大小的。這兩者都是以減少tcp請求次數增大單個文件大小來進行優化的。

    就頭部壓縮來看:HTTP/1.1 版的頭信息是ASCII編碼,也就是不經過壓縮的,當我們請求只攜帶少量數據時,http頭部可能要比載荷要大許多,尤其是有了很長的cookie之後這一點尤爲顯著,頭部壓縮毫無疑問可以對性能有很大提升。

    就服務器推送來看:少去了資源請求的時間,服務端可以將可能用到的資源推送給服務端以待使用。這項能力幾乎是革新了之前應答模式的認知,對性能提升也有巨大幫助。

    因此很多優化都是在基於tcp及http的一些問題來避免和繞過的。事實上多數的優化都是針對網絡通信這個部分在做。

    4、建立TCP連接後發起http請求

    5、服務器端響應http請求,瀏覽器得到html代碼

    以上是網絡通信部分,接下來將會對頁面渲染部分進行敘述。

    • 當瀏覽器拿到HTML文檔時首先會進行HTML文檔解析,構建DOM樹。
    • 遇到css樣式如link標籤或者style標籤時開始解析css,構建樣式樹。HTML解析構建和CSS的解析是相互獨立的並不會造成衝突,因此我們通常將css樣式放在head中,讓瀏覽器儘早解析css。
    • 當html的解析遇到script標籤會怎樣呢?答案是停止DOM樹的解析開始下載js。因爲js是會阻塞html解析的,是阻塞資源。其原因在於js可能會改變html現有結構。例如有的節點是用js動態構建的,在這種情況下就會停止dom樹的構建開始下載解析js。腳本在文檔的何處插入,就在何處執行。當 HTML 解析器遇到一個 script 標記時,它會暫停構建 DOM,將控制權移交給 JavaScript 引擎;等 JavaScript 引擎運行完畢,瀏覽器會從中斷的地方恢復 DOM 構建。而因此就會推遲頁面首繪的時間。可以在首繪不需要js的情況下用async和defer實現異步加載。這樣js就不會阻塞html的解析了。當HTML解析完成後,瀏覽器會將文檔標註爲交互狀態,並開始解析那些處於“deferred”模式的腳本,也就是那些應在文檔解析完成後才執行的腳本。然後,文檔狀態將設置爲“完成”,一個“加載”事件將隨之觸發。

    注意,異步執行是指下載。執行js時仍然會阻塞。

    • 在得到DOM樹和樣式樹後就可以進行渲染樹的構建了。應注意的是渲染樹和 DOM 元素相對應的,但並非一一對應。比如非可視化的 DOM 元素不會插入呈現樹中,例如“head”元素。如果元素的 display 屬性值爲“none”,那麼也不會顯示在呈現樹中(但是 visibility 屬性值爲“hidden”的元素仍會顯示)

    clipboard.png

    渲染樹構建完畢後將會進行佈局。佈局使用流模型的Layout算法。所謂流模型,即是指Layout的過程只需進行一遍即可完成,後出現在流中的元素不會影響前出現在流中的元素,Layout過程只需從左至右從上至下一遍完成即可。但實際實現中,流模型會有例外。Layout是一個遞歸的過程,每個節點都負責自己及其子節點的Layout。Layout結果是相對父節點的座標和尺寸。其過程可以簡述爲:

    clipboard.png

    • 此時renderTree已經構建完畢,不過瀏覽器渲染樹引擎並不直接使用渲染樹進行繪製,爲了方便處理定位(裁剪),溢出滾動(頁內滾動),CSS轉換/不透明/動畫/濾鏡,蒙版或反射,Z (Z排序)等,瀏覽器需要生成另外一棵樹 - 層樹。因此繪製過程如下:1、獲取 DOM 並將其分割爲多個層(RenderLayer) 2、將每個層柵格化,並獨立的繪製進位圖中 3、將這些位圖作爲紋理上傳至 GPU 4、複合多個層來生成最終的屏幕圖像(終極layer)。

    三、HTML及CSS樣式的解析

    HTML解析是一個將字節轉化爲字符,字符解析爲標記,標記生成節點,節點構建樹的過程。。CSS樣式的解析則由於複雜的樣式層疊而變得複雜。對此不同的渲染引擎在處理上有所差異,後文將會就這點進行詳細講解

    1、HTML的解析分爲標記化和樹構建兩個階段

    標記化算法:

    是詞法分析過程,將輸入內容解析成多個標記。HTML標記包括起始標記、結束標記、屬性名稱和屬性值。標記生成器識別標記,傳遞給樹構造器,然後接受下一個字符以識別下一個標記;如此反覆直到輸入的結束。
    該算法的輸出結果是 HTML 標記。該算法使用狀態機來表示。每一個狀態接收來自輸入信息流的一個或多個字符,並根據這些字符更新下一個狀態。當前的標記化狀態和樹結構狀態會影響進入下一狀態的決定。這意味着,即使接收的字符相同,對於下一個正確的狀態也會產生不同的結果,具體取決於當前的狀態。
    樹構建算法:

    在樹構建階段,以 Document 爲根節點的 DOM 樹也會不斷進行修改,向其中添加各種元素。
    標記生成器發送的每個節點都會由樹構建器進行處理。規範中定義了每個標記所對應的 DOM 元素,這些元素會在接收到相應的標記時創建。這些元素不僅會添加到 DOM 樹中,還會添加到開放元素的堆棧中。此堆棧用於糾正嵌套錯誤和處理未關閉的標記。其算法也可以用狀態機來描述。這些狀態稱爲“插入模式”。

    以下將會舉一個例子來分析這兩個階段:

    clipboard.png

    標記化:初始狀態是數據狀態。

    • 遇到字符 < 時,狀態更改爲“標記打開狀態”。接收一個 a-z字符會創建“起始標記”,狀態更改爲“標記名稱狀態”。這個狀態會一直保持到接收> 字符。在此期間接收的每個字符都會附加到新的標記名稱上。在本例中,我們創建的標記是 html 標記。
    • 遇到 > 標記時,會發送當前的標記,狀態改回“數據狀態”。 標記也會進行同樣的處理。目前 html 和 body 標記均已發出。現在我們回到“數據狀態”。接收到 Hello world 中的 H 字符時,將創建併發送字符標記,直到接收</body> 中的<。我們將爲 Hello world 中的每個字符都發送一個字符標記。
    • 現在我們回到“標記打開狀態”。接收下一個輸入字符 / 時,會創建 end tag token 並改爲“標記名稱狀態”。我們會再次保持這個狀態,直到接收 >。然後將發送新的標記,並回到“數據狀態”。 輸入也會進行同樣的處理。

    還是以上的例子,我們來看看樹構建

    樹構建:樹構建階段的輸入是一個來自標記化階段的標記序列。

    • 第一個模式是“initial mode”。接收 HTML 標記後轉爲“before html”模式,並在這個模式下重新處理此標記。這樣會創建一個 HTMLHtmlElement 元素,並將其附加到 Document 根對象上。
    • 然後狀態將改爲“before head”。此時我們接收“body”標記。即使我們的示例中沒有“head”標記,系統也會隱式創建一個 HTMLHeadElement,並將其添加到樹中。
    • 現在我們進入了“in head”模式,然後轉入“after head”模式。系統對 body 標記進行重新處理,創建並插入 HTMLBodyElement,同時模式轉變爲“body”。
    • 現在,接收由“Hello world”字符串生成的一系列字符標記。接收第一個字符時會創建並插入“Text”節點,而其他字符也將附加到該節點
    • 接收 body 結束標記會觸發“after body”模式。現在我們將接收 HTML 結束標記,然後進入“after after body”模式。接收到文件結束標記後,解析過程就此結束。解析結束後的操作

    在此階段,瀏覽器會將文檔標註爲交互狀態,並開始解析那些處於“deferred”模式的腳本,也就是那些應在文檔解析完成後才執行的腳本。然後,文檔狀態將設置爲“完成”,一個“加載”事件將隨之觸發。

    完整解析過程如下圖:

    clipboard.png

    2、CSS的解析與層疊規則

    每一個呈現器都代表了一個矩形的區域,通常對應於相關節點的 CSS 框,這一點在 CSS2 規範中有所描述。它包含諸如寬度、高度和位置等幾何信息。就是我們 CSS 裏常提到的盒子模型。構建呈現樹時,需要計算每一個呈現對象的可視化屬性。這是通過計算每個元素的樣式屬性來完成的。由於應用規則涉及到相當複雜的層疊規則,所以給樣式樹的構建造成了巨大的困難。爲什麼說它複雜?因爲同一個元素可能涉及多條樣式,就需要判斷最終到底哪條樣式生效。首先我們來了解一下css的樣式層疊規則

    ①層疊規則:

    根據不同的樣式來源優先級排列從小到大:

    • 1>、用戶端聲明:來自瀏覽器的樣式,被稱作 UA style,是瀏覽器默認的樣式。 比如,對於 DIV 元素,瀏覽器默認其 ‘display’ 的特性值是 “block”,而 SPAN 是 “inline”。
    • 2>、一般用戶聲明:這個樣式表是使用瀏覽器的用戶,根據自己的偏好設置的樣式表。比如,用戶希望所有 P 元素中的字體都默認顯示成藍色,可以先定義一個樣式表,存成 css 文件。
    • 3>、一般作者聲明:即開發者在開發網頁時,所定義的樣式表。
    • 4>、加了’!important’ 的作者聲明
    • 5>、加了’!important’ 的用戶聲明

    !important 規則1:根據 CSS2.1 規範中的描述,’!important’ 可以提高樣式的優先級,它對樣式優先級的影響是巨大的。
    注意,’!important’ 規則在 IE7 以前的版本中是被支持不完善。因此,經常被用作 CSS hack2。

    如果來源和重要性相同則根據CSS specificity來進行判定。

    特殊性的值可以看作是一個由四個數組成的一個組合,用 a,b,c,d 來表示它的四個位置。 依次比較 a,b,c,d 這個四個數比較其特殊性的大小。比如,a 值相同,那麼 b 值大的組合特殊性會較大,以此類推。 注意,W3C 中並不是把它作爲一個 4 位數來看待的。
    a,b,c,d 值的確定規則:

    • 如果 HTML 標籤的 ‘style’ 屬性中該樣式存在,則記 a 爲 1;
    • 數一下選擇器中 ID 選擇器的個數作爲 b 的值。比如,樣式中包含 ‘#c1’ 和 ‘#c2’ 的選擇器;
    • 其他屬性以及僞類(pseudo-classes)的總數量是 c 的值。比如’.con’,’:hover’ 等;
    • 元素名和僞元素的數量是 d 的值

    在這裏我們來看一個W3C給出的例子:

    clipboard.png

    那麼在如下例子中字體的顯示應當爲綠色:

    clipboard.png

    總結爲表格的話計算規則如下:

    clipboard.png

    ②CSS解析

    爲了簡化樣式計算,Firefox 還採用了另外兩種樹:規則樹和樣式上下文樹。Webkit 也有樣式對象,但它們不是保存在類似樣式上下文樹這樣的樹結構中,只是由 DOM 節點指向此類對象的相關樣式。

    1>、Firefox的規則樹和樣式上下文樹:

    樣式上下文包含端值。要計算出這些值,應按照正確順序應用所有的匹配規則,並將其從邏輯值轉化爲具體的值。例如,如果邏輯值是屏幕大小的百分比,則需要換算成絕對的單位。規則樹的點子真的很巧妙,它使得節點之間可以共享這些值,以避免重複計算,還可以節約空間。
    所有匹配的規則都存儲在樹中。路徑中的底層節點擁有較高的優先級。規則樹包含了所有已知規則匹配的路徑。規則的存儲是延遲進行的。規則樹不會在開始的時候就爲所有的節點進行計算,而是隻有當某個節點樣式需要進行計算時,纔會向規則樹添加計算的路徑。
    這個想法相當於將規則樹路徑視爲詞典中的單詞。如果我們已經計算出如下的規則樹:

    clipboard.png

    假設我們需要爲內容樹中的另一個元素匹配規則,並且找到匹配路徑是 B - E - I(按照此順序)。由於我們在樹中已經計算出了路徑 A - B - E - I - L,因此就已經有了此路徑,這就減少了現在所需的工作量。

    那麼Firefox是如何解決樣式計算難題的呢?接下來看一個樣例,假設我們有如下HTML代碼:

    clipboard.png

    並且我們有如下規則:

    clipboard.png

    爲了簡便起見,我們只需要填充兩個結構:color 結構和 margin 結構。color 結構只包含一個成員(即“color”),而 margin 結構包含四條邊。
    形成的規則樹如下圖所示(節點的標記方式爲“節點名 : 指向的規則序號”):

    clipboard.png

    上下文樹如下圖所示(節點名 : 指向的規則節點):

    clipboard.png

    假設我們解析 HTML 時遇到了第二個 <div> 標記,我們需要爲此節點創建樣式上下文,並填充其樣式結構。
    經過規則匹配,我們發現該 <div> 的匹配規則是第 1、2 和 6 條。這意味着規則樹中已有一條路徑可供我們的元素使用,我們只需要再爲其添加一個節點以匹配第 6 條規則(規則樹中的 F 節點)。
    我們將創建樣式上下文並將其放入上下文樹中。新的樣式上下文將指向規則樹中的 F 節點。
    現在我們需要填充樣式結構。首先要填充的是 margin 結構。由於最後的規則節點 (F) 並沒有添加到 margin 結構,我們需要上溯規則樹,直至找到在先前節點插入中計算過的緩存結構,然後使用該結構。我們會在指定 margin 規則的最上層節點(即 B 節點)上找到該結構。
    我們已經有了 color 結構的定義,因此不能使用緩存的結構。由於 color 有一個屬性,我們無需上溯規則樹以填充其他屬性。我們將計算端值(將字符串轉化爲 RGB 等)並在此節點上緩存經過計算的結構。
    第二個 元素處理起來更加簡單。我們將匹配規則,最終發現它和之前的 span 一樣指向規則 G。由於我們找到了指向同一節點的同級,就可以共享整個樣式上下文了,只需指向之前 span 的上下文即可。
    對於包含了繼承自父代的規則的結構,緩存是在上下文樹中進行的(事實上 color 屬性是繼承的,但是 Firefox 將其視爲 reset 屬性,並緩存到規則樹上)。
    例如,如果我們在某個段落中添加 font 規則:

    clipboard.png

    那麼,該段落元素作爲上下文樹中的 div 的子代,就會共享與其父代相同的 font 結構(前提是該段落沒有指定 font 規則)。

    2>、Webkit的樣式解析

    在 Webkit 中沒有規則樹,因此會對匹配的聲明遍歷 4 次。首先應用非重要高優先級的屬性(由於作爲其他屬性的依據而應首先應用的屬性,例如 display),接着是高優先級重要規則,然後是普通優先級非重要規則,最後是普通優先級重要規則。這意味着多次出現的屬性會根據正確的層疊順序進行解析。最後出現的最終生效。

    四、渲染樹的構建

    樣式樹和DOM樹連接在一起形成一個渲染樹,渲染樹用來計算可見元素的佈局並且作爲將像素渲染到屏幕上的過程的輸入。值得一提的是,Gecko 將視覺格式化元素組成的樹稱爲“框架樹”。每個元素都是一個框架。Webkit 使用的術語是“渲染樹”,它由“呈現對象”組成。 Webkit 和 Gecko 使用的術語略有不同,但整體流程是基本相同的。

    接下來將來看一下兩種渲染引擎的工作流程:
    Webkit 主流程:

    ![clipboard.png

    Mozilla 的 Gecko 呈現引擎主流程

    clipboard.png

    雖然 Webkit 和 Gecko 使用的術語略有不同,但整體流程是基本相同的。

    Gecko 將視覺格式化元素組成的樹稱爲“框架樹”。每個元素都是一個框架。
    Webkit 使用的術語是“呈現樹”,它由“呈現對象”組成。
    對於元素的放置,Webkit 使用的術語是“佈局”,而 Gecko 稱之爲“重排”。
    對於連接 DOM 節點和可視化信息從而創建呈現樹的過程,Webkit 使用的術語是“附加”。有一個細微的非語義差別,就是 Gecko 在 HTML 與 DOM 樹之間還有一個稱爲“內容槽”的層,用於生成 DOM 元素。我們會逐一論述流程中的每一部分。

    五、關於瀏覽器渲染過程中需要了解的概念

    Repaint(重繪)——屏幕的一部分要重畫,比如某個CSS的背景色變了。但是元素的幾何尺寸沒有變。
    Reflow(重排)——意味着元件的幾何尺寸變了,我們需要重新驗證並計算Render Tree。是Render Tree的一部分或全部發生了變化。這就是Reflow,或是Layout。reflow 會從這個root frame開始遞歸往下,依次計算所有的結點幾何尺寸和位置,在reflow過程中,可能會增加一些frame,比如一個文本字符串必需被包裝起來。
    onload事件——當 onload 事件觸發時,頁面上所有的DOM,樣式表,腳本,圖片,flash都已經加載完成了。
    DOMContentLoaded事件——當 DOMContentLoaded 事件觸發時,僅當DOM加載完成,不包括樣式表,圖片,flash。
    首屏時間——當瀏覽器顯示第一屏頁面所消耗的時間,在國內的網絡條件下,通常一個網站,如果“首屏時間”在2秒以內是比較優秀的,5秒以內用戶可以接受,10秒以上就不可容忍了。
    白屏時間——指瀏覽器開始顯示內容的時間。但是在傳統的採集方式裏,是在HTML的頭部標籤結尾裏記錄時間戳,來計算白屏時間。在這個時刻,瀏覽器開始解析身體標籤內的內容。而現代瀏覽器不會等待CSS樹(所有CSS文件下載和解析完成)和DOM樹(整個身體標籤解析完成)構建完成纔開始繪製,而是馬上開始顯示中間結果。所以經常在低網速的環境中,觀察到頁面由上至下緩慢顯示完,或者先顯示文本內容後再重繪成帶有格式的頁面內容。

    六、頁面優化方案

    本文的主題在於從瀏覽器的渲染過程談頁面優化。瞭解瀏覽器如何通信並將拿到的數據如何進行解析渲染,本節將從網絡通信、頁面渲染、資源預取及如何除了以上方案外,如何藉助chrome來針對一個頁面進行實戰優化四個方面來談。

    從網絡通信過程入手可以做的優化

    減少DNS查找

    每一次主機名解析都需要一次網絡往返,從而增加請求的延遲時間,同時還會阻塞後續請求。

    重用TCP連接

    儘可能使用持久連接,以消除 TCP 握手和慢啓動延遲;

    減少HTTP重定向

    HTTP 重定向極費時間,特別是不同域名之間的重定向,更加費時;這裏面既有額外的 DNS 查詢、TCP 握手,還有其他延遲。最佳的重定向次數爲零。

    使用 CDN(內容分發網絡)

    把數據放到離用戶地理位置更近的地方,可以顯著減少每次 TCP 連接的網絡延遲,增大吞吐量。

    去掉不必要的資源

    任何請求都不如沒有請求快。說到這,所有建議都無需解釋。延遲是瓶頸,最快的速度莫過於什麼也不傳輸。然而,HTTP 也提供了很多額外的機制,比如緩存和壓縮,還有與其版本對應的一些性能技巧。

    在客戶端緩存資源

    應該緩存應用資源,從而避免每次請求都發送相同的內容。(瀏覽器緩存)

    傳輸壓縮過的內容

    傳輸前應該壓縮應用資源,把要傳輸的字節減至最少:確保每種要傳輸的資源採用最好的壓縮手段。(Gzip,減少60%~80%的文件大小)

    消除不必要的請求開銷

    減少請求的 HTTP 首部數據(比如HTTPcookie),節省的時間相當於幾次往返的延遲時間。

    並行處理請求和響應

    請求和響應的排隊都會導致延遲,無論是客戶端還是服務器端。這一點經常被忽視,但卻會無謂地導致很長延遲。

    針對協議版本採取優化措施

    HTTP 1.x 支持有限的並行機制,要求打包資源、跨域分散資源,等等。相對而言,
    HTTP 2.0 只要建立一個連接就能實現最優性能,同時無需針對 HTTP 1.x 的那些優化方法。
    但是壓縮、使用緩存、減少dns等的優化方案無論在哪個版本都同樣適用

    你需要了解的資源預取

    preload :可以對當前頁面所需的腳本、樣式等資源進行預加載,而無需等到解析到 script 和 link 標籤時才進行加載。這一機制使得資源可以更早的得到加載並可用,且更不易阻塞頁面的初步渲染,進而提升性能。
    用法文檔:

    https://developer.mozilla.org...

    prefetch:prefetch 和 preload 一樣,都是對資源進行預加載,但是 prefetch 一般預加載的是其他頁面會用到的資源。 當然,prefetch 不會像 preload 一樣,在頁面渲染的時候加載資源,而是利用瀏覽器空閒時間來下載。當進入下一頁面,就可直接從 disk cache 裏面取,既不影響當前頁面的渲染,又提高了其他頁面加載渲染的速度。
    用法文檔:

    https://developer.mozilla.org...

    subresource: 被Chrome支持了有一段時間,並且已經有些搔到預加載當前導航/頁面(所含有的資源)的癢處了。但它有一個問題——沒有辦法處理所獲取內容的優先級(as也並不存在),所以最終,這些資源會以一個相當低的優先級被加載,這使得它能提供的幫助相當有限

    prerender:prerender 就像是在後臺打開了一個隱藏的 tab,會下載所有的資源、創建DOM、渲染頁面、執行js等等。如果用戶進入指定的鏈接,隱藏的這個頁面就會立馬進入用戶的視線。 但是要注意,一定要在十分確定用戶會點擊某個鏈接時才使用該特性,否則客戶端會無端的下載很多資源和渲染這個頁面。 正如任何提前動作一樣,預判總是有一定風險出錯。如果提前的動作是昂貴的(比如高CPU、耗電、佔用帶寬),就要謹慎使用了。

    preconnect: preconnect 允許瀏覽器在一個 HTTP 請求正式發給服務器前預先執行一些操作,這包括

    dns-prefetch:通過 DNS 預解析來告訴瀏覽器未來我們可能從某個特定的 URL 獲取資源,當瀏覽器真正使用到該域中的某個資源時就可以儘快地完成 DNS 解析

    這些屬性雖然並非所有瀏覽器都支持,但是不支持的瀏覽器也只是不處理而已,而是別的話則會省去很多時間。因此,合理的使用資源預取可以顯著提高頁面性能。

    高效合理的css選擇符可以減輕瀏覽器的解析負擔。

    因爲css是逆向解析的所以應當避免多層嵌套。

    避免使用通配規則。如 *{} 計算次數驚人!只對需要用到的元素進行選擇

    儘量少的去對標籤進行選擇,而是用class。如:#nav li{},可以爲li加上nav_item的類名,如下選擇.nav_item{}

    不要去用標籤限定ID或者類選擇符。如:ul#nav,應該簡化爲#nav

    儘量少的去使用後代選擇器,降低選擇器的權重值。後代選擇器的開銷是最高的,儘量將選擇器的深度降到最低,最高不要超過三層,更多的使用類來關聯每一個標籤元素。

    考慮繼承。瞭解哪些屬性是可以通過繼承而來的,然後避免對這些屬性重複指定規則

    從js層面談頁面優化

    ①解決渲染阻塞
    如果在解析HTML標記時,瀏覽器遇到了JavaScript,解析會停止。只有在該腳本執行完畢後,HTML渲染纔會繼續進行。所以這阻塞了頁面的渲染。
    解決方法:在標籤中使用 async或defer特性
    ②減少對DOM的操作
    對DOM操作的代價是高昂的,這在網頁應用中的通常是一個性能瓶頸。
    解決辦法:修改和訪問DOM元素會造成頁面的Repaint和Reflow,循環對DOM操作更是罪惡的行爲。所以請合理的使用JavaScript變量儲存內容,考慮大量DOM元素中循環的性能開銷,在循環結束時一次性寫入。
    減少對DOM元素的查詢和修改,查詢時可將其賦值給局部變量。
    ③使用JSON格式來進行數據交換
    JSON是一種輕量級的數據交換格式,採用完全獨立於語言的文本格式,是理想的數據交換格式。同時,JSON是 JavaScript原生格式,這意味着在 JavaScript 中處理 JSON數據不需要任何特殊的 API 或工具包。
    ④讓需要經常改動的節點脫離文檔流
    因爲重繪有時確實不可避免,所以只能儘可能限制重繪的影響範圍。

    如何藉助chrome針對性優化頁面

    首先打開控制檯,點擊Audits一欄,會看到如下表單。在選取自己需要模擬測試的情況後點擊run audits,即可開始頁面性能分析。

    clipboard.png

    然後將會得到分析結果及優化建議:

    clipboard.png

    我們可以逐項根據現有問題進行優化,如性能類目(performance)中的第一項優化建議延遲加載屏幕外圖像(defer offscreen images),點擊後就能看到詳情以下詳情:

    clipboard.png

    而具體頁面的指標優化可以根據給出的建議進行逐條優化。目前提供的性能分析及建議的列表包括性能分析、漸進式web應用、最佳實踐、無障礙訪問及搜索引擎優化五個部分。基本上涵蓋了常見優化方案及性能點的方方面面,開發時合理使用也能更好的提升頁面性能

    相信以上優化方案之所以行之有效的原因大都可以在本文中找出原因。理論是用來指導實踐的,即不能閉門造車式的埋頭苦幹,也不能毫不實踐的誇誇其談。這樣纔會形成完整的知識體系,讓知識體系樹更加龐大。知道該如何優化是一回事,真正合理應用是另一回事,要有好的性能,要着手於能做的每一件“小事”。

    七、附錄

    性能優化是一門藝術,更是一門綜合藝術。這其中涉及很多知識點。而這些知識點都有很多不錯的文章進行了總結。如果你想深入探究或許這裏推薦的文章會給你啓發。

    HTTP2詳解:

    https://www.jianshu.com/p/e57...
    TCP擁塞控制:

    https://www.cnblogs.com/losby...
    頁面性能分析網站:

    https://gtmetrix.com/analyze....
    Timing官方文檔:

    https://www.w3.org/TR/navigat...
    chrome中的高性能網絡:

    https://www.cnblogs.com/xuan5...

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章