前言
在日常开发中我们经常遇到一些频繁事件的触发情况,比如在滚动事件中需要做个复杂计算、防止一个按钮的多次点击提交操作、监听窗口的 resize
事件处理一些逻辑等等。这些需求都可以通过函数防抖动或者节流来实现。
防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户频繁触发这个函数,且每次触发函数的间隔小于等待时间,防抖的情况下只会调用一次,而节流的会间隔一定时间触发函数,接下来我们来分析一下两者的区别。
防抖
触发高频事件后 n 秒内函数只会执行一次,如果 n 秒内高频事件再次被触发,则重新计算时间
思路:大部分的实现方式是每次触发事件时清除定时器然后调用方法。这次我们换个思路优化一下,每次触发事件的时候只更新触发时间,然后每次就开启一个定时比对触发时间。
为了更直观的感受事件频繁调用,我们来写个例子:
下面是个 React 组件,添加了 onMouseMove
事件监听鼠标停留的坐标位置。
/** App.tsx */
const App = () => {
const [position, setPosition] = useState<string>('')
const [fontSize, setFontSize] = useState<number>(0)
const handleMove = (event: React.MouseEvent) => {
setPosition(`${event.pageX}, ${event.pageY}`)
// 随机设置当前坐标字体大小
setFontSize(Math.ceil(Math.min(Math.random() * 100, 100)))
}
return (
<div className={ styles.container }>
<div className={ styles.content } onMouseMove={handleMove} style={{ fontSize }}>
{ position }
</div>
<button className={ styles.btn }>{ fontSize }, 够大了</button>
</div>
)
}
我们来看看效果:
接下来我们修改
handleMove
添加防抖逻辑:
const handleMove = useCallback(debounce((event: React.MouseEvent) => {
setPosition(`${event.pageX}, ${event.pageY}`)
setFontSize(Math.ceil(Math.min(Math.random() * 100, 100)))
}, 2000), [])
新旧版比对
先来看下旧版的防抖实现代码逻辑,每次触发事件时清除当前延时,开启下一个定时:
export default function debounce(func, waitTime) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, waitTime);
};
}
接下来我们把逻辑优化一下,每次触发事件的时候只更新触发时间,开启定时,判断距离上次触发是否已经过了设置的等待时间,如果过了等待时间就执行 func
:
export default function debounce(func, waitTime) {
let timer, preTime, args, result;
let now = () => new Date().getTime()
let later = () => {
let passedTime = now() - preTime;
if (waitTime > passedTime) {
timer = setTimeout(later, waitTime - passedTime);
} else {
clearTimeout(timer);
timer = null;
// func 可能会有返回值
result = func.apply(this, args);
// 这个检查是必需的,因为 func 可能递归调用 debounce
if (!timer) {
args = null;
}
}
};
let debounced = (..._args) => {
args = _args
preTime = now();
if (!timer) {
timer = setTimeout(later, waitTime);
}
return result;
};
return debounced;
}
以上两种方式实现的效果如下:
立即执行
以上两个函数触发的时机都是在停止触发后 n 秒才执行 func
,如果想要反过来,先立刻执行函数,然后等到停止触发 n 秒后,才可以重新触发执行那要怎么做?
接下来我们按照上面的需求修改下 debounce
函数,添加一个 immediate
参数控制是否先立即执行,代码如下:
export default function debounce(func, waitTime, immediate = false) {
let timer, preTime, args, result;
let now = () => new Date().getTime()
let later = () => {
let passedTime = now() - preTime;
if (waitTime > passedTime) {
timer = setTimeout(later, waitTime - passedTime);
} else {
clearTimeout(timer);
timer = null;
if (!immediate) {
result = func.apply(this, args);
}
// 这个检查是必需的,因为 func 可能递归调用 debounce
if (!timer) {
args = null;
}
}
};
let debounced = (..._args) => {
args = _args
preTime = now();
if (!timer) {
timer = setTimeout(later, waitTime);
if (immediate) {
result = func.apply(this, args);
}
}
return result;
};
return debounced;
}
这里设置了 immediate: true
最终效果如下:
取消防抖
现在又有一个问题,比如 debounce
的间隔时间是 10 秒,immediate
为 true
,这样的话,只有等 10 秒后才能重新触发下一个事件。如果能有一个按钮,点击后,取消防抖,这样再去触发 debounce
,是不是就又可以立刻执行事件了?按照上面的思路,我们给 debounce
添加一个取消逻辑吧。
export default function debounce(func, waitTime, immediate = false) {
...
debounced.cancel = () => {
clearTimeout(timer);
timer = args = null;
};
return debounced;
}
最终效果如下:
节流
高频事件触发,但在 n 秒内只会执行一次,所以节流会稀释函数的执行频率
思路:节流的实现,有两种主流的实现方式
- 标记触发时间,每次触发事件时比对当前时间
- 设置定时器:每次触发事件时都判断当前是否有等待的定时器
另外以上两种方式效果上会有所不同,效果分别是首次执行以及结束后执行,实现的方式也有所不同。我们通过参数 leading
表示首次是否执行,trailing
表示结束后是否再执行一次。
标记触发时间
当触发事件的时候,计算距离上次触发时间(当前时间戳 - 上次标记的时间戳: 初始值为 0,如果大于设置的时间间隔,就执行函数,并更新触发时间,否则,不执行。
export default function throttle(func, waitTime) {
let preTime = 0
const now = () => new Date().getTime()
return (...args) => {
if (now() - preTime > waitTime) {
func.apply(this, args);
preTime = now();
}
}
}
最终效果如下:
设置定时器
当触发事件的时候,设置一个定时器,再触发事件的时候,判断定时器是否存在,存在就不执行,直到触发定时器,执行函数 func
,清空定时器,这样下次触发事件就会开启一个新的定时器。
export default function throttle(func, waitTime) {
let timer
return (...args) => {
if (!timer) {
timer = setTimeout(() => {
timer = null;
func.apply(this, args)
}, waitTime)
}
}
}
最终效果如下:
从上面的最终效果可以看出两种方式的区别:
- 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
- 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后还会再执行一次事件
合二为一
如果结合以上两种方式的逻辑,我们就可以实现一个能立刻执行,停止触发后还能在最后再执行一次,且看代码如下:
export default function throttle(func, waitTime) {
let timer, args, result;
let preTime = 0;
const now = () => new Date().getTime()
let later = () => {
preTime = now()
timer = null;
result = func.apply(this, args);
if (!timer) {
args = null;
}
};
let throttled = (..._args) => {
let passedTime = now() - preTime;
args = _args;
// 修改过系统本地时间,passedTime 会小于 0
if (passedTime > waitTime || passedTime < 0) {
clearTimeout(timer);
timer = null;
preTime = now();
result = func.apply(this, args);
if (!timer) {
args = null;
}
} else if (!timer) {
timer = setTimeout(later, waitTime - passedTime);
}
return result;
};
return throttled;
}
最终效果如下:
优化
我们还可以给 throttle
添加一个 options
来控制是标记触发时间还是设置定时器的方式,我们约定:
leading:false
表示标记触发时间方式,trailing: false
表示禁用设置定时器方式,且两者不应该同时禁用。代码逻辑如下:
export default function throttle(func, waitTime, options = { }) {
let timer, args, result;
let preTime = 0;
const now = () => new Date().getTime()
let later = () => {
preTime = options.leading === false ? 0 : now();
timer = null;
result = func.apply(this, args);
if (!timer) {
args = null;
}
};
let throttled = (..._args) => {
if (!preTime && options.leading === false) {
preTime = now()
};
let passedTime = now() - preTime;
args = _args;
if (passedTime > waitTime) {
clearTimeout(timer);
timer = null;
preTime = now();
result = func.apply(this, args);
if (!timer) {
args = null;
}
} else if (!timer && options.trailing !== false) {
timer = setTimeout(later, waitTime - passedTime);
}
return result;
};
return throttled;
}
这里设置了 leading:false
最终效果如下:
取消节流
同样的,在 throttle
我们也加个 cancel
方法吧
export default function throttle(func, waitTime, options = { }) {
...
throttled.cancel = () => {
clearTimeout(timer);
preTime = 0;
timer = null;
};
return throttled;
}