文章目录
一、scroll 滚动事件
由于滚动事件可以高速触发,因此事件处理程序不应执行计算成本高的操作,例如 DOM 修改。 相反,建议使用 requestAnimationFrame()
、setTimeout()
或 CustomEvent
来优化事件,如下所示。
// 使用 requestAnimationFrame 节流
let lastKnownScrollPosition = 0;
let ticking = false;
function doSomething(scrollPosition) {
// Do something with the scroll position
}
document.addEventListener(
"scroll",
function (e) {
lastKnownScrollPosition = window.scrollY;
if (!ticking) {
// 使用 requestAnimationFrame 节流
window.requestAnimationFrame(function () {
doSomething(lastKnownScrollPosition);
ticking = false;
});
ticking = true;
}
},
{
// 使用 passive 可改善的滚屏性能
passive: true,
}
);
注意, input events 和 animation frames(动画帧)以大致相同的速率触发,因此通常不需要上面面的优化。
二、使用 requestAnimationFrame 节流
1. 屏幕刷新频率
图像在屏幕上更新的速度(屏幕上的图像每秒钟出现的次数),它的单位是赫兹(Hz)。
60Hz:显示器也会以每秒60次的频率正在不断的更新屏幕上的图像,每次的间隔时间是 16.7ms(1000/60≈16.7) 。
2. 动画原理
动画本质就是要让人眼看到图像被刷新而引起变化的视觉效果,这个变化要以连贯的、平滑的方式进行过渡。
3. setTimeout
利用seTimeout实现的动画在某些低端机上会出现卡顿、抖动的现象。 原因:
- setTimeout的执行时间并不是确定的。在Javascript中, setTimeout 任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,因此 setTimeout 的实际执行时间一般要比其设定的时间晚一些。
- 刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的屏幕刷新频率可能会不同,而 setTimeout只能设置一个固定的时间间隔,这个时间不一定和屏幕的刷新时间相同。
4. requestAnimationFrame
- requestAnimationFrame 充分利用显示器的刷新机制,由系统来决定回调函数的执行时机,从而节省系统资源,提高系统性能,改善视觉效果。
- requestAnimationFrame 的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题。
- requestAnimationFrame 告诉浏览器希望执行一个动画,让浏览器在下一个动画帧安排一次网页重绘。
- requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率。
- 显示器有固定的刷新频率(60Hz或75Hz),也就是说,每秒最多只能重绘60次或75次,requestAnimationFrame的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。
- 一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了CPU、GPU和电力。
5. requestAnimationFrame 优势
- CPU节能:使用setTimeout实现的动画,当页面被隐藏或最小化时,setTimeout 仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,完全是浪费CPU资源。而requestAnimationFrame则完全不同,当页面处理未激活的状态下,该页面的屏幕刷新任务也会被系统暂停,因此跟着系统步伐走的requestAnimationFrame也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了CPU开销。
- 函数节流:在高频率事件(resize,scroll等)中,为了防止在一个刷新间隔内发生多次函数执行,使用requestAnimationFrame可保证每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。一个刷新间隔内函数执行多次时没有意义的,因为显示器每16.7ms刷新一次,多次绘制并不会在屏幕上体现出来。
6. requestAnimationFrame 优雅降级
由于requestAnimationFrame目前还存在兼容性问题,而且不同的浏览器还需要带不同的前缀。因此需要通过优雅降级的方式对requestAnimationFrame进行封装,优先使用高级特性,然后再根据不同浏览器的情况进行回退,直止只能使用setTimeout的情况。
// 开源组件库 ngx-tethys里的requestAnimationFrame优雅降级
const availablePrefixes = ['moz', 'ms', 'webkit'];
function requestAnimationFramePolyfill(): typeof requestAnimationFrame {
let lastTime = 0;
return function(callback: FrameRequestCallback): number {
const currTime = new Date().getTime();
const timeToCall = Math.max(0, 16 - (currTime - lastTime));
const id = setTimeout(() => {
callback(currTime + timeToCall);
}, timeToCall) as any; // setTimeout type warn
lastTime = currTime + timeToCall;
return id;
};
}
function getRequestAnimationFrame(): typeof requestAnimationFrame {
if (typeof window === 'undefined') {
return () => 0;
}
if (window.requestAnimationFrame) {
// https://github.com/vuejs/vue/issues/4465
return window.requestAnimationFrame.bind(window);
}
const prefix = availablePrefixes.filter(key => `${key}RequestAnimationFrame` in window)[0];
return prefix ? (window as any)[`${prefix}RequestAnimationFrame`] : requestAnimationFramePolyfill();
}
export function cancelRequestAnimationFrame(id: number): any {
if (typeof window === 'undefined') {
return null;
}
if (window.cancelAnimationFrame) {
return window.cancelAnimationFrame(id);
}
const prefix = availablePrefixes.filter(
key => `${key}CancelAnimationFrame` in window || `${key}CancelRequestAnimationFrame` in window
)[0];
return prefix
? ((window as any)[`${prefix}CancelAnimationFrame`] || (window as any)[`${prefix}CancelRequestAnimationFrame`])
// @ts-ignore
.call(this, id)
: clearTimeout(id);
}
export const reqAnimFrame = getRequestAnimationFrame();
三、使用 passive 可改善的滚屏性能
移动端的一些事件比如 touchstart 、 touchmove 、 touchend 、 touchcancel 等,如果在这些事件中阻止默认行为,页面会被禁止滚动或缩放。
而浏览器无法事先知道一个监听器是否会禁止默认行为,要等监听器执行之后,才会去执行默认行为。而监听器的执行是要耗时的,如果在 event.preventDefault() 之前耗时了 2秒 ,这样就会导致页面卡顿。
为了提升此种场景下的滚动体验,我们需要有一个参数来告诉浏览器,我的事件监听器中不会有 event.preventDefault() ,你可以不用等监听器执行完毕,请尽情的滚动吧。所以有了 passive 属性。为了兼容之前的 useCapture ,把最后一个参数改成了一个对象 options。
所以在绑定移动端相关的 touch 和滚动事件时,尽可能使用 { passive: true }
来提升性能和体验,避免出现页面卡顿。
然而,不是所有的浏览器都支持 passive 特性,不支持 passive 特性的浏览器会把最后一个参数当作 useCapture ,所以需要这段精妙的代码判断是否支持 passive 特性:
// Test via a getter in the options object to see
// if the passive property is accessed
var supportsPassive = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function() {
supportsPassive = true;
}
});
window.addEventListener("test", null, opts);
} catch (e) {}
// Use our detect's results.
// passive applied if supported, capture will be false either way.
elem.addEventListener(
'touchstart',
fn,
supportsPassive ? { passive: true } : false
);
通过 Object.defineProperty
设置一个 passive 的 get 访问器,添加一个 test 的事件,当浏览器支持的时候会调用 get 访问器,在 get 访问器中设置 supportsPassive。
在Angular框架中,Angular CDK 早已在 @angular/cdk/platform
模块提供了normalizePassiveListenerOptions({passive: true})
供我们解决兼容性的问题,核心代码如下:
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/** Cached result of whether the user's browser supports passive event listeners. */
let supportsPassiveEvents: boolean;
/**
* Checks whether the user's browser supports passive event listeners.
* See: https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
*/
export function supportsPassiveEventListeners(): boolean {
if (supportsPassiveEvents == null && typeof window !== 'undefined') {
try {
window.addEventListener(
'test',
null!,
Object.defineProperty({}, 'passive', {
get: () => (supportsPassiveEvents = true),
}),
);
} finally {
supportsPassiveEvents = supportsPassiveEvents || false;
}
}
return supportsPassiveEvents;
}
/**
* Normalizes an `AddEventListener` object to something that can be passed
* to `addEventListener` on any browser, no matter whether it supports the
* `options` parameter.
* @param options Object to be normalized.
*/
export function normalizePassiveListenerOptions(
options: AddEventListenerOptions,
): AddEventListenerOptions | boolean {
return supportsPassiveEventListeners() ? options : !!options.capture;
}
添加绑定事件支持 passive 参数的相关Issue:https://github.com/angular/angular/issues/8866