事件委托、函数节流及防抖

一、事件委托:
  • JS事件处理程序的性能缺陷:

由于事件处理程序可以为现代web应用程序提供交互能力,因此许多开发人员会部分青红皂白的向页面中添加大量的处理程序。在创建GUI的语言(比如C#)中,为GUI中的每一个按钮添加一个onclick事件处理程序是司空见惯的事情,而且这样做也不会导至什么问题。可是在js中,添加到页面上的事件处理程序都会占用内存,内存占用的越多性能就越差,且必须事先就指定好所有的事件处理程序而导致的DOM访问次数的增加会延迟整个页面的交互就绪时间。 
————《JavaScript高级程序设计(三版)》

我想JS高程中对于事件处理程序中的问题说的很清楚了,基本上就是说事件处理程序绑定的越多越影响性能,但是又不能说不绑定事件,所以我们需要一种方法来减少绑定的事件。

  • 事件对象:

在触发DOM上的某个事件的时候,就会产生一个事件对象event,这个对象中包含着所有与事件有关的信息,其中包括导至事件的元素、事件的类型以及其他与特定事件相关的信息。

兼容DOM的浏览器会将一个event对象传入到事件处理程序中,无论指定事件处理程序时使用什么方法,都会传入一个event对象:

var btn = document.getElementById('myBtn');
btn.onclick.function (event) {
    alert(event.type);      //'click'
}
 
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

上面的事件处理程序会弹出一个警告框,框中显示有event.type属性表示的事件类型。

当然,event对象上包含很多特定的属性和方法,这里只列出一个我们会用到的属性,那就是target属性,这个属性表明了我们的事件的目标。

当然,上面说的是通用的现代浏览器的event对象,当这个对象存在与IE中的时候,又不一样了:

在使用DOM0级方法添加事件处理程序的时候,event对象是作为window对象的一个属性存在的,看下面的例子:

var btn = document.getElementById('myBtn');
btn.onclick.function () {
    var event = window.event;
    alert(event.type);      //'click'
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

如上面所见,我们通过window.event取得了event对象,并检测了被触发事件的类型。

那么在IE中的target属性又是什么样的呢?其实差不多,不过是target属性被替换成了srcElement属性来表示事件的目标,但是因为事件处理程序的作用域是根据指定它的方式来确定的,因此不能认为this会始终等于时间目标,所以最好使用window.event.srcElement来最终确定事件的目标!

因此,一个兼容IE的事件目标的函数如下:

oUl.onclick = function(ev){
    var ev = ev || window.event;
    var target = ev.target || ev.srcElement;
    // 程序
 }
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5
  • 事件委托:

什么是事件委托呢?书上的原话是:“事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件”,简单点解释的话,就是假如我们有一个列表,如下:

<ul>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
</ul>
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

如果需要每个li点击之后display为none,那么传统的做法是用一个for循环遍历每一个li节点,然后在循环里面给每一个li都绑定上一个click事件;

而使用事件委托呢?就是给ul添加一个onclick的事件处理程序。。。看到这里或许就要问了,我给ul添加点击事件,那岂不是在ul里面随便点击一下就会触发了?确实如此,所以这里要进行很重要的一步:判断我们点击的到底是什么,然后再根据我们获知的点击对象去执行对应的函数。

那么如何去判断我们点击了什么呢?

首先要用到一个我们平时在绑定事件的时候都会禁止掉的一个东西:事件冒泡。试想一下如果ul和li上都绑定了点击事件,同时li上绑定的事件没有禁止掉事件冒泡,那么当我们点击li的时候就会先触发li上的点击事件然后再触发ul上的点击事件了。同理,我们可以通过冒泡来获取click事件再执行ul上的事件处理程序。

其次,我们需要判断冒泡上来的事件流是否是在li上面触发的,如果随便一个都能冒泡并触发ul上的点击事件那也没什么意义了。这里就要用到我们上面提到的事件目标对象的获取了,通过检测我们获取到的目标的nodeName,看其是不是li就可以了。

最终的事件委托代码如下:

var oUl = document.getElementById("ul");
oUl.onclick = function(ev){

    var ev = ev || window.event,
        target = ev.target || ev.srcElement;

    // 因为nodeName获取到的节点名是大写,因此需要使用toLowerCase()来将其转换为小写
    if (target.nodeName.toLowerCase() == "li") {
        //执行函数
    }
 }
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
二、函数节流:

DOM操作是昂贵的,嗯,所以FB搞了个Virtual DOM来尽量简化你的DOM操作,《高性能javascript》一书也强调了要尽量减少DOM操作~嗯,为什么要提到这个呢?

相信很多人都用过两个事件:onresize跟onscroll,这两个事件一个在页面放大缩小时触发一个在页面滚动时触发,一般情况下伴随着这两个事件的肯定是DOM节点的定位啊,scrollTop距离的检测啊之类的操作,回到最初的问题,都说了DOM操作是很昂贵的你还在每一次onresize跟onscroll事件触发的时候去执行DOM操作,浪费机器性能很好玩么!

既然发现了问题,那么就要解决问题,解决的办法就是函数节流。函数节流背后的思想很简单:因为onresize跟onscroll这类事件是连续不断的触发的,那么在每次触发的时候我们就开一个定时器,将DOM操作延迟,然后在下一次事件触发的时候,我们把这个定时器给关掉,这样的结果就是onresize事件一路触发的时候,我们开定时器,关定时器,开定时器,关定时器。。。一直等到最后你不执行onresize的操作了,再等待延迟的时间后,最后这个没有被关闭的定时器开始执行回调函数,访问DOM节点。

对于函数节流,有两种实现的方法:

  • 第一种是《javascript高级程序设计》中的方法:
function throttle(method , context){
    clearTimeout(method.tId);

    method.tId=setTimeout(function(){
        method.call(context);
    },500);
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 第二种是网上的方法:
function throttle(method , delay){
    var timer=null;

    return function(){
        var context = this, 
            args = arguments;
        clearTimeout(timer);

        timer = setTimeout(function(){
            method.apply(context , args);
        } , delay);
    }
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
三、函数防抖:

函数节流是一个很好用的方案,但假如我并不希望每次都是要事件结束后等待delay的事件后才执行回调,但是又希望减少DOM操作,那该如何处理呢?

我们先给定一个时间段duration,过了这个时间段以后我们执行相应的操作,如果没有过这个时间段,那么就按照函数节流的思路,开关定时器就行了~

function throttle(method , duration ,delay ){
    var timer = null, 
        // 记录下开始执行函数的时间
        begin = new Date();

    return function(){
        var context = this, 
            args = arguments, 
            // 记录下当前时间
            current = new Date();
        // 函数节流里的思路
        clearTimeout(timer);

        // 记录下的两个时间相减再与duration进行比较
        if(current-begin >= duration){
             method.apply(context , args);
             begin = current;
        }else{  
            timer = setTimeout(function(){
                method.apply(context , args);
            } , delay);
        }
    }
}
<think>我们参考上述引用中关于节流函数(throttle)的定义和实现思路,特别是JavaScript中的实现示例。在.NET中,我们可以使用异步编程模型和CancellationToken来实现类似功能。节流函数的核心思想是:在一定时间间隔内,无论触发多少次,只执行一次函数(或者我们可以控制为执行一次,但也可以有其他变体,如执行最后一次)。根据引用[2]和[4],节流有两种常见实现:时间戳版和定时器版。 在.NET中,我们可以利用异步方法、Task.Delay和锁机制来实现。这里我们主要考虑两种场景: 1. 时间戳版:记录上一次执行的时间,每次调用时检查当前时间与上一次执行的时间差,如果大于设定的间隔则执行,否则忽略。 2. 定时器版:在每次调用时,如果当前没有正在等待的延迟任务,则设置一个延迟任务来执行函数;如果已有延迟任务,则忽略本次调用(或者另一种策略是重置定时器,但节流通常不重置,而防抖会重置)。 但是,根据引用[3]和[4]中的描述,节流函数通常有两种行为: - 前缘(leading)触发:在时间间隔的开始就触发一次。 - 后缘(trailing)触发:在时间间隔的末尾触发一次。 在引用[3]的代码示例中,使用的是定时器版,并且是后缘触发(即在时间间隔结束时执行)。 在.NET中,我们可以使用`SemaphoreSlim`或者锁来确保线程安全,因为节流函数可能被多个线程同时调用。 下面我们实现一个后缘触发的节流函数(定时器版),它会在一定时间间隔内只执行一次,且是最后一次调用(注意:这里我们可能希望执行的是最后一次调用的参数,但节流通常不保证参数,而是保证频率。不过根据需求,我们可以设计为保留最后一次调用的参数)。 但是请注意,节流函数在连续触发时,只会每隔一段时间执行一次,而不会执行每一次触发。所以,我们可能希望执行的是第一次触发(前缘)或者最后一次触发(后缘),或者两者都执行(即前缘后缘都执行)。 根据引用[5],节流防抖(debounce)是有区别的。防抖是连续触发时只执行最后一次(在触发后等待一段时间没有再次触发才执行),而节流是固定时间间隔内执行一次。 这里我们实现一个后缘触发的节流函数(即时间间隔内多次调用,只会在时间间隔结束时执行一次,且执行的是最后一次调用的参数)。 设计思路: 1. 我们需要一个函数包装器,它接受一个函数和一个时间间隔。 2. 我们需要记录最后一次调用的参数(如果有的话)。 3. 我们需要一个标志来指示是否已经有一个延迟任务在运行。 4. 当函数被调用时,如果当前没有延迟任务,则设置一个延迟任务,在延迟结束后执行函数(使用最后一次调用的参数);如果有延迟任务,则只更新参数,不重新设置延迟。 但是,注意:在节流函数中,我们通常不会重置定时器(防抖会重置)。而节流是固定时间间隔,所以每次调用不会重置定时器,而是检查定时器是否已经启动。如果已经启动,则忽略(或者更新参数,但不重置定时器)。在定时器到期后,我们执行函数,然后重置标志,允许下一次调用启动新的定时器。 然而,引用[3]中的节流实现是:在每次调用时,如果定时器不存在(或标志允许执行),则设置定时器,并在定时器到期后执行。在定时器未到期期间,所有调用都被忽略(但该实现中更新了参数,并在定时器到期时执行最后一次调用的参数)。所以它实际上执行的是第一次调用(因为第一次调用时设置定时器)后,在延迟时间内再次调用会更新参数,然后在延迟结束时执行一次(使用最新参数)。所以它并不是完全的前缘,而是后缘(因为第一次调用后,后续调用会更新参数,但执行发生在延迟结束时)。 我们参考引用[3]的代码,用C#实现一个类似的节流函数(后缘触发)。 但是,在.NET中,我们需要注意多线程环境。我们可以使用`lock`来保证线程安全。 下面我们实现一个支持异步方法的节流函数(如果被包装的函数是异步的,我们也需要支持)。 我们定义两个版本: 1. 同步版本:用于包装同步方法。 2. 异步版本:用于包装异步方法。 我们先实现异步版本,因为同步版本可以通过异步版本包装(或者分别实现)。 根据需求,我们可能希望节流函数能够处理有返回值的函数,但是节流函数通常用于不需要返回值的场景(比如事件处理),因为多次调用只有一次执行,那么返回值应该如何处理?通常,我们只关注最后一次调用的结果,但调用者可能无法直接获取返回值(因为调用是异步的)。因此,我们这里只考虑无返回值的函数(Action和Func<Task>)。 实现步骤(异步版本): - 我们需要一个锁对象(例如`SemaphoreSlim`)来保护共享状态(标志和最后一次调用的参数)。 - 状态:一个标志`isWaiting`表示是否正在等待执行,一个`lastArgs`来保存最后一次调用的参数(如果有参数的话)。 - 对于无参数的函数,我们只需要记录调用次数,不需要参数。 但是,为了通用性,我们可以使用泛型参数来支持带参数的函数。 由于我们无法预知调用者何时调用,以及调用的参数,我们使用一个队列或者直接保存最后一次参数(因为我们只需要最后一次)。 设计一个泛型类`Throttler<T>`,但为了简化,我们可以先实现一个无参数的版本,然后扩展到有参数。 不过,根据引用[3]的示例,我们实现一个无参数的版本,然后可以很容易扩展到有参数。 无参数异步版本(包装一个Func<Task>): ```csharp public class Throttler { private readonly Func<Task> _func; private readonly int _delay; private bool _isWaiting = false; public Throttler(Func<Task> func, int delay) { _func = func; _delay = delay; } public async Task InvokeAsync() { if (_isWaiting) { return; } _isWaiting = true; await Task.Delay(_delay); try { await _func(); } finally { _isWaiting = false; } } } ``` 但是,上面的代码有一个问题:它只会在延迟结束后执行一次,但是在延迟期间的所有调用都被忽略(不保存状态)。而且,它执行的是第一次调用时的状态(因为延迟结束后执行的是第一次调用时设置的任务)。但是,我们想要的是最后一次调用的状态,那么我们需要在等待期间不断更新要执行的任务吗?实际上,引用[3]中的JavaScript示例是保存了最后一次调用的参数,并在执行时使用该参数。 修改:我们需要在等待期间,如果有新的调用,则更新要执行的任务(即保存最新的参数)。但是,对于无参数函数,我们不需要参数,只需要知道在延迟期间有调用,我们就执行一次(但不需要多次执行,因为节流是规定时间一次)。所以,我们可以用一个标志来记录在等待期间是否有新的调用,如果有,则在执行完第一次任务后,再重新开始一次(但这样就不是固定间隔了,可能会连续执行)。因此,我们通常不这样设计。 根据引用[3],他们的做法是:在等待期间,每次调用都会更新参数(但不会重置定时器),然后在定时器到期时,使用最新的参数执行一次。因此,我们只需要保存最新的参数。 所以,我们修改为支持带参数的版本: 带参数异步版本(包装一个Func<T, Task>): ```csharp public class Throttler<T> { private readonly Func<T, Task> _func; private readonly int _delay; private bool _isWaiting = false; private T _lastArg; private bool _hasPendingInvocation = false; public Throttler(Func<T, Task> func, int delay) { _func = func; _delay = delay; } public async Task InvokeAsync(T arg) { // 保存最新的参数 _lastArg = arg; _hasPendingInvocation = true; if (_isWaiting) { // 如果正在等待,则直接返回,等待定时器到期后执行 return; } _isWaiting = true; await Task.Delay(_delay); // 在等待期间,可能有多次调用,所以检查是否有待处理的调用 while (_hasPendingInvocation) { // 重置标志 _hasPendingInvocation = false; try { await _func(_lastArg); } catch { // 异常处理,根据需要 } } _isWaiting = false; } } ``` 但是,上面的代码在延迟结束后,如果期间有多次调用,它会执行一次(使用最新的参数),然后退出。但是,它不会处理在本次执行期间的新调用(因为执行完就把_isWaiting设为false了)。而且,我们使用了一个循环,但实际上我们只需要执行一次(因为节流就是一次)。所以,我们并不需要循环。 实际上,我们只需要在延迟结束后,检查是否有待处理的调用(即最后一次调用的参数),然后执行一次,不管延迟期间调用多少次,只执行一次(使用最后一次的参数)。因此,修改如下: ```csharp public class Throttler<T> { private readonly Func<T, Task> _func; private readonly int _delay; private bool _isWaiting = false; private T _lastArg; private bool _hasPendingInvocation = false; public Throttler(Func<T, Task> func, int delay) { _func = func; _delay = delay; } public async Task InvokeAsync(T arg) { _lastArg = arg; _hasPendingInvocation = true; if (_isWaiting) { return; } _isWaiting = true; await Task.Delay(_delay); // 重置待处理标志,以便在下次调用时重新记录 _hasPendingInvocation = false; try { await _func(_lastArg); } finally { _isWaiting = false; } } } ``` 但是,这里有一个问题:在等待期间,如果有多次调用,那么_lastArg会被更新多次,最后执行的是最后一次的参数。但是,在等待期间,我们只保存了最后一次的参数,这符合要求。 然而,在延迟结束后,我们只执行了一次,然后重置了状态。那么,如果在执行函数的过程中(即`await _func(_lastArg);`)又有新的调用,那么新的调用会设置`_hasPendingInvocation=true`,并且由于`_isWaiting`已经重置为false,所以它会启动一个新的延迟任务。这样,两次执行之间的间隔可能会小于_delay(因为第一次执行还没结束,第二次调用就开始了)。为了避免这种情况,我们应该在执行函数之前重置状态?不行,因为我们需要在执行前保存待处理标志。 实际上,我们可以在开始执行时,先获取当前的参数,然后重置待处理标志,再执行函数。但是,这样在执行过程中如果有新的调用,新的调用会设置新的参数和待处理标志,并且启动新的等待(因为_isWaiting在执行前已经重置?不,我们在执行前并没有重置_isWaiting)。 修改:我们在执行函数前,先清除待处理标志,然后释放锁(即允许新的调用进入等待),但是这样在函数执行期间,新的调用会启动新的定时器,导致两次执行间隔小于_delay。所以,我们应该在函数执行结束后再重置_isWaiting。 上面的代码已经是这样了:在finally块中重置_isWaiting。所以,在函数执行期间,如果有新的调用,新的调用会设置_lastArg和_hasPendingInvocation,但是因为_isWaiting还是true(直到函数执行完才设为false),所以新的调用不会启动新的定时器,而是返回。直到本次执行结束,新的调用才会启动新的定时器。这样,两次执行之间的间隔至少是_delay(因为新的定时器会等待_delay)。 但是,注意:在函数执行期间,新的调用会更新_lastArg,但不会立即执行,直到本次函数执行结束,并且新的调用启动定时器(等待_delay)后执行。所以,两次执行的时间间隔是:函数执行时间 + _delay。这不符合节流的定义(固定间隔)。因此,我们通常希望节流是固定时间间隔,不管函数执行时间。 因此,我们需要将函数执行和定时器分离:定时器只负责时间间隔,而函数执行可能在定时器到期后立即执行(如果函数执行时间很长,那么下一次执行会在当前函数执行完成后,再加上定时器延迟?)。这可能会导致延迟积累。 所以,我们可能需要使用一个独立的后台任务来执行函数,这样就不会阻塞定时器的循环。但是,这会使问题复杂化。 根据需求,节流函数通常用于限制执行频率,不要求精确的固定间隔,而是保证在某个时间段内只执行一次。所以,上面的实现可能已经满足大部分场景。 但是,我们也可以使用一个周期性定时器(如System.Threading.Timer)来实现更精确的节流。不过,周期性定时器会以固定间隔触发,不管上一次是否执行完成。这可能导致并发执行,所以需要加锁。 这里我们采用简单的方式:使用Task.Delay和异步方法。 另外,我们还需要考虑异常处理:如果被包装的函数抛出异常,我们希望异常不会影响节流器的状态(即我们需要重置状态),上面的finally块已经确保重置_isWaiting。 现在,我们实现一个无参数的版本(同步和异步),以及带参数的版本(同步和异步)。为了简化,我们可以只实现异步版本,因为同步版本可以通过调用异步版本并等待(同步等待)来实现,但这可能导致死锁。因此,我们分别实现同步版本。 但是,在.NET中,我们通常建议使用异步版本,避免阻塞线程。 由于时间关系,我们实现一个通用的异步节流函数(无参数和带参数),并提供一个同步版本(如果必须)。 另外,我们还可以实现一个取消功能:在等待期间,如果取消,则放弃执行。 下面,我们实现一个支持取消的版本。 由于用户要求基于.NET 8,我们可以使用CancellationToken。 修改后的带参数异步版本(支持取消): ```csharp public class Throttler<T> { private readonly Func<T, Task> _func; private readonly int _delay; private bool _isWaiting = false; private T _lastArg; private bool _hasPendingInvocation = false; private CancellationTokenSource _cts; public Throttler(Func<T, Task> func, int delay) { _func = func; _delay = delay; } public async Task InvokeAsync(T arg, CancellationToken cancellationToken = default) { // 保存最新的参数 _lastArg = arg; _hasPendingInvocation = true; if (_isWaiting) { return; } _isWaiting = true; // 合并传入的取消令牌和内部取消令牌(用于在多次调用时取消上一次的等待?但这里我们不需要,因为每次调用共享同一个等待) // 我们使用一个新的CancellationTokenSource,以便在多次调用时可以取消上一次的等待(但节流通常不取消,而是等待完成) // 但是,如果用户取消了,我们应该取消等待。 // 我们创建一个新的CancellationTokenSource,并和传入的cancellationToken关联 _cts = new CancellationTokenSource(); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); try { await Task.Delay(_delay, linkedCts.Token); } catch (TaskCanceledException) { // 如果取消,则重置状态并退出 _isWaiting = false; _hasPendingInvocation = false; return; } // 重置待处理标志 _hasPendingInvocation = false; try { await _func(_lastArg); } finally { _isWaiting = false; } } // 提供一个取消当前等待的方法 public void Cancel() { _cts?.Cancel(); } } ``` 但是,上面的取消设计可能有问题:因为每次调用InvokeAsync都会创建一个新的CancellationTokenSource,并且覆盖_cts,所以Cancel方法只能取消最后一次的等待。而且,在等待期间,如果有新的调用,那么上一次的等待不会被取消(因为新的调用会设置新的_cts,而旧的_cts被覆盖,无法取消)。因此,我们可能需要取消上一次的等待,以便立即开始新的等待(这实际上是防抖的行为,而不是节流)。所以,在节流函数中,我们通常不取消上一次的等待,而是让它自然结束。 因此,我们去掉取消功能(或者只支持传入CancellationToken,用于取消本次等待,但不影响下一次调用)。 简化:我们只使用传入的CancellationToken来取消本次等待(如果等待期间用户取消,则放弃执行)。 修改: ```csharp public class Throttler<T> { private readonly Func<T, Task> _func; private readonly int _delay; private bool _isWaiting = false; private T _lastArg; private bool _hasPendingInvocation = false; public Throttler(Func<T, Task> func, int delay) { _func = func; _delay = delay; } public async Task InvokeAsync(T arg, CancellationToken cancellationToken = default) { _lastArg = arg; _hasPendingInvocation = true; if (_isWaiting) { return; } _isWaiting = true; try { await Task.Delay(_delay, cancellationToken); } catch (TaskCanceledException) { // 取消后,重置状态并退出 _isWaiting = false; _hasPendingInvocation = false; return; } // 重置待处理标志 _hasPendingInvocation = false; try { await _func(_lastArg); } finally { _isWaiting = false; } } } ``` 现在,这个版本支持传入CancellationToken来取消等待。如果取消,则本次等待不会执行函数,并且重置状态。 但是,这里有一个问题:如果在等待期间,有多次调用,那么最后一次调用的参数会被保存,但是如果在等待期间取消了,那么这些参数将不会被执行(因为取消了等待,并且重置了状态)。这符合预期。 对于同步版本,我们可以使用类似的方式,但是同步版本中,我们使用Thread.Sleep?这会导致调用线程阻塞,所以不推荐。我们可以使用异步版本,然后同步等待(.GetAwaiter().GetResult()),但这可能会引起死锁。因此,我们只提供异步版本。 另外,我们还可以实现一个无参数的版本,通过重载。 无参数异步版本: ```csharp public class Throttler { private readonly Func<Task> _func; private readonly int _delay; private bool _isWaiting = false; private bool _hasPendingInvocation = false; public Throttler(Func<Task> func, int delay) { _func = func; _delay = delay; } public async Task InvokeAsync(CancellationToken cancellationToken = default) { _hasPendingInvocation = true; if (_isWaiting) { return; } _isWaiting = true; try { await Task.Delay(_delay, cancellationToken); } catch (TaskCanceledException) { _isWaiting = false; _hasPendingInvocation = false; return; } _hasPendingInvocation = false; try { await _func(); } finally { _isWaiting = false; } } } ``` 但是,我们可以通过一个非泛型类来包装,或者使用同一个类,通过不同的包装方式。为了简化,我们可以使用同一个类名,通过不同的构造函数。 不过,我们也可以设计一个静态工厂方法来创建节流器。 最后,我们还可以考虑使用委托的兼容性,比如Action和Func<Task>的转换。但是,为了简单,我们分别实现。 另外,我们还可以实现一个时间戳版(前缘触发)的节流函数,这里就不展开了。 总结:我们实现了后缘触发的节流函数(异步版本),支持带参数和不带参数,支持取消。 使用示例: ```csharp // 带参数的示例 Func<string, Task> myFunc = async (arg) => { Console.WriteLine($"Executed with arg: {arg}"); await Task.Delay(100); // 模拟异步操作 }; var throttler = new Throttler<string>(myFunc, 1000); // 1秒节流 // 连续调用 await throttler.InvokeAsync("Call 1"); await throttler.InvokeAsync("Call 2"); await throttler.InvokeAsync("Call 3"); // 输出:在1秒后输出 "Executed with arg: Call 3" ``` 注意:连续调用时,只有第一次调用会启动定时器,后面的调用会更新参数,然后定时器到期后执行最后一次参数。 但是,如果我们希望每次调用都能被记录,并且每隔一段时间执行一次(而不是只执行最后一次),那么我们可以使用队列来保存所有参数,然后逐个执行。但是,这就不属于节流了,而是限流(比如使用信号量限制并发数,并且以固定速率执行)。 所以,根据需求,节流函数通常只关心执行频率,不关心参数个数,所以保存最后一次参数即可。 以上实现仅供参考,实际应用中可能需要根据具体场景调整。 相关问题: 1. 节流函数(throttle)与防抖函数(debounce)有什么区别? 2. 如何实现一个前缘触发的节流函数(即立即执行一次,然后等待时间间隔)? 3. 在.NET中,如何实现同步的节流函数(用于同步方法)? 4. 节流函数在哪些实际场景中会用到? 5. 如何确保节流函数的线程安全?</think>在.NET 8中实现节流函数(throttle)的核心思路是:**在规定时间间隔内,无论触发多少次操作,只执行最后一次调用**。以下是完整实现和解析: --- ### 一、节流函数核心实现(异步版) ```csharp using System; using System.Threading; using System.Threading.Tasks; public class Throttler { private readonly Func<CancellationToken, Task> _func; private readonly int _delay; private bool _isThrottling = false; private CancellationTokenSource _pendingCts = new(); public Throttler(Func<CancellationToken, Task> func, int delayMilliseconds) { _func = func; _delay = delayMilliseconds; } public async Task InvokeAsync(CancellationToken cancellationToken = default) { // 取消前一个等待中的请求 _pendingCts.Cancel(); _pendingCts = new CancellationTokenSource(); if (_isThrottling) return; _isThrottling = true; try { await Task.Delay(_delay, _pendingCts.Token).ConfigureAwait(false); await _func(cancellationToken).ConfigureAwait(false); } catch (TaskCanceledException) { // 忽略取消请求(正常行为) } finally { _isThrottling = false; } } } ``` --- ### 二、关键设计解析 1. **时间窗口控制** 通过 `Task.Delay(_delay)` 创建时间窗口,确保在 `_delay` 毫秒内只执行一次操作[^4]。 2. **取消机制** - 使用 `CancellationTokenSource` 取消前一个等待中的请求,确保只保留最后一次调用[^5]。 - 调用 `_pendingCts.Cancel()` 时,前一个 `Task.Delay` 会抛出 `TaskCanceledException`(被安全忽略)。 3. **线程安全** - `_isThrottling` 标志防止并发执行。 - 使用 `ConfigureAwait(false)` 避免上下文切换开销[^1]。 --- ### 三、使用示例(输入搜索场景) ```csharp // 1. 定义实际业务逻辑(如API请求) async Task SearchAsync(CancellationToken ct) { await Task.Delay(300); // 模拟网络请求 Console.WriteLine($"搜索值: {_searchText}"); } // 2. 创建节流器(500ms时间窗口) var throttler = new Throttler(SearchAsync, 500); // 3. 在输入事件中触发 private string _searchText = ""; async void OnTextChanged(string text) { _searchText = text; await throttler.InvokeAsync(); // 高频调用会被节流 } // 测试:连续快速输入 "hello" OnTextChanged("h"); OnTextChanged("he"); OnTextChanged("hel"); OnTextChanged("hell"); OnTextChanged("hello"); // 输出结果(500ms后): // 搜索值: hello ``` --- ### 四、同步版本实现 ```csharp public class SyncThrottler { private readonly Action _action; private readonly int _delay; private DateTime _lastRun = DateTime.MinValue; public SyncThrottler(Action action, int delayMilliseconds) { _action = action; _delay = delayMilliseconds; } public void Invoke() { var now = DateTime.Now; if ((now - _lastRun).TotalMilliseconds < _delay) return; _lastRun = now; _action(); } } // 使用示例 var throttler = new SyncThrottler(() => Console.WriteLine("执行"), 500); throttler.Invoke(); // 首次立即执行 throttler.Invoke(); // 500ms内调用被忽略 ``` --- ### 五、应用场景 1. **UI事件** - 文本框实时搜索(如百度搜索建议) - 窗口大小调整事件(`window.onresize`)[^1] 2. **游戏开发** - 玩家连续点击攻击按钮时限制频率 3. **API调用** - 防止高频触发付费API产生超额费用[^3] --- ### 六、对比防抖(Debounce) | 特性 | 节流 (Throttle) | 防抖 (Debounce) | |------------|--------------------------|------------------------| | 执行时机 | 时间窗口内执行**一次** | 停止触发后执行**一次** | | 适用场景 | 实时搜索、进度上报 | 提交按钮防重复点击 | | 用户体验 | 响应更及时 | 避免无效操作 | | 数学表达 | $f(x)$ 在 $\Delta t$ 内最多执行一次 | $f(x)$ 在触发结束后执行[^2] | > 根据需求选择:需要**即时反馈**用节流(如游戏控制),需要**最终状态**用防抖(如表单提交)[^4]。 --- ### 相关问题 1. 如何在Blazor中实现组件级别的节流控制? 2. 节流函数的时间间隔如何根据网络状态动态调整? 3. 如何用`System.Threading.Channels`实现生产者-消费者模式的节流? 4. 节流与信号量(Semaphore)在并发控制中的区别是什么? 5. 在分布式系统中如何实现跨节点的全局节流? [^1]: 高频事件如`resize`、`scroll`适用节流优化[引用1] [^2]: 节流稀释执行频率,防抖等待最终状态[引用2] [^3]: 单位时间内只生效一次的核心逻辑[引用3] [^4]: 节流保证操作流畅性,防抖避免无效操作[引用4] [^5]: 通过取消令牌实现请求丢弃[引用5]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值