前端高效能滾動 scroll 及頁面渲染優化

ChokCoco發表於2016-05-18

最近在研究頁面渲染及web動畫的效能問題,以及拜讀《CSS SECRET》(CSS揭祕)這本大作。

本文主要想談談頁面優化之滾動優化。

主要內容包括了為何需要優化滾動事件,滾動與頁面渲染的關係,節流與防抖,pointer-events:none 優化滾動。因為本文涉及了很多很多基礎,是我自己學習記錄的一個過程,如果上面列出的知識點都瞭然於胸了,就可以不必往下看了。

滾動優化的由來

滾動優化其實也不僅僅指滾動(scroll 事件),還包括了例如 resize 這類會頻繁觸發的事件。簡單的看看:

var i = 0;
window.addEventListener('scroll',function(){
    console.log(i++);
},false);

輸出如下:

在繫結 scroll 、resize 這類事件時,當它發生時,它被觸發的頻次非常高,間隔很近。如果事件中涉及到大量的位置計算、DOM 操作、元素重繪等工作且這些工作無法在下一個 scroll 事件觸發前完成,就會造成瀏覽器掉幀。加之使用者滑鼠滾動往往是連續的,就會持續觸發 scroll 事件導致掉幀擴大、瀏覽器 CPU 使用率增加、使用者體驗受到影響。

在滾動事件中繫結回撥應用場景也非常多,在圖片的懶載入、下滑自動載入資料、側邊浮動導航欄等中有著廣泛的應用。

當使用者瀏覽網頁時,擁有平滑滾動經常是被忽視但卻是使用者體驗中至關重要的部分。當滾動表現正常時,使用者就會感覺應用十分流暢,令人愉悅,反之,笨重不自然卡頓的滾動,則會給使用者帶來極大不舒爽的感覺。

滾動與頁面渲染的關係

為什麼滾動事件需要去優化?因為它影響了效能。那它影響了什麼效能呢?額……這個就要從頁面效能問題由什麼決定說起。

我覺得搞技術一定要追本溯源,不要看到別人一篇文章說滾動事件會導致卡頓並說了一堆解決方案優化技巧就如獲至寶奉為圭臬,我們需要的不是拿來主義而是批判主義,多去源頭看看。

從問題出發,一步一步尋找到最後,就很容易找到問題的癥結所在,只有這樣得出的解決方法才容易記住。

說教了一堆廢話,不喜歡的直接忽略哈,回到正題,要找到優化的入口就要知道問題出在哪裡,對於頁面優化而言,那麼我們就要知道頁面的渲染原理:

瀏覽器渲染原理我在我上一篇文章裡也要詳細的講到,不過更多的是從動畫渲染的角度去講的:【Web動畫】CSS3 3D 行星運轉 && 瀏覽器渲染原理 。

想了想,還是再簡單的描述下,我發現每次 review 這些知識點都有新的收穫,這次換一張圖,以 chrome 為例子,一個 Web 頁面的展示,簡單來說可以認為經歷了以下下幾個步驟:

  • JavaScript:一般來說,我們會使用 JavaScript 來實現一些視覺變化的效果。比如做一個動畫或者往頁面裡新增一些 DOM 元素等。
  • Style:計算樣式,這個過程是根據 CSS 選擇器,對每個 DOM 元素匹配對應的 CSS 樣式。這一步結束之後,就確定了每個 DOM 元素上該應用什麼 CSS 樣式規則。
  • Layout:佈局,上一步確定了每個 DOM 元素的樣式規則,這一步就是具體計算每個 DOM 元素最終在螢幕上顯示的大小和位置。web 頁面中元素的佈局是相對的,因此一個元素的佈局發生變化,會聯動地引發其他元素的佈局發生變化。比如,<body> 元素的寬度的變化會影響其子元素的寬度,其子元素寬度的變化也會繼續對其孫子元素產生影響。因此對於瀏覽器來說,佈局過程是經常發生的。
  • Paint:繪製,本質上就是填充畫素的過程。包括繪製文字、顏色、影像、邊框和陰影等,也就是一個 DOM 元素所有的可視效果。一般來說,這個繪製過程是在多個層上完成的。
  • Composite:渲染層合併,由上一步可知,對頁面中 DOM 元素的繪製是在多個層上進行的。在每個層上完成繪製過程之後,瀏覽器會將所有層按照合理的順序合併成一個圖層,然後顯示在螢幕上。對於有位置重疊的元素的頁面,這個過程尤其重要,因為一旦圖層的合併順序出錯,將會導致元素顯示異常。

這裡又涉及了層(GraphicsLayer)的概念,GraphicsLayer 層是作為紋理(texture)上傳給 GPU 的,現在經常能看到說 GPU 硬體加速,就和所謂的層的概念密切相關。但是和本文的滾動優化相關性不大,有興趣深入瞭解的可以自行 google 更多。

簡單來說,網頁生成的時候,至少會渲染(Layout+Paint)一次。使用者訪問的過程中,還會不斷重新的重排(reflow)和重繪(repaint)。

