【若川视野 x 源码共读】第25期 | 跟着underscore学防抖

622 阅读8分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

1.学习内容

2.“防抖”关键词出现的背景

在各种各样的浏览器事件中,需要特别关注的事件是那些容易过度触发的事件。

事件类型事件方法名
window上的事件scroll,resize 等
鼠标事件mousedown、mousemove、mouseover 等
键盘事件keyup、keydown

以上列出的事件都是存在被频繁触发的风险,频繁触发可能会引发页面的抖动甚至卡顿。为了规避这种情况,需要一些手段来控制事件被触发的频率。就是在这样的背景下,throttle(事件节流)和 debounce(事件防抖)出现了。

3."防抖"的定义

防抖函数 debounce 指的是某个函数在某段时间内,无论触发了多少次回调,都只执行最后一次。

防抖的中心思想在于:我会等你到底。在某段时间内,不管你触发了多少次回调,我都只认最后一次。

生动化的解释:

(1)假定在做公交车时,司机需等待最后一个人进入后再关门。

(2)每次新进一个人,司机就会把计时器清零并重新开始计时,重新等待 1 分钟再关门。

(3)如果后续 1 分钟内都没有乘客上车,司机会认为乘客都上来了,司机会认为确实没有人需要搭这趟车了,将关门发车。

上述例子和防抖函数的对应关系:

(1)「上车的乘客」就是我们频繁操作事件而不断涌入的回调任务;

(2)「1 分钟」就是计时器,它是司机决定「关门」的依据,如果有新的「乘客」上车,将清零并重新计时;

(3)「关门」就是最后需要执行的函数。

4.防抖函数的实现

4.1 准备工作

要模拟一个块级元素的滚动操作,定义一个容器container限制高度为300px,内部定义4个小块加起来总高度超出container即可。代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title></title>
  <style>
		.container {
			border: 1px solid #4DC7EC;
			width: 200px;
			height: 300px;
			overflow-y: auto;
			text-align: center;
			margin: 0 auto;
		}
		.block {
			border: 1px solid #4BA946;
			margin-left: 50px;
			width: 100px;
			height: 100px;
		}
  </style>
</head>
<body>
	<div class="container">
		<div class="block"></div>
		<div class="block"></div>
		<div class="block"></div>
		<div class="block"></div>
	</div>
</body>
<script src="./src/index.js"></script>
</html>

效果如下:

为container监听scroll事件(index.js代码):

var container = document.getElementsByClassName('container')[0];
function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this',this)
	console.log('滚动了')
}
container.addEventListener('scroll', handleScroll)

轻轻触发一下滚动就调用了好多次handleScroll:

4.2 版本1:简单地增加延时

var container = document.getElementsByClassName('container')[0];
function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this',this)
	console.log('滚动了')
}
function debounce(func,wait) {
	let timeout
	return function() {
		clearTimeout(timeout)
		timeout = setTimeout(func,wait)
	}
}
container.addEventListener('scroll', debounce(handleScroll,1000))

debounce函数最后得返回一个函数,函数中利用setTimeout延迟执行了被包装的函数。看一下执行的效果:

轻轻滚动一下,控制台只输出了一次。观察输出的内容,发现如下问题:

(1)事件对象获取不到了

(2)this指针由指向container变为指向了window

继续优化,来解决这些问题

4.3 版本2:解决this指针的指向问题

var container = document.getElementsByClassName('container')[0];
function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this',this)
	console.log('滚动了')
}
function debounce(func,wait) {
	let timeout
	return function() {
		const context = this;
		clearTimeout(timeout)
		timeout = setTimeout(func.apply(context),wait)
	}
}
container.addEventListener('scroll', debounce(handleScroll,1000))

this指针之所以改变了是因为把setTimeout包裹了,由于setTimeout是在全局作用下实现的,是window上的方法,所以this指针指向了window。这个版本先保存了调用者上下文context,然后使用apply方法指定func函数的this。

我们看一下调用结果:

4.4 版本3 解决event对象为undefined问题

var container = document.getElementsByClassName('container')[0];
function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this',this)
	console.log('滚动了')
}
function debounce(func,wait) {
	let timeout
	return function() {
		const args = arguments
		const context = this;
		clearTimeout(timeout)
		timeout = setTimeout(func.apply(context,args),wait)
	}
}
container.addEventListener('scroll', debounce(handleScroll,1000))

版本1中被debounce包裹后的handleScroll之所以不能输出事件对象是因为debounce函数返回的函数中在调用func时,没有给其传递参数。而如上代码获取了包裹函数的arguments参数(一个类数组对象),然后调用func(handleScroll)的时候传递进去。

下面的代码在运行效果上和上面的代码等价:

var container = document.getElementsByClassName('container')[0];
function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this',this)
	console.log('滚动了')
}
function debounce(func,wait) {
	let timeout
	return function(e) {
		const context = this;
		clearTimeout(timeout)
		timeout = setTimeout(func.apply(context,[e]),wait)
	}
}
container.addEventListener('scroll', debounce(handleScroll,1000))

可能比较好理解,但是没有直接使用arguments简洁。

代码的运行效果如下:

4.5 版本4 立刻执行

如果希望立刻执行函数,然后等待一段时间才可以触发重新执行则需要继续对代码修改:

var container = document.getElementsByClassName('container')[0];

function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this', this)
	console.log('滚动了')
}

function debounce(func, wait, immediate) {
	let timeout
	return function() {
		const args = arguments
		const context = this;
		if (timeout) {
			clearTimeout(timeout)
		}
		if (immediate) {
			var callNow = !timeout;
			timeout = setTimeout(function() {
				timeout = null;
				func.apply(context, args)
			}, wait)
			if (callNow) {
				func.apply(context, arguments)
			}
		} else {
			timeout = setTimeout(func.apply(context, arguments), wait)
		}
	}
}
container.addEventListener('scroll', debounce(handleScroll, 1000,true))

整体的逻辑是判断是否立刻执行,两个分支。如果不是立刻执行则和原来的代码一样。立刻执行的时候要判断是否可以立刻调用,只有当前timeout为假的情况下也就是没有延时的时候才可以立刻调用。否则不立刻调用,开始定时调用。

4.6 版本5 立刻调用的时候要返回值

如果被debounce包装的函数可以有返回值,想把返回值也获取到

var container = document.getElementsByClassName('container')[0];

function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this', this)
	console.log('滚动了')
	return 1
}

function debounce(func, wait, immediate) {
	let timeout,result
	return function() {
		const args = arguments
		const context = this;
		if (timeout) {
			clearTimeout(timeout)
		}
		if (immediate) {
			var callNow = !timeout;
			timeout = setTimeout(function() {
				timeout = null;
				func.apply(context, args)
			}, wait)
			if (callNow) {
				result = func.apply(context, arguments)
			}
		} else {
			timeout = setTimeout(func.apply(context, arguments), wait)
		}
		console.log('result', result)
		return result
	}
}
container.addEventListener('scroll', debounce(handleScroll, 1000,true))

例如上面的代码handleScroll当中但会了一个1,则立刻执行的时候会得到这个返回值,然后返回。下图展示了运行效果:

立刻执行的时候有返回值,然后进入到异步的触发则没有返回值,打印不出来1。

另外感觉事件监听这种形式的用法没办法利用返回值,例如:

container.addEventListener('scroll', debounce(handleScroll, 1000,true))

这里handleScroll的返回值怎么利用呢?没太想明白~像一些复杂计算的情况可以:

const res = debounce(computeSomething, 1000,true)

4.7 版本6:取消防抖

希望能取消 debounce 函数,若immediate 为 true,则只有等 10 秒后才能重新触发事件,希望有一个按钮,点击后,取消防抖,这样再去触发,就可以立刻执行。

var container = document.getElementsByClassName('container')[0];

function handleScroll(e) {
	console.log('事件对象', e)
	console.log('this', this)
	console.log('滚动了')
	return 1
}

function debounce(func, wait, immediate) {
	let timeout,result
	const debounced = function() {
		const args = arguments
		const context = this;
		if (timeout) {
			clearTimeout(timeout)
		}
		if (immediate) {
			var callNow = !timeout;
			timeout = setTimeout(function() {
				timeout = null;
				func.apply(context, args)
			}, wait)
			if (callNow) {
				result = func.apply(context, arguments)
			}
		} else {
			timeout = setTimeout(func.apply(context, arguments), wait)
		}
		console.log('result', result)
		return result
	}
	debounced.cancel = function(){
		clearTimeout(timeout)
		timeout = null;
	}
	return debounced
}
const betterScroll = debounce(handleScroll, 2000,true)
container.addEventListener('scroll', betterScroll)
function cancelDebounce() {
	betterScroll.cancel()
}

debounce函数中不再是一个匿名函数了,而是名为debounced的函数,并在debounced增加一个cancel属性,为一个函数,清除定时器。

在html代码中增加一个按钮,用于取消防抖:

<div style="margin:0 auto; width:200px;text-align: center;">
		<button onclick="cancelDebounce()">取消防抖</button>
</div>

4.8 underscore的debounce

import restArguments from './restArguments.js';
import now from './now.js';

// When a sequence of calls of the returned function ends, the argument
// function is triggered. The end of a sequence is defined by the `wait`
// parameter. If `immediate` is passed, the argument function will be
// triggered at the beginning of the sequence instead of at the end.
export default function debounce(func, wait, immediate) {
  var timeout, previous, args, result, context;

  var later = function() {
    var passed = now() - previous;
    if (wait > passed) {
      timeout = setTimeout(later, wait - passed);
    } else {
      timeout = null;
      if (!immediate) result = func.apply(context, args);
      // This check is needed because `func` can recursively invoke `debounced`.
      if (!timeout) args = context = null;
    }
  };

  var debounced = restArguments(function(_args) {
    context = this;
    args = _args;
    previous = now();
    if (!timeout) {
      timeout = setTimeout(later, wait);
      if (immediate) result = func.apply(context, args);
    }
    return result;
  });

  debounced.cancel = function() {
    clearTimeout(timeout);
    timeout = args = context = null;
  };

  return debounced;
}

看完感觉这个高大上,但是可读性不是很强。抽象出later函数,还是一个递归的函数。在debounced函数中调用了later。理解的时候要从调用处开始,debounced调用later,所以先从debounced看。

在debounced给previous赋值为当前时间,表示本次调用的时间。然后判断timeout,不存在timeout则等待wait后执行later。然后判断是否是立刻调用,如果是则调用。

在later函数中,计算经过了多长时间passed 。如果wait大于passed说明延时还没有结束,继续延时,但是时间更新为wait-passed 也就是剩余的时间。否则延时时间已经到了可以执行函数了。

5.收货总结

1.了解闭包的应用场景之一(实现防抖)

2.了解防抖的背景,含义,应用场景和实现方法

3.了解underscore函数库中防抖函数的实现思路