[轉]Javascript高性能動畫與頁面渲染

No setTimeout, No setInterval

做者 李光毅 發佈於 2014年4月30日javascript

若是你不得不使用setTimeout或者setInterval來實現動畫,那麼緣由只能是你須要精確的控制動畫。但我認爲至少在如今這個時間點,高級瀏覽器、甚至手機瀏覽器的普及程度足夠讓你有理由有條件在實現動畫時使用更高效的方式。css

什麼是高效

頁面是每一幀變化都是系統繪製出來的(GPU或者CPU)。但這種繪製又和PC遊戲的繪製不一樣,它的最高繪製頻率受限於顯示器的刷新頻率(而非顯卡),因此大多數狀況下最高的繪製頻率只能是每秒60幀(frame per second,如下用fps簡稱),對應於顯示器的60Hz。60fps是一個最理想的狀態,在平常對頁面性能的測試中,60fps也是一個重要的指標,the closer the better。在Chrome的調試工具中,有很多工具都是用於衡量當前幀數:html

 

接下來的工做中,咱們將會用到這些工具,來實時查看咱們頁面的性能。git

60fps是動力也是壓力,由於它意味着咱們只有16.7毫秒(1000 / 60)來繪製每一幀。若是使用setTimeout或者setInterval(如下統稱爲timer)來控制繪製,問題就來了。github

首先,Timer計算延時的精確度不夠。延時的計算依靠的是瀏覽器的內置時鐘,而時鐘的精確度又取決於時鐘更新的頻率(Timer resolution)。IE8及其以前的IE版本更新間隔爲15.6毫秒。假設你設定的setTimeout延遲爲16.7ms,那麼它要更新兩個15.6毫秒纔會該觸發延時。這也意味着無端延遲了 15.6 x 2 - 16.7 = 14.5毫秒。web

 
            16.7ms
DELAY: |------------|

CLOCK: |----------|----------|
          15.6ms    15.6ms

因此即便你給setTimeout設定的延時爲0ms,它也不會當即觸發。目前Chrome與IE9+瀏覽器的更新頻率都爲4ms(若是你使用的是筆記本電腦,而且在使用電池而非電源的模式下,爲了節省資源,瀏覽器會將更新頻率切換至於系統時間相同,也就意味着更新頻率更低)。chrome

退一步說,假使timer resolution可以達到16.7ms,它還要面臨一個異步隊列的問題。由於異步的關係setTimeout中的回調函數並不是當即執行,而是須要加入等待隊列中。但問題是,若是在等待延遲觸發的過程當中,有新的同步腳本須要執行,那麼同步腳本不會排在timer的回調以後,而是當即執行,好比下面這段代碼:

function runForSeconds(s) {
    var start = +new Date();
    while (start + s * 1000 > (+new Date())) {}
}

document.body.addEventListener("click", function () {
    runForSeconds(10);
}, false);

setTimeout(function () {
    console.log("Done!");
}, 1000 * 3);

若是在等待觸發延遲的3秒過程當中,有人點擊了body,那麼回調仍是準時在3s完成時觸發嗎?固然不能,它會等待10s,同步函數老是優先於異步函數:

等待3秒延遲 |    1s    |    2s    |    3s    |--->console.log("Done!");

通過2秒     |----1s----|----2s----|          |--->console.log("Done!");

點擊body後

覺得是這樣:|----1s----|----2s----|----3s----|--->console.log("Done!")--->|------------------10s----------------|

實際上是這樣:|----1s----|----2s----|------------------10s----------------|--->console.log("Done!");

John Resign有三篇關於Timer性能與準確性的文章: 1.Accuracy of JavaScript Time, 2.Analyzing Timer Performance, 3.How JavaScript Timers Work。從文章中能夠看到Timer在不一樣平臺瀏覽器與操做系統下的一些問題。

再退一步說,假設timer resolution可以達到16.7ms,而且假設異步函數不會被延後,使用timer控制的動畫仍是有不盡如人意的地方。這也就是下一節要說的問題。

