最近在研究頁面渲染及web動畫的性能問題,以及拜讀《CSS SECRET》【https://github.com/cssmagic/CSS-Secrets】(CSS揭秘)這本大作。
本文主要想談?wù)勴撁鎯?yōu)化之滾動優(yōu)化。
主要內(nèi)容包括了為何需要優(yōu)化滾動事件,滾動與頁面渲染的關(guān)系,節(jié)流與防抖,pointer-events:none 優(yōu)化滾動。因為本文涉及了很多很多基礎(chǔ),是我自己學習記錄的一個過程,如果上面列出的知識點都了然于胸了,就可以不必往下看了。
滾動優(yōu)化的由來
滾動優(yōu)化其實也不僅僅指滾動(scroll?事件),還包括了例如 resize 這類會頻繁觸發(fā)的事件。簡單的看看:
vari?=?0;
window.addEventListener('scroll',function(){
console.log(i++);
},false);
輸出如下:
在綁定 scroll 、resize 這類事件時,當它發(fā)生時,它被觸發(fā)的頻次非常高,間隔很近。如果事件中涉及到大量的位置計算、DOM 操作、元素重繪等工作且這些工作無法在下一個 scroll 事件觸發(fā)前完成,就會造成瀏覽器掉幀。加之用戶鼠標滾動往往是連續(xù)的,就會持續(xù)觸發(fā) scroll 事件導致掉幀擴大、瀏覽器 CPU 使用率增加、用戶體驗受到影響。
在滾動事件中綁定回調(diào)應(yīng)用場景也非常多,在圖片的懶加載、下滑自動加載數(shù)據(jù)、側(cè)邊浮動導航欄等中有著廣泛的應(yīng)用。
當用戶瀏覽網(wǎng)頁時,擁有平滑滾動經(jīng)常是被忽視但卻是用戶體驗中至關(guān)重要的部分。當滾動表現(xiàn)正常時,用戶就會感覺應(yīng)用十分流暢,令人愉悅,反之,笨重不自然卡頓的滾動,則會給用戶帶來極大不舒爽的感覺。
滾動與頁面渲染的關(guān)系
為什么滾動事件需要去優(yōu)化?因為它影響了性能。那它影響了什么性能呢?額......這個就要從頁面性能問題由什么決定說起。
我覺得搞技術(shù)一定要追本溯源,不要看到別人一篇文章說滾動事件會導致卡頓并說了一堆解決方案優(yōu)化技巧就如獲至寶奉為圭臬,我們需要的不是拿來主義而是批判主義,多去源頭看看。
從問題出發(fā),一步一步尋找到最后,就很容易找到問題的癥結(jié)所在,只有這樣得出的解決方法才容易記住。
說教了一堆廢話,不喜歡的直接忽略哈,回到正題,要找到優(yōu)化的入口就要知道問題出在哪里,對于頁面優(yōu)化而言,那么我們就要知道頁面的渲染原理:
以 chrome 為例子,一個 Web 頁面的展示,簡單來說可以認為經(jīng)歷了以下下幾個步驟:
JavaScript:一般來說,我們會使用?JavaScript?來實現(xiàn)一些視覺變化的效果。比如做一個動畫或者往頁面里添加一些?DOM?元素等。
Style:計算樣式,這個過程是根據(jù)?CSS?選擇器,對每個?DOM?元素匹配對應(yīng)的?CSS?樣式。這一步結(jié)束之后,就確定了每個?DOM?元素上該應(yīng)用什么?CSS?樣式規(guī)則。
Layout:布局,上一步確定了每個?DOM?元素的樣式規(guī)則,這一步就是具體計算每個?DOM?元素最終在屏幕上顯示的大小和位置。web?頁面中元素的布局是相對的,因此一個元素的布局發(fā)生變化,會聯(lián)動地引發(fā)其他元素的布局發(fā)生變化。比如,?元素的寬度的變化會影響其子元素的寬度,其子元素寬度的變化也會繼續(xù)對其孫子元素產(chǎn)生影響。因此對于瀏覽器來說,布局過程是經(jīng)常發(fā)生的。
Paint:繪制,本質(zhì)上就是填充像素的過程。包括繪制文字、顏色、圖像、邊框和陰影等,也就是一個?DOM?元素所有的可視效果。一般來說,這個繪制過程是在多個層上完成的。
Composite:渲染層合并,由上一步可知,對頁面中?DOM?元素的繪制是在多個層上進行的。在每個層上完成繪制過程之后,瀏覽器會將所有層按照合理的順序合并成一個圖層,然后顯示在屏幕上。對于有位置重疊的元素的頁面,這個過程尤其重要,因為一旦圖層的合并順序出錯,將會導致元素顯示異常。
這里又涉及了層(GraphicsLayer)的概念,GraphicsLayer 層是作為紋理(texture)上傳給 GPU 的,現(xiàn)在經(jīng)常能看到說 GPU 硬件加速,就和所謂的層的概念密切相關(guān)。但是和本文的滾動優(yōu)化相關(guān)性不大,有興趣深入了解的可以自行 google 更多。
簡單來說,網(wǎng)頁生成的時候,至少會渲染(Layout+Paint)一次。用戶訪問的過程中,還會不斷重新的重排(reflow)和重繪(repaint)。
其中,用戶 scroll 和 resize 行為(即是滑動頁面和改變窗口大小)會導致頁面不斷的重新渲染。
當你滾動頁面時,瀏覽器可能會需要繪制這些層(有時也被稱為合成層)里的一些像素。通過元素分組,當某個層的內(nèi)容改變時,我們只需要更新該層的結(jié)構(gòu),并僅僅重繪和柵格化渲染層結(jié)構(gòu)里變化的那一部分,而無需完全重繪。顯然,如果當你滾動時,像視差網(wǎng)站這樣有東西在移動時,有可能在多層導致大面積的內(nèi)容調(diào)整,這會導致大量的繪制工作。
防抖(Debouncing)和節(jié)流(Throttling)
scroll 事件本身會觸發(fā)頁面的重新渲染,同時?scroll?事件的 handler 又會被高頻度的觸發(fā), 因此事件的 handler 內(nèi)部不應(yīng)該有復雜操作,例如 DOM 操作就不應(yīng)該放在事件處理中。
針對此類高頻度觸發(fā)事件問題(例如頁面 scroll ,屏幕 resize,監(jiān)聽用戶輸入等),下面介紹兩種常用的解決方法,防抖和節(jié)流。
防抖(Debouncing)
防抖技術(shù)即是可以把多個順序地調(diào)用合并成一次,也就是在一定時間內(nèi),規(guī)定事件被觸發(fā)的次數(shù)。
通俗一點來說,看看下面這個簡化的例子:
//?簡單的防抖動函數(shù)
functiondebounce(func,?wait,?immediate)?{
//?定時器變量
vartimeout;
returnfunction()?{
//?每次觸發(fā)?scroll?handler?時先清除定時器
clearTimeout(timeout);
//?指定?xx?ms?后觸發(fā)真正想進行的操作?handler
timeout?=?setTimeout(func,?wait);
};
};
//?實際想綁定在?scroll?事件上的?handler
functionrealFunc(){
console.log("Success");
}
//?采用了防抖動
window.addEventListener('scroll',debounce(realFunc,500));
//?沒采用防抖動
window.addEventListener('scroll',realFunc);
上面簡單的防抖的例子可以拿到瀏覽器下試一下,大概功能就是如果 500ms 內(nèi)沒有連續(xù)觸發(fā)兩次 scroll 事件,那么才會觸發(fā)我們真正想在 scroll 事件中觸發(fā)的函數(shù)。
上面的示例可以更好的封裝一下:
//?防抖動函數(shù)
functiondebounce(func,?wait,?immediate)?{
vartimeout;
returnfunction()?{
varcontext?=this,?args?=?arguments;
varlater?=function()?{
timeout?=null;
if(!immediate)?func.apply(context,?args);
};
varcallNow?=?immediate?&&?!timeout;
clearTimeout(timeout);
timeout?=?setTimeout(later,?wait);
if(callNow)?func.apply(context,?args);
};
};
varmyEfficientFn?=?debounce(function()?{
//?滾動中的真正的操作
},?250);
//?綁定監(jiān)聽
window.addEventListener('resize',?myEfficientFn);
節(jié)流(Throttling)
防抖函數(shù)確實不錯,但是也存在問題,譬如圖片的懶加載,我希望在下滑過程中圖片不斷的被加載出來,而不是只有當我停止下滑時候,圖片才被加載出來。又或者下滑時候的數(shù)據(jù)的 ajax 請求加載也是同理。
這個時候,我們希望即使頁面在不斷被滾動,但是滾動 handler 也可以以一定的頻率被觸發(fā)(譬如 250ms 觸發(fā)一次),這類場景,就要用到另一種技巧,稱為節(jié)流函數(shù)(throttling)。
節(jié)流函數(shù),只允許一個函數(shù)在 X 毫秒內(nèi)執(zhí)行一次,只有當上一次函數(shù)執(zhí)行后過了你規(guī)定的時間間隔,才能進行下一次該函數(shù)的調(diào)用。
與防抖相比,節(jié)流函數(shù)最主要的不同在于它保證在 X 毫秒內(nèi)至少執(zhí)行一次我們希望觸發(fā)的事件 handler。
與防抖相比,節(jié)流函數(shù)多了一個 mustRun 屬性,代表 mustRun 毫秒內(nèi),必然會觸發(fā)一次 handler ,同樣是利用定時器,看看簡單的示例:
//?簡單的節(jié)流函數(shù)
functionthrottle(func,?wait,?mustRun)?{
vartimeout,
startTime?=newDate();
returnfunction()?{
varcontext?=this,
args?=?arguments,
curTime?=newDate();
clearTimeout(timeout);
//?如果達到了規(guī)定的觸發(fā)時間間隔,觸發(fā)?handler
if(curTime?-?startTime?>=?mustRun){
func.apply(context,args);
startTime?=?curTime;
//?沒達到觸發(fā)間隔,重新設(shè)定定時器
}else{
timeout?=?setTimeout(func,?wait);
}
};
};
//?實際想綁定在?scroll?事件上的?handler
functionrealFunc(){
console.log("Success");
}
//?采用了節(jié)流函數(shù)
window.addEventListener('scroll',throttle(realFunc,500,1000));
上面簡單的節(jié)流函數(shù)的例子可以拿到瀏覽器下試一下,大概功能就是如果在一段時間內(nèi) scroll 觸發(fā)的間隔一直短于 500ms ,那么能保證事件我們希望調(diào)用的 handler 至少在 1000ms 內(nèi)會觸發(fā)一次。
使用 rAF(requestAnimationFrame)觸發(fā)滾動事件
上面介紹的抖動與節(jié)流實現(xiàn)的方式都是借助了定時器 setTimeout ,但是如果頁面只需要兼容高版本瀏覽器或應(yīng)用在移動端,又或者頁面需要追求高精度的效果,那么可以使用瀏覽器的原生方法 rAF(requestAnimationFrame)。
requestAnimationFrame
window.requestAnimationFrame() 這個方法是用來在頁面重繪之前,通知瀏覽器調(diào)用一個指定的函數(shù)。這個方法接受一個函數(shù)為參,該函數(shù)會在重繪前調(diào)用。
rAF 常用于 web 動畫的制作,用于準確控制頁面的幀刷新渲染,讓動畫效果更加流暢,當然它的作用不僅僅局限于動畫制作,因為同時它也是一個定時器。
通常來說,rAF 被調(diào)用的頻率是每秒 60 次,也就是 1000/60 ,觸發(fā)頻率大概是 16.7ms 。
簡單而言,使用?requestAnimationFrame 來觸發(fā)滾動事件,相當于上面的:
throttle(func,?xx,?16.7)//xx?代表?xx?ms內(nèi)不會重復觸發(fā)事件?handler
簡單的示例如下:
varticking?=false;//?rAF?觸發(fā)鎖
functiononScroll(){
if(!ticking)?{
requestAnimationFrame(realFunc);
ticking?=true;
}
}
functionrealFunc(){
//?do?something...
console.log("Success");
ticking?=false;
}
//?滾動事件監(jiān)聽
window.addEventListener('scroll',?onScroll,false);
上面簡單的使用 rAF 的例子可以拿到瀏覽器下試一下,大概功能就是在滾動的過程中,保持以 16.7ms 的頻率觸發(fā)事件 handler。
使用?requestAnimationFrame 優(yōu)缺點并存,首先我們不得不考慮它的兼容問題,其次因為它只能實現(xiàn)以 16.7ms 的頻率來觸發(fā),代表它的可調(diào)節(jié)性十分差。但是相比?throttle(func, xx, 16.7) ,用于更復雜的場景時,rAF 可能效果更佳,性能更好。
總結(jié)一下
防抖動:防抖技術(shù)即是可以把多個順序地調(diào)用合并成一次,也就是在一定時間內(nèi),規(guī)定事件被觸發(fā)的次數(shù)。
節(jié)流函數(shù):只允許一個函數(shù)在 X 毫秒內(nèi)執(zhí)行一次,只有當上一次函數(shù)執(zhí)行后過了你規(guī)定的時間間隔,才能進行下一次該函數(shù)的調(diào)用。
rAF:16.7ms 觸發(fā)一次 handler,降低了可控性,但是提升了性能和精確度。
簡化 scroll 內(nèi)的操作
上面介紹的方法都是如何去優(yōu)化 scroll 事件的觸發(fā),避免 scroll 事件過度消耗資源的。
但是從本質(zhì)上而言,我們應(yīng)該盡量去精簡 scroll 事件的 handler ,將一些變量的初始化、不依賴于滾動位置變化的計算等都應(yīng)當在 scroll 事件外提前就緒。
建議如下:
避免在scroll 事件中修改樣式屬性 /將樣式操作從 scroll 事件中剝離
輸入事件處理函數(shù),比如 scroll / touch 事件的處理,都會在?requestAnimationFrame?之前被調(diào)用執(zhí)行。
因此,如果你在 scroll 事件的處理函數(shù)中做了修改樣式屬性的操作,那么這些操作會被瀏覽器暫存起來。然后在調(diào)用?requestAnimationFrame?的時候,如果你在一開始做了讀取樣式屬性的操作,那么這將會導致觸發(fā)瀏覽器的強制同步布局。
滑動過程中嘗試使用 pointer-events:none禁止鼠標事件
大部分人可能都不認識這個屬性,嗯,那么它是干什么用的呢?
pointer-events是一個 CSS 屬性,可以有多個不同的值,屬性的一部分值僅僅與 SVG 有關(guān)聯(lián),這里我們只關(guān)注pointer-events:none的情況,大概的意思就是禁止鼠標行為,應(yīng)用了該屬性后,譬如鼠標點擊,hover 等功能都將失效,即是元素不會成為鼠標事件的 target。
可以就近 F12 打開開發(fā)者工具面板,給 標簽添加上?pointer-events:none 樣式,然后在頁面上感受下效果,發(fā)現(xiàn)所有鼠標事件都被禁止了。
那么它有什么用呢?
pointer-events:none可用來提高滾動時的幀頻。的確,當滾動時,鼠標懸停在某些元素上,則觸發(fā)其上的 hover 效果,然而這些影響通常不被用戶注意,并多半導致滾動出現(xiàn)問題。對 body?元素應(yīng)用?pointer-events:none,禁用了包括 hover?在內(nèi)的鼠標事件,從而提高滾動性能。
.disable-hover?{
pointer-events:none;
}
大概的做法就是在頁面滾動的時候, 給 添加上?.disable-hover 樣式,那么在滾動停止之前, 所有鼠標事件都將被禁止。當滾動結(jié)束之后,再移除該屬性。
上面說 pointer-events:none?可用來提高滾動時的幀頻 的這段話摘自pointer-events-MDN,還專門有文章講解過這個技術(shù):
使用pointer-events:none實現(xiàn)60fps滾動(https://www.thecssninja.com/javascript/pointer-events-60fps)。
這就完了嗎?沒有,張鑫旭有一篇專門的文章,用來探討?pointer-events:none 是否真的能夠加速滾動性能,并提出了自己的質(zhì)疑:
pointer-events:none提高頁面滾動時候的繪制性能?(http://www.zhangxinxu.com/wordpress/2014/01/pointer-events-none-avoiding-unnecessary-paints/)
結(jié)論見仁見智,使用?pointer-events:none 的場合要依據(jù)業(yè)務(wù)本身來定奪,拒絕拿來主義,多去源頭看看,動手實踐一番再做定奪。
轉(zhuǎn)自:ChokCoco(http://www.cnblogs.com/coco1s/p/5499469.html)