[Web] Performance

前言

最近看了Google的Learn How to Develop the Next Generation of Applications for the Web和Udacity上介紹關於Performance的影片在此紀錄一下,範例及圖片大多取自於其中。

DOM & CSSOM

HTML轉換成DOM (Document Object Model) 的流程如下圖

首先會先將HTML字串

轉換成一個一個的token

再將這些token轉換成一個一個的node

最後就形成了DOM

CSS轉換成CSSOM (CSS Object Model) 的流程如下圖

直接看到轉換成node的部分

p是body的子節點,因為所有可見的內容都是body的一部份。

其中子節點會繼承父節點的屬性。

CSS Selector

1
2
3
4
<div>
<h1>xxx</h1>
<p>xxx</p>
</div>

以下兩種選擇會是哪一種比較快?

1
2
h1 { ... }
div p { ... }

第一種會找所有的h1並且設置屬性,第二種則是找到所有的p且往上找是否父節點為div。

因此直接選擇h1會比較快。

CSS是從右到左,因為從右邊讀取效率會比較高,從左到右會浪費許多時間在錯誤的查找上。

Why do browsers match CSS selectors from right to left?

渲染樹

建立渲染樹的過程中,會從DOM中找node且在CSSOM找是否有相對應的node然後加到渲染樹上。

由於渲染樹只會加上可見的元素,因此display:none的span不會加到樹上,但Pseudo Elements則會。

Attribute

viewport可以告知瀏覽器Layout的寬度應該多長,若沒告知則為預設的980px。

1
<meta name="viewport" content="width=device-width;">

media

若是不希望阻塞渲染,可在css來源的tag加上media屬性,如此一來檔案仍會下載但不會阻塞。

1
<link href="print.css" rel="stylesheet" media="print">

async

可在不需要依賴DOM的script上加上async屬性,如此一來就不會阻塞渲染。

1
<script src="app.js" async></script>

Critical Rendering Path Diagrams


發出HTML請求->發出CSS請求並同時建立DOM->建立CSSOM->渲染


發出HTML請求->發出CSS、JavaScript請求並同時建立DOM(遇到JavaScript處則暫停建立DOM)->建立CSSOM->執行JavaScript->繼續建立DOM->渲染

瀏覽器會使用preload scanner將所有css和js一起載入。

Pixel Pipeline

利用js修改DOM渲染的流程可以分為以下三種

  1. JS / CSS > Style > Layout > Paint > Composite

    像是修改margin-leftwidth等等。

  2. JS / CSS > Style > Paint > Composite

    像是修改background-imagecolor等等。

  3. JS / CSS > Style > Composite

    像是修改transform等等。

需要的流程越少成本就越小,因此要盡量選擇第三種方法。

CSS Triggers可以查看每個style所會觸發的流程。

Load, Idle, Animate, Response

我們必須把真正需要的在Load載入來減少時間,像是基本架構、重要的文字等等。

在Idle的時候則可以載入等一下可能會使用到的東西。

Chrome DevTools

最近Chrome的Timeline和Profiles等等似乎都合併到Performance裡了。

FPS Metor用來查看即時的狀況

Paint Flashing用來查看即時Paint的情形

經由左上角的錄製鈕來逐Frame檢視

Screenshots用來查看每個Frame的當前畫面直接鎖定需要的地方

若是大量動畫的狀態要盡量保持在60fps也就是16ms以下(1000/60)才會順暢。

Summary圖表顯示了所花的時間

Event Log則可以看細部所花的時間,Self Time代表在程式內部的,Total Time則是包含了內部呼叫的其他函式。

開啟Memory選項則可以看到內存的使用情形。

實機測試 + Chrome DevTools

若希望使用開發者工具來檢視手機上的結果
可開啟設定->開發者選項->USB偵錯

在Chrome上連結設chrome://inspect即可看見手機上的網頁,在需要測試的按inspect即可進入開發者模式。

若想要直接在手機上開啟本機伺服器檔案,則可以Port forwarding設置且按下Enable port forwarding即可。

Micro Optimizations

由於JavaScript不見得是照我們寫的運行,我們無法知道如何引擎是做最佳化,所以不需要花時間在微最佳化上。

1
2
3
4
// Don't waste time between them
for ( var i = 0 ; i < len ; i ++ ) ...
while ( ++ i < len ) ...

requestAnimationFrame

雖然60fps換算下來是16ms,但我們實際上必須要在更短的時間內執行完程式,因為會有額外的時間拿來做style計算、layer管理等等。

setTimeout和setInterval不適合拿來處理動畫,因為若我們在像是style計算中突然要執行JavaScript,如此一來整個渲染流程又會重新執行而造成頻率不一致,而requestAnimationFrame可以妥善的安排JavaScript執行的時間。

1
2
3
4
5
6
function render(time) {
...
}
var requestId = requestAnimationFrame(render);
//取消動畫
cancelAnimationFrame(requestId);

Web Workers

Web Workers可以讓js運行在不同的thread而不會造成阻塞。

Memory Management

  1. 盡量別用delete,因為JavaScript引擎會自動最佳化,若是delete其中的元素則得重新計算。
  2. null不會真的清空物件,只會讓物件指向null
  3. 全域變數不會被GC回收
  4. 取消綁定事件若不再需要
  5. 若使用資料快取要妥善管理

考慮以下的情況

1
2
3
4
5
6
7
8
9
10
11
12
var myObj = {
callMeMaybe: function () {
var myRef = this;
var val = setTimeout(function () {
console.log('Time is running out!');
myRef.callMeMaybe();
}, 1000);
}
};
myObj.callMeMaybe();
myObj = null;

