javascript之防抖和节流

318 阅读5分钟

前言

在前端开发中会遇到一些频繁的事件触发,比如: window 的 resize、scroll;mousedown、mousemove;keyup、keydown

举个例子

debounce.html

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="IE=edge, chrome=1">
    <title>debounce</title>
    <style>
        #container{
            width: 100%; height: 200px; line-height: 200px; text-align: center; color: #fff; background-color: #444; font-size: 30px;
        }
    </style>
</head>
<body>
    <div id="container"></div>
    <script src="debounce.js"></script>
</body>
</html>

debounce.js

var count = 1;
var container = document.getElementById('container');

function getUserAction(){
    container.innerHTML = count++;
}

container.onmousemove = debounse(getUserAction, 1000, true);

为了解决这个问题,一般有两种解决方案: 1、防抖debounce;2、节流throttle

1、防抖

防抖debounce原理:如果用手指一直按住一个弹簧,它将不会弹起直到你松手为止。你尽管触发事件,但是我一定在事件触发n秒后才执行,如果你在一个事件触发的n秒内又触发了这个事件,那我就以新的事件的时间为准,n 秒后才执行,总之,就是要等你触发完事件n秒内不再触发事件,我才执行,真是任性呐!

第一版

function debounce(fn, wait){
    let timer = null;
    return function(){
        clearTimeout(timer);
        timer = setTimeout(fn, wait);
    }
}

//在上面例子基础上使用
container.onmousemove = debounce(getUserAction, 1000);

现在随你怎么移动,反正你移动完1000ms内不再触发,我才执行事件。看看使用效果。

第二版,解决this问题

debounce返回函数中this,是container元素,但是getUserAction执行中this确是window。解决办法

function debounce(fn, wait) {
    let timer = null;
    
    return function () {
        var context = this;
        clearTimeout(timer);
        
        timer = setTimeout(function () {
            fn.apply(context);
        }, wait);
    }
}

第三版,解决event问题

js 在事件处理函数中会提供事件对象 event。

function getUserAction(event){
    console.log(event);
    container.innerHTML = count++;
}

function debounce(fn, wait) {
    let timer = null;
    return function () {
        var context = this;
        var args = arguments;
        clearTimeout(timer);
        
        timer = setTimeout(function () {
            fn.apply(context, args);
        }, wait);
    }
}

第四版,添加立即执行参数

不希望非要等到事件停止触发后才执行,希望立刻执行函数,然后等到停止触发 n 秒后,才可以重新触发执行。

function debounce(fn, wait, immediate) {
    let timer = null;

    return function () {

        var context = this;
        if (timer) clearTimeout(timer);

        if (immediate){
            var callNow = !timer;
            timer = setTimeout(function() {
                timer = null;
            }, wait);

            if (callNow) fn.apply(context, arguments);
        } else {
            timer = setTimeout(function () {
                fn.apply(context, arguments);
            }, wait);
        }
    }
}

第五版,有返回值

此时注意一点,就是getUserAction函数可能是有返回值的,所以我们也要返回函数的执行结果,但是当 immediate 为 false 的时候,因为使用了 setTimeout ,我们将func.apply(context,args)的返回值赋给变量,最后再 return 的时候,值将会一直是 undefined,所以我们只在 immediate为true 的时候返回函数的执行结果。

function debounce(fn, wait, immediate) {
    let timer = null, result;

    return function () {

        var context = this;
        if (timer) clearTimeout(timer);

        if (immediate){
            var callNow = !timer;
            timer = setTimeout(function() {
                timer = null;
            }, wait);

            if (callNow) result = fn.apply(context, arguments);

        } else {
            timer = setTimeout(function () {
                fn.apply(context, arguments);
            }, wait);
        }
        
        return result;
    }
}

第六版,有取消事件

希望能取消 debounce函数,比如说我debounce的时间间隔是1秒钟,immediate 为true,这样的话,我只有等1秒后才能重新触发事件,现在我希望有一个按钮,点击后,取消防抖,这样我再去触发,就可以又立刻执行了。

function debounce(fn, wait, immediate) {
    let timer = null, result;

    var debounced =  function () {

        var context = this;
        if (timer) clearTimeout(timer);

        if (immediate){
            var callNow = !timer;
            timer = setTimeout(function() {
                timer = null;
            }, wait);

            if (callNow) result = fn.apply(context, arguments);

        } else {
            timer = setTimeout(function () {
                fn.apply(context, arguments);
            }, wait);
        }
        return result;
    }
    
    debounced.cancel = function () {
        clearTimeout(timer);
        timer = null;
    };
    
    return debounced;
}

使用

var count = 1;
var container = document.getElementById('container');
var button = document.getElementById('button');
var setUserAction = debounce(getUserAction, 1000, true);

