函数的防抖

函数防抖中的抖动就是执行的意思,而一般的抖动都是持续的、多次的、频繁的执行某一段代码。函数防抖就是某函数持续多次执行,我们希望让它冷静下来再执行。也就是当持续触发事件的时候,函数是完全不执行的,等最后一次触发结束的一段时间之后,再去执行。在前端开发中经常会遇到这种频繁的事件触发,比如:

  • window 的 resize、scroll
  • mousedown、mousemove
  • keyup、keydown 、等等……

上面简单例子中鼠标从左边滑到右边就触发了 165 次。

若是复杂的回调函数或是网络请求,在 1 秒触发了 60 次,那么每个回调就必须在 1000 / 60 = 16.67ms 内完成,否则就会有卡顿出现。严重的影响了用户体验与应用的性能。为了解决这个问题,一般有两种解决方案:

  1. 函数的防抖(debounce)
  2. 函数的节流(throttle)

函数的防抖(debounce)

函数防抖和节流,都是控制事件触发频率的方法。其中防抖的原理就是:事件尽管触发,但是在事件触发 n 秒后才执行,如果你在一个事件触发的 n 秒内又触发相同事件,那我就以新的事件 n 秒后才执行,舍弃掉上一次事件触发执行操作。简单地说就是频繁的触发事件完毕后的 n 秒内不再触发同一事件,函数才会执行


延迟执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<div class="box"></div>
<script>
var box = document.querySelector(".box")
function getUserAction(event) {
console.log("当前的this指向(对象元素)",this)
console.log("event 事件对象",event)
}
// 防抖函数
function debounce(fun, wait) {
var timerId; // 定义一个延时器id
return function(event) {
var e = event || window.event;

var context = this
// 改变传递过来fun的内部this指向当前的this(调用者)此步操作是没有执行 fun 方法的
fun = fun.bind(context, e)

// 为了在在wait秒之内,只触发一次,就应该把之前的延时器清除,
clearInterval(timerId)

// 重新设置新的延时器
timerId = window.setTimeout(fun, wait)
}
}
box.onmousemove = debounce(getUserAction, 1000)
</script>

这种方法有一个不足的地方是:

因为使用了 setTimeout 延迟执行,导致func始终是异步执行的

通过 func.bind(context, event) 调用后的返回值赋给变量,最后再 return 的时候,值将会一直是 undefined,无法实现防抖函数返回返回值功能

因此延申了下面的立即执行的方式,进而可以获取到返回值


立即执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function debounce(fun, wait) {
var timerId;
var result;
return function(event) {
var e = e || window.event;
var context = this;
//为了在wait秒之内,只触发一次,就应该把之前的延时器清除
if (timerId) {
clearInterval(timerId)
}
// 接下来判断是否过了那段延迟的时间 判断依据是 timerId 为空(undefined null)
if (!timerId) {
fun().apply(context, e);
}
// 设置延迟器
timerId = setTimeout(function () {
timerId = null;
}, 1000)
}
}

封装函数

将两者封装成一个函数通过变量 immediate来控制是立即执行(true)还是延迟执行(false)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function debounce(fun, wait, immediate) {
var timerId;
var result;
var debounced = function(event) {
var e = event || window.event;
var timerId;
var context = this;
if (timerId) {
clearInterval(timerId)
}

if (immediate) { // 立即执行,后延迟

if(!timerId) {
fun().apply(context, e)
}

timerId = setTimeout(function() {
timerId = null;
}, wait)

} else { // 先延迟,后执行
setTimeout(function() {
fun().apply(context,e)
}, wait)
}
}
return debounced;
}
box.onmousemove = debounce(getUserAction, 1000, true);

取消

在真实的开发中,函数的防抖应用场景中经常需要取消防抖函数的功能,如:在微博中下拉加载功能(每次用户的下拉加载操作应用都要向服务器发送网络请求获取最新的微博信息)若下拉操作只有等 10 秒后才能重新触发事件,并且可能因为设备网络信号原因需要在10秒内取消本次加载操作的应用场景。

将取消函数作为防抖函数的取消方法(属性)一并返回就可以实现取消防抖函数的效果即可

1
2
3
4
5
6
7
8
9
10
11
12
debounced.cancel = function () {
clearTimeout(timerId)
timerId = null
}