即使設為null,setTimeout仍會持續進行,這是因為myRef在closure中指向myObj,因此myObj不會被GC回收。

How To Write Fast, Memory-Efficient JavaScript

Selector

當Selector條件越多效能就越受影響,使用.box-three會比:nth-child(3)的選擇好。

Demo

box-recalc-style-slow

CSS

1
2
3
body.toggled main .box-container .box:nth-child(2n) {
background: #777 !important;
}

JavaScript

1
2
3
button.addEventListener('click', function() {
document.body.classList.toggle('toggled');
});

如此一來每個.box都會看且判斷是否是偶數個,再逐一往上(左)找是否符合

效能

改進方法如下

CSS

1
2
3
.box.gray {
background-color: #777 !important;
}

JavaScript

1
2
3
4
5
6
7
button.addEventListener('click', function() {
document.body.classList.toggle('toggled');
var boxes = container.querySelectorAll(".box") ;
for ( var i = 0 ; i < boxes.length ; i += 2 ){
boxes[i].classList.toggle('gray',document.body.classList.contains('toggled'));
}
});

如此一來會直接先找到.gray才找.box,省去了一半的數量

效能

Forced Synchronous Layouts

若我們每次都要取得Layout再重新計算,這樣會造成Forced Synchronous Layouts

Demo

Slow stuff

1
2
3
4
5
6
7
8
9
10
11
if (goSlow) {
while (i--) {
ps[i].style.width = sizer.offsetWidth + 'px';
}
}
else {
size = sizer.offsetWidth;
while (i--) {
ps[i].style.width = size + 'px';
}
}

可以看到先把Node的寬度快取住會比每次都要重新取得快的許多。

Demo

pizza-perf

1
2
3
4
5
6
7
function changePizzaSizes(size) {
for (var i = 0; i < document.querySelectorAll(".randomPizzaContainer").length; i++) {
var dx = determineDx(document.querySelectorAll(".randomPizzaContainer")[i], size);
var newwidth = (document.querySelectorAll(".randomPizzaContainer")[i].offsetWidth + dx) + 'px';
document.querySelectorAll(".randomPizzaContainer")[i].style.width = newwidth;
}
}

改為

1
2
3
4
5
6
7
8
function changePizzaSizes(size) {
var randomPizzas = document.querySelectorAll(".randomPizzaContainer") ;
for (var i = 0; i < randomPizzas.length; i++) {
var dx = determineDx(randomPizzas[i], size);
var newwidth = (randomPizzas[i].offsetWidth + dx) + 'px';
randomPizzas[i].style.width = newwidth;
}
}

查看determineDx函式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function determineDx (elem, size) {
var oldwidth = elem.offsetWidth;
var windowwidth = document.querySelector("#randomPizzas").offsetWidth;
var oldsize = oldwidth / windowwidth;
// Changes the slider value to a percent width
function sizeSwitcher (size) {
switch(size) {
case "1":
return 0.25;
case "2":
return 0.3333;
case "3":
return 0.5;
default:
console.log("bug in sizeSwitcher");
}
}
var newsize = sizeSwitcher(size);
var dx = (newsize - oldsize) * windowwidth;
return dx;
}

發現原本是計算差值然後加上去,這樣子可以改寫成直接給予新的值即可,因此改寫為

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function changePizzaSizes(size) {
switch(size) {
case "1":
newWidth = 0.25;
case "2":
newWidth = 0.3333;
case "3":
newWidth = 0.5;
default:
console.log("bug in sizeSwitcher");
}
var randomPizzas = document.querySelectorAll(".randomPizzaContainer") ;
for (var i = 0; i < randomPizzas.length; i++) {
randomPizzas[i].style.width = newWidth + "%" ;
}
}

CSS or JavaScript?

動畫究竟該用CSS還是JavaScript?

  1. 當元素是單一的用CSS
  2. 當需要信號控制像是stop、slow down、reverse等等則用JavaScript
  3. 更改Layout(位置)或需要Paint是相當耗效能的
  4. 盡量用transforms或opacity
  5. 原則上CSS動畫會在compositor thread上執行,所以當主執行緒有繁重的任務時動畫不會被干擾,JavaScript則會佔用主執行緒
  6. 不論CSS或JavaScript動畫只要觸發Layout或Paint都會在主執行緒上執行

CSS Versus JavaScript Animations
Animations and Performance

will-change

利用多個Layer可以讓元素跳過Layout和Paint而直接到Composite,但過多的Layer會造成管理Layer上效能的問題。

will-change屬性會告知瀏覽器即將運行的事件,讓瀏覽器建立一個Layer

1
2
3
.circle {
will-change : transform;
}

也可以在will-change上設置top、left等等,雖然會增加一層Layer但仍得進行Layout和Paint,因此不會有太大的改善。

在舊的瀏覽器上可以使用translateZ達到同樣的效果,一樣會告知瀏覽器建立Layer。

1
2
3
.circle{
transform: translateZ(0) ;
}

可以用Chrome的DevTools的Layer標籤查看網頁Layer情形。

可在Detail看到形成Layer的原因。

Demo

demo-promo

section#background加上will-change:transform;

1
2
3
4
5
6
section#background {
will-change: transform;
background: #1e2124 url("../../images/parallax-bg.jpg") center 0 no-repeat;
width: 960px;
height: 3000px;
}

可以在發現原本Paint整個頁面的變成只剩下Paint滾動條。

參考

Learn How to Develop the Next Generation of Applications for the Web
Website Performance Optimization
Browser Rendering Optimization