垂直同步問題

這裏請再容許我引入另外一個常量60——屏幕的刷新率60Hz。

60Hz和60fps有什麼關係?沒有任何關係。fps表明GPU渲染畫面的頻率,Hz表明顯示器刷新屏幕的頻率。一幅靜態圖片,你能夠說這副圖片的fps是0幀/秒,但絕對不能說此時屏幕的刷新率是0Hz,也就是說刷新率不隨圖像內容的變化而變化。遊戲也好瀏覽器也好,咱們談到掉幀,是指GPU渲染畫面頻率下降。好比跌落到30fps甚至20fps,但由於視覺暫留原理,咱們看到的畫面仍然是運動和連貫的。

接上一節,咱們假設每一次timer都不會有延時,也不會被同步函數干擾,甚至能把時間縮短至16ms,那麼會發生什麼呢:

(點擊圖像放大)

在22秒處發生了丟幀

若是把延遲時間縮的更短,丟失的幀數也就更多:

實際狀況會比以上想象的複雜的多。即便你能給出一個固定的延時,解決60Hz屏幕下丟幀問題,那麼其餘刷新頻率的顯示器應該怎麼辦,要知道不一樣設備、甚至相同設備在不一樣電池狀態下的屏幕刷新率都不盡相同。

以上同時還忽略了屏幕刷新畫面的時間成本。問題產生於GPU渲染畫面的頻率和屏幕刷新頻率的不一致:若是GPU渲染出一幀畫面的時間比顯示器刷新一張畫面的時間要短(更快),那麼當顯示器尚未刷新完一張圖片時,GPU渲染出的另外一張圖片已經送達並覆蓋了前一張,致使屏幕上畫面的撕裂,也就是是上半部分是前一張圖片,下半部分是後一張圖片:

PC遊戲中解決這個問題的方法是開啓垂直同步(v-sync),也就是讓GPU妥協,GPU渲染圖片必須在屏幕兩次刷新之間,且必須等待屏幕發出的垂直同步信號。但這樣一樣也是要付出代價的:下降了GPU的輸出頻率,也就下降了畫面的幀數。以致於你在玩須要高幀數運行的遊戲時(好比競速、第一人稱射擊)感受到「頓卡」,由於掉幀。

requestAnimationFrame

在這裏不談requestAnimationFrame(如下簡稱rAF)用法,具體請參考MDN:Window.requestAnimationFrame()。咱們來具體談談rAF所解決的問題。

從上一節咱們能夠總結出實現平滑動畫的兩個因素

  1. 時機(Frame Timing): 新的一幀準備好的時機
  2. 成本(Frame Budget): 渲染新的一幀須要多長的時間

這個Native API把咱們從糾結於多久刷新的一次的困境中解救出來(其實rAF也不關心距離下次屏幕刷新頁面還須要多久)。當咱們調用這個函數的時候,咱們告訴它須要作兩件事: 1. 咱們須要新的一幀;2.當你渲染新的一幀時須要執行我傳給你的回調函數

那麼它解決了咱們上面描述的第一個問題,產生新的一幀的時機。

那麼第二個問題呢。不,它無能爲力。好比能夠對比下面兩個頁面:

  1. DEMO
  2. DEMO-FIXED

對比兩個頁面的源碼,你會發現只有一處不一樣:

// animation loop
function update(timestamp) {
    for(var m = 0; m < movers.length; m++) {
        // DEMO 版本
        //movers[m].style.left = ((Math.sin(movers[m].offsetTop + timestamp/1000)+1) * 500) + 'px';

        // FIXED 版本
        movers[m].style.left = ((Math.sin(m + timestamp/1000)+1) * 500) + 'px';
        }
    rAF(update);
};
rAF(update);

