深入浅出的节流函数 Throttle

184 阅读6分钟

函数节流指的是某个函数在一定时间间隔内(如 5 秒)只执行一次,在这 5 秒内会无视后面所产生的函数调用请求,也不会延长时间间隔。 5 秒间隔结束后第一次遇到新的函数调用请求才会再次触发执行,然后这5 秒内依旧无视后来产生的函数调用请求。

使用场景及实现

节流函数通常被使用于函数被频繁调用的场景,例如:widow.onresize()事件、touchmove事件、上传进度、scroll事件等情况。直接使用 throttle API 很简单,大伙应该都又使用过,那具体该如何实现throttle 这个函数呢?

实现方案有两种:

  • 第一种:使用时间戳来判断是否已到执行时间,记录上一次执行的时间戳,然后每次触发事件执行回调,回调中判断当前的时间戳距离上一次执行时间戳的间隔是否已达到时间差,如果是则执行,并且更新上次执行的时间戳,一次循环。
  • 第二种:使用定时器,比如当 scroll 事件出发时,打印一个 Hello World,然后设置一个500ms 的定时器,此后每次触发 scroll 事件触发回调函数,如果已经存在定时器,则回调不执行方法,直到定时器触发,handler 被清除,然后重新设置定时器。

第一种方案实现(时间戳)

// fn 是需要执行的函数
// wait 是时间间隔
const throttle = (fn, wait=50) => {
    // 上次执行 fn 的时间戳
    let previous = 0
    // 将 throttle 处理结果当做函数返回
    return function(...args) => {
        // 获取当前时间,转换成时间戳,单位毫秒
        let now = Date.now()
        // 将当前时间戳和上一次执行函数的时间戳进行对比
        // 大于等待时间就把 previous 设置为当前时间戳,并执行函数 fn
        if (now - previous > wait) {
            previous = now
            fn.apply(this, args)
        }
    }
}

// DEMO
// 执行 throttle 函数返回新函数
const newFn = throttle(() => console.log('fn 函数执行'), 1000)
// 每个10 毫秒 执行一次 newFn 函数,但是只有时间间隔大于1000时 才会执行
setInterval(newFn, 10)

第二种方案实现(定时器)

// fn 是需要执行的函数
// wait 是时间间隔
const throttle = (fn, wait=50) => {
    // 上次执行 fn 的定时器
    let timer = null
    // 将 throttle 处理结果当做函数返回
    return function(...args) => {
        let context = this
        // 判断 timer 是否为 null
        // 为null 将timer 设置为当前setTimeout定时器
        // 当定时器达到设置的 wait 时间时,执行函数fn,并将timer 设置为 null 
        if (!timer) {
            timer = setTimeout(() => {
                clearTimeout(timer)
                fn.apply(context, args)
                timer = null
            }, wait)
        }
    }
}

// DEMO
// 执行 throttle 函数返回新函数
const newFn = throttle(() => console.log('fn 函数执行'), 1000)
// 每个10 毫秒 执行一次 newFn 函数,但是只有时间间隔大于1000时 才会执行
setInterval(newFn, 10)

两种方案的区别

两种方案的区别在于,使用时间戳实现的节流函数会在第一次触发事件时立即执行,以后每次 wait 时间之后才会再次执行一次,并且最后一次触发事件不会立即被执行;而定时器实现的节流函数在第一次触发时是不会立即执行的,而是在 wait 时间滞后性才会执行,当最后一次定制触发后,还会再执行一次函数。

underscore源码解读

上述代码实现了两种简单的节流函数,不过underscore 实现了更高级的功能,即新增了两个功能

  • 配置是否需要响应事件刚开始的那次回调(leading参数,false时忽略)
  • 配置是否需要响应事件结束后的那次回调(trailing参数,false时忽略)

配置{leading: false}时,事件刚开始的那次回调不执行;配置{trailing: false}时,事件结束后的那次回调不执行,不过需要注意的是,这两者不能同时配置。

所以在 underscore 中的节流函数有 3种调用方式,默认的(有头有尾),设置{leading: false}的,以及设置{trailing: false}的。

上述实现throttle 的两种方案中,第一种方案实现这 3 种调用方式存在一个问题,即事件停止触发时无法响应回调,所以 { trailing: true } 时无法生效。第二种方案来实现也存在一个问题,因为定时器是延迟执行的,所以事件停止触发时必然会响应回调,所以 { trailing: false } 时无法生效。

所以 underscore 采用的方案是两种方案搭配来实现这个功能的。

const throttle = function(func, wait, options) {
    var timeout, context, args, result;
    
    // 上次执行回调的时间戳
    var previous = 0;
    // 无参数传入时,初始化 options 为空对象
    if (!options) options = {};
    
    var later = function() {
        // 当设置 { leading: false } 时
        // 每次触发回调函数后设置 previous 为 0
        // 不然为当前时间
        previous = options.leading === false ? 0 : _.now();
        
        // 防止内存泄漏,设置为 null 便于后面根据 !timeout 设置新的 timeout
        timeout = null;
        
        // 执行函数
        result = func.apply(context, args);
        if (!timeout) context = args = null;
    };
    
    // 每次触发时间回调都执行这个函数
    // 函数内判断是否执行 func
    // func 才是我们业务层代码想要执行的函数
    var throttled = function() {
        // 记录当前时间
        var now = _.now();
        
        // 第一次执行时(此时 previous 为 0,之后为上一次时间戳)
        // 并且设置了 { leading: false } (表示第一次执行回调不执行)
        // 此时设置 previous 为当前值,表示刚执行过,本次就不执行了
        if (!previous && options.leading === false) previous = now;
        
        // 距离下一次触发 func 还需要等待的时间
        vat remaining = wait - (now - previous);
        context = this;
        args = arguments;
        
        // 要么是到了间隔时间了,随即触发方法 (remaining <= 0)
        // 要么是没有传入 { leading: false },并且第一次触发回调,立即触发
        // 此时 previous 为 0,wait - (now - previous) 也满足 <= 0
        // 之后变回把 previous 值迅速设置为 now
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                
                // clearTimeout(timeout) 并不会把timeout 设为 null
                // 手动设置,便于后续判断
                timeout = null;
            }
            
            // 设置 previous 为当前时间
            previous = now;
            
            // 执行 func 函数
            result = func.apply(context, args);
            if (!timeout) context = args = null;
        } else if (!timeout && options.trailing !== false) {
            // 最后一次需要触发的情况
            // 如果已经存在一个定时器,则不会进入该 if 分支
            // 如果 { trailing: false },即最后一次不需要出发了,也不会进入这个分支
            // 间隔 remaining milliseconds 后触发 later 方法
            timeout = setTimeout(later, remaining);
        };
        return result;
    };
    
    // 手动取消
    throttle.cancel = function() {
        clearTimeout(timeout);
        previous = 0;
        timeout = context = args = null;
    };
    
    // 执行 _.throttle 返回 throttled 函数
    return throttled;
}

小结

以上就是对节流函数throttle 的一个整理,希望对大家有所帮助!

有什么不对的地方欢迎大家指正!🤝

如果本文对你有所帮助,记得点赞支持一下哦!💕

参考

underscore.js