改進渲染效能範例 1 - News Aggregator
21 Jul 2018使用 News Aggregator 作為改進渲染效能的範例,以下列出需要改進之處與解法,並附上測試結果。
這個範例主要展示
- 檢測 Forced Synchronous Layout(FSL)與 Layout Thrashing 發生的原因、解法。
- 從像素管道(Browser Rendering Pipeline)的角度優化程式碼,避免版面配置(Layout)和繪製(Paint)、盡量使用合成(Composite)就好。
- 使用
requestAnimationFrame
取代setTimeout
或setInterval
,讓瀏覽器依照自身狀況優化動畫效能。 - 使用
will-change
升階,減少繪製區域,但不可過度使用,以免增加合成階段的負擔。
衡量準則
- 接近 60fps
- 避免 Forced Synchronous Layout(FSL)與 Layout Thrashing
- 減少各階段或函式的執行時間
#1 改進一:向下滾動整個頁面時,怎麼卡卡的?
注意這些 story 最前面的 badge .story__score
,它們會隨著所在視窗的高低而呈現不同的深淺變化,上面較淺,下面較深。
檢視 Chrome DevTools Timeline,發現好多紅色的警示。
這是由於向下滾動頁面時觸發 scroll event,會呼叫 colorizeAndScaleStories,其中會不斷的讀取目前 getBoundingClientRect 的值並做計算和寫入透明度,導致 Forced Synchronous Layout(FSL),不停的 FSL,最後造成 Layout Thrashing。
更新頻率約 11 ~ 14fps。
拔掉這個功能,馬上順很多。
拔掉 colorizeAndScaleStories 這個 function,刪除 main.addEventListener('scroll', function() {...}
中呼叫 colorizeAndScaleStories()
的部份,並刪除 onStoryData 的最後一段。
// 刪除這一段
if (storyLoadCount === 0) {
colorizeAndScaleStories();
}
這個功能的完整修改請見這裡。
更新頻率提高為 45fps。
#2 改進二:點擊項目,打開內容頁,怎麼又是卡卡的?
打開內容頁的過程,明顯有卡卡的狀況。
檢視 Chrome DevTools Timeline。
右上角紅色三角形警示記號表示這裡有 FSL 的狀況。這一段程式碼是在設定 storyDetails 的位置,先從 getBoundingClientRect()
取得 position left 的值,然後根據比例(也就是當時 left 值減去其十分之一)設定下一次 storyDetails 移動的目標位置,導致不停強迫瀏覽器在此幀(Frame)必須取得最新值後又馬上更新樣式,直到 storyDetails 的 left 為零為止。
var storyDetailsPosition = storyDetails.getBoundingClientRect(); // 讀取
// 略
storyDetails.style.left = left + 'px'; // 寫入
點此看原始碼。
animate 這個 function 總共花了 47.7ms(通常會小於 3ms),畫面更新頻率是 13fps。
因此,針對這個功能的改善目標為
- 避免 FSL
- 提高更新頻率為 60fps
修改如下。
方法一
捨棄 setTimeout,改用 requestAnimationFrame,讓瀏覽器自行決定執行 animate 的時機,恰巧消除了 FSL,更新頻率為 36fps。
但仍未達到 60fps,來看方法二。
方法二
- 使用 transform 改寫點擊項目後打開內容頁的這一塊,改用 transform 後只需執行合成步驟,避免了 left 需要版面配置和繪製的階段。同時也因為不需一直讀取和更新 storyDetail 的位置,成功消除 FSL。
- 將 story-details 升階,以減少繪製區域。
.story-details {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: stretch;
align-content: stretch;
position: fixed;
top: 0;
width: 100%;
height: 100%;
background: white;
z-index: 2;
box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.06), 0px 2px 5px 0px rgba(0, 0, 0, 0.08),
0px 2px 7px 0px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: transform 0.3s;
will-change: transform;
}
.visible {
transform: translateX(0);
}
.hidden {
transform: translateX(100%);
}
移除對每一個 story-details 的子元素升階,這會造成過多的圖層,造成合成效能不佳。
.story-details * {
will-change: transform;
transform: translateZ(0);
}
由於改用 transform 來打開 storyDetail,因此也要改寫 onStoryClick、砍掉 animate;在建立 storyDetail 時先放在畫面之外,待點擊後再移到畫面中。其實這裡不需要為每個項目都建立自己的 storyDetail,而可以只建立一個,之後替換內容即可,這樣就可以避免 DOM Element 的操作和產生更多圖層、增加合成的負擔。
function onStoryClick(details) {
if (inDetails) {
return;
}
inDetails = true;
var storyDetails = $('#sd-' + details.id);
setTimeout(showStory.bind(this, storyDetails), 60);
if (!storyDetails) {
// 省略...
storyDetails = document.createElement('section');
storyDetails.setAttribute('id', 'sd-' + details.id);
storyDetails.classList.add('story-details');
storyDetails.classList.add('hidden');
storyDetails.innerHTML = storyDetailsHtml;
document.body.appendChild(storyDetails);
// 省略...
storyDetails.classList.add('visible');
storyDetails.classList.remove('hidden');
// 省略...
}
}
function showStory(storyDetails) {
if (!storyDetails) {
return;
}
storyDetails.classList.remove('hidden');
storyDetails.classList.add('visible');
}
function hideStory(storyDetails) {
storyDetails.classList.add('hidden');
storyDetails.classList.remove('visible');
inDetails = false;
}
這個功能的完整修改請見這裡。
每個 Frame 經歷各階段所花的時間如下圖,重新計算樣式、更新 Render Tree 和合成,沒有版面配置(Layout)和繪製(Paint),更新頻率超過 60fps。
待更新
仍有一些地方是可以改善的…見 full list of bugs