从lodash库中窥探防抖与节流

1. 防抖与节流出现的背景

在日常搬砖中我们都会发现JS有很多高频率触发的事件,比如scroll、mouseover、keydown之类的。举个实际例子,有个输入框,在我们输入的同时,希望向后端请求获得自动补全的功能,比如输入“防”就可以提示“防抖节流”,但是我们又不希望每次keydown或者input的change都发起一个新的请求,这时候我们就需要使用到防抖和节流了

2. 防抖节流的概念

防抖:设置一个时间间隔K秒,在K秒内多次触发事件,只会在最后一次事件结束后K秒触发事件回调,如果在最后一次事件结束不满K秒的过程中再次触发时间则会清除掉之前的定时器,并重新计时。
节流:设置一个时间间隔K秒,开始频繁的触发事件,事件每隔K秒便会触发一次

3. lodash防抖节流实现

不考虑别人的库是怎么实现的,最直观的讲如何实现防抖

第一步:闭包内 ||全局 || vue对象 之类的地方上定义一个随时都能访问到的变量timeout
第二步: 在事件触发的时候,通过timeout判断定时器是否存在,存在就clear掉,不存在就给timeout赋上一个定时器进行用户的回调函数

如何实现节流

第一步:闭包内 ||全局 || vue对象 之类的地方上定义一个随时都能访问到的变量timeout
第二步: 在事件触发的时候,通过timeout判断定时器是否存在,如果存在就啥都不干,如果不存在就加一个定时器

这太容易了!让我们看看lodash是怎么写的防抖吧,附上源码(源码略多),他里面还会import一些其他函数,大致就是一些简单的功能函数,比如now获取当前时间,toNumber转换到数字,isObject判断是不是对象(这个函数还是个错的,基于typeof写并不能正确判断数据类型,但是应该也没人会把一个new String(xxx)塞进去当option)

function debounce(func, wait, options) {
        var lastArgs,             // arguments暂存(因为arguments和this都是debounce函数接收的)
            lastThis,             // this暂存     (而最终调用却是invokeFunc函数,所以需要利用闭包暂存变量)
            maxWait,              // option中maxWait最大等待时间字段,代表超过这个时间,回调可以再次被触发,用于节流复用防抖代码
            result,               // return出去的回调函数的返回值,回调有返回值&&回调被触发才有值
            timerId,              // 定时器对象
            lastCallTime,         // 上次调用debounced的时间
            lastInvokeTime = 0,   // 最后一次调用用户回调的时间
            leading = false,      // 是否立即执行一次回调函数,默认不执行
            maxing = false,       // 是否有最大等待时间,默认没有
            trailing = true;      // 是否在最后执行一次回调函数,默认执行

        if (typeof func != 'function') {
            throw new TypeError(FUNC_ERROR_TEXT);
        }

        wait = toNumber(wait) || 0;

        if (isObject(options)) {
            leading = !!options.leading;
            maxing = 'maxWait' in options;
            maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
            trailing = 'trailing' in options ? !!options.trailing : trailing;
        }

        // 绑定暂存的arguments和this,执行用户回调,并返回函数的返回值
        function invokeFunc(time) {
            var args = lastArgs,
                thisArg = lastThis;

            lastArgs = lastThis = undefined;
            lastInvokeTime = time;
            result = func.apply(thisArg, args);
            return result;
        }

        // isInvoking为true && 没有建立定时器时调用,防抖开始运作
        function leadingEdge(time) {
            // Reset any `maxWait` timer.
            lastInvokeTime = time;
            // Start the timer for the trailing edge.
            timerId = setTimeout(timerExpired, wait);
            // Invoke the leading edge.
            return leading ? invokeFunc(time) : result;
        }

        // 计算需等待时间
        function remainingWait(time) {
            var timeSinceLastCall = time - lastCallTime,
                timeSinceLastInvoke = time - lastInvokeTime,
                timeWaiting = wait - timeSinceLastCall;

            return maxing
                ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)
                : timeWaiting;
        }

        // 判断是否能invoke,用于来判断是否能 制造定时器 来执行用户的回调函数
        function shouldInvoke(time) {
            var timeSinceLastCall = time - lastCallTime,
                timeSinceLastInvoke = time - lastInvokeTime;

            /** 
                lastCallTime未定义||
                这次调用和上次调用的差不小于用户传入的间隔||
                该间隔小于0||
                设置的最大间隔时间存在,差值超过了最大间隔时间(为节流设计,maxing需要存在)
                都是应该Invoke的状态
            **/
            return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
                (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
        }

        // 等待时间结束后,如果能invoke,通过trailingEdge触发用户回调,如果不能 计算剩余时间 再重置定时器
        function timerExpired() {
            var time = now();
            if (shouldInvoke(time)) {
                return trailingEdge(time);
            }

            timerId = setTimeout(timerExpired, remainingWait(time));
        }

        // 等待时间结束后的执行逻辑 :
        // 清空定时器变量,如果 最后需要执行回调 && 暂存的arguments存在 执行用户回调
        function trailingEdge(time) {
            timerId = undefined;

            if (trailing && lastArgs) {
                return invokeFunc(time);
            }
            lastArgs = lastThis = undefined;
            return result;
        }

        // 暴露的接口,手动取消防抖
        function cancel() {
            if (timerId !== undefined) {
                clearTimeout(timerId);
            }
            lastInvokeTime = 0;
            lastArgs = lastCallTime = lastThis = timerId = undefined;
        }

        // 暴露的接口,如果定时器存在,直接触发回调
        function flush() {
              return timerId === undefined ? result : trailingEdge(now());
        }

        // 防抖核心函数
        function debounced() {
            var time = now(), isInvoking = shouldInvoke(time);

            lastArgs = arguments;
            lastThis = this;
            lastCallTime = time;

            if (isInvoking) { // 一开始lastCallTime为undefined所以isInvoking为true
                if (timerId === undefined) {
                    return leadingEdge(lastCallTime); // 这个是初始状态
                }
                if (maxing) { // 节流触发方式之一:时间到了,重造定时器,并执行回调
                    timerId = setTimeout(timerExpired, wait);
                    return invokeFunc(lastCallTime);
                }
            }
            if (timerId === undefined) { 
            /** 
                这段逻辑和正常(只触发一次)的防抖无关,对应节流触发方式之二:trailingEdge(定时器正常到时间)
                正常的trailingEdge不会新建新的定时器,所以需要在这里新建定时器
                两种触发方式互斥,通过触发后影响isInvoking的状态防止二次触发
            **/
                timerId = setTimeout(timerExpired, wait);
            }
            return result;
        }
        debounced.cancel = cancel;
        debounced.flush = flush;
        return debounced;
    }