其中,使用者 scroll 和 resize 行為(即是滑動頁面和改變視窗大小)會導致頁面不斷的重新渲染。

當你滾動頁面時,瀏覽器可能會需要繪製這些層(有時也被稱為合成層)裡的一些畫素。通過元素分組,當某個層的內容改變時,我們只需要更新該層的結構,並僅僅重繪和柵格化渲染層結構裡變化的那一部分,而無需完全重繪。顯然,如果當你滾動時,像視差網站(戳我看看)這樣有東西在移動時,有可能在多層導致大面積的內容調整,這會導致大量的繪製工作。

防抖(Debouncing)和節流(Throttling)

scroll 事件本身會觸發頁面的重新渲染,同時 scroll 事件的 handler 又會被高頻度的觸發, 因此事件的 handler 內部不應該有複雜操作,例如 DOM 操作就不應該放在事件處理中。

針對此類高頻度觸發事件問題(例如頁面 scroll ,螢幕 resize,監聽使用者輸入等),下面介紹兩種常用的解決方法,防抖和節流。

防抖(Debouncing)

防抖技術即是可以把多個順序地呼叫合併成一次,也就是在一定時間內,規定事件被觸發的次數。

通俗一點來說,看看下面這個簡化的例子:

// 簡單的防抖動函式
function debounce(func, wait, immediate) {
    // 定時器變數
    var timeout;
    return function() {
        // 每次觸發 scroll handler 時先清除定時器
        clearTimeout(timeout);
        // 指定 xx ms 後觸發真正想進行的操作 handler
        timeout = setTimeout(func, wait);
    };
};

// 實際想繫結在 scroll 事件上的 handler
function realFunc(){
    console.log("Success");
}

// 採用了防抖動
window.addEventListener('scroll',debounce(realFunc,500));
// 沒采用防抖動
window.addEventListener('scroll',realFunc);

上面簡單的防抖的例子可以拿到瀏覽器下試一下,大概功能就是如果 500ms 內沒有連續觸發兩次 scroll 事件,那麼才會觸發我們真正想在 scroll 事件中觸發的函式。

上面的示例可以更好的封裝一下:

// 防抖動函式
function debounce(func, wait, immediate) {
    var timeout;
    return function() {
        var context = this, args = arguments;
        var later = function() {
            timeout = null;
            if (!immediate) func.apply(context, args);
        };
        var callNow = immediate && !timeout;
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
        if (callNow) func.apply(context, args);
    };
};

var myEfficientFn = debounce(function() {
    // 滾動中的真正的操作
}, 250);

// 繫結監聽
window.addEventListener('resize', myEfficientFn);

節流(Throttling)

防抖函式確實不錯,但是也存在問題,譬如圖片的懶載入,我希望在下滑過程中圖片不斷的被載入出來,而不是隻有當我停止下滑時候,圖片才被載入出來。又或者下滑時候的資料的 ajax 請求載入也是同理。

這個時候,我們希望即使頁面在不斷被滾動,但是滾動 handler 也可以以一定的頻率被觸發(譬如 250ms 觸發一次),這類場景,就要用到另一種技巧,稱為節流函式(throttling)。

節流函式,只允許一個函式在 X 毫秒內執行一次,只有當上一次函式執行後過了你規定的時間間隔,才能進行下一次該函式的呼叫。

與防抖相比,節流函式最主要的不同在於它保證在 X 毫秒內至少執行一次我們希望觸發的事件 handler。

與防抖相比,節流函式多了一個 mustRun 屬性,代表 mustRun 毫秒內,必然會觸發一次 handler ,同樣是利用定時器,看看簡單的示例:

// 簡單的節流函式
function throttle(func, wait, mustRun) {
    var timeout,
        startTime = new Date();

    return function() {
        var context = this,
            args = arguments,
            curTime = new Date();

        clearTimeout(timeout);
        // 如果達到了規定的觸發時間間隔,觸發 handler
        if(curTime - startTime >= mustRun){
            func.apply(context,args);
            startTime = curTime;
        // 沒達到觸發間隔,重新設定定時器
        }else{
            timeout = setTimeout(func, wait);
        }
    };
};
// 實際想繫結在 scroll 事件上的 handler
function realFunc(){
    console.log("Success");
}
// 採用了節流函式
window.addEventListener('scroll',throttle(realFunc,500,1000));

上面簡單的節流函式的例子可以拿到瀏覽器下試一下,大概功能就是如果在一段時間內 scroll 觸發的間隔一直短於 500ms ,那麼能保證事件我們希望呼叫的 handler 至少在 1000ms 內會觸發一次。

使用 rAF(requestAnimationFrame)觸發滾動事件

上面介紹的抖動與節流實現的方式都是藉助了定時器 setTimeout ,但是如果頁面只需要相容高版本瀏覽器或應用在移動端,又或者頁面需要追求高精度的效果,那麼可以使用瀏覽器的原生方法 rAF(requestAnimationFrame)。

requestAnimationFrame

