大多數設備的刷新頻率是60Hz,也就說是瀏覽器對每一幀畫面的渲染工做要在16ms內完成,超出這個時間,頁面的渲染就會出現卡頓現象,影響用戶體驗。前端的用戶體驗給了前端直觀的印象,所以對B/S架構的開發人員來講,熟悉瀏覽器的內部執行原理顯得尤其重要。css
瀏覽器大致上由如下幾個組件組成,各個瀏覽器可能有一點不一樣。html
web database
注意:chrome瀏覽器與其餘瀏覽器不一樣,chrome使用多個渲染引擎實例,每一個Tab頁一個,即每一個Tab都是一個獨立進程。前端
Chrome瀏覽器使用多個進程來隔離不一樣的網頁,在Chrome中打開一個網頁至關於起了一個進程,每一個tab網頁都有由其獨立的渲染引擎實例。由於若是非多進程的話,若是瀏覽器中的一個tab網頁崩潰,將會致使其餘被打開的網頁應用。另外相對於線程,進程之間是不共享資源和地址空間的,因此不會存在太多的安全問題,而因爲多個線程共享着相同的地址空間和資源,因此會存在線程之間有可能會惡意修改或者獲取非受權數據等複雜的安全問題。html5
在內核控制下各線程相互配合以保持同步,一個瀏覽器一般由如下常駐線程組成:jquery
用戶請求的HTML文本(text/html)經過瀏覽器的網絡層到達渲染引擎後,渲染工做開始。每次一般渲染不會超過8K的數據塊,其中基礎的渲染流程圖:git
webkit引擎渲染的詳細流程,其餘引擎渲染流程稍有不一樣:github
渲染流程有四個主要步驟:web
以上步驟是一個漸進的過程,爲了提升用戶體驗,渲染引擎試圖儘量快的把結果顯示給最終用戶。它不會等到全部HTML都被解析完才建立並佈局渲染樹。它會在從網絡層獲取文檔內容的同時把已經接收到的局部內容先展現出來。chrome
DOM樹的構建過程是一個深度遍歷過程:當前節點的全部子節點都構建好後纔會去構建當前節點的下一個兄弟節點。DOM樹的根節點就是document對象。數據庫
DOM樹的生成過程當中可能會被CSS和JS的加載執行阻塞,具體能夠參見下一章。當HTML文檔解析過程完畢後,瀏覽器繼續進行標記爲deferred模式的腳本加載,而後就是整個解析過程的實際結束觸發DOMContentLoaded事件,並在async文檔文檔執行完以後觸發load事件。
生成DOM樹的同時會生成樣式結構體CSSOM(CSS Object Model)Tree,再根據CSSOM和DOM樹構造渲染樹Render Tree,渲染樹包含帶有顏色,尺寸等顯示屬性的矩形,這些矩形的順序與顯示順序基本一致。從MVC的角度來講,能夠將Render樹當作是V,DOM樹與CSSOM樹當作是M,C則是具體的調度者,比HTMLDocumentParser等。
能夠這麼說,沒有DOM樹就沒有Render樹,可是它們之間不是簡單的一對一的關係。Render樹是用於顯示,那不可見的元素固然不會在這棵樹中出現了,譬如<head>
。除此以外,display等於none的也不會被顯示在這棵樹裏頭,可是visibility等於hidden的元素是會顯示在這棵樹裏頭的。
DOM對象類型很豐富,什麼head、title、div,而Render樹相對來講就比較單一了,畢竟它的職責就是爲了之後的顯示渲染用嘛。Render樹的每個節點咱們叫它渲染器renderer。
一棵Render樹大概是醬紫,左邊是DOM樹,右邊是Render樹:
從上圖咱們能夠看出,renderer與DOM元素是相對應的,但並非一一對應,有些DOM元素沒有對應的renderer,而有些DOM元素卻對應了好幾個renderer,對應多個renderer的狀況是廣泛存在的,就是爲了解決一個renderer描述不清楚如何顯示出來的問題,譬若有下拉列表的select元素,咱們就須要三個renderer:一個用於顯示區域,一個用於下拉列表框,還有一個用於按鈕。
另外,renderer與DOM元素的位置也多是不同的。那些添加了float
或者position:absolute
的元素,由於它們脫離了正常的文檔流,構造Render樹的時候會針對它們實際的位置進行構造。
上面肯定了renderer的樣式規則後,而後就是重要的顯示元素佈局了。當renderer構造出來並添加到Render樹上以後,它並無位置跟大小信息,爲它肯定這些信息的過程,接下來是佈局(layout)。
瀏覽器進行頁面佈局基本過程是以瀏覽器可見區域爲畫布,左上角爲(0,0)
基礎座標,從左到右,從上到下從DOM的根節點開始畫,首先肯定顯示元素的大小跟位置,此過程是經過瀏覽器計算出來的,用戶CSS中定義的量未必就是瀏覽器實際採用的量。若是顯示元素有子元素得先去肯定子元素的顯示信息。
佈局階段輸出的結果稱爲box盒模型(width,height,margin,padding,border,left,top,…),盒模型精確表示了每個元素的位置和大小,而且全部相對度量單位此時都轉化爲了絕對單位。
在繪製(painting)階段,渲染引擎會遍歷Render樹,並調用renderer的 paint() 方法,將renderer的內容顯示在屏幕上。繪製工做是使用UI後端組件完成的。
迴流(reflow):當瀏覽器發現某個部分發生了點變化影響了佈局,須要倒回去從新渲染。reflow 會從<html>
這個 root frame
開始遞歸往下,依次計算全部的結點幾何尺寸和位置。reflow 幾乎是沒法避免的。如今界面上流行的一些效果,好比樹狀目錄的摺疊、展開(實質上是元素的顯示與隱藏)等,都將引發瀏覽器的 reflow。鼠標滑過、點擊……只要這些行爲引發了頁面上某些元素的佔位面積、定位方式、邊距等屬性的變化,都會引發它內部、周圍甚至整個頁面的從新渲染。一般咱們都沒法預估瀏覽器到底會 reflow 哪一部分的代碼,它們都彼此相互影響着。
重繪(repaint):改變某個元素的背景色、文字顏色、邊框顏色等等不影響它周圍或內部佈局的屬性時,屏幕的一部分要重畫,可是元素的幾何尺寸沒有變。
每次Reflow,Repaint後瀏覽器還須要合併渲染層並輸出到屏幕上。全部的這些都會是動畫卡頓的緣由。
Reflow 的成本比 Repaint 的成本高得多的多。一個結點的 Reflow 頗有可能致使子結點,甚至父點以及同級結點的 Reflow 。在一些高性能的電腦上也許還沒什麼,可是若是 Reflow 發生在手機上,那麼這個過程是延慢加載和耗電的。能夠在csstrigger上查找某個css屬性會觸發什麼事件。
reflow與repaint的時機:
display:none
會觸發 reflow,而 visibility:hidden
只會觸發 repaint,由於沒有發生位置變化。在瀏覽器拿到HTML、CSS、JS等外部資源到渲染出頁面的過程,有一個重要的概念關鍵渲染路徑(Critical Rendering Path)。例如爲了保障首屏內容的最快速顯示,一般會提到一個漸進式頁面渲染,可是爲了漸進式頁面渲染,就須要作資源的拆分,那麼以什麼粒度拆分、要不要拆分,不一樣頁面、不一樣場景策略不一樣。具體方案的肯定既要考慮體驗問題,也要考慮工程問題。瞭解原理可讓咱們更好的優化關鍵渲染路徑,從而得到更好的用戶體驗。
現代瀏覽器老是並行加載資源,例如,當 HTML 解析器(HTML Parser)被腳本阻塞時,解析器雖然會中止構建 DOM,但仍會識別該腳本後面的資源,並進行預加載。
同時,因爲下面兩點:
存在阻塞的 CSS 資源時,瀏覽器會延遲 JavaScript 的執行和 DOM 構建。另外:
因此,script 標籤的位置很重要。實際使用時,能夠遵循下面兩個原則:
下面來看看 CSS 與 JavaScript 是具體如何阻塞資源的。
<style> p { color: red; }</style> <link rel="stylesheet" href="index.css">
這樣的 link 標籤(不管是否 inline)會被視爲阻塞渲染的資源,瀏覽器會優先處理這些 CSS 資源,直至 CSSOM 構建完畢。
渲染樹(Render-Tree)的關鍵渲染路徑中,要求同時具備 DOM 和 CSSOM,以後纔會構建渲染樹。即,HTML 和 CSS 都是阻塞渲染的資源。HTML 顯然是必需的,由於包括咱們但願顯示的文本在內的內容,都在 DOM 中存放,那麼能夠從 CSS 上想辦法。
最容易想到的固然是精簡 CSS 並儘快提供它。除此以外,還能夠用媒體類型(media type)和媒體查詢(media query)來解除對渲染的阻塞。
<link href="index.css" rel="stylesheet"> <link href="print.css" rel="stylesheet" media="print"> <link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">
第一個資源會加載並阻塞。
第二個資源設置了媒體類型,會加載但不會阻塞,print 聲明只在打印網頁時使用。
第三個資源提供了媒體查詢,會在符合條件時阻塞渲染。
關於CSS加載的阻塞狀況:
沒有js的理想狀況下,html與css會並行解析,分別生成DOM與CSSOM,而後合併成Render Tree,進入Rendering Pipeline;但若是有js,css加載會阻塞後面js語句的執行,而(同步)js腳本執行會阻塞其後的DOM解析(因此一般會把css放在頭部,js放在body尾)
JavaScript 的狀況比 CSS 要更復雜一些。若是沒有 defer 或 async,瀏覽器會當即加載並執行指定的腳本,「當即」指的是在渲染該 script 標籤之下的HTML元素以前,也就是說不等待後續載入的HTML元素,讀到就加載並執行。觀察下面的代碼:
<p>Do not go gentle into that good night,</p> <script>console.log("inline1")</script> <p>Old age should burn and rave at close of day;</p> <script src="app.js"></script> <p>Rage, rage against the dying of the light.</p> <script src="app.js"></script> <p>Old age should burn and rave at close of day;</p> <script>console.log("inline2")</script> <p>Rage, rage against the dying of the light.</p>
這裏的 script 標籤會阻塞 HTML 解析,不管是否是 inline-script。上面的 P 標籤會從上到下解析,這個過程會被兩段 JavaScript 分別打斷一次(加載、執行)。
解析過程當中不管遇到的JavaScript是內聯仍是外鏈,只要瀏覽器遇到 script 標記,喚醒JavaScript解析器,就會進行暫停 (blocked )瀏覽器解析HTML,並等到 CSSOM 構建完畢,纔去執行js腳本。由於腳本中可能會操做DOM元素,而若是在加載執行腳本的時候DOM元素並無被解析,腳本就會由於DOM元素沒有生成取不到響應元素,因此實際工程中,咱們經常將資源放到文檔底部。
defer
與 async
能夠改變以前的那些阻塞情形,這兩個屬性都會使 script 異步加載,然而執行的時機是不同的。注意 async 與 defer 屬性對於 inline-script 都是無效的,因此下面這個示例中三個 script 標籤的代碼會從上到下依次執行。
<script async>console.log("1")</script> <script defer>console.log("2")</script> <script>console.log("3")</script>
上面腳本會按需輸出 1 2 3,故,下面兩節討論的內容都是針對設置了 src 屬性的 script 標籤。
先放個熟悉的圖~
藍色線表明網絡讀取,紅色線表明執行時間,這倆都是針對腳本的;綠色線表明 HTML 解析。
<script src="app1.js" defer></script> <script src="app2.js" defer></script> <script src="app3.js" defer></script>
defer 屬性表示延遲執行引入 JavaScript,即 JavaScript 加載時 HTML 並未中止解析,這兩個過程是並行的。整個 document 解析完畢且 defer-script 也加載完成以後(這兩件事情的順序無關),會執行全部由 defer-script 加載的 JavaScript 代碼,再觸發DOMContentLoaded
(初始的 HTML 文檔被徹底加載和解析完成以後觸發,無需等待樣式表圖像和子框架的完成加載) 事件。
defer 不會改變 script 中代碼的執行順序,示例代碼會按照 一、二、3 的順序執行。因此,defer 與相比普通 script,有兩點區別:載入 JavaScript 文件時不阻塞 HTML 的解析,執行階段被放到 HTML 標籤解析完成以後。
async 屬性表示異步執行引入的 JavaScript,與 defer 的區別在於,若是已經加載好,就會開始執行,不管此刻是 HTML 解析階段仍是 DOMContentLoaded 觸發(HTML解析完成事件)以後。須要注意的是,這種方式加載的 JavaScript 依然會阻塞 load 事件。換句話說,async-script 可能在 DOMContentLoaded 觸發以前或以後執行,但必定在 load 觸發以前執行。
從上一段也能推出,多個 async-script 的執行順序是不肯定的,誰先加載完誰執行。值得注意的是,向 document 動態添加 script 標籤時,async 屬性默認是 true。
使用 document.createElement 建立的 script 默認是異步的,示例以下。
console.log(document.createElement("script").async); // true
因此,經過動態添加 script 標籤引入 JavaScript 文件默認是不會阻塞頁面的。若是想同步執行,須要將 async 屬性人爲設置爲 false。
若是使用 document.createElement 建立 link 標籤會怎樣呢?
const style = document.createElement("link"); style.rel = "stylesheet"; style.href = "index.css"; document.head.appendChild(style); // 阻塞?
其實這隻能經過試驗肯定,已知的是,Chrome 中已經不會阻塞渲染,Firefox、IE 在之前是阻塞的,如今會怎樣目前不太清楚。
結合渲染流程,能夠針對性的優化渲染性能:
這裏主要參考Google的瀏覽器渲染性能的基礎講座,想看更詳細內容能夠去瞅瞅~
setTimeout(callback)和setInterval(callback)沒法保證callback函數的執行時機,極可能在幀結束的時候執行,從而致使丟幀,以下圖:
requestAnimationFrame(callback)
能夠保證callback函數在每幀動畫開始的時候執行。
注意:jQuery3.0.0之前版本的animate函數就是用setTimeout來實現動畫,能夠經過jquery-requestAnimationFrame這個補丁來用requestAnimationFrame替代setTimeout
JS代碼運行在瀏覽器的主線程上,與此同時,瀏覽器的主線程還負責樣式計算、佈局、繪製的工做,若是JavaScript代碼運行時間過長,就會阻塞其餘渲染工做,極可能會致使丟幀。
前面提到每幀的渲染應該在16ms內完成,但在動畫過程當中,因爲已經被佔用了很多時間,因此JavaScript代碼運行耗時應該控制在3-4毫秒。
若是真的有特別耗時且不操做DOM元素的純計算工做,能夠考慮放到Web Workers中執行。
var dataSortWorker = new Worker("sort-worker.js"); dataSortWorker.postMesssage(dataToSort); // 主線程不受Web Workers線程干擾 dataSortWorker.addEventListener('message', function(evt) { var sortedData = e.data; // Web Workers線程執行結束 // ... });
因爲Web Workers不能操做DOM元素的限制,因此只能作一些純計算的工做,對於不少須要操做DOM元素的邏輯,能夠考慮分步處理,把任務分爲若干個小任務,每一個任務都放到requestAnimationFrame
中回調執行
var taskList = breakBigTaskIntoMicroTasks(monsterTaskList); requestAnimationFrame(processTaskList); function processTaskList(taskStartTime) { var nextTask = taskList.pop(); // 執行小任務 processTask(nextTask); if (taskList.length > 0) { requestAnimationFrame(processTaskList); } }
打開Chrome DevTools > Timeline > JS Profile
,錄製一次動做,而後分析獲得的細節信息,從而發現問題並修復問題。
添加或移除一個DOM元素、修改元素屬性和樣式類、應用動畫效果等操做,都會引發DOM結構的改變,從而致使瀏覽器要repaint或者reflow。那麼這裏能夠採起一些措施。
儘可能保持class的簡短,或者使用Web Components框架。
.box:nth-last-child(-n+1) .title {} // 改善後 .final-box-title {}
因爲瀏覽器的優化,現代瀏覽器的樣式計算直接對目標元素執行,而不是對整個頁面執行,因此咱們應該儘量減小須要執行樣式計算的元素的個數。
佈局就是計算DOM元素的大小和位置的過程,若是你的頁面中包含不少元素,那麼計算這些元素的位置將耗費很長時間。
佈局的主要消耗在於:1. 須要佈局的DOM元素的數量;2. 佈局過程的複雜程度
當你修改了元素的屬性以後,瀏覽器將會檢查爲了使這個修改生效是否須要從新計算佈局以及更新渲染樹,對於DOM元素的幾何屬性修改,好比width/height/left/top等,都須要從新計算佈局。
對於不能避免的佈局,可使用Chrome DevTools工具的Timeline查看佈局的耗時,以及受影響的DOM元素數量。
老的佈局模型以相對/絕對/浮動的方式將元素定位到屏幕上,而Floxbox佈局模型用流式佈局的方式將元素定位到屏幕上。
經過一個小實驗能夠看出兩種佈局模型的性能差距,一樣對1300個元素佈局,浮動佈局耗時14.3ms,Flexbox佈局耗時3.5ms。
IE10+支持。
根據渲染流程,JS腳本是在layout以前執行,可是咱們能夠強制瀏覽器在執行JS腳本以前先執行佈局過程,這就是所謂的強制同步佈局。
requestAnimationFrame(logBoxHeight); // 先寫後讀,觸發強制佈局 function logBoxHeight() { // 更新box樣式 box.classList.add('super-big'); // 爲了返回box的offersetHeight值 // 瀏覽器必須先應用屬性修改,接着執行佈局過程 console.log(box.offsetHeight); } // 先讀後寫,避免強制佈局 function logBoxHeight() { // 獲取box.offsetHeight console.log(box.offsetHeight); // 更新box樣式 box.classList.add('super-big'); }
在JS腳本運行的時候,它能獲取到的元素樣式屬性值都是上一幀畫面的,都是舊的值。所以,若是你在當前幀獲取屬性以前又對元素節點有改動,那就會致使瀏覽器必須先應用屬性修改,結果執行佈局過程,最後再執行JS邏輯。
若是連續快速的屢次觸發強制同步佈局,那麼結果更糟糕。
好比下面的例子,獲取box的屬性,設置到paragraphs上,因爲每次設置paragraphs都會觸發樣式計算和佈局過程,而下一次獲取box的屬性必須等到上一步設置結束以後才能觸發。
function resizeWidth() { // 會讓瀏覽器陷入'讀寫讀寫'循環 for (var i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = box.offsetWidth + 'px'; } } // 改善後方案 var width = box.offsetWidth; function resizeWidth() { for (var i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = width + 'px'; } }
注意:可使用FastDOM來確保讀寫操做的安全,從而幫你自動完成讀寫操做的批處理,還能避免意外地觸發強制同步佈局或快速連續佈局,消除大量操做DOM的時候的佈局抖動。
Paint就是填充像素的過程,一般這個過程是整個渲染流程中耗時最長的一環,所以也是最須要避免發生的一環。
若是Layout被觸發,那麼接下來元素的Paint必定會被觸發。固然純粹改變元素的非幾何屬性,也可能會觸發Paint,好比背景、文字顏色、陰影效果等。
繪製並不是老是在內存中的單層畫面裏完成的,實際上,瀏覽器在必要時會將一幀畫面繪製成多層畫面,而後將這若干層畫面合併成一張圖片顯示到屏幕上。
這種繪製方式的好處是,使用transform來實現移動效果的元素將會被正常繪製,同時不會觸發其餘元素的繪製。
瀏覽器會把相鄰區域的渲染任務合併在一塊兒進行,因此須要對動畫效果進行精密設計,以保證各自的繪製區域不會有太多重疊。
另外能夠實現一樣效果的不一樣方式,應該採用性能更好的那種。
打開DevTools,在彈出的面板中,選中More Tools > Rendering
選項卡下的Paint flashing,這樣每當頁面發生繪製的時候,屏幕就會閃現綠色的方框。經過該工具能夠檢查Paint發生的區域和時機是否是能夠被優化。
經過Chrome DevTools中的Timeline > Paint
選項能夠查看更細節的Paint信息
使用transform/opacity實現動畫效果,會跳過渲染流程的佈局和繪製環節,只作渲染層的合併。
Type | Func |
---|---|
Position | transform: translate(-px,-px) |
Scale | transform: scale(-) |
Rotation | transform: rotate(-deg) |
Skew | transform: skew(X/Y)(-deg) |
Matrix | transform: matrix(3d)(..) |
Opacity | opacity: 0-1 |
使用transform/opacity的元素必須獨佔一個渲染層,因此必須提高該元素到單獨的渲染層。
應用動畫效果的元素應該被提高到其自有的渲染層,但不要濫用。
在頁面中建立一個新的渲染層最好的方式就是使用CSS屬性will-change,對於目前還不支持will-change屬性、但支持建立渲染層的瀏覽器,能夠經過3D transform屬性來強制瀏覽器建立一個新的渲染層。須要注意的是,不要建立過多的渲染層,這意味着新的內存分配和更復雜的層管理。
注意,IE11,Edge17都不支持這一屬性。
.moving-element { will-change: transform; transform: translateZ(0); }
儘管提高渲染層看起來很誘人,但不能濫用,由於更多的渲染層意味着更多的額外的內存和管理資源,因此當且僅當須要的時候才爲元素建立渲染層。
* { will-change: transform; transform: translateZ(0); }
開啓Timeline > Paint
選項,而後錄製一段時間的操做,選擇單獨的幀,看到每一個幀的渲染細節,在ESC彈出框有個Layers選項,能夠看到渲染層的細節,有多少渲染層,爲什麼被建立?
用戶輸入事件處理函數會在運行時阻塞幀的渲染,而且會致使額外的佈局發生。
理想狀況下,當用戶和頁面交互,頁面的渲染層合併線程將接收到這個事件並移動元素。這個響應過程是不須要主線程參與,不會致使JavaScript、佈局和繪製過程發生。
可是若是被觸摸的元素綁定了輸入事件處理函數,好比touchstart/touchmove/touchend,那麼渲染層合併線程必須等待這些被綁定的處理函數執行完畢才能執行,也就是用戶的滾動頁面操做被阻塞了,表現出的行爲就是滾動出現延遲或者卡頓。
簡而言之就是你必須確保用戶輸入事件綁定的任何處理函數都可以快速的執行完畢,以便騰出時間來讓渲染層合併線程完成他的工做。
輸入事件處理函數,好比scroll/touch事件的處理,都會在requestAnimationFrame以前被調用執行。
所以,若是你在上述輸入事件的處理函數中作了修改樣式屬性的操做,那麼這些操做就會被瀏覽器暫存起來,而後在調用requestAnimationFrame的時候,若是你在一開始就作了讀取樣式屬性的操做,那麼將會觸發瀏覽器的強制同步佈局操做。
經過requestAnimationFrame能夠對樣式修改操做去抖動,同時也可使你的事件處理函數變得更輕
function onScroll(evt) { // Store the scroll value for laterz. lastScrollY = window.scrollY; // Prevent multiple rAF callbacks. if (scheduledAnimationFrame) { return; } scheduledAnimationFrame = true; requestAnimationFrame(readAndUpdatePage); } window.addEventListener('scroll', onScroll);
網上的帖子大多深淺不一,甚至有些先後矛盾,在下的文章都是學習過程當中的總結,若是發現錯誤,歡迎留言指出~
參考:
PS:歡迎你們關注個人公衆號【前端下午茶】,一塊兒加油吧~
另外能夠加入「前端下午茶交流羣」微信羣,長按識別下面二維碼便可加我好友,備註加羣,我拉你入羣~