深度剖析瀏覽器渲染性能原理,你到底知道多少
渲染卡頓是怎么回事?
網(wǎng)頁不僅應(yīng)該被快速加載,同時還應(yīng)該流暢運行,比如快速響應(yīng)的交互,如絲般順滑的動畫等。
大多數(shù)設(shè)備的刷新頻率是60次/秒,也就說是瀏覽器對每一幀畫面的渲染工作要在16ms內(nèi)完成,超出這個時間,頁面的渲染就會出現(xiàn)卡頓現(xiàn)象,影響用戶體驗。
為了保證頁面的渲染效果,需要充分了解瀏覽器是如何處理HTML/JavaScript/CSS的。
渲染流程分為幾步?

JavaScript:JavaScript實現(xiàn)動畫效果,DOM元素操作等。Style(計算樣式):確定每個DOM元素應(yīng)該應(yīng)用什么CSS規(guī)則。Layout(布局):計算每個DOM元素在最終屏幕上顯示的大小和位置。由于web頁面的元素布局是相對的,所以其中任意一個元素的位置發(fā)生變化,都會聯(lián)動的引起其他元素發(fā)生變化,這個過程叫reflow。Paint(繪制):在多個層上繪制DOM元素的的文字、顏色、圖像、邊框和陰影等。Composite(渲染層合并):按照合理的順序合并圖層然后顯示到屏幕上。
實際場景下,大概會有三種常見的渲染流程(也即是Layout和Paint步驟是可避免的):

結(jié)合渲染流程怎么優(yōu)化渲染性能呢?
結(jié)合上述的渲染流程,我們可以去針對性的分析并優(yōu)化每個步驟。
優(yōu)化 JavaScript 的執(zhí)行效率 降低樣式計算的范圍和復雜度 避免大規(guī)模、復雜的布局 簡化繪制的復雜度、減少繪制區(qū)域 優(yōu)先使用渲染層合并屬性、控制層數(shù)量 對用戶輸入事件的處理函數(shù)去抖動(移動設(shè)備)
優(yōu)化 JavaScript 的執(zhí)行效率,具體可以做什么?
用 requestAnimationFrame 實現(xiàn)動畫
在 JS 中實現(xiàn)動畫應(yīng)該避免使用 setTimeout 或 setInterval,盡量使用 requestAnimationFrame。
setTimeout(callback) 和 setInterval(callback) 無法保證 callback 函數(shù)的執(zhí)行時機,很可能在幀結(jié)束的時候執(zhí)行,從而導致丟幀,如下圖:

requestAnimationFrame(callback) 可以保證 callback 函數(shù)在每幀動畫開始的時候執(zhí)行。
// requestAnimationFrame將保證updateScreen函數(shù)在每幀的開始運行
requestAnimationFrame(updateScreen);
注意:jQuery 的 animate 函數(shù)就是用 setTimeout 來實現(xiàn)動畫,可以通過jquery-requestAnimationFrame這個補丁來用requestAnimationFrame替代setTimeout
使用 Web Workers
把耗時長的 JavaScript 代碼放到 Web Workers 中去做。
JavaScript 代碼運行在瀏覽器的主線程上,與此同時,瀏覽器的主線程還負責樣式計算、布局、繪制的工作,如果 JavaScript 代碼運行時間過長,就會阻塞其他渲染工作,很可能會導致丟幀。
前面提到每幀的渲染應(yīng)該在 16ms 內(nèi)完成,但在動畫過程中,由于已經(jīng)被占用了不少時間,所以JavaScript 代碼運行耗時應(yīng)該控制在 3-4 毫秒。
如果真的有特別耗時且不操作 DOM 元素的純計算工作,可以考慮放到 Web Workers 中執(zhí)行。
var dataSortWorker = new Worker("sort-worker.js");
dataSortWorker.postMesssage(dataToSort);
// 主線程不受Web Workers線程干擾
dataSortWorker.addEventListener('message', function(evt) {
var sortedData = e.data;
// Web Workers線程執(zhí)行結(jié)束
// ...
});
批量更新 DOM
把 DOM 元素的更新劃分為多個小任務(wù),分別在多個 frame 中去完成。
由于 Web Workers 不能操作 DOM 元素的限制,所以只能做一些純計算的工作,對于很多需要操作 DOM 元素的邏輯,可以考慮分步處理,把任務(wù)分為若干個小任務(wù),每個任務(wù)都放到requestAnimationFrame 中回調(diào)執(zhí)行
var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function processTaskList(taskStartTime) {
var nextTask = taskList.pop();
// 執(zhí)行小任務(wù)
processTask(nextTask);
if (taskList.length > 0) {
requestAnimationFrame(processTaskList);
}
}
分析 JavaScript 的性能
使用 Chrome DevTools 的 Timeline 來分析 JavaScript 的性能
打開 Chrome DevTools > Timeline > JS Profile,錄制一次動作,然后分析得到的細節(jié)信息,從而發(fā)現(xiàn)問題并修復問題。