DEMO版本之因此慢的緣由是,在修改每個物體的left值時,會請求這個物體的offsetTop值。這是一個很是耗時的reflow操做(具體還有哪些耗時的reflow操做能夠參考這篇: How (not) to trigger a layout in WebKit)。這一點從Chrome調試工具中能夠看出來(截圖中的某些功能須要在Chrome canary版本中才可啓用)

未矯正的版本

可見大部分時間都花在了rendering上,而矯正以後的版本:

rendering時間大大減小了

但若是你的回調函數耗時真的很嚴重,rAF仍是能夠爲你作一些什麼的。好比當它發現沒法維持60fps的頻率時,它會把頻率下降到30fps,至少可以保持幀數的穩定,保持動畫的連貫。

使用rAF推遲代碼

沒有什麼是萬能的,面對上面的狀況,咱們須要對代碼進行組織和優化。

看看下面這樣一段代碼:

function jank(second) {
    var start = +new Date();
    while (start + second * 1000 > (+new Date())) {}
}

div.style.backgroundColor = "red";

// some long run task
jank(5);

div.style.backgroundColor = "blue";

不管在任何的瀏覽器中運行上面的代碼,你都不會看到div變爲紅色,頁面一般會在假死5秒,而後容器變爲藍色。這是由於瀏覽器的始終只有一個線程在運行(能夠這麼理解,由於js引擎與UI引擎互斥)。雖然你告訴瀏覽器此時div背景顏色應該爲紅色,可是它此時還在執行腳本,沒法調用UI線程。

有了這個前提,咱們接下來看這段代碼:

var div = document.getElementById("foo");

var currentWidth = div.innerWidth; 
div.style.backgroundColor = "blue";

// do some "long running" task, like sorting data

這個時候咱們不只僅須要更新背景顏色,還須要獲取容器的寬度。能夠想象它的執行順序以下:

當咱們請求innerWidth一類的屬性時,瀏覽器會覺得咱們立刻須要,因而它會當即更新容器的樣式(一般瀏覽器會攢着一批,等待時機一次性的repaint,以便節省性能),並把計算的結果告訴咱們。這一般是性能消耗量大的工做。

但若是咱們並不是當即須要獲得結果呢?

上面的代碼有兩處不足,

  1. 更新背景顏色的代碼過於提早,根據前一個例子,咱們知道,即便在這裏告知了瀏覽器我須要更新背景顏色,瀏覽器至少也要等到js運行完畢才能調用UI線程;

  2. 假設後面部分的long runing代碼會啓動一些異步代碼,好比setTimeout或者Ajax請求又或者web-worker,那應該儘早爲妙。

綜上所述,若是咱們不是那麼迫切的須要知道innerWidth,咱們可使用rAF推遲這部分代碼的發生:

requestAnimationFrame(function(){
    var el = document.getElementById("foo");

    var currentWidth = el.innerWidth;
    el.style.backgroundColor = "blue";

    // ...
});

// do some "long running" task, like sorting data

可見即便咱們在這裏沒有使用到動畫,但仍然可使用rAF優化咱們的代碼。執行的順序會變成:

在這裏rAF的用法變成了:把代碼推遲到下一幀執行。

有時候咱們須要把代碼推遲的更遠,好比這個樣子:

再好比咱們想要一個效果分兩步執行:1.div的display變爲block;2. div的top值縮短移動到某處。若是這兩項操做都放入同一幀中的話,瀏覽器會同時把這兩項更改應用於容器,在同一幀內。因而咱們須要兩幀把這兩項操做區分開來:

requestAnimationFrame(function(){
   el.style.display = "block";
   requestAnimationFrame(function(){
      // fire off a CSS transition on its `top` property
      el.style.top = "300px";
   });
});

這樣的寫法好像有些不太講究,Kyle Simpson有一個開源項目h5ive,它把上面的用法封裝了起來,而且提供了API。實現起來很是簡單,摘一段代碼瞧瞧:

function qID(){
    var id;
    do {
        id = Math.floor(Math.random() * 1E9);
    } while (id in q_ids);
    return id;
}

