
前言
前端代碼離不開瀏覽器環境,理解 js、css 代碼如何在瀏覽器中工作是非常重要的。
如何優化渲染過程中的回流,重繪?script 腳本在頁面中是怎么個加載順序?了解這些對前端性能優化起著非常大的作用。
借著這篇文章,讓自己對這塊知識的理解更深一步。
渲染
渲染樹(Render Tree)
瀏覽器通過解析 HTML 和 CSS 后,形成對應的 DOM 樹和 CSSOM 樹。
從根節點開始解析 DOM 樹節點并匹配對應的 CSSOM 樣式規則,選擇可見的的節點,最終結合成一顆渲染樹。

從上圖能看到渲染樹的特點:
- 渲染樹中不包含 head、script、link、meta 之類不可見的節點
- CSS 定義的樣式規則將和實際的 DOM 匹配,并且被 display:none 修飾的節點最終不會出現在渲染樹中
渲染階段

根據上圖,整個渲染階段分為三部分:
- 渲染樹的形成:通過 DOM 和 CSSOM 形成渲染樹
- 布局 Layout(自動重排 Reflow):基于頁面的流式布局,遍歷渲染樹節點,不斷計算節點最終的位置,幾何信息,樣式等屬性后,輸出一個“盒模型”
- 繪制 Paint(柵格化):將節點位置,大小根據屏幕的窗口大小換算成真實的像素,同顏色等屬性一同“畫到”頁面上
回流和重繪
基本概念
- 回流 Reflow:某些元素位置、幾何形狀的更改需要瀏覽器重新計算相關元素。
- 重繪 Repaint:將回流重排好的元素繪制到頁面上,但也因某些 js、css 的修改導致渲染樹發生變化,瀏覽器需要再次繪制頁面。
兩者的關系:觸發回流一定會觸發重繪, 而觸發重繪卻不一定會觸發回流
下圖很形象的展示了 Mozilla 頁面的渲染過程。

觸發回流條件
- 首次布局渲染頁面
- 改變瀏覽器窗口大小
- 改變字體
- 網頁內容變化
- 觸發 CSS 偽類
- 操作 DOM
- style 樣式表發生變化
- 調用 DOM 元素的 offsetXX, clientXX,scrollXX,getClientRects 等屬性方法,獲取元素當前的位置度量信息(參見)
如何測試網頁性能
都知道頻繁的渲染過程會影響網頁性能,但怎么知道網頁開始渲染內容了呢?
我們可以通過 Chrome 的 F12,選擇 Rendering 來查看網頁的性能。


- Paint flashing: 以綠色高亮重繪區域
- Layout Shift Regions: 以藍色高亮布局發生變化的區域
結合上面的方法,用 一個簡單的 Demo 來示意:

能從圖中看到,這些操作 觸發了瀏覽器的重繪:
- 鼠標移至按鈕上,觸發了默認的 hover 效果(出現綠框)
- 改變元素 color 屬性(出現綠框)
- 修改元素 top 屬性,不斷改變元素位置影響布局(出現綠框,藍框)
提升渲染性能
布局/回流 和 繪制/重繪 是頁面渲染必須會經過的兩個過程,不斷觸發它們肯定會增加性能的消耗。
瀏覽器會對這些操作做優化(把它們放到一個隊列,批量進行操作),但如果我們調用上面提到的 offsetXX, clientXX,scrollXX,getClientRects 等屬性方法就會強制刷新這個隊列,導致這些隊列批量優化無效。
下面列舉一些簡單優化方式:
- 不要使用 table 布局 table 布局會破壞 HTML 流式解析過程,甚至內部元素改動會觸發整個 table 重繪
- 將需更改的 class 放到最里層 明確元素位置,減少父類元素不必要渲染判斷
- 使用 fixed、absolute 屬性修飾復雜多變的處理(動畫) 將改變范圍降到最低程度,避免影響到父級元素
- 合并,減少 DOM 操作;通過虛擬 DOM 來代替
腳本的加載
link 和 script 加載文件的差異
注:均放在 head 標簽內。
考個問題:CSS 定義在 head 中,其需加載 5 秒,請問頁面加載后內容會先優先展示嗎?
我被渲染出來了
我原先以為頁面內容會優先渲染,CSS 加載完成后才改變內容樣式。其實這是錯的。

從上圖看到,頁面加載后,body 內元素就已經解析好了,只是沒有渲染到頁面上。隨后 CSS 文件加載后,帶有樣色的內容才被渲染到頁面上。
延遲的 link 的加載阻斷了頁面渲染,但并沒有影響 HTML 的解析,當 CSS 加載后,DOM 完成解析,CSSOM 和 DOM 形成渲染樹,最后將內容渲染到頁面上。
反問,將 link 替換成 script 效果也一樣嗎?

