基于选择性超时的事件处理:首先立即执行,然后去抖动

Selective timeout based events handling: immediate first, debounce next

假设存在随机序列的外部操作(例如滚动事件)。我需要立即处理第一个动作,然后取消所有间隔小于某个给定增量的动作,然后处理下一个应该为该增量延迟的动作。应以相同方式处理进一步的操作。

这看起来像是 debounce-immediate 和 simple debounce 的组合。我准备了一张图表来演示这个想法。

这里最好的是什么solution/approach?不知有没有现成的花样...

更新

感谢所有参与者!对于研究,我创建了 plunker four 五种不同的实现建议在答案中:https://plnkr.co/N9nAwQ.

const handler = [
  processEvent, // normal
  debounceNext(processEvent, DELAY), // dhilt
  makeRateLimitedEventHandler(DELAY, processEvent), // user650881
  debounceWithDelay(processEvent, DELAY, 0), // willem-dhaeseleer
  _.debounce(processEvent, DELAY, {leading: true}) // lodash debounce + leading,
  debounceish(DELAY, processEvent) //Mikk3lRo
];

一个好消息是 Lodash 有一个 leading-flag debounce 实现来解决这个问题(感谢 Willem D'Haeseleer)。 是来自 Mikk3lRo 答案的精彩演示,他还提供了一些有用的综合。

我调查了来源和结果:仅 visual point to memory allocation stuff... I didn't find any performance issues, and the views seem okey. So the ultima ratio was the code itself. All sources were converted to ES6 (as you can see in Plunker) for I can compare them fully. I excluded my (it is a bit excessive, despite I like how it looks). The timestamp version is very interesting! The postDelay version's nice, though it wasn't a requested feature (so that 的形式对两个 lodash 演示有双倍延迟)。

我决定不依赖 lodash(换句话说,我当然会使用带前导选项的 lodash debounce),所以我选择了 Mikk3lRo 的 debounceish

PS 我想分享那一点点赏金(不幸的是没有这样的选择)或者甚至从我的声誉中获得更多分数(但是不是 200,太多了,对只有 100 的获胜者不公平)。我什至不能投票两次......没关系。

这是我认为可以按照您描述的方式工作的东西。如果不是,那至少是个好东西。

// set up the event bus

const start = getMilli()
const bus = createBus()
bus.on('event', e => console.log(`[${getPassage(start)}] [${e}] original bus: saw event`))

const wrappedBus = wrapBus(1600, 'event', bus)
wrappedBus.on('event', e => console.log(`[${getPassage(start)}] [${e}] wrapped bus: saw event`))
wrappedBus.on('skipped', e => console.log(`[${getPassage(start)}] [${e}] skipped by wrapped bus`))
wrappedBus.on('last before interval', e => console.log(`[${getPassage(start)}] [${e}] this was the last event before the end of the interval`))
wrappedBus.on('interval tick', _ => console.log(`[${getPassage(start)}] interval tick`))

// trigger events on the bus every so often

let totalTime = 0
const intervalTime = 300
setInterval(() => {
  totalTime += intervalTime
  bus.trigger('event', totalTime)
}, intervalTime)

function getMilli() {
  return (new Date()).getTime()
}

function getPassage(from) {
  return getMilli() - from
}

// creates a simple event bus
function createBus() {
  const cbs = {}
  
  return {
    on: (label, cb) => {
      if(cbs.hasOwnProperty(label)) cbs[label].push(cb)
      else cbs[label] = [cb]
    },
    
    trigger: (label, e) => {
      if(cbs.hasOwnProperty(label)) cbs[label].forEach(f => f(e))
    },
  }
}

// creates a new bus that should trigger the way you described
function wrapBus(waitInterval, eventLabel, bus) {
  const newBus = createBus()
  
  let deliveredFirst = false
  let gotIgnoredEvent = false
  let lastIgnoredEvent = undefined

  setInterval(() => {
    // just here so we know when this interval timer is ticking
    newBus.trigger('interval tick', null)

    // push the last event before the end of this interval
    if(gotIgnoredEvent) {
      gotIgnoredEvent = false
      deliveredFirst = false
      newBus.trigger(eventLabel, lastIgnoredEvent)
      newBus.trigger('last before interval', lastIgnoredEvent)
    }
  }, waitInterval)
  
  bus.on(eventLabel, function(e) {
    if(!deliveredFirst) {
      newBus.trigger(eventLabel, e)
      deliveredFirst = true
      gotIgnoredEvent = false
    }
    else {
      gotIgnoredEvent = true
      lastIgnoredEvent = e
      // this is here just to see when the wrapped bus skipped events
      newBus.trigger('skipped', e)
    }
  })
  
  return newBus
}

您可以跟踪上次事件时间,并且仅在需要后续检查时才创建计时器事件。

function makeRateLimitedEventHandler(delta_ms, processEvent) {
    var timeoutId = 0;  // valid timeoutId's are positive.
    var lastEventTimestamp = 0;

    var handler = function (evt) {
        // Any untriggered handler will be discarded.
        if (timeoutId) {
            clearTimeout(timeoutId);
            timeoutId = 0;
        }
        var curTime = Date.now();
        if (curTime < lastEventTimestamp + delta_ms) {
            // within delta of last event, postpone handling
            timeoutId = setTimeout(function () {
                processEvent(evt);
            }, delta_ms);
        } else {
            // long enough since last event, handle now
            processEvent(evt);
        }

        // Set lastEventTimestamp to time of last event after delta test.
        lastEventTimestamp = Date.now();
    };
    return handler;
}

var DELTA_MS = 5000;
var processEvent = function (evt) { console.log('handling event'); };
el.addEventHandler('some-event', makeRateLimitedEventHandler(DELTA_MS, processEvent));

您的视觉效果与使用前导选项的标准 lodash 去抖动行为没有什么不同,唯一的区别是您只显示一半的增量而不是完整的增量。
因此,您的解决方案可以这么简单。

_.debounce(cb, delta * 2, {leading: true});

https://lodash.com/docs/4.17.4#debounce

如果你想让最后的延迟更长一些,你可以通过包装去抖动方法和处理程序来解决这个问题。这样你就可以在处理程序中设置超时,并在去抖动包装器中取消它。
您必须检查当前调用是否是前导调用,以便在这种情况下不添加超时。

它可能看起来像这样:

const _ = require('lodash');
const bb = require('bluebird');

function handler(arg) {
    console.log(arg, new Date().getSeconds());
}

const debounceWithDelay = (func, delay, postDelay) => {
    let postDebounceWait;
    let timeOutLeading = false;
    const debounced = _.debounce((...args) => {
        // wrap the handler so we can add an additional timeout to the debounce invocation
        if (timeOutLeading) {
            /*
             for the first invocation we do not want an additional timeout.
             We can know this is the leading invocation because,
             we set timeOutLeading immediately to false after invoking the debounced function.
             This only works because the debounced leading functionality is synchronous it self.
             ( aka it does not use a trampoline )
             */
            func(...args);
        } else {
            postDebounceWait = setTimeout(() => {
                func(...args)
            }, postDelay);
        }
    }, delay, {leading: true});
    return (...args) => {
        // wrap the debounced method it self so we can cancel the post delay timer that was invoked by debounced on each invocation.
        timeOutLeading = true;
        clearTimeout(postDebounceWait);
        debounced(...args);
        timeOutLeading = false;
    }
};

const debounceDelay = debounceWithDelay(handler, 50, 2000);

(async function () {
    console.log(new Date().getSeconds());
    debounceDelay(1);
    debounceDelay(2);
    debounceDelay(3);
    debounceDelay(4);
    await bb.delay(3000);
    debounceDelay(5);
    await bb.delay(3000);
    debounceDelay(6);
    debounceDelay(7);
    debounceDelay(8);
})();

可运行脚本:

这是我的尝试:

const debounceNext = (cb, delay) => { 
  let timer = null;
  let next = null;

  const runTimer = (delay, event) => {
    timer = setTimeout(() => {
      timer = null;
      if(next) {
        next(event);
        next = null;
        runTimer(delay);
      }
    }, delay);
  };  

  return (event) => {
    if(!timer) {
      cb(event);
    }
    else {
      next = cb;
      clearTimeout(timer);
    }
    runTimer(delay, event);
  }
};

const processEvent = (event) => console.log(event);
const debouncedHandler = debounceNext(processEvent, 125);
myElement.addEventListener('scroll', debouncedHandler);

使用单个计时器的 vanilla JS 中一个非常简单的解决方案:

function debounceish(delta, fn) {
    var timer = null;
    return function(e) {
        if (timer === null) {
            //Do now
            fn(e);
            //Set timer that does nothing (but is not null until it's done!)
            timer = setTimeout(function(){
                timer = null;
            }, delta);
        } else {
            //Clear existing timer
            clearTimeout(timer);
            //Set a new one that actually does something
            timer = setTimeout(function(){
                fn(e);
                //Set timer that does nothing again
                timer = setTimeout(function(){
                    timer = null;
                }, delta);
            }, delta);
        }
    };
}

function markEvt(e) {
    var elm = document.createElement('div');
    elm.style.cssText = 'position:absolute;background:tomato;border-radius:3px;width:6px;height:6px;margin:-3px;';
    elm.style.top = e.clientY + 'px';
    elm.style.left = e.clientX + 'px';
    document.body.appendChild(elm);
}

document.addEventListener('click', debounceish(2000, markEvt));
<p>Click somewhere (2000ms delta) !</p>

使用相同类型的可视化比较 6 个提案:

var methods = {
    default: function(delay, fn) {
        return fn;
    },
    dhilt_debounceNext: (delay, cb) => { 
      let timer = null;
      let next = null;

      const runTimer = (delay, event) => {
        timer = setTimeout(() => {
          timer = null;
          if(next) {
            next(event);
            next = null;
            runTimer(delay);
          }
        }, delay);
      };  

      return (event) => {
        if(!timer) {
          cb(event);
        }
        else {
          next = cb;
          clearTimeout(timer);
        }
        runTimer(delay, event);
      }
    },
    
    Mikk3lRo_debounceish(delta, fn) {
        var timer = null;
        return function(e) {
            if (timer === null) {
                //Do now
                fn(e);
                //Set timer that does nothing (but is not null until it's done!)
                timer = setTimeout(function(){
                    timer = null;
                }, delta);
            } else {
                //Clear existing timer
                clearTimeout(timer);
                //Set a new one that actually does something
                timer = setTimeout(function(){
                    fn(e);
                    //Set timer that does nothing again
                    timer = setTimeout(function(){
                        timer = null;
                    }, delta);
                }, delta);
            }
        };
    },
    
    user650881_makeRateLimitedEventHandler: function(delta_ms, processEvent) {
        var timeoutId = 0;  // valid timeoutId's are positive.
        var lastEventTimestamp = 0;

        var handler = function (evt) {
            // Any untriggered handler will be discarded.
            if (timeoutId) {
                clearTimeout(timeoutId);
                timeoutId = 0;
            }
            var curTime = Date.now();
            if (curTime < lastEventTimestamp + delta_ms) {
                // within delta of last event, postpone handling
                timeoutId = setTimeout(function () {
                    processEvent(evt);
                }, delta_ms);
            } else {
                // long enough since last event, handle now
                processEvent(evt);
            }

            // Set lastEventTimestamp to time of last event after delta test.
            lastEventTimestamp = Date.now();
        };
        return handler;
    },
    
    Willem_DHaeseleer_debounceWithDelay: (delay, func) => {
        let postDebounceWait;
        let timeOutLeading = false;
        const debounced = _.debounce((...args) => {
            // wrap the handler so we can add an additional timeout to the debounce invocation
            if (timeOutLeading) {
                /*
                 for the first invocation we do not want an additional timeout.
                 We can know this is the leading invocation because,
                 we set timeOutLeading immediately to false after invoking the debounced function.
                 This only works because the debounced leading functionality is synchronous it self.
                 ( aka it does not use a trampoline )
                 */
                func(...args);
            } else {
                postDebounceWait = setTimeout(() => {
                    func(...args)
                }, delay);
            }
        }, delay, {leading: true});
        return (...args) => {
            // wrap the debounced method it self so we can cancel the post delay timer that was invoked by debounced on each invocation.
            timeOutLeading = true;
            clearTimeout(postDebounceWait);
            debounced(...args);
            timeOutLeading = false;
        }
    },
    
    Willem_DHaeseleer_lodashWithLeading: (delta, cb) => {
        return _.debounce(cb, delta * 2, {leading: true});
    },
    
    Javier_Rey_selfCancelerEventListener: function (delta, fn) {
        return function(ev) {
            var time = new Date().getTime();
            if (ev.target.time && time - ev.target.time < delta) {return;}
            ev.target.time = time;
            fn(ev);
        };
    },
};

var method_count = 0;
var colors = ['grey', 'tomato', 'green', 'blue', 'red', 'orange', 'yellow', 'black'];
function markEvt(method) {
    var style = 'position:absolute;border-radius:3px;width:6px;height:6px;margin:-3px;';
    style += 'background:' + colors[method_count] + ';';
    if (method_count > 0) {
      style += 'transform:rotate(' + Math.floor(360 * method_count / (Object.keys(methods).length - 1)) + 'deg) translateY(-8px);';
    }
    var elm = document.createElement('div');
    elm.innerHTML = '<span style="width:.8em;height:.8em;border-radius:.4em;display:inline-block;background:' + colors[method_count] + '"></span> ' + method;
    document.body.appendChild(elm);
    
    method_count++;
    return function(e) {
        elm = document.createElement('div');
        elm.style.cssText = style;
        elm.style.top = e.clientY + 'px';
        elm.style.left = e.clientX + 'px';
        document.body.appendChild(elm);
    };
}

for (var method in methods) {
    document.addEventListener('click', methods[method](2000, markEvt(method)));
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>

请注意,我需要对某些方法进行细微调整以获得通用界面。考虑到评论表明它并没有按照 OP 的要求进行调整,改编 Cully 的答案比我愿意付出的努力要多。

很明显,Javier Rey 的方法与其他方法完全不同。 Dhilt,user650881 和我自己的方法似乎是一致的。 Willem D'Haeseleer 的两种方法都有双倍的延迟(和其他细微差别),但似乎也表现一致。据我了解,双重延迟完全是故意的,尽管我对 OP 的理解不是这样。

I would say that Willem D'Haeseleer's lodash method is without a doubt the simplest - if you already use lodash that is. Without external dependencies my method is IMO simplest - but I may be biased on that one ;)