window.requestAnimationFrame() 這個方法是用來在頁面重繪之前,通知瀏覽器呼叫一個指定的函式。這個方法接受一個函式為參,該函式會在重繪前呼叫。

rAF 常用於 web 動畫的製作,用於準確控制頁面的幀重新整理渲染,讓動畫效果更加流暢,當然它的作用不僅僅侷限於動畫製作,因為同時它也是一個定時器。

通常來說,rAF 被呼叫的頻率是每秒 60 次,也就是 1000/60 ,觸發頻率大概是 16.7ms 。

簡單而言,使用 requestAnimationFrame 來觸發滾動事件,相當於上面的:

throttle(func, xx, 16.7) //xx 代表 xx ms內不會重複觸發事件 handler

簡單的示例如下:

var ticking = false; // rAF 觸發鎖

function onScroll(){
  if(!ticking) {
    requestAnimationFrame(realFunc);
    ticking = true;
  }
}

function realFunc(){
    // do something...
    console.log("Success");
    ticking = false;
}
// 滾動事件監聽
window.addEventListener('scroll', onScroll, false);

上面簡單的使用 rAF 的例子可以拿到瀏覽器下試一下,大概功能就是在滾動的過程中,保持以 16.7ms 的頻率觸發事件 handler。

使用 requestAnimationFrame 優缺點並存,首先我們不得不考慮它的相容問題,其次因為它只能實現以 16.7ms 的頻率來觸發,代表它的可調節性十分差。但是相比 throttle(func, xx, 16.7) ,用於更復雜的場景時,rAF 可能效果更佳,效能更好。

總結一下

  • 防抖動:防抖技術即是可以把多個順序地呼叫合併成一次,也就是在一定時間內,規定事件被觸發的次數。
  • 節流函式:只允許一個函式在 X 毫秒內執行一次,只有當上一次函式執行後過了你規定的時間間隔,才能進行下一次該函式的呼叫。
  • rAF:16.7ms 觸發一次 handler,降低了可控性,但是提升了效能和精確度。

簡化 scroll 內的操作

上面介紹的方法都是如何去優化 scroll 事件的觸發,避免 scroll 事件過度消耗資源的。

但是從本質上而言,我們應該儘量去精簡 scroll 事件的 handler ,將一些變數的初始化、不依賴於滾動位置變化的計算等都應當在 scroll 事件外提前就緒。

建議如下:

避免在scroll 事件中修改樣式屬性 / 將樣式操作從 scroll 事件中剝離

輸入事件處理函式,比如 scroll / touch 事件的處理,都會在 requestAnimationFrame 之前被呼叫執行。

因此,如果你在 scroll 事件的處理函式中做了修改樣式屬性的操作,那麼這些操作會被瀏覽器暫存起來。然後在呼叫 requestAnimationFrame 的時候,如果你在一開始做了讀取樣式屬性的操作,那麼這將會導致觸發瀏覽器的強制同步佈局。

滑動過程中嘗試使用 pointer-events: none 禁止滑鼠事件

大部分人可能都不認識這個屬性,嗯,那麼它是幹什麼用的呢?

pointer-events 是一個 CSS 屬性,可以有多個不同的值,屬性的一部分值僅僅與 SVG 有關聯,這裡我們只關注 pointer-events: none 的情況,大概的意思就是禁止滑鼠行為,應用了該屬性後,譬如滑鼠點選,hover 等功能都將失效,即是元素不會成為滑鼠事件的 target。

可以就近 F12 開啟開發者工具皮膚,給 <body> 標籤新增上 pointer-events: none 樣式,然後在頁面上感受下效果,發現所有滑鼠事件都被禁止了。

那麼它有什麼用呢?

pointer-events: none 可用來提高滾動時的幀頻。的確,當滾動時,滑鼠懸停在某些元素上,則觸發其上的 hover 效果,然而這些影響通常不被使用者注意,並多半導致滾動出現問題。對 body 元素應用 pointer-events: none ,禁用了包括 hover 在內的滑鼠事件,從而提高滾動效能。

.disable-hover {
    pointer-events: none;
}

大概的做法就是在頁面滾動的時候, 給 <body> 新增上 .disable-hover 樣式,那麼在滾動停止之前, 所有滑鼠事件都將被禁止。當滾動結束之後,再移除該屬性。

可以檢視這個 demo 頁面。

上面說 pointer-events: none 可用來提高滾動時的幀頻 的這段話摘自 pointer-events-MDN ,還專門有文章講解過這個技術:

使用pointer-events:none實現60fps滾動 。

這就完了嗎?沒有,張鑫旭有一篇專門的文章,用來探討 pointer-events: none 是否真的能夠加速滾動效能,並提出了自己的質疑:

pointer-events:none提高頁面滾動時候的繪製效能?

結論見仁見智,使用 pointer-events: none 的場合要依據業務本身來定奪,拒絕拿來主義,多去源頭看看,動手實踐一番再做定奪。

其他參考文獻(都是好文章,值得一讀):

到此本文結束,如果還有什麼疑問或者建議,可以多多交流,原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

相關文章