将防抖节流运用于实际场景中
目录
- 防抖
- 节流
- 应用场景
- 案例(含特例)
- 总结
- 拓展:执行顺序
一、防抖
对于短时间内连续触发的事件(如:滚动事件),防抖的含义就是让某个时间期限(如 1000 毫秒)内,事件处理函数只执行一次。
思路:在第一次触发事件时,不立即执行函数,而是给出一个期限值(delay)比如 200ms,然后:
- 如果在 delay 内没有再次触发事件,那么就执行函数。
- 如果在 delay 内再次触发事件,那么当前的计时取消,重新开始计时。
效果:如果短时间内大量触发同一事件,只会执行一次函数。
实现:既然前面都提到了计时,那实现的关键就在于setTimeout这个函数,由于还需要一个变量来保存计时,考虑维护全局纯净,可以借助闭包来实现。
// 以滚动事件为例子
function debounce(fn,delay){
let timer = null // 借助闭包
return function() {
if(timer){
clearTimeout(timer)
}
timer = setTimeout(fn,delay) // 简化写法
}
}
// 重写事件
function showTop () {
var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
console.log('滚动条位置:' + scrollTop);
}
window.onscroll = debounce(showTop,200) // 实际使用根据需要来配置间断值
出现问题:如果在限定时间段内,不断触发滚动事件(比如某个用户闲着无聊,按住滚动不断的拖来拖去),只要不停止触发,理论上就永远不会输出当前距离顶部的距离。
要求:即使用户不断拖动滚动条,也能在某个时间间隔之后给出反馈,这个时候可以考虑节流。
二、节流
思路:设置类似控制阀门一样定期开放的函数,也就是让函数执行一次后,在某个时间段内暂时失效,过了这段时间后再重新激活(类似于技能冷却时间),也利用了 setTimeout 的异步执行机制。
效果:如果短时间内大量触发同一事件,那么在函数执行一次之后,该函数在指定的时间期限内不再工作,直至过了这段时间才重新生效。
实现
方式一:【定时器】借助 setTimeout 来实现,利用状态位 valid 来表示当前函数是否处于工作状态。
function throttle(fn,delay){
let valid = true
return function() {
if(valid){
// 利用异步执行的原理
setTimeout(() => {
fn()
valid = true;
}, delay)
}
// 工作时间,执行函数并且在间隔期内把状态位设为无效
valid = false
}
}
// 重写事件
function showTop () {
var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
console.log('滚动条位置:' + scrollTop);
}
window.onscroll = throttle(showTop,1000)
方式二:【时间戳】利用时间戳差值是否大于指定间隔时间来做判定
function throttle(fn, delay) {
let prev = Date.now()
return function () {
let now = Date.now()
if (now - prev >= delay) {
fn()
prev = Date.now()
}
}
}
方式三:【定时器 + 时间戳】在节流函数内部使用开始时间 startTime、当前时间 curTime 与 delay 来计算剩余时间 remaining,以作为标志来判断。
function throttle(fn, delay) {
let timer = null
let startTime = Date.now()
return function () {
let curTime = Date.now()
let remaining = delay - (curTime - startTime)
clearTimeout(timer)
if (remaining <= 0) {
fn()
startTime = Date.now()
} else {
timer = setTimeout(fn, remaining)
}
}
}
三、应用场景
防抖(debounce):只执行最后一次
- 搜索框搜索输入,在用户在不断输入值时,如果只需用户最后一次输入完再发送请求,可以用防抖来节约请求资源。
- 手机号、邮箱验证输入检测 (change、input、blur、keyup 等事件触发,每次键入都会触发)的场景。
- window 触发 resize 的时候,不断的调整浏览器窗口大小会不断的触发这个事件,但实际只需窗口调整完成后再计算窗口大小,为防止重复渲染,可以用防抖来让其只触发一次。
- 鼠标的 mousemove、mouseover 事件。
- 网页导航条的多次点击的情况,只需要最后一次跳转。
- 等等
节流(throttle):对那些耗性能的高频操作减少执行次数
- 搜索框搜索联想的功能。
- 鼠标不断点击触发的事件,比如轮播图的切换。
- 监听滚动事件 onscroll,比如是否滑到底部自动加载更多,用 throttle 来判断。
- 提交按钮,对于用户高频点击提交的情况,可以防止表单重复提交。
- DOM 元素的拖拽功能实现(mousemove)
- 射击游戏的 mousedown/keydown 事件(单位时间只能发射一颗子弹)
- Canvas 模拟画板功能(mousemove)
- 计算鼠标移动的距离(mousemove)
- 等等
四、案例(含特例)
1 防抖:输入案例
- 输入框input在输入过程中会多次触发,但是我们只要最后一次 —— 防抖。
- 注意this的指向,因为内部函数最终是input调用的,因此箭头函数内的this是指向input,但是fn函数传过来的this是指向window的,因此需要解决(下面两种方法均可)
<input type='text' />
<script>
var input = document.querySelector('input');
/* 如果不加防抖:在输入过程中会一直触发
* input.oninput = function () {
* console.log(input.value);
*/ }
// 解决方法:防抖 ----> 利用setTimeout的标识timer来判断是否开启的定时器
function debounce(fn, delay) {
let timer = null; // 闭包
this.a = 1;
return function () {
if (timer != null) {
clearTimeout(timer); // 标识作为参数传递
}
this.a = 2;
timer = setTimeout(() => {
// 箭头函数: <input type='text' /> '1' 此时可以该匿名函数内的this参数
// 非箭头函数:Window {window: Window, self: Window, document: document, name: '', location: Location, …} '1'
console.log(this.a, "1"); // 2
// 但是对于外部传进来函数的的this,指的还是window
fn(); // Window {window: Window, self: Window, document: document, name: '', location: Location, …} '2'
}, delay)
}
}
input.oninput = debounce(function () { console.log(this, "2"); }, 1000);
// 改成 写法1
function debounce(fn, delay) {
let timer = null; // 闭包
return function () {
if (timer != null) {
clearTimeout(timer); // 标识作为参数传递
}
timer = setTimeout(() => { // 现在使用箭头函数,setTimeout里面指的是整个return函数作用域
// 把当前的this替换那边函数的this
fn.call(this);
// 或者 fn.bind(this)();
}, delay)
}
}
input.oninput = debounce(function () { console.log(this.value); }, 1000);
// 写法2
function debounce(fn, delay) {
let timer = null; // 闭包
return function () {
if (timer != null) {
clearTimeout(timer); // 标识作为参数传递
}
// 对setTimeout绑定bind(apply、call不行,他们会立即执行)
timer = setTimeout(function () { // 箭头函数
// 这时候setTimeout异步指向window
// 对setTimeout绑定bind后指向input
// console.log(this) 为<input type='text' />
fn.call(this); // 在用这个this替换传过来的this
}.bind(this), delay)
}
}
input.oninput = debounce(function () { console.log(this.value); }, 1000);
</script>
2 节流:滚动案例
- 滚动条滚动过程中会多次触发,但是我们不需要太多次——节流。
<br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br>
<script>
// 节流:减少执行次数
// 例如:这样设置后,1s只能执行一次了
let flag = true;
window.onscroll = function () {
// 1.第一次为true肯定执行
if (flag) {
// 2.setTimeout是异步函数,遇到异步函数,进入event table,1s后进入任务队列
// 4.当同步任务执行完毕,到任务队列执行该函数,执行后flag为true
setTimeout(() => {
console.log('123');
flag = true;
}, 1000)
}
// 3.此时flag为false,再次触发时不执行
flag = false;
}
// 写法1
function throttle(fn, delay) {
let flag = true;
return function () {
if (flag) {
setTimeout(() => {
fn.call(this);
flag = true;
}, delay)
}
flag = false;
}
}
window.onscroll = throttle(function () { console.log(window.pageYOffset) }, 1000)
</script>
3 每个请求必须发送的问题
对于购买页来说,购买页改变任何一个选项,都会调用查价接口,然后显示对应的价格。尤其是购买数量,这是一个数字选择器,如果用户频繁点击“+”号,就会连续调用多次查价接口,但最后一次的查价接口返回的数据才是最后选择的正确的价格。每个查价接口逐个请求完毕的时候,显示价格也会逐个改变,最终变成最后正确的价格,一般来说,这是比较不友好的,用户点了多次后,不想看到价格在变化,尽管最终是正确的价格,但这个变化的过程是不能接受的。
现在的问题是:不能使用防抖来解决该问题,因为不能设置过长的定时器,查价接口不能等太久,也不能设置过短的定时器,否则会出现上面说的问题(价格在变化)。对于这种问题,可以采用入栈、取栈顶元素以比对请求参数的方法解决。
// 查价
async getPrice() {
// 请求参数
const reqData = this.handleData()
// push 入栈
this.priceStack.push(reqData)
const { result } = await getProductPrice(reqData)
// 核心代码:取栈顶元素(最后请求的参数)比对
if(this.$lang.isEqual(this.$array.last(this.priceStack), reqData)) {
// 展示价格代码...
}
}
上述的 this.$lang.isEqual、this.$array.last 均是 lodash 插件提供的方法,需要注册到 Vue 中。
import array from 'lodash/array'
import Lang from 'lodash/lang'
Vue.prototype.$array = array
Vue.prototype.$lang = Lang
五、总结
1 防抖
function debouce(fn,delay) {
// 此处的 ...arguments 是 debouce 的参数
let timer = null;
let args = arguments
return function() {
// 此处的 ...arguments 是 demo 的参数
if(timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.call(this, ...args)
}, delay)
}
}
function fn(param) {
// 此处的 ...arguments 是 fn 的参数
console.log(param);
}
var demo = debouce(fn,1000);
2 节流
/* 定时器
* 1. 当第一次触发事件时,不会立即执行函数,而是在 delay 秒后才执行。
* 2. 而后再怎么频繁触发事件,也都是每 delay 时间才执行一次。
*/ 3. 当最后一次停止触发后,由于定时器的 delay 延迟,可能还会执行一次函数。
function throttle(fn,delay) {
// 此处的 ...arguments 是 throttle 的参数
let flag = true;
let args = arguments
return function() {
// 此处的 ...arguments 是 demo 的参数
if(flag) {
setTimeout(() => {
fn.call(this, ...args);
flag = true;
}, delay)
}
flag = false;
}
}
function fn(param) {
// 此处的 ...arguments 是 fn 的参数
console.log(param);
}
var demo = throttle(fn,1000);
/* 时间戳
* 1. 再怎么频繁地触发事件,也都是每 delay 时间才执行一次。
*/ 2. 而当最后一次事件触发完毕后,事件也不会再被执行了。
function throttle(fn, delay) {
let prev = Date.now()
let args = arguments
return function () {
let now = Date.now()
if (now - prev >= delay) {
fn.call(this, ...args)
prev = Date.now()
}
}
}
/* 时间戳 + 定时器
* 1. 在节流函数内部使用开始时间 startTime、当前时间 curTime 与 delay 来计算剩余时间 remaining
* 2. 当 remaining<=0 时表示该执行事件处理函数了(保证了第一次触发事件就能立即执行事件处理函数和每隔 delay 时间执行一次事件处理函数)
* 3. 如果还没到时间的话就设定在 remaining 时间后再触发 (保证了最后一次触发事件后还能再执行一次事件处理函数)
*/ 4. 当然在 remaining 这段时间中如果又一次触发事件,那么会取消当前的计时器,并重新计算一个 remaining 来判断当前状态。
function throttle(fn, delay) {
let timer = null
let startTime = Date.now()
let args = arguments
return function () {
let curTime = Date.now()
let remaining = delay - (curTime - startTime)
clearTimeout(timer)
if (remaining <= 0) {
fn.call(this, ...args)
startTime = Date.now()
} else {
timer = setTimeout(() => {
fn.call(this, ...args)
}, remaining)
}
}
}
六、拓展:执行顺序
1 防抖 + setTimeout
function debouce(fn,delay) {
let timer = null;
return function() {
if(timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.call(this, ...arguments)
}, delay)
}
}
function fn(param) {
console.log(param);
}
var demo = debouce(fn,1000);
// 题1:连续触发只执行最后一次
demo(1);
demo(2);
demo(4);
// 4
// 题2:先执行124,遇到demo(3)立即放入任务队列,而这个时候4的1s还没结束(4是异步任务),执行3后清除4
demo(1);
demo(2);
setTimeout(()=>{demo(3)})
demo(4);
// 3
// 题3:先执行124,遇到demo(3)等1s后放入任务队列,而这个时候4的1s还没结束,执行3后清除4
demo(1);
demo(2);
setTimeout(()=>{demo(3)},1000)
demo(4);
// 3
// 题4:先执行124,遇到demo(3)等1.001s后放入任务队列,而这个时候4的1s结束了,然后执行3
demo(1);
demo(2);
setTimeout(()=>{demo(3)},1001)
demo(4);
// 4 3
// 题5:先执行12,遇到demo(3)等1s后放入任务队列,而这个时候2的1s结束了,然后执行3
demo(1);
demo(2);
setTimeout(()=>{demo(3)},1000)
// 2 3
2 节流 + setTimeout
function throttle(fn,delay) {
let flag = true;
return function() {
if(flag) {
setTimeout(() => {
fn.call(this,...arguments);
flag = true;
}, delay)
}
flag = false;
}
}
function fn(param) {
console.log(param);
}
var demo = throttle(fn,1000);
// 题1:第一次还没结束后面的为false
demo(1);
demo(2);
demo(3);
// 1
// 题2:先执行12,遇到demo(3)立即放入任务队列,而这个时候1的1s还没结束(1是异步任务),3不执行
demo(1);
demo(2);
setTimeout(()=>{demo(3)})
// 1
// 题3:先执行12,遇到demo(3)在1s后放入任务队列,而这个时候1的1s结束了(1是异步任务),3执行
demo(1);
demo(2);
setTimeout(()=>{demo(3)},1000)
// 1 3
// 题4:任务队列里有1的setTimeout和demo(2)、demo(3)(1s后进入任务队列)、demo(4)(1s后进入任务队列)
demo(1);
setTimeout(()=>{demo(2)})
setTimeout(()=>{demo(3)},1000)
setTimeout(()=>{demo(4)},1000)
// 1 3