Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【进阶 7-2 期】深入浅出防抖函数 debounce #39

Open
yygmind opened this issue Jun 12, 2019 · 3 comments
Open

【进阶 7-2 期】深入浅出防抖函数 debounce #39

yygmind opened this issue Jun 12, 2019 · 3 comments

Comments

@yygmind
Copy link
Owner

yygmind commented Jun 12, 2019

引言

上一节我们认识了节流函数 throttle,了解了它的定义、实现原理以及在 underscore 中的实现。这一小节会继续之前的篇幅聊聊防抖函数 debounce,结构是一样的,将分别介绍定义、实现原理并给出了 2 种实现代码并在最后介绍在 underscore 中的实现,欢迎大家拍砖。

有什么想法或者意见都可以在评论区留言,下图是本文的思维导图,高清思维导图和更多文章请看我的 Github

111

定义及解读

防抖函数 debounce 指的是某个函数在某段时间内,无论触发了多少次回调,都只执行最后一次。假如我们设置了一个等待时间 3 秒的函数,在这 3 秒内如果遇到函数调用请求就重新计时 3 秒,直至新的 3 秒内没有函数调用请求,此时执行函数,不然就以此类推重新计时。

img

举一个小例子:假定在做公交车时,司机需等待最后一个人进入后再关门,每次新进一个人,司机就会把计时器清零并重新开始计时,重新等待 1 分钟再关门,如果后续 1 分钟内都没有乘客上车,司机会认为乘客都上来了,将关门发车。

此时「上车的乘客」就是我们频繁操作事件而不断涌入的回调任务;「1 分钟」就是计时器,它是司机决定「关门」的依据,如果有新的「乘客」上车,将清零并重新计时;「关门」就是最后需要执行的函数。

如果你还无法理解,看下面这张图就清晰多了,另外点击 这个页面 查看节流和防抖的可视化比较。其中 Regular 是不做任何处理的情况,throttle 是函数节流之后的结果(上一小节已介绍),debounce 是函数防抖之后的结果。

image-20190525193539745

原理及实现

实现原理就是利用定时器,函数第一次执行时设定一个定时器,之后调用时发现已经设定过定时器就清空之前的定时器,并重新设定一个新的定时器,如果存在没有被清空的定时器,当定时器计时结束后触发函数执行。

实现 1

// 实现 1
// fn 是需要防抖处理的函数
// wait 是时间间隔
function debounce(fn, wait = 50) {
    // 通过闭包缓存一个定时器 id
    let timer = null
    // 将 debounce 处理结果当作函数返回
    // 触发事件回调时执行这个返回函数
    return function(...args) {
      	// 如果已经设定过定时器就清空上一次的定时器
        if (timer) clearTimeout(timer)
      
      	// 开始设定一个新的定时器,定时器结束后执行传入的函数 fn
        timer = setTimeout(() => {
            fn.apply(this, args)
        }, wait)
    }
}

// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000)
// 停止滑动 1 秒后执行函数 () => console.log('fn 防抖执行了')
document.addEventListener('scroll', betterFn)

实现 2

上述实现方案已经可以解决大部分使用场景了,不过想要实现第一次触发回调事件就执行 fn 有点力不从心了,这时候我们来改写下 debounce 函数,加上第一次触发立即执行的功能。

// 实现 2
// immediate 表示第一次是否立即执行
function debounce(fn, wait = 50, immediate) {
    let timer = null
    return function(...args) {
        if (timer) clearTimeout(timer)
      
      	// ------ 新增部分 start ------ 
      	// immediate 为 true 表示第一次触发后执行
      	// timer 为空表示首次触发
        if (immediate && !timer) {
            fn.apply(this, args)
        }
      	// ------ 新增部分 end ------ 
      	
        timer = setTimeout(() => {
            fn.apply(this, args)
        }, wait)
    }
}

// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000, true)
// 第一次触发 scroll 执行一次 fn,后续只有在停止滑动 1 秒后才执行函数 fn
document.addEventListener('scroll', betterFn)

实现原理比较简单,判断传入的 immediate 是否为 true,另外需要额外判断是否是第一次执行防抖函数,判断依旧就是 timer 是否为空,所以只要 immediate && !timer 返回 true 就执行 fn 函数,即 fn.apply(this, args)

加强版 throttle

现在考虑一种情况,如果用户的操作非常频繁,不等设置的延迟时间结束就进行下次操作,会频繁的清除计时器并重新生成,所以函数 fn 一直都没办法执行,导致用户操作迟迟得不到响应。

有一种思想是将「节流」和「防抖」合二为一,变成加强版的节流函数,关键点在于「 wait 时间内,可以重新生成定时器,但只要 wait 的时间到了,必须给用户一个响应」。这种合体思路恰好可以解决上面提出的问题。

给出合二为一的代码之前先来回顾下 throttle 函数,上一小节中有详细的介绍。

