debounce 的替代方案 —— requestAnimationFrame

376 阅读2分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

常见的浏览器页面刷新频率是每秒 60 次,也就是 60fps,一帧的时间在 16.7ms 左右。一些针对游戏市场开发的屏幕或移动端设备可以支持到120fps。帧率变化可以通过 chrome devtools 查看:打开 devtool -> more tools -> rendering -> Frame Rendering Stats

没有滚动优化的例子:

import React, { FC, useLayoutEffect } from 'react';

const Index: FC = () => {
  useLayoutEffect(() => {
    const fn = () => console.log('scrollY:', window.pageYOffset);

    window.addEventListener('scroll', fn);
    return () => window.removeEventListener('scroll', fn);
  }, []);

  return <div/>;
};

上面的简单组件实现滚动时打印滚动距离,尝试运行可以看到输出:

scrollY: 1
scrollY: 13
scrollY: 19
scrollY: 27
scrollY: 34
scrollY: 42
scrollY: 49
scrollY: 57
scrollY: 66
scrollY: 74
scrollY: 82
scrollY: 89
scrollY: 96
scrollY: 100
scrollY: 103
scrollY: 105
scrollY: 106

滚轮只是轻微触发一下就执行了很多次,scroll 触发频率远高于浏览器的刷新频率,如果 fn 是一个复杂函数,在单次函数调用时间内没能完成函数执行,滚动时会出现掉帧,出现页面抖动或滚动不连续的问题,影响性能、电池寿命用户体验。

常见的解决方法就是通过 debounce 函数实现去抖动(使用节流做法也可以),比如下面代码:

import React, { FC, useLayoutEffect } from 'react';
import debounce from 'lodash/debounce';

const Index: FC = () => {
  useLayoutEffect(() => {
    const fn = debounce(() => console.log('scrollY:', window.pageYOffset), 250);
    window.addEventListener('scroll', fn);
    return () => window.removeEventListener('scroll', fn);
  }, []);

  return <div/>;
};

驱动都的时间间隔可手动配置,上面配置 250ms 去抖,尝试触发打印:

scrollY: 106
scrollY: 159

可以看到函数的执行频率明显下降,而且滚动触发间隔较大。debounce 函数通常通过 setTimeout 函数实现。

requestAnimationFrame 是一个浏览器内置的 API(下面简称 rAF),传入一个回调函数作为参数,要求浏览器在下次重绘之前进行调用,执行频率一般是60Hz,根据 W3C 规范,一般会跟随屏幕刷新次数,比如浏览器掉帧到 30fps,rAF 的执行频率也会是 30次每秒。

兼容写法:

window.rAF = (function(){
  return  window.requestAnimationFrame       ||
          window.webkitRequestAnimationFrame ||
          window.mozRequestAnimationFrame    ||
          function( callback ){
            window.setTimeout(callback, 1000 / 60);
          };
})();

使用 rAF 重写上面的滚动触发组件:

import React, { FC, useLayoutEffect } from 'react';

const Index: FC = () => {
  useLayoutEffect(() => {
    let ticking = false;

    const fn = () => {
      console.log('scrollY:', window.pageYOffset);
      ticking = false;
    };

    const onScroll = () => {
      if (!ticking) {
        requestAnimationFrame(fn);
        ticking = true;
      }
    };
    window.addEventListener('scroll', onScroll);
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  return <div/>;
};

这里用到一个技巧,使用 ticking 记录 fn 是否执行完成,如果未完成,直接跳出不执行,这样能保证 fn 的执行跟随浏览器渲染帧,不会出现无效渲染。

  • 参考资料

MDN: requestAnimationFrame

Timing control for script-based animations

The most accurate way to schedule a function in a web browser