function queue(cb) {
    var qid = qID();

    q_ids[qid] = rAF(function(){
        delete q_ids[qid];
        cb.apply(publicAPI,arguments);
    });

    return qid;
}

function queueAfter(cb) {
    var qid;

    qid = queue(function(){
        // do our own rAF call here because we want to re-use the same `qid` for both frames
        q_ids[qid] = rAF(function(){
            delete q_ids[qid];
            cb.apply(publicAPI,arguments);
        });
    });

    return qid;
}

使用方法:

// 插入下一幀
id1 = aFrame.queue(function(){
    text = document.createTextNode("##");
    body.appendChild(text);
});

// 插入下下一幀
id2 = aFrame.queueAfter(function(){
    text = document.createTextNode("!!");
    body.appendChild(text);
});

使用rAF解耦代碼

先從一個2011年twitter遇到的bug提及。

當時twitter加入了一個新功能:「無限滾動」。也就是當頁面滾至底部的時候,去加載更多的twitter:

$(window).bind('scroll', function () {
    if (nearBottomOfPage()) {
        // load more tweets ...
    }
});

可是在這個功能上線以後,發現了一個嚴重的bug:通過幾回滾動到最底部以後,滾動就會變得奇慢無比。

通過排查發現,原來是一條語句引發的:$details.find(".details-pane-outer");

這還不是真正的罪魁禍首,真正的緣由是由於他們將使用的jQuery類庫從1.4.2升級到了1.4.4版。而這jQuery其中一個重要的升級是把Sizzle的上下文選擇器所有替換爲了querySelectorAll。可是這個接口原實現使用的是getElementsByClassName。雖然querySelectorAll在大部分狀況下性能仍是不錯的。但在經過Class名稱選擇元素這一項是佔了下風。有兩個對比測試能夠看出來:1.querySelectorAll v getElementsByClassName 2.jQuery Simple Selector

經過這個bug,John Resig給出了一條(其實是兩條,可是今天只取與咱們話題有關的)很是重要的建議

It’s a very, very, bad idea to attach handlers to the window scroll event.

他想表達的意思是,像scroll,resize這一類的事件會很是頻繁的觸發,若是把太多的代碼放進這一類的回調函數中,會延遲頁面的滾動,甚至形成沒法響應。因此應該把這一類代碼分離出來,放在一個timer中,有間隔的去檢查是否滾動,再作適當的處理。好比以下代碼:

var didScroll = false;

$(window).scroll(function() {
    didScroll = true;
});

setInterval(function() {
    if ( didScroll ) {
        didScroll = false;
        // Check your page position and then
        // Load in more results
    }
}, 250)

這樣的做法相似於Nicholas將須要長時間運算的循環分解爲「片」來進行運算:

// 具體能夠參考他寫的《javascript高級程序設計》
// 也能夠參考他的這篇博客: http://www.nczonline.net/blog/2009/01/13/speed-up-your-javascript-part-1/
function chunk(array, process, context){
    var items = array.concat();   //clone the array
    setTimeout(function(){
        var item = items.shift();
        process.call(context, item);

        if (items.length > 0){
            setTimeout(arguments.callee, 100);
        }
    }, 100);
}

原理實際上是同樣的,爲了優化性能、爲了防止瀏覽器假死,將須要長時間運行的代碼分解爲小段執行,可以使瀏覽器有時間響應其餘的請求。

回到rAF上來,其實rAF也能夠完成相同的功能。好比最初的滾動代碼是這樣:

function onScroll() {
    update();
}

function update() {

    // assume domElements has been declared
    for(var i = 0; i < domElements.length; i++) {

        // read offset of DOM elements
        // to determine visibility - a reflow

        // then apply some CSS classes
        // to the visible items - a repaint

    }
}

window.addEventListener('scroll', onScroll, false);

這是很典型的反例:每一次滾動都須要遍歷全部元素,並且每一次遍歷都會引發reflow和repaint。接下來咱們要作的事情就是把這些費時的代碼從update中解耦出來。