var d = debounce(getUserAction, 1000, true)
box.onmousemove = d;

document.getElementsByTagName("button")[0].onclick = function () {
console.log("取消防抖")
d.cancel()
}

函数的节流(throttle)

让函数有节制地执行,而不是毫无节制的触发一次就执行一次

与函数防抖不同的地方是:

      函数节流:一旦连续触发该事件(设定),会隔特定的时间内执行一次。因此只要连续触发,执行次数不一定只有一次。
        
      函数防抖:一旦连续触发该事件,是不会触发执行函数。必须保证当前事件触发后,并且没有下一次事件的触发(在指定时间下),才可以触发事件。

使用定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function throttle(func, wait) {
var timerId;

var throttled = function(event) {
var e = event || window.event;
var context = this;
if (!timerId) {
timerId = setTimeout(function() {
timerId = null
func.apply(context, e);
}, wait)
}
}
return throttled;
}

使用时间戳

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function throttle(func, wait) {
// 储存上一次执行的时间戳, 第一次赋值为0,意味着第一次肯定能执行
var previous = 0;

var throttled = function(event) {
var e = event || window.event;
var context = this;
// 获得当前的时间戳(毫秒)
var timeNow = new Date().getTime();
if (timeNow >= previous + wait) {
fn.apply(context, e)
previous = timeNow
}
}
return throttled;
}

比较两者方式的不同

  1. 使用时间戳事件会立刻执行,使用定时器事件会在 n 秒后第一次执行
  2. 使用时间戳事件停止触发后没有办法再执行事件,使用定时器事件停止触发后依然会再执行一次事件

优化

这时那我们设置个 options 作为第三个参数,然后根据传的值判断到底哪种效果,我们约定:

  • leading:false 表示禁用第一次执行
  • trailing: false 表示禁用停止触发的回调
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
function throttle(func, wait, options) {
// 计时器id, this, 参数
var timeout, context, args
// 上一次的时间
var previous = 0;
// 判断是否设置配置选项options
if (!options) {
options = {};
}
// 创建节流函数
var throttled = function() {
// 获取时间戳
var now = new Date().getTime();
// previous 为 0 即第一次调用,且 禁用第一次执行时
if (!previous && options.leading === false) {
previous = now;
}
// 事件触发间隔与 规定间隔时间(wait) 差值
var remaining = wait - (now - previous);

context = this;

args = arguments;
// 如果没有剩余的时间了
// 若禁用第一次执行,第一次执行时 remaining = wait 不会进入该判断
if (remaining <= 0) {
// 清除上一次计时器
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
// 更新本次时间
previous = now;

// 立即调用函数
func.apply(context, args);

// 清理工作 js 的垃圾回收
if (!timeout) {
context = args = null;
}
// 还有剩余时间,但是想添加一个最后指定时间的触发回调
} else if (!timeout && options.trailing !== false) {
// 这里是函数防抖
// !timeout 还未添加回调,已经添加结束回调不执行次代码
// ptions.trailing !== false 不禁用停止触发的回调
// 创建最后一次触发回调
timeout = setTimeout(later, remaining);

// 清理工作 js 的垃圾回收
if (!timeout) {
context = args = null;
}
}
};

var later = function() {
// 事件结束后根据是否立即执行重新设置previous
// 无需立即执行将previous设置成0
// 需要立即执行previous设置成本次方法触发的时间
previous = options.leading === false ? 0 : new Date().getTime();
timeout = null;
func.apply(context, args);
};

return throttled;
}

注意:上面的代码的实现中有这样一个问题:就是 leading:false 和 trailing: false 不能同时设置。(在真实开发中不可能遇到leading:false 和 trailing: false都需要为false的情况)

如果同时设置的话,比如当你将鼠标移出的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再移入的话,就会立刻执行,就违反了 leading: false,bug 就出来了,所以,这个 throttle 只有三种用法:

1
2
3
4
5
6
7
container.onmousemove = throttle(getUserAction, 1000);
container.onmousemove = throttle(getUserAction, 1000, {
leading: false
});
container.onmousemove = throttle(getUserAction, 1000, {
trailing: false
});

取消

在 debounce 的实现中,我们加了一个 cancel 方法,throttle 我们也加个 cancel 方法:

1
2
3
4
5
throttled.cancel = function() {
clearTimeout(timeout);
previous = 0;
timeout = null;
}