这一堆代码相比之前极简的防抖节流有什么区别都提供了什么功能?
1. lodash防抖实现逻辑: 通过不断更新now和lastCallTime,让shouldInvoke始终返回false无法触发回调,直到事件不触发不更新lastCallTime才能触发回调
2. lodash节流实现逻辑:通过option中maxWait的传入,放宽了shouldInvoke的判定条件,使定时器能够正常触发用户定义的回调函数,并在事件的循环回调中加入maxing判断逻辑,作为触发用户定义回调的第二入口,并通过影响isInvoking的状态防止二次触发
3. 函数的包装和闭包的应用,让防抖和节流可以复用且不互相影响
4. 按时间间隔制造定时器,没有定时器的重复定义和消除的过程,通过 时间差定时器存在状态 来判断是否添加新的定时器任务
5. 灵活的参数,通过leading和trailing来控制回调在一连串的事件行为的开始还是结束时被触发
6. lastArgs、lastThis让函数之间通信更加便利

// 先定义一个防抖
let throttle_a = throttle(functionCB(){...}, waitTime),
// 然后再对其传参
throttle_a(param1,...,paramN)
// 这些参数可以在functionCB中访问
function functionCB () { 
    console.log(arguments) // 可以拿到上面的param1,...,paramN
}
  1. 平时为undefined的result会在回调执行后,赋上functionCB的返回值,并被return出去,可以进行进一步的操作
// 比如我们给onscroll="scroll加一个防抖"
let throttle_test = throttle(CB, 1000, option)

function scroll () {
    let a = throttle_test()
    // 这里a会得到something,然后进行相关操作
    ... doSomething with somethingFromCB
}
function functionCB () { 
    return somethingFromCB
}

8.节流的代码复用(利用option中的maxWait打通debounced中if (maxing) {…}中的逻辑)

    function throttle(func, wait, options) {
        var leading = true, trailing = true;

        if (typeof func != 'function') {
            throw new TypeError(FUNC_ERROR_TEXT);
        }
        if (isObject(options)) {
            leading = 'leading' in options ? !!options.leading : leading;
            trailing = 'trailing' in options ? !!options.trailing : trailing;
        }
        return debounce(func, wait, {
            'leading': leading,  // 默认为true
            'maxWait': wait,     // maxWait为用户设置的最大的等待时间,从而把防抖变成节流
            'trailing': trailing // 默认为true
        });
    }
  1. 两种定时器创建方式(定时器到时、maxing到时的强制触发),可以构建出一种防抖和节流的结合体,比如wait定为1000,maxWait定为5000,也就是在5000ms内连续折腾只能触发一次,但是5000ms后又可以触发,使用更加灵活

4. 自己造一套简易版的防抖节流

防抖:


function debounce (func, wait, immediate = true) { let timeout, _this, args; let later = () => setTimeout (() => { timeout = null if (!immediate) { func.apply(_this, args); _this = args = null; } },wait); let debounced = function (...params) { if (!timeout) { timeout = later(); if (immediate){ func.apply(this, params); } else { _this = this; args = params; } } else { clearTimeout(timeout); timeout = later(); } } return debounced; };

节流:

function throttle (func, wait, immediate = true) {
    let timeout, _this, args

    let later = () => setTimeout (() => {
        timeout = null
        if (!immediate) {
            func.apply(_this, args);
            _this = args = null;
        }
    }, wait);

    let throttled = function (...params) {
        if (!timeout) {
            timeout = later();
            if (immediate){
                immediate = false
                func.apply(this, params);
            }
            _this = this;
            args = params;
        }
     }
    return throttled;
};

相比lodash的我舍弃了什么(为了表达的清晰一些,代码没有封装复用)
1. 因为不复用,防抖节流各司其职,不需要通过maxWait实现两套逻辑
2. 舍弃了option,换了个immediate,代表回调是在一开始还是最后执行,类似于leading与trailing的作用
3. 舍弃了时间的判断,按照一开始的简易思路,防抖粗暴的新建和清除定时器,节流则是通过定时器的有无,来判断是否建立新的定时器,思路更加直观,但是从底层去思考,时间的判断虽然不直观,但是性能上应该比建立定时器和清除定时器要好很多,毕竟人家是个完善的JS库。。。所以结论应该是我的防抖比lodash的要差,但是我的节流也没有建立重复的定时器而且少了不必要的时间判断性能还会好些?!?!
4. 没有了maxWait,不能制造防抖和节流的结合体
5. 没有return result,其实。。。你也不见得用得到你自己回调函数的返回值,一般都是进行一种行为,上传个东西,打个点,拉个数据啥的
6. 没有暴露cancel和flush方法,其实。。。一般你也用不到这个功能,而且这俩函数很好写,一个干掉定时器,一个执行回调(顺手也可以干掉定时器),有需要加上即可~

###完~

发表评论