首先咱們仍然須要給scroll事件添加回調函數,用於記錄滾動的狀況,以方便其餘函數的查詢:

var latestKnownScrollY = 0;

function onScroll() {
    latestKnownScrollY = window.scrollY;
}

接下來把分離出來的repaint或者reflow操做所有放入一個update函數中,而且使用rAF進行調用:

function update() {
    requestAnimationFrame(update);

    var currentScrollY = latestKnownScrollY;

    // read offset of DOM elements
    // and compare to the currentScrollY value
    // then apply some CSS classes
    // to the visible items
}

// kick off
requestAnimationFrame(update);

其實解耦的目的已經達到了,但還須要作一些優化,好比不能讓update無限執行下去,須要設標誌位來控制它的執行:

var latestKnownScrollY = 0,
    ticking = false;

function onScroll() {
    latestKnownScrollY = window.scrollY;
    requestTick();
} 

function requestTick() {
    if(!ticking) {
        requestAnimationFrame(update);
    }
    ticking = true;
}

而且咱們始終只須要一個rAF實例的存在,也不容許無限次的update下去,因而咱們還須要一個出口:

function update() {
    // reset the tick so we can
    // capture the next onScroll
    ticking = false;

    var currentScrollY = latestKnownScrollY;

    // read offset of DOM elements
    // and compare to the currentScrollY value
    // then apply some CSS classes
    // to the visible items
}

// kick off - no longer needed! Woo.
// update();

理解Layer

Kyle Simpson說:

Rule of thumb: don’t do in JS what you can do in CSS.

如以上所說,即便使用rAF,仍是會有諸多的不便。咱們還有一個選擇是使用css動畫:雖然瀏覽器中UI線程與js線程是互斥,但這一點對css動畫不成立。

在這裏不聊css動畫的用法。css動畫運用的是什麼原理來提高瀏覽器性能的。

首先咱們看看淘寶首頁的焦點圖:

我想提出一個問題,爲何明明可使用translate 2d去實現的動畫,它要用3d去實現呢?

我不是淘寶的員工,但個人第一猜想這麼作的緣由是爲了使用translate3d hack。簡單來講若是你給一個元素添加上了-webkit-transform: translateZ(0);或者-webkit-transform: translate3d(0,0,0);屬性,那麼你就等於告訴了瀏覽器用GPU來渲染該層,與通常的CPU渲染相比,提高了速度和性能。(我很肯定這麼作會在Chrome中啓用了硬件加速,但在其餘平臺不作保證。就我獲得的資料而言,在大多數瀏覽器好比Firefox、Safari也是適用的)。

但這樣的說法其實並不許確,至少在如今的Chrome版本中這算不上一個hack。由於默認渲染全部的網頁時都會通過GPU。那麼這麼作還有必要嗎?有。在理解原理以前,你必須先了解一個層(Layer)的概念。

html在瀏覽器中會被轉化爲DOM樹,DOM樹的每個節點都會轉化爲RenderObject, 多個RenderObject可能又會對應一個或多個RenderLayer。瀏覽器渲染的流程以下:

  1. 獲取 DOM 並將其分割爲多個層(RenderLayer)
  2. 將每一個層柵格化,並獨立的繪製進位圖中
  3. 將這些位圖做爲紋理上傳至 GPU
  4. 複合多個層來生成最終的屏幕圖像(終極layer)。

