瀏覽器的 16ms 渲染幀--摘抄
由於現在廣泛使用的屏幕都有固定的刷新率(比如最新的一般在 60Hz), 在兩次硬件刷新之間瀏覽器進行兩次重繪是沒有意義的只會消耗性能。 瀏覽器會利用這個間隔 16ms(1000ms/60)適當地對繪制進行節流, 因此 16ms 就成為頁面渲染優化的一個關鍵時間。 尤其在異步渲染中,要利用 流式渲染 就必須考慮到這個渲染幀間隔。
TL;DR
為方便查閱源碼和相關資料,本文以 Chromium 的 Blink 引擎為例分析。如下是一些分析結論:
- 一個渲染幀內 commit 的多次 DOM 改動會被合並渲染;
- 耗時 JS 會造成丟幀;
- 渲染幀間隔為 16ms 左右;
- 避免耗時腳本、交錯讀寫樣式以保證流暢的渲染。
渲染幀的流程
渲染幀是指瀏覽器一次完整繪制過程,幀之間的時間間隔是 DOM 視圖更新的最小間隔。 由於主流的屏幕刷新率都在 60Hz,那麽渲染一幀的時間就必須控制在 16ms 才能保證不掉幀。 也就是說每一次渲染都要在 16ms 內頁面才夠流暢不會有卡頓感。 這段時間內瀏覽器需要完成如下事情:
- 腳本執行(JavaScript):腳本造成了需要重繪的改動,比如增刪 DOM、請求動畫等
- 樣式計算(CSS Object Model):級聯地生成每個節點的生效樣式。
- 布局(Layout):計算布局,執行渲染算法
- 重繪(Paint):各層分別進行繪制(比如 3D 動畫)
- 合成(Composite):合成各層的渲染結果
最初 Webkit 使用定時器進行渲染間隔控制, 2014 年時開始 使用顯示器的 vsync 信號控制渲染(其實直接控制的是合成這一步)。 這意味著 16ms 內多次 commit 的 DOM 改動會合並為一次渲染。
耗時 JS 會造成丟幀
JavaScript 在並發編程上一個重要特點是“Run To Completion”。在事件循環的一次 Tick 中, 如果要執行的邏輯太多會一直阻塞下一個 Tick,所有異步過程都會被阻塞。 一個流暢的頁面中,JavaScript 引擎中的執行隊列可能是這樣的:
執行 JS -> 空閑 -> 繪制(16ms)-> 執行 JS -> 空閑 -> 繪制(32ms)-> ...
如果在某個時刻有太多 JavaScript 要執行,就會丟掉一次幀的繪制:
執行很多 JS...(20ms)-> 空閑 -> 繪制(32ms)-> ...
例如下面的腳本在保持 JavaScript 忙的狀態(持續 5s)下每隔 1s 新增一行 DOM 內容。
<div id="message"></div>
<script>
var then = Date.now()
var i = 0
var el = document.getElementById(‘message‘)
while (true) {
var now = Date.now()
if (now - then > 1000) {
if (i++ >= 5) {
break;
}
el.innerText += ‘hello!\n‘
console.log(i)
then = now
}
}
</script>
可以觀察到雖然每秒都會寫一次 DOM,但在 5s 結束後才會全部渲染出來,明顯耗時腳本阻塞了渲染。
測量渲染幀間隔
瀏覽器的渲染間隔其實是很難測量的。即使通過 clientHeight 這樣的接口也只能強制進行Layout,是否 Paint 上屏仍未可知。
幸運的是,最新的瀏覽器基本都支持了 requestAnimationFrame 接口。 使用這個 API 可以請求瀏覽器在下一個渲染幀執行某個回調,於是測量渲染間隔就很方便了:
var then = Date.now()
var count = 0
function nextFrame(){
requestAnimationFrame(function(){
count ++
if(count % 20 === 0){
var time = (Date.now() - then) / count
var ms = Math.round(time*1000) / 1000
var fps = Math.round(100000/ms) / 100
console.log(`count: ${count}\t${ms}ms/frame\t${fps}fps`)
}
nextFrame()
})
}
nextFrame()
每次 requestAnimationFrame
回調執行時發起下一個 requestAnimationFrame
,統計一段時間即可得到渲染幀間隔,以及 fps。逼近 16.6 ms 有木有!
渲染優化建議
現在我們知道瀏覽器需要在 16ms 內完成整個 JS->Style->Layout->Paint->Composite 流程,那麽基於此有哪些頁面渲染的優化方式呢?
避免耗時的 JavaScript 代碼
耗時超過 16ms 的 JavaScript 可能會丟幀讓頁面變卡。如果有太多事情要做可以把這些工作重新設計,分割到各個階段中執行。並充分利用緩存和懶初始化等策略。不同執行時機的 JavaScript 有不同的優化方式:
- 初始化腳本(以及其他同步腳本)。對於大型 SPA 中首頁卡死瀏覽器也是常事,建議增加服務器端渲染或者應用懶初始化策略。
- 事件處理函數(以及其他異步腳本)。在復雜交互的 Web 應用中,耗時腳本可以優化算法或者遷移到 Worker 中。Worker 在移動端的兼容性已經不很錯了,可以生產環境使用。
避免交錯讀寫樣式
在編寫涉及到布局的腳本時,常常會多次讀寫樣式。比如:
// 觸發一次 Layout
var h = div.clientHeight
div.style.height = h + 20
// 再次觸發 Layout
var w = div.clientWidth
div.style.width = w + 20
因為瀏覽器需要給你返回正確的寬高,上述代碼片段中每次 Layout 觸發都會阻塞當前腳本。 如果把交錯的讀寫分隔開,就可以減少觸發 Layout 的次數:
// 觸發一次 Layout
var h = div.clientHeight
var w = div.clientWidth
div.style.height = h + 20
div.style.width = w + 20
小心事件觸發的渲染
我們知道 DOM 事件的觸發 是異步的,但事件處理器的執行是可能在同一個渲染幀的, 甚至就在同一個 Tick。例如異步地獲取 HTML 並拼接到當前頁面上, 通過監聽 XHR 的 onprogress 事件 來模擬流式渲染:
var xhr = new XMLHttpRequest(),
method = ‘GET‘,
url = ‘http://harttle.land‘
xhr.open(method, url, true)
xhr.onprogress = function () {
div.innerHTML = xmlhttp.responseText
};
xhr.send()
上述渲染算法在網絡情況較差時是起作用的,但不代表它是正確的。 比如當 http://harttle.land 對應的 HTML 非常大而且網絡很好時, onprogress
事件處理器可能碰撞在同一個渲染幀中,或者幹脆在同一個 Tick。 這樣頁面會長時間空白,即使 onprogress
早已被調用過。
關於異步渲染的阻塞行為,可參考 http://harttle.land/2016/11/26/dynamic-dom-render-blocking.html
瀏覽器的 16ms 渲染幀--摘抄