最近、私はページレンダリングとWebアニメーションのパフォーマンスの問題を勉強し、名著「CSS SECRET」を読んでいます。
この記事では主にページの最適化のうちスクロールの最適化について話したいと思います。
主な内容には、スクロール イベントを最適化する必要がある理由、スクロールとページ レンダリングの関係、スロットルと手ぶれ補正、スクロールを最適化するための pointer-events:none が含まれます。この記事には非常に多くの基本事項が含まれているため、上記の知識ポイントを明確に理解している場合は、これ以上読む必要はありません。
スクロールの最適化の起源
スクロールの最適化とは、実際にはスクロール (スクロール イベント) だけを指すのではなく、サイズ変更などの頻繁にトリガーされるイベントも含みます。簡単に見てみましょう:
var i = 0;window.addEventListener('scroll',function(){ console.log(i++);},false);
出力は次のとおりです:
スクロールやサイズ変更などのバインド イベントが発生すると、非常に頻繁かつ非常に短い間隔でトリガーされます。イベントに多くの位置計算、DOM 操作、要素の再描画などが含まれ、次のスクロール イベントがトリガーされるまでにこれらのタスクを完了できない場合、ブラウザーでフレームがドロップする原因になります。さらに、ユーザーのマウス スクロールは継続的に行われることが多いため、スクロール イベントがトリガーされ続け、フレーム ドロップが拡大し、ブラウザーの CPU 使用率が増加し、ユーザー エクスペリエンスに影響を与えます。
スクロール イベントでコールバックをバインドするためのアプリケーション シナリオは数多くあり、画像の遅延読み込み、スライド ダウン時のデータの自動読み込み、サイド フローティング ナビゲーション バーなどで広く使用されています。
ユーザーが Web を閲覧するとき、スムーズなスクロールは見落とされがちですが、ユーザー エクスペリエンスの重要な部分です。スクロールが正常に動作する場合、ユーザーはアプリケーションが非常にスムーズで快適であると感じますが、逆に、重くて不自然なスクロールはユーザーに大きな不快感を与えます。
スクロールとページ レンダリングの関係
スクロール イベントを最適化する必要があるのはなぜですか?パフォーマンスに影響するからです。それで、それはどのようなパフォーマンスに影響しますか?さて...これは、ページのパフォーマンスの問題を決定するものから始まります。
テクノロジーに取り組むときは、原点に戻る必要があると思います。イベントのスクロールが遅延の原因になるという他人の記事を読んだり、ソリューションの最適化テクニックについて話したりするのは、宝物を見つけてそれとして受け取るようなものです。私たちに必要なのは、それを教義として使うことではなく、もっと頻繁に情報源にアクセスして批判することです。
問題から始めて、最後まで段階的に検索することで、問題の核心を見つけやすくなります。
くだらない話をたくさんしてきましたが、気に入らない場合は無視してください。最適化への入り口を見つけるには、ページの最適化について知る必要があります。ページのレンダリング原理を知るには:
前回の記事でもブラウザ レンダリングの原理について詳しく説明しますが、アニメーション レンダリングの観点から詳しく説明します: [Web アニメーション] CSS3 3D 惑星運動 && ブラウザ レンダリングの原理。
考えた後、これらの知識ポイントを確認するたびに新しい洞察が得られることに気づきました。今回は、Web ページの表示を例に挙げます。簡単に言えば、次の手順を実行しました:
JavaScript: 一般的に言えば、視覚的な変更を実現するために JavaScript を使用します。たとえば、アニメーションを作成したり、ページに DOM 要素を追加したりします。
スタイル: スタイルを計算するこのプロセスは、CSS セレクターに基づいて各 DOM 要素に対応する CSS スタイルを照合します。このステップが完了すると、各 DOM 要素にどの CSS スタイル ルールを適用するかが決定されます。
Layout: Layout、前のステップでは各 DOM 要素のスタイル ルールを決定しました。このステップでは、最終的に画面に表示される各 DOM 要素のサイズと位置を具体的に計算します。 Web ページ内の要素のレイアウトは相対的なものであるため、1 つの要素のレイアウトを変更すると、他の要素のレイアウトも変更されます。たとえば、
要素の幅の変更はその子要素の幅に影響し、その子要素の幅の変更はその孫要素にも引き続き影響します。したがって、ブラウザの場合、レイアウト処理が頻繁に発生します。ペイント: ペイントは基本的にピクセルを埋めるプロセスです。描画テキスト、色、イメージ、境界線、影など、つまり DOM 要素のすべての視覚効果が含まれます。通常、この塗装プロセスは複数のレイヤーで実行されます。
コンポジット: レンダリングレイヤーの結合 前のステップからわかるように、ページ内の DOM 要素の描画は複数のレイヤーで実行されます。各レイヤーの描画プロセスが完了すると、ブラウザはすべてのレイヤーを適切な順序で 1 つのレイヤーに結合し、画面に表示します。このプロセスは、レイヤーが間違った順序で結合されると要素が異常に表示されるため、要素が重なっているページでは特に重要です。
这里又涉及了层(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 事件上的 handlerfunction 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 事件上的 handlerfunction 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 打开开发者工具面板,给
标签添加上 pointer-events: none 样式,然后在页面上感受下效果,发现所有鼠标事件都被禁止了。那么它有什么用呢?
pointer-events: none 可用来提高滚动时的帧频。的确,当滚动时,鼠标悬停在某些元素上,则触发其上的 hover 效果,然而这些影响通常不被用户注意,并多半导致滚动出现问题。对 body 元素应用 pointer-events: none ,禁用了包括 hover 在内的鼠标事件,从而提高滚动性能。
.disable-hover { pointer-events: none;}
大概的做法就是在页面滚动的时候, 给
添加上 .disable-hover 样式,那么在滚动停止之前, 所有鼠标事件都将被禁止。当滚动结束之后,再移除该属性。可以查看这个 demo 页面。
上面说 pointer-events: none 可用来提高滚动时的帧频 的这段话摘自 pointer-events-MDN ,还专门有文章讲解过这个技术:
使用pointer-events:none实现60fps滚动 。
这就完了吗?没有,张鑫旭有一篇专门的文章,用来探讨 pointer-events: none 是否真的能够加速滚动性能,并提出了自己的质疑:
pointer-events:none提高页面滚动时候的绘制性能?
结论见仁见智,使用 pointer-events: none 的场合要依据业务本身来定夺,拒绝拿来主义,多去源头看看,动手实践一番再做定夺。
其他参考文献(都是好文章,值得一读):
到此本文结束,如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。
如果本文对你有帮助,请点下推荐,写文章不容易。