跳到主要内容

防抖和节流

防抖

lodash.debounce

underscore - debounce

最初接触实现一个防抖函数的需求,是在前端封装 React 组件的过程中,当时是要实现一个搜索下拉框,根据输入提示搜索内容。根据<input>input事件来监听用户输入,并调用后端接口传递输入信息来获取提示信息(实际上要实现更好的搜索输入提示确实需要考虑很多情况,这里只考虑利用防抖来控制接口调用的实现)。

实时编辑器
结果
Loading...

抖动(bounce)其实来源于电路中的名词 —— 接点弹跳,大概就是开关接触的时候发出的连续的电流信号会对电路造成影响,通过"去弹跳"(debounce)来合并电流信号的发出,避免对电路产生影响。

衍生到前端领域,可以理解页面在连续请求后端接口的过程中,前端对于请求数据的展示会连续不断更新,这期间会导致页面中渲染出来的提示内容不稳定;同时连续不断的请求接口也会增加服务器接口处理的压力。

JS 防抖的基本思想是,对于连续调用函数的情况,最后限制只会真正执行一次函数。所以要对一个函数进行防抖限制,可以采用以下步骤:

  • 首先在原函数内部创建一个定时器setTimeout,设置经过一定延迟后执行函数
  • 在每次执行函数的时候,清除上一次设置的定时器,并设置一个新的定时器;如果是短时间内连续调用的情况,通过清除上一次设置的定时器来保证始终只会执行最后一次设置的定时器中的回调函数
实时编辑器
结果
Loading...

为了复用防抖的逻辑,可以封装一个高级函数,根据指定延迟执行时间和指定函数来生成一个防抖函数。

const debounced = (fn, timeout) => {
let timerId;
return function() {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn.call(this, ...arguments);
}, timeout);
};
};

精准防抖

对于防抖函数有一个常见的冲突就是第一次调用是否要立即执行函数,例如上面的input事件,在我们输入第一个字符的时候是否要去立即执行回调函数,接下来的输入才去使用防抖控制其只执行最后一次。为此我们需要在高阶函数的参数中加上一个参数immediate

const debounced = (fn, timeout, immediate) => {
let timerId;
return function() {
// 判断是否第一次执行,这一步必须要下面的timerId = null来配合
if (immediate && !timerId) {
fn.call(this, ...arguments);
}

// 清除上一次的定时任务
if (timerId) {
clearTimeout(timerId);
}

timerId = setTimeout(() => {
fn.call(this, ...arguments);
// 清除最后的定时器Id
timerId = null;
}, timeout);
};
};

节流

lodash.throttle

underscore- throttle

throttle,节流放在前端领域内,经常遇到的情况是,某些 DOM 事件会在没有间隔的情况下反复触发,例如页面的scroll事件,短期内滚动鼠标滚轮可能立即造成上百次的onScroll事件触发,而上文说过,这些 DOM 事件都会被放到任务队列中等到执行,如果不加以限制,会造成任务队列占用内存空间增加,同时也影响其它任务队列中代码的执行效率。

节流的思想其实是在防抖的基础上放松一点限制,防抖限制一段时间内连续调用的话最后只会执行一次函数,节流是在一段时间内连续调用的话,控制函数在这期间每隔一定的延迟才去执行,而不是反复无间隔执行。

要实现函数节流执行,一种思路是需要记录上一次函数执行的时间戳,每一次执行函数和上次执行时间进行对比,如果小于限制的延迟时间,就不予执行,如果大于延迟时间,就执行并且更新执行时间。

另一种思路是第一次执行利用setTimeout设置一个定时器,等待延迟时间后自动执行回调函数,并在回调函数内部清除设置的定时器 Id,以后每次执行根据定时器 Id 检查定时器是否存在,如果存在就不做任何操作,如果不存在则设置一个新的定时器。

仍然可以利用一个高阶函数根据指定延迟时间和指定函数生成一个节流函数:

// 利用时间戳
const throttled = (fn, delay) => {
// 第一次执行
let lastInvokeTime = 0;

return function() {
let timeConsumed = Date.now() - lastInvokeTime;
if (timeConsumed >= delay) {
// 更新当前执行时间
lastInvokeTime = Date.now();
fn.call(this, ...arguments);
}
};
};

// 利用setTimeout
const throttled = (fn, timeout) => {
let timerId;
return function() {
if (!timerId) {
timerId = setTimeout(() => {
timerId = null;
fn.call(this, ...arguments);
}, timeout);
}
};
};

精准节流

如果总结一下上述两种实现节流函数的不同点,会发现:

  • 利用时间戳方式控制更加精确,setTimeout实际执行的时间需要算上 CPU 延迟,任务队列中其它任务执行的时间等,并且时间戳方式的控制可以保证首次调用就立即执行;
  • 利用setTimeout的方式更为简洁,setTimeout不能保证首次调用立即执行,但是setTimeout总是能保证节流之后会执行一次,类似于防抖的效果。
const throttled = (fn, delay) => {
let lastInvokeTime = 0,
timerId;
return function() {
// 保证立即执行一次
let timeout = Date.now() - lastInvokeTime;
if (timeout >= delay) {
lastInvokeTime = Date.now();
fn.call(this, ...arguments);
} else {
// 这部分是保证最后执行一次
if (timerId) {
clearTimeout(timerId);
}

timerId = setTimeout(() => {
lastInvokeTime = Date.now();
timerId = null;
fn.call(this, ...arguments);
}, delay);
}
};
};

为此,节流函数中引入leadingtrailing两个概念:

  • leading:标识首次调用立即执行函数
  • trailing:标识节流之后再额外触发一次函数执行,类似于防抖的效果

要实现leadingtrailing的效果,除了需要添加额外的参数控制,还需要将上述两种实现相结合:

  • 利用时间戳来控制执行规律,并根据leading判断首次调用是否立即执行函数;
  • 利用setTimeout根据trailing保证最后执行一次
const throttled = (fn, delay, leading = true, trailing = true) => {
let lastInvokeTime = 0,
timerId;
return function() {
if (!lastInvokeTime && leading === false) {
lastInvokeTime = Date.now();
}
let timeout = Date.now() - lastInvokeTime;
if (timeout >= delay) {
lastInvokeTime = Date.now();
fn.call(this, ...arguments);
} else if (trailing) {
// 如果设置trailing,每次必须要清除上一次设置的定时器,类似于防抖的原理
if (timerId) {
clearTimeout(timerId);
}

timerId = setTimeout(() => {
// 这里需要根据是否立即执行来设置最后执行时间戳
lastInvokeTime = leading ? Date.now() : 0;
timerId = null;
fn.call(this, ...arguments);
}, delay);
}
};
};