JS进阶 - 逐步实现 throttle

手写 throttle 函数

用途

throttle 作为节流阀,能在高频次反复调用某个方法时对调用的速率进行控制,比如:监听页面滚动时减少方法的频繁调用、提交按钮防止短时间内的多次点击。

原理

每次调用 throttle 方法时,都会更新 previous 为当前的时间戳,再次调用时比较 previous 和当前时间戳(now)的差值是否大于设定的值(wait),如果差值大于 wait,则再次调用,如果差值小于 wait,则忽略,这样就实现了在一段时间内只调用一次方法

实现

首先来解析一下:下面是一段常见的调用 throttle 方法的代码

document.querySelector('#btn').addEventListener(
    "click",
    _throttle(function(){
        console.count();
    }, 2000)
);

在触发点击事件的回调函数时,可以看做是:_throttle(callback).call(event.target, event),很明显,_throttle(callback)需要返回一个函数,那么就可以形成闭包,有了闭包上一次的触发时间戳就能够被记录下来,以下代码实现了 throttle 的基本功能

<button id="btn">click me</button>
<script>
    function _throttle(func, wait){
        // 初始化时间戳
        let previous = 0;
        // 返回 function,且引用了父函数的变量,形成了闭包
        return function() {
            const now = Date.now();
            // 如果本次和上次的时间戳差值大于 wait 的值,则执行方法
            if (now - previous > wait) {
                // 记录当前时间戳
                previous = now;
                // 将回调函数指向调用 throttle 方法的 this,并传入参数
                  // 如果这里不绑定 this,那么 this 不会指向调用他的函数,而会指向全局对象 Window
                return func.apply(this, arguments);
            }
        };
    };
    // 对按钮设置监听
    document.querySelector('#btn').addEventListener(
        "click",
        _throttle(function(){
            console.count();
        }, 2000)
    );
</script>

进阶

underscorejs 的 throttle 方法提供了更多的选项:启用/禁用前边界调用或后边界调用。

可禁用前边界调用

默认情况下,前边界调用是开启的(leading:true),首次调用时会立即执行函数,此时 previous 的值为 0,所。如果禁用了前边界调用,首次调用将不会执行,实现也很简单,只需要在初始化时,将 0 改为当前时间戳即可。(有备注的地方为新增的代码)

function _throttle(func, wait, options = {}){
    let context, args, result;
    let previous = 0;
    return function() {
        const now = Date.now();
        // leading 默认是 undefined ,这里使用全等,即只有在手动传入 false 时才会禁用前边界调用
        if (!previous && options.leading === false) {
            previous = now;
        }
        args = arguments;
        context = this;
        if (now - previous > wait) {
            previous = now;
            result = func.apply(context, args);
            context = args = null;
        }
        return result;
    };
};
const btnElement = document.querySelector("#btn");
btnElement.addEventListener(
    "click",
    _throttle(
        function(){
            console.count();
        },
        2000,
        { leading: false }
    )
);

可启用后边界调用

默认情况下,后边界调用是被禁用的(trailing:false),wait 的值为 1 时,一秒内点击三次按钮只会触发一次调用,但如果想让最后一次点击也在下一次可执行时生效,就可以使用后边界调用。

其原理就是设置定时器,在下一次可执行时再次执行函数,代码和 function 中的类似,只不过 previous 就不一定是当前时间戳了,因为要考虑到下一次调用时,如果是启用了前边界调用,则需要立即触发,如果这时候 previous 是当前时间戳,两数相减有可能小于 wait ,那就不能立即执行了,所以需要对 leading 的值进行判断

function _throttle(func, wait, options = {}){
    let context, args, result;
    let timeout = null;
    let previous = 0;
    return function() {
        const now = Date.now();
        if (!previous && options.leading === false) {
            previous = now;
        }
        args = arguments;
        context = this;
        // 计算当前到下一次可触发时的时间间隔
        // 因为时间可能被调整,所以这里取差值的绝对值
        const remaining = wait - Math.abs(now - previous);
        if (remaining <= 0) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            result = func.apply(context, args);
            previous = now;
            context = args = null;
        } else if (!timeout && options.trailing === true) {
            timeout = setTimeout(() => {
                // 增加对 leading 的判断
                previous = options.leading === false ? now : 0;
                timeout = null;
                result = func.apply(context, args);
                context = args = null;
            }, remaining);
        }
        return result;
    };
};
const btnElement = document.querySelector("#btn");
btnElement.addEventListener(
    "click",
    _throttle(
        function(){
            console.count();
        },
        2000,
        { trailing: true }
    )
);

与 underscore 比较

来看 underscore 的 throttle 代码:

_.throttle = function(func, wait, options) {
    var timeout, context, args, result;
    var previous = 0;
    if (!options) options = {};

    var later = function() {
        previous = options.leading === false ? 0 : _.now();
        timeout = null;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
    };

    var throttled = function() {
        var now = _.now();
        if (!previous && options.leading === false) previous = now;
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            previous = now;
            result = func.apply(context, args);
            if (!timeout) context = args = null;
        } else if (!timeout && options.trailing !== false) {
            timeout = setTimeout(later, remaining);
        }
        return result;
    };

    throttled.cancel = function() {
        clearTimeout(timeout);
        previous = 0;
        timeout = context = args = null;
    };

    return throttled;
};

有几点不同的是:

  1. underscore 没有取now - previous的绝对值,而是在remaining > wait时一律允许触发操作。
  2. 在控制后边界执行的函数中(underscore 将它写在 later 方法中),将 timeout 设置为 null,但又在后面判断了一次 timeout 是否存在,多数的说法是为了防止 func 在执行期间有新的 timeout 被设置,如果 timeout 被清空了,代表不再有等待执行的 func,也清空 context 和 args,不过没有看到具体的例子。
  3. 增加了 cancel 方法,可以途中取消节流阀

完整代码

function _throttle(func, wait, options = {}){
    let context, args, result;
    let timeout = null;
    let previous = 0;
    
    const throttled = function() {
        const now = Date.now();
        if (!previous && options.leading === false) {
            previous = now;
        }
        args = arguments;
        context = this;
        const remaining = wait - Math.abs(now - previous);
        if (remaining <= 0) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            result = func.apply(context, args);
            previous = now;
            context = args = null;
        } else if (!timeout && options.trailing === true) {
            timeout = setTimeout(() => {
                previous = options.leading === false ? now : 0;
                timeout = null;
                result = func.apply(context, args);
                if(!timeout){
                    context = args = null;
                }
            }, remaining);
        }
        return result;
    };
    
    throttled.cancel = function() {
        clearTimeout(timeout);
        previous = 0;
        timeout = context = args = null;
    };
    
    return throttled
};

JS进阶 - 逐步实现 throttle

https://hashencode.github.io/post/ec9e4646/

作者

BiteByte

发布于

2020-08-08

更新于

2024-11-15

许可协议