// fn 是需要执行的函数
// wait 是时间间隔
const throttle = (fn, wait = 50) => {
  // 上一次执行 fn 的时间
  let previous = 0
  // 将 throttle 处理结果当作函数返回
  return function(...args) {
    // 获取当前时间,转换成时间戳,单位毫秒
    let now = +new Date()
    // 将当前时间和上一次执行函数的时间进行对比
    // 大于等待时间就把 previous 设置为当前时间并执行函数 fn
    if (now - previous > wait) {
      previous = now
      fn.apply(this, args)
    }
  }
}

结合 throttle 和 debounce 代码,加强版节流函数 throttle 如下,新增逻辑在于当前触发时间和上次触发的时间差小于时间间隔时,设立一个新的定时器,相当于把 debounce 代码放在了小于时间间隔部分。

// fn 是需要节流处理的函数
// wait 是时间间隔
function throttle(fn, wait) {
  
  // previous 是上一次执行 fn 的时间
  // timer 是定时器
  let previous = 0, timer = null
  
  // 将 throttle 处理结果当作函数返回
  return function (...args) {
    
    // 获取当前时间,转换成时间戳,单位毫秒
    let now = +new Date()
    
    // ------ 新增部分 start ------ 
    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔
    if (now - previous < wait) {
    	 // 如果小于,则为本次触发操作设立一个新的定时器
       // 定时器时间结束后执行函数 fn 
       if (timer) clearTimeout(timer)
       timer = setTimeout(() => {
          previous = now
        	fn.apply(this, args)
        }, wait)
    // ------ 新增部分 end ------ 
      
    } else {
       // 第一次执行
       // 或者时间间隔超出了设定的时间间隔,执行函数 fn
       previous = now
       fn.apply(this, args)
    }
  }
}

// DEMO
// 执行 throttle 函数返回新函数
const betterFn = throttle(() => console.log('fn 节流执行了'), 1000)
// 第一次触发 scroll 执行一次 fn,每隔 1 秒后执行一次函数 fn,停止滑动 1 秒后再执行函数 fn
document.addEventListener('scroll', betterFn)

看完整段代码会发现这个思想和上篇文章介绍的 underscore 中 throttle 的实现思想非常相似。

underscore 源码解析

看完了上文的基本版代码,感觉还是比较轻松的,现在来学习下 underscore 是如何实现 debounce 函数的,学习一下优秀的思想,直接上代码和注释,本源码解析依赖于 underscore 1.9.1 版本实现。

// 此处的三个参数上文都有解释
_.debounce = function(func, wait, immediate) {
  // timeout 表示定时器
  // result 表示 func 执行返回值
  var timeout, result;

  // 定时器计时结束后
  // 1、清空计时器,使之不影响下次连续事件的触发
  // 2、触发执行 func
  var later = function(context, args) {
    timeout = null;
    // if (args) 判断是为了过滤立即触发的
    // 关联在于 _.delay 和 restArguments
    if (args) result = func.apply(context, args);
  };

  // 将 debounce 处理结果当作函数返回
  var debounced = restArguments(function(args) {
    if (timeout) clearTimeout(timeout);
    if (immediate) {
      // 第一次触发后会设置 timeout,
      // 根据 timeout 是否为空可以判断是否是首次触发
      var callNow = !timeout;
      timeout = setTimeout(later, wait);
      if (callNow) result = func.apply(this, args);
    } else {
    	// 设置定时器
      timeout = _.delay(later, wait, this, args);
    }

    return result;
  });

  // 新增 手动取消
  debounced.cancel = function() {
    clearTimeout(timeout);
    timeout = null;
  };

  return debounced;
};

// 根据给定的毫秒 wait 延迟执行函数 func
_.delay = restArguments(function(func, wait, args) {
  return setTimeout(function() {
    return func.apply(null, args);
  }, wait);
});

相比上文的基本版实现,underscore 多了以下几点功能。

  • 1、函数 func 的执行结束后返回结果值 result
  • 2、定时器计时结束后清除 timeout,使之不影响下次连续事件的触发
  • 3、新增了手动取消功能 cancel
  • 4、immediate 为 true 后只会在第一次触发时执行,频繁触发回调结束后不会再执行

小结

  • 函数节流和防抖都是「闭包」、「高阶函数」的应用

  • 函数节流 throttle 指的是某个函数在一定时间间隔内(例如 3 秒)执行一次,在这 3 秒内 无视后来产生的函数调用请求

    • 节流可以理解为养金鱼时拧紧水龙头放水,3 秒一滴
      • 「管道中的水」就是我们频繁操作事件而不断涌入的回调任务,它需要接受「水龙头」安排
      • 「水龙头」就是节流阀,控制水的流速,过滤无效的回调任务
      • 「滴水」就是每隔一段时间执行一次函数
      • 「3 秒」就是间隔时间,它是「水龙头」决定「滴水」的依据
    • 应用:监听滚动事件添加节流函数后,每隔固定的一段时间执行一次
    • 实现方案 1:用时间戳来判断是否已到执行时间,记录上次执行的时间戳,然后每次触发后执行回调,判断当前时间距离上次执行时间的间隔是否已经达到时间差(Xms) ,如果是则执行,并更新上次执行的时间戳,如此循环
    • 实现方案 2:使用定时器,比如当 scroll 事件刚触发时,打印一个 hello world,然后设置个 1000ms 的定时器,此后每次触发 scroll 事件触发回调,如果已经存在定时器,则回调不执行方法,直到定时器触发,handler 被清除,然后重新设置定时器
  • 函数防抖 debounce 指的是某个函数在某段时间内,无论触发了多少次回调,都只执行最后一次

    • 防抖可以理解为司机等待最后一个人进入后再关门,每次新进一个人,司机就会把计时器清零并重新开始计时
      • 「上车的乘客」就是我们频繁操作事件而不断涌入的回调任务
      • 「1 分钟」就是计时器,它是司机决定「关门」的依据,如果有新的「乘客」上车,将清零并重新计时
      • 「关门」就是最后需要执行的函数
    • 应用:input 输入回调事件添加防抖函数后,只会在停止输入后触发一次
    • 实现方案:使用定时器,函数第一次执行时设定一个定时器,之后调用时发现已经设定过定时器就清空之前的定时器,并重新设定一个新的定时器,如果存在没有被清空的定时器,当定时器计时结束后触发函数执行