與 link 不同,script 的加載會阻斷頁面 HTML 的解析,瀏覽器解析完 script 后,會等待 js 文件加載完后,頁面才開始后續的解析,body 內容才出現。
head 和 body 中的 script 標簽
學前端時相信都聽過這樣的名言:
CSS 寫在 head 里,js 寫在 body 結束標簽前
知道了上面 link 和 script 的區別后,應該明白前半句的含義,下面來解釋下后半句。
下面 script 均在 body 中。
頁面渲染 和 script 加載
先看下腳本在 body 中的一般情況:
在 body 內部的首位分別加載兩個 js 文件,前者延遲 3 秒,后者延遲 5 秒,為了清楚他們的“工作”情況,在 head 中添加了定時器示意。
我被渲染了

能看到 body 中定義的內聯腳本首先工作,初始化 foo 變量。
隨后加載 addTen.js,并阻斷頁面渲染。3 秒后,輸出 js 內容(foo 賦值為 10),頁面并重新開始解析,展示 div 內容。
最后加載 addOne.js ,繼續等待 2 秒后,輸出 js 內容(foo 賦值為 11)。

多個 script 文件的加載
如果前一個 js 文件加載慢于后一個,會有怎么個效果?
我被渲染了
兩個 script 標簽并行加載,1 秒后 addOne.js 首先加載完畢,等待 4s 秒后,addTen.js 加載完后,頁面直接渲染(因為 script 已經全部完成)。

簡單總結下
- 無論在 head 還是 body 中,瀏覽器會等待 script 文件的加載(阻斷頁面解析渲染)
- 多個 script 的文件加載是異步的,不存在互相影響(后一個文件不需要等待前一個加載完后才下載),執行順序同定義順序
所以建議 script 放在 body 結束標簽之前,確保頁面內容全部解析完成并開始渲染。
DOM 的 DOMContentLoaded 事件
DOMContentLoaded 事件可以來確定整個 DOM 是否全部加載完成,下面我們簡單測試下:
我被渲染了
最終輸出:
addTen.jsfoo 10addOne.jsfoo 11[ready] document
DOMContentLoaded 事件的定義是異步回調方式,當 DOM 加載完成后觸發,即使寫在最前面,也會等待后面的 script 加載完成后才觸發。
這里順便提個 window.onload :
window.onload 和 DOMContentLoaded 不同,前者會等待頁面中所有的資源加載完畢后再調用執行(比如:img 標簽),后者在 DOM 加載完畢后即觸發。
“真正的異步腳本”——動態腳本
能看到無論 script 放在那個位置,瀏覽器都會等待他們直至 body 內的文件全部加載完。
那有什么 真正的異步 腳本加載嗎?(不會阻斷頁面解析)
那就是 動態腳本。
如果你接觸過第三方網頁統計腳本,那將比較了解,下面給段示例代碼:
我被渲染了
最終輸出:
addTen.jsafoo 10addOne.jsfoo 11[ready] document已加載 5 秒已加載 6 秒已加載 7 秒已加載 8 秒dynamicScript.js is runningdynamicScript.js loaded已加載 9 秒已加載 10 秒

定義了需要加載 8 秒的 dynamicScript.js 文件,所有的 script 加載方式依舊異步,但 dynamicScript.js 在 DOMContentLoaded 觸發后,最后才執行,瀏覽器并沒有等待它的加載完成后才渲染頁面。
我們也可以將它放在 head 中。這種通過腳本來動態修改 DOM 結構的加載方式是 無阻塞式 的,不受其他腳本加載的影響。
defer 和 async
我們可以在 script 定義 defer 、 async ,使整個腳本加載方式更加友好。比如:被修飾的腳本在 head 中,將不會阻斷 body 內容的展示。
注意: defer 修飾的腳本將延遲到 body 中所有定義的腳本之后,DOM(頁面內容)加載完之前觸發; async 不會像 defer 一樣等待 body 中的腳本,而是當前腳本一加載完畢就觸發。
我被渲染了
加載順序:
已加載 1 秒已加載 2 秒scriptAsync.js已加載 3 秒已加載 4 秒addTen.jsfoo 10addOne.jsfoo 11scriptDefer.js[ready] document已加載 5 秒已加載 6 秒已加載 7 秒已加載 8 秒dynamicScript.js is runningdynamicScript.js loaded已加載 9 秒已加載 10 秒
本文使用 mdnice 排版