scroll 滚动事件节流、requestAnimationFrame、性能优化

一、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

猜你喜欢

转载自blog.csdn.net/Kate_sicheng/article/details/125712876