参考

underscore.js

前端性能优化原理与实践

文章穿梭机

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-
  2. 关注我的 GitHub,让我们成为长期关系
  3. 关注公众号「高级前端进阶」,每周重点攻克一个前端面试重难点,公众号后台回复「资料」 送你精选前端优质资料。

@Damon0820
Copy link

    关于实现2,betterFn只有在第一次执行的时候是立即执行的,以后再也不会执行。
    有时候我们会想要一种效果:在每次大于wait时间没有触发betterFn(可以想象成休息一段时间),再次触发betterFn的第一次也能立即执行。即重新定义第一次,这个第一次不是betterFn函数生成后的第一次执行,而是用户在每次进行频繁操作的第一次。在实现2,改动代码如下:
function debounce(fn, wait = 200, immediate = false) {
	let timer = null
	return function (...args) {
		if (timer) clearTimeout(timer)
		if (immediate && !timer) {
			fn.apply(this, args)
		}
		timer = setTimeout(() => {
                        // ------ 新增部分 start ------ 
			clearTimeout(timer)
			timer = null
                        // ------ 新增部分 end------ 
			fn.apply(this, args)
		}, wait)
	}
}

@Damon0820
Copy link

关于加强版throttle。
测试用例中描述是这样的:// 第一次触发 scroll 执行一次 fn,每隔 1 秒后执行一次函数 fn,停止滑动 1 秒后再执行函数 fn。这里有一个问题,最后停止滑动1秒后再执行的函数fn,是betterFn被触发最后一次的fn吗?有可能不是!
做个场景假设。1. 假设betterFn被触发多次,2. 倒数第二次被触发时,now - previous < wait在时间段内,则fn(记为fn^1)被加入延时器中,延时wait时间才执行。3. 最后一次被触发时,now - previous === wait, 则fn(记为fn^0)立即执行。4. 过了小于wait时间后,倒数第二次的延时器时间到了,fn^1执行。 问题出来了,最后一次执行的fn可能是betterFn的倒数第二次触发的回调。show code如下:

function throttle(fn, wait = 200) {
	let previous = 0
	let timer = null
	return function(...args) {
		let now = Date.now()
		if (now - previous > wait) {
                        // ------ 新增部分 start ------ 
			//  取消上个wait时间内,等待触发(但是过时)的回调。
			if (timer) {
				clearTimeout(timer)
				timer = null
			}
                        // ------ 新增部分 end------
			previous = now
			fn.apply(this, args)
		} else {
			if (timer) clearTimeout(timer)
			timer = setTimeout(() => {
				clearTimeout(timer)
				timer = null
				fn.apply(this, args)
			}, wait)
		}
	}
}

@toFrankie
Copy link

我认为加强版 throttle 那里有个小问题:

// 我拷出来放到编辑器格式化过的,并移除相关的注释(强迫症)
function throttle(fn, wait) {
  let previous = 0
  let timer = null

  return function (...args) {
    let now = Number(new Date())

    // 新增
    if (timer) clearTimeout(timer) // <--------------- 应该移到到这里

    if (now - previous < wait) {
      // 1️⃣
      // if (timer) clearTimeout(timer) // <---------- 原先的位置,应移到到外面一层
      timer = setTimeout(() => {
        previous = now
        fn.apply(this, args)
      }, wait)
    } else {
      // 2️⃣
      previous = now
      fn.apply(this, args)
    }
  }
}

考虑一种情况:

假设我在 4s 的时候,触发了一次,这时应该走 2️⃣ 逻辑。然后在 4.9s 的时候,又触发了一次这时候走的 1️⃣ 逻辑。然后时间到了 5s 触发了一次(后面就停止操作了),它会走 2️⃣ 逻辑一次,接着时间来的 5.9s,它还会执行一遍 fn.apply(this, args),因为在 5s 触发时,没有 clearTimeout()

当然上面举例都是比较理想的状态,实际场景可能几乎碰不到,但从严谨触发,应该在每次触发的时候都清除一下定时器。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants