前端性能优化之节流-throttle

892 阅读5分钟

上次介绍了前端性能优化之防抖-debounce,这次来聊聊它的兄弟-节流。

再拿乘电梯的例子来说:坐过电梯的都知道,在电梯关门但未上升或下降的一小段时间内,如果有人从外面按开门按钮,电梯是会再开门的。要是电梯空间没有限制的话,那里面的人就一直在等。。。后来电梯工程师收到了好多投诉,于是他们就改变了方案,设定每隔一定时间,比如30秒,电梯就会关门,下一节电梯会继续等待30秒。

专业术语概括就是:每隔一定时间,执行一次函数。

最简易版的代码实现:

function throttle(fn, delay) {
    let timer = null;

    return function() {
        const context = this;
        const args = arguments;

        if (!timer) {
            timer = setTimeout(() => {
                fn.apply(context, args);
                timer = null;
            }, delay);
        }
    };
}

很好理解,返回一个匿名函数形成闭包,并维护了一个局部变量timer。只有在timer不为null才开启定时器,而timer为null的时机则是定时器执行完毕。

除了定时器,还可以用时间戳实现:

function throttle(fn, delay) {
    let last = 0;

    return function() {
        const context = this;
        const args = arguments;

        const now = +new Date();
        const offset = now - last;

        if (offset > delay) {
            last = now;
            fn.apply(context, args);
        }
    };
}

last代表上次执行fn的时刻,每次执行匿名函数都会计算当前时刻与last的间隔,是否比我们设定的时间间隔大,若大于,则执行fn,并更新last的值。

比较上述两种实现方式,其实是有区别的: 定时器方式,第一次触发并不会执行fn,但停止触发之后,还会再次执行一次fn 时间戳方式,第一次触发会执行fn,停止触发后,不会再次执行一次fn

两种方式是可以互补的,可以将其结合起来,即能第一次触发会执行fn,又能在停止触发后,再次执行一次fn:

function throttle(fn, delay) {
    let last = 0;
    let timer = null;

    return function() {
        const context = this;
        const args = arguments;

        const now = +new Date();
        const offset = now - last;

        if (offset > delay) {
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }

            last = now;
            fn.apply(context, args);
        }
        else if (!timer) {
            timer = setTimeout(() => {
                last = +new Date();
                timer = null;
                fn.apply(context, args);
            }, delay - offset);
        }
    };
}

匿名函数内有个if...else,第一个是判断时间戳,第二个是判断定时器,对比下前面两种实现方式。 首先是时间戳方式的简易版:

if (offset > delay) {
  last = now;
  fn.apply(context, args);
}

混合版:

if (offset > delay) {
  if (timer) {      // 注意这里
    clearTimeout(timer);
    timer = null;
  }

  last = now;
  fn.apply(context, args);
}

可以发现,混合版比简易版多了对timer不为null的判断,并清除了定时器、将timer置为null。 再是定时器实现方式的简易版:

if (!timer) {
  timer = setTimeout(() => {
    fn.apply(context, args);
    timer = null;
  }, delay);
}

混合版:

else if (!timer) {
  timer = setTimeout(() => {
    last = +new Date();   // 注意这里
    timer = null;
    fn.apply(context, args);
  }, delay - offset);
}

可以看到,混合版比简易版多了对last变量的重置,而last变量是时间戳实现方式中判断的重要因素。这里要注意下,因为是在定时器的回调中,所以last的重置值要重新获取当前时间戳,而不能使用变量now。

通过以上对比,我们可以发现,混合版是综合了两种不同实现方式的作用,但除去开始和结束阶段的不同,两者的共同作用是一致的--执行fn函数。所以,同一个时刻,执行fn函数的语句只能存在一个!在混合版的实现中,时间戳判断里,去除了定时器的影响,定时器判断里,去除了时间戳的影响。

对于立即执行和停止触发后的再次执行,我们可以通过参数来控制,适应需求的变化。 假设规定{ immediate: false } 阻止立即执行,{ trailing: false } 阻止停止触发后的再次触发:

function throttle(fn, delay, options = {}) {
    let timer = null;
    let last = 0;

    return function() {
        const context = this;
        const args = arguments;

        const now = +new Date();
        
        if (last === 0 && options.immediate === false) {    // 这个条件语句是新增的
            last = now;
        }

        const offset = now - last;

        if (offset > delay) {
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }

            last = now;
            fn.apply(context, args);
        }
        else if (!timer && options.trailing !== false) {  // options.trailing !== false 是新增的
            timer = setTimeout(() => {
                last = options.immediate === false ? 0 : +new Date();;
                timer = null;
                fn.apply(context, args);
            }, delay - offset);
        }
    };
}

相对于混合版,除了新增了一个参数options,其它不同之处已在代码中标明。 思考下,立即执行是时间戳方式实现的,那么想要阻止立即执行的话,只要阻止第一次触发时,offset > delay 条件的成立就行了!如何判断是第一次触发?last变量只有初始化时,值才会是0,再加上我们手动传入的参数,阻止立即执行的条件就满足了:

if (last === 0 && options.immediate === false) {    
  last = now;
}

条件满足后,我们重置last变量的初始值为当前时间戳,那么第一次 offset > delay 就不会成立了! 然后想阻止停止触发后的再次执行,仔细一想,要是不需要这个功能的话,时间戳的实现不就可以满足了?对!我们只要变相地去除定时器就好了:

!timer && options.trailing !== false

如果我们不手动传入{ trailing: false } ,这个条件是永远不会成立的,即定时器永远不会开启。

不过有个问题在于,immediate和trailing不能同时设置为false,原因在于,{ trailing: false } 的话,停止触发后不会再次执行,然后关键的last变量也就不会被重置为0,下一次再次触发又会立即执行,这样就有冲突了。