降低樣式計算的范圍和復雜度,具體可以做什么?
添加或移除一個 DOM 元素、修改元素屬性和樣式類、應(yīng)用動畫效果等操作,都會引起 DOM 結(jié)構(gòu)的改變,從而導致瀏覽器需要重新計算每個元素的樣式,對整個頁面或部分頁面重新布局,這就是所謂的樣式計算。
樣式計算主要分為兩步:創(chuàng)建一套匹配的樣式選擇器,為匹配的樣式選擇器計算具體的樣式規(guī)則
降低樣式選擇器的復雜度
盡量保持 class 的簡短,或者使用 Web Components 框架。
.box:nth-last-child(-n+1) .title {}
// 改善后
.final-box-title {}
減少需要執(zhí)行樣式計算的元素個數(shù)
由于瀏覽器的優(yōu)化,現(xiàn)代瀏覽器的樣式計算直接對目標元素執(zhí)行,而不是對整個頁面執(zhí)行,所以我們應(yīng)該盡可能減少需要執(zhí)行樣式計算的元素的個數(shù)
避免大規(guī)模、復雜的布局,具體可以做什么?
布局就是計算 DOM 元素的大小和位置的過程,如果你的頁面中包含很多元素,那么計算這些元素的位置將耗費很長時間。
布局的主要消耗在于:
需要布局的DOM元素的數(shù)量; 布局過程的復雜程度
盡可能避免觸發(fā)布局
當你修改了元素的屬性之后,瀏覽器將會檢查為了使這個修改生效是否需要重新計算布局以及更新渲染樹,對于DOM元素的“幾何屬性”修改,比如width/height/left/top等,都需要重新計算布局。
對于不能避免的布局,可以使用Chrome DevTools工具的Timeline查看明細。

可以查看布局的耗時,以及受影響的DOM元素數(shù)量。
使用flexbox替代老的布局模型
老的布局模型以相對/絕對/浮動的方式將元素定位到屏幕上。Floxbox 布局模型用流式布局的方式將元素定位到屏幕上。通過一個小實驗可以看出兩種布局模型的性能差距,同樣對 1300 個元素布局,浮動布局耗時 14.3ms,F(xiàn)lexbox 布局耗時 3.5ms。

避免強制同步布局事件的發(fā)生
前面提過,將一幀畫面渲染的屏幕上的流程是:

首先是 JavaScript 腳本,然后是 Style,然后是 Layout,但是我們可以強制瀏覽器在執(zhí)行JavaScript 腳本之前先執(zhí)行布局過程,這就是所謂的強制同步布局。
requestAnimationFrame(logBoxHeight);
// 先寫后讀,觸發(fā)強制布局
function logBoxHeight() {
// 更新box樣式
box.classList.add('super-big');
// 為了返回box的offersetHeight值
// 瀏覽器必須先應(yīng)用屬性修改,接著執(zhí)行布局過程
console.log(box.offsetHeight);
}
// 先讀后寫,避免強制布局
function logBoxHeight() {
// 獲取box.offsetHeight
console.log(box.offsetHeight);
// 更新box樣式
box.classList.add('super-big');
}
在 JavaScript 腳本運行的時候,它能獲取到的元素樣式屬性值都是上一幀畫面的,都是舊的值。因此,如果你在當前幀獲取屬性之前又對元素節(jié)點有改動,那就會導致瀏覽器必須先應(yīng)用屬性修改,結(jié)果執(zhí)行布局過程,最后再執(zhí)行 JavaScript 邏輯。
避免連續(xù)的強制同步布局發(fā)生
如果連續(xù)快速的多次觸發(fā)強制同步布局,那么結(jié)果更糟糕。
比如下面的例子,獲取 box 的屬性,設(shè)置到 paragraphs 上,由于每次設(shè)置 paragraphs 都會觸發(fā)樣式計算和布局過程,而下一次獲取 box 的屬性必須等到上一步設(shè)置結(jié)束之后才能觸發(fā)。
function resizeWidth() {
// 會讓瀏覽器陷入'讀寫讀寫'循環(huán)
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來確保讀寫操作的安全,從而幫你自動完成讀寫操作的批處理,還能避免意外地觸發(fā)強制同步布局或快速連續(xù)布局
簡化繪制的復雜度、減少繪制區(qū)域,具體可以做什么?
繪制就是填充像素的過程,通常這個過程是整個渲染流程中耗時最長的一環(huán),因此也是最需要避免發(fā)生的一環(huán)。
如果Layout被觸發(fā),那么接下來元素的Paint一定會被觸發(fā)。當然純粹改變元素的非幾何屬性,也可能會觸發(fā)Paint,比如背景、文字顏色、陰影效果等。
提升移動或漸變元素的繪制層
繪制并非總是在內(nèi)存中的單層畫面里完成的,實際上,瀏覽器在必要時會將一幀畫面繪制成多層畫面,然后將這若干層畫面合并成一張圖片顯示到屏幕上。
這種繪制方式的好處是,使用transform來實現(xiàn)移動效果的元素將會被正常繪制,同時不會觸發(fā)其他元素的繪制。
減少繪制區(qū)域
瀏覽器會把相鄰區(qū)域的渲染任務(wù)合并在一起進行,所以需要對動畫效果進行精密設(shè)計,以保證各自的繪制區(qū)域不會有太多重疊。
簡化繪制的復雜度
可以實現(xiàn)同樣效果的不同方式,我們應(yīng)該采用性能更好的那種。
通過Chrome DevTools來分析繪制復雜度和時間消耗,盡可能降低這些指標
打開DevTools,按下鍵盤的ESC鍵,在彈出的面板中,選中rendering選項卡下的Enable paint flashing,這樣每當頁面發(fā)生繪制的時候,屏幕就會閃現(xiàn)綠色的方框。通過該工具可以檢查Paint發(fā)生的區(qū)域和時機是不是可以被優(yōu)化。

通過Chrome DevTools中的 Timeline > Paint 選項可以查看更細節(jié)的 Paint 信息。
優(yōu)先使用渲染層合并屬性、控制層數(shù)量,具體可以做什么?
使用transform/opacity實現(xiàn)動畫效果
使用 transform/opacity 實現(xiàn)動畫效果,會跳過渲染流程的布局和繪制環(huán)節(jié),只做渲染層的合并。

使用 transform/opacity 的元素必須獨占一個渲染層,所以必須提升該元素到單獨的渲染層。
提升動畫效果中的元素
應(yīng)用動畫效果的元素應(yīng)該被提升到其自有的渲染層,但不要濫用。
在頁面中創(chuàng)建一個新的渲染層最好的方式就是使用CSS屬性winll-change,對于目前還不支持will-change屬性、但支持創(chuàng)建渲染層的瀏覽器,可以通過3D transform屬性來強制瀏覽器創(chuàng)建一個新的渲染層。需要注意的是,不要創(chuàng)建過多的渲染層,這意味著新的內(nèi)存分配和更復雜的層管理。
.moving-element {
will-change: transform;
transform: translateZ(0);
}
管理渲染層、避免過多數(shù)量的層
盡管提升渲染層看起來很誘人,但不能濫用,因為更多的渲染層意味著更多的額外的內(nèi)存和管理資源,所以當且僅當需要的時候才為元素創(chuàng)建渲染層。
* {
will-change: transform;
transform: translateZ(0);
}
使用Chrome DevTools來了解頁面的渲染層情況
開啟Chrome DevTools > Timeline > Paint選項,然后錄制一段時間的操作,選擇單獨的幀,看到每個幀的渲染細節(jié),在ESC彈出框有個Layers選項,可以看到渲染層的細節(jié),有多少渲染層?為何被創(chuàng)建?

對用戶輸入事件的處理函數(shù)去抖動(移動設(shè)備),具體可以做什么?
用戶輸入事件處理函數(shù)會在運行時阻塞幀的渲染,并且會導致額外的布局發(fā)生。
避免使用運行時間過長的輸入事件處理函數(shù)
理想情況下,當用戶和頁面交互,頁面的渲染層合并線程將接收到這個事件并移動元素。這個響應(yīng)過程是不需要主線程參與,不會導致JavaScript、布局和繪制過程發(fā)生。

但是如果被觸摸的元素綁定了輸入事件處理函數(shù),比如touchstart/touchmove/touchend,那么渲染層合并線程必須等待這些被綁定的處理函數(shù)執(zhí)行完畢才能執(zhí)行,也就是用戶的滾動頁面操作被阻塞了,表現(xiàn)出的行為就是滾動出現(xiàn)延遲或者卡頓。
簡而言之就是你必須確保用戶輸入事件綁定的任何處理函數(shù)都能夠快速的執(zhí)行完畢,以便騰出時間來讓渲染層合并線程完成他的工作。

避免在輸入事件處理函數(shù)中修改樣式屬性
輸入事件處理函數(shù),比如scroll/touch事件的處理,都會在requestAnimationFrame之前被調(diào)用執(zhí)行。
因此,如果你在上述輸入事件的處理函數(shù)中做了修改樣式屬性的操作,那么這些操作就會被瀏覽器暫存起來,然后在調(diào)用requestAnimationFrame的時候,如果你在一開始就做了讀取樣式屬性的操作,那么將會觸發(fā)瀏覽器的強制同步布局操作。

對滾動事件處理函數(shù)去抖動
通過requestAnimationFrame可以對樣式修改操作去抖動,同時也可以使你的事件處理函數(shù)變得更輕
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);
總結(jié)點什么
網(wǎng)站性能優(yōu)化是一個有一定門檻的細致活,需要對瀏覽器的機制有很好的理解,同時也應(yīng)該學會利用Chrome DevTools去分析并解決實際問題。