function getUserAction(e){
    container.innerHTML = count++;
}

button.addEventListener('click', function () {
    setUserAction.cancel();
});
container.onmousemove = setUserAction;

防抖的适用场景

按钮提交场景,多次点击提交按钮,只执行最后一次提交。

服务端验证场景:表单验证需要服务端配合,只执行一段连续的输入事件的最后一次,还有搜索联想词功能类似

节流

函数节流throttle:如果你持续触发事件,每隔一段时间,只执行一次事件。

用时间戳

function throttle(fn, wait){
    var prevTime = 0;

    return function () {
        var nowTime = +new Date();
        var context = this;
        var args = arguments;

        if (nowTime - prevTime > wait){
            fn.apply(context, args);
            prevTime = nowTime;
        }
    }
}

开始立即执行,鼠标移除立即停止

用定时器

function throttle2(fn, wait) {
    var timer = null;

    return function () {

        var context = this;
        var args = arguments;

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

container.onmousemove = throttle2(getUserAction, 3000);

开始不会立即执行,鼠标移除出后还会在n秒后执行一次

二者结合

开始立即执行,最后移出后n秒还执行一次

function throttle3(fn, wait) {
    var timer = null;
    var preTime = 0;

    return function () {

        var context = this;
        var nowTime = +new Date();
        var remaining = wait - (nowTime - preTime);

        if (remaining <= 0 || remaining > wait){
            if (timer){
                clearTimeout(timer);
                timer = null;
            } 
            preTime = nowTime;  
            fn.apply(context, arguments);

        } else if (!timer) {
            timer = setTimeout(() => {
                timer = null;
                preTime = +new Date();
                fn.apply(context, arguments);
            }, wait);
        }
    }
}

复合场景

但是我有时也希望无头有尾,或者有头无尾,这个咋办?

那我们设置个options作为第三个参数,然后根据传的值判断到底哪种效果,我们约定:

  • leading:false 表示禁用第一次执行
  • trailing: false 表示禁用停止触发的回调
function throttle3(fn, wait, options) {
    var timer = null;
    var preTime = 0;

    return function () {

        var context = this;
        var nowTime = +new Date();

        if (!preTime && options.leadig === false) {
            //开始不立即执行
            preTime = nowTime;
        }

        var remaining = wait - (nowTime - preTime);

        //间隔时间等于或超过wait
        if (remaining <= 0 || remaining > wait){
            if (timer){
                clearTimeout(timer);
                timer = null;
            } 
            preTime = nowTime;  
            fn.apply(context, arguments);

        } else if (!timer && options.trailing !== false) {
            //有一个定时器就不在执行,结束再执行一次
            timer = setTimeout(() => {
                timer = null;
                preTime = options.leading === false ? 0 : +new Date();
                fn.apply(context, arguments);
            }, wait);
        }
    }
}

添加取消

function throttle3(fn, wait, options) {
    var timer = null;
    var preTime = 0;

    var throttled = function () {

        var context = this;
        var nowTime = +new Date();

        if (!preTime && options.leadig === false) {//开始不立即执行
            preTime = nowTime;
        }

        var remaining = wait - (nowTime - preTime);

        //间隔时间等于或超过wait
        if (remaining <= 0 || remaining > wait){
            if (timer){
                clearTimeout(timer);
                timer = null;
            } 
            preTime = nowTime;  
            fn.apply(context, arguments);

        } else if (!timer && options.trailing !== false) {//有一个定时器就不在执行,结束再执行
            timer = setTimeout(() => {
                timer = null;
                preTime = options.leading === false ? 0 : +new Date();
                fn.apply(context, arguments);
            }, wait);
        }
    }
    throttled.cancel = function () {
        clearTimeout(timer);
        timer = null;
        preTime = 0;
    }
    return throttled;
}

我们要注意 underscore 的实现中有这样一个问题:

那就是 leading:false 和 trailing: false 不能同时设置。

如果同时设置的话,比如当你将鼠标移出的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再移入的话,就会立刻执行,就违反了 leading: false,bug 就出来了,所以,这个 throttle 只有三种用法:

container.onmousemove = throttle3(getUserAction, 3000);
container.onmousemove = throttle2(getUserAction, 3000, {
    leading: false
});
container.onmousemove = throttle2(getUserAction, 3000, {
    trailing: false
});

节流适用场景

拖拽场景:固定时间内只执行一次,防止超高频次触发位置变动

缩放场景:监控浏览器resize

动画场景:避免短时间内多次触发动画引起性能问题

参考链接:

github.com/mqyqingfeng…

segmentfault.com/a/119000000…

underscorejs.org/#throttle

www.alloyteam.com/2012/11/jav…

www.cnblogs.com/fsjohnhuang…