這和遊戲中的3D渲染相似,雖然咱們看到的是一個立體的人物,但這我的物的皮膚是由不一樣的圖片「貼」和「拼」上去的。網頁比此還多了一個步驟,雖然最終的網頁是由多個位圖層合成的,但咱們看到的只是一個複印版,最終只有一個層。固然有的層是沒法拼合的,好比flash。以愛奇藝的一個播放頁(http://www.iqiyi.com/v_19rrgyhg0s.html)爲例,咱們能夠利用Chrome的Layer面板(默認不啓用,須要手動開啓)查看頁面上全部的層:

咱們能夠看到頁面上由以下層組成:

OK,那麼問題來了。

假設我如今想改變一個容器的樣式(能夠看作動畫的一個步驟),而且是一種最糟糕的狀況,改變它的長和寬——爲何說改變長和寬是最糟糕的狀況呢。一般改變一個物體的樣式須要如下四個步驟:

任何屬性的改變都致使瀏覽器從新計算容器的樣式,好比你改變的是容器的尺寸或者位置(reflow),那麼首先影響的就是容器的尺寸和位置(也影響了與它相關的父節點本身點相鄰節點的位置等),接下來瀏覽器還須要對容器從新繪製(repaint);但若是你改變的只是容器的背景顏色等無關容器尺寸的屬性,那麼便省去了第一步計算位置的時間。也就是說若是改變屬性在瀑布圖中開始的越早(越往上),那麼影響就越大,效率就越低。reflow和repaint會致使全部受影響節點所在layer的位圖重繪,反覆執行上面的過程,致使效率下降。

爲了把代價降到最低,固然最好只留下compositing layer這一個步驟便可。假設當咱們改變一個容器的樣式時,影響的只是它本身,而且還無需重繪,直接經過在GPU中改變紋理的屬性來改變樣式,豈不是更好?這固然是能夠實現的,前提是你有本身的layer

這也是上面硬件加速hack的原理,也是css動畫的原理——給元素建立本身layer,而非與頁面上大部分的元素共用layer。

什麼樣的元素才能建立本身layer呢?在Chrome中至少要符合如下條件之一:

  • Layer has 3D or perspective transform CSS properties(有3D元素的屬性)
  • Layer is used by <video> element using accelerated video decoding(video標籤並使用加速視頻解碼)
  • Layer is used by a <canvas> element with a 3D context or accelerated 2D context(canvas元素並啓用3D)
  • Layer is used for a composited plugin(插件,好比flash)
  • Layer uses a CSS animation for its opacity or uses an animated webkit transform(CSS動畫)
  • Layer uses accelerated CSS filters(CSS濾鏡)
  • Layer with a composited descendant has information that needs to be in the composited layer tree, such as a clip or reflection(有一個後代元素是獨立的layer)
  • Layer has a sibling with a lower z-index which has a compositing layer (in other words the layer is rendered on top of a composited layer)(元素的相鄰元素是獨立layer)

很明顯剛剛咱們看到的播放頁中的flash和開啓了translate3d樣式的焦點圖符合上面的條件。

同時你也能夠勾選Chrome開發工具中的rendering選顯卡下的Show composited layer borders 選項。頁面上的layer便會加以邊框區別開來。爲了驗證咱們的想法,看下面這樣一段代碼:

<html>
<head>
  <style type="text/css">
  div {
      -webkit-animation-duration: 5s;
      -webkit-animation-name: slide;
      -webkit-animation-iteration-count: infinite;
      -webkit-animation-direction: alternate;
      width: 200px;
      height: 200px;
      margin: 100px;
      background-color: skyblue;
  }
  @-webkit-keyframes slide {
      from {
          -webkit-transform: rotate(0deg);
      }
      to {
          -webkit-transform: rotate(120deg);
      }
  }
  </style>
</head>
<body>
  <div id="foo">I am a strange root.</div>
</body>
</html>

運行時的timeline截圖以下:

可見元素有本身的layer,而且在動畫的過程當中沒有觸發reflow和repaint。

最後再看看淘寶首頁,不只僅只有焦點圖才擁有了獨立的layer:

但太多的layer也未必是一件好事情,有興趣的同窗能夠看一看這篇文章:Jank Busting Apple's Home Page。看一看在蘋果首頁太多layer時出現的問題。

參考文章:


感謝王保平對本文的審校。

 

原文:

http://www.infoq.com/cn/articles/javascript-high-performance-animation-and-page-rendering/

相關文章
相關標籤/搜索