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

时间:2017-10-08 16:59:03

标签: javascript algorithm debouncing

假设有外部动作的随机序列(例如滚动事件)。我需要立即处理第一个操作,然后关闭所有以小于某个给定delta的间隔发生的操作,然后处理下一个应该延迟该delta的操作。应以同样的方式处理进一步的行动。

这看起来像去抖动即时和简单去抖的组合。我准备了一个图表来证明这个想法。

enter image description here

这里最好的解决方案/方法是什么?我想知道是否有一些现成的模式...

更新

我要感谢所有参与者!对于研究,我创建了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有一个领先旗帜的辩论实施,满足了这个问题(感谢Willem D'Haeseleer)。 here是Mikk3lRo回答的很酷的演示,他还提供了一些有用的综合。

我调查了源代码和结果:表单只是visual指向内存分配的东西......我没有发现任何性能问题,并且视图似乎很好。所以最终比例是代码本身。所有来源都转换为ES6(正如您在Plunker中看到的那样)因为我可以完全比较它们。我排除了我的own try(尽管我喜欢它的样子但它有点过分了)。时间戳版本非常有趣! postDelay版本很不错,虽然它不是一个请求的功能(因此snippet demo对两个lodash演示有两倍的延迟)。

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

PS 我想分享那小小的赏金(遗憾的是没有这样的选择),或者甚至从我的声望中获得更多分数(但不是200,太多了,并且会对胜利者不公平,只有100)。我甚至不能投两次......没关系。

5 个答案:

答案 0 :(得分:2)

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



<p>Click somewhere (2000ms delta) !</p>
&#13;
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)));
}
&#13;
&#13;
&#13;

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

&#13;
&#13;
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>
&#13;
{{1}}
&#13;
&#13;
&#13;

请注意,我需要对某些方法进行微调,以获得通用接口。适应Cully的答案比我愿意考虑的更多努力,考虑到评论表明它无论如何都没有做OP所希望的。

应该很清楚,Javier Rey的方法与其他方法完全不同。 Dhilt,user650881和我自己的方法似乎一致。 Willem D&#39; Haeseleer的方法都有两倍的延迟(和其他微妙的差异),但似乎也表现得一致。据我所知,双延迟完全是故意的,虽然这不是我理解OP的方式。

  

我会说Willem D&#39; Haeseleer的lodash方法毫无疑问是最简单的 - 如果你已经使用了lodash那么。没有外部依赖,我的方法是最简单的IMO - 但我可能会偏向于那个;)

答案 1 :(得分:1)

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

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));

答案 2 :(得分:1)

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

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

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

如果你想让最后一个延迟更长,你可以通过包装debounced方法和处理程序来解决这个问题。这样你就可以在处理程序中设置超时,并在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);
})();

Runnable脚本:

Edit 40zq8y59p9

答案 3 :(得分:0)

这里的东西我认为按照你描述的方式工作。如果没有,那至少可以解决一些问题。

&#13;
&#13;
// 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
}
&#13;
&#13;
&#13;

答案 4 :(得分:0)

这是我的尝试:

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);