手写防抖与节流
手写防抖与节流
什么是防抖和节流?
首先需要澄清一个比较常见的误区,那就是防抖和节流在前端开发中并不是特指一个方法,而是应该将其拆分去看待,实质上, 防抖(debounce) 和 节流(throttle) 是两个不同的函数,只不过他们都是优化高频执行代码的一种手段而已,所以通常是一起出现在我们的视野中,导致经常会被我们误认为是一个方法。而它们的使用场景是在监听一些浏览器事件时,例如:resize
、scroll
、keypress
、mousemove
等,这类事件会不断的调用已绑定事件的回调函数,极大的浪费资源,降低了前端性能。为了优化体验,需要对这类事件进行调用次数的限制,为此我们就可以使用 防抖(debounce) 和 节流(throttle) 的方式来减少调用频率。
定义
节流:n 秒内只运行一次,若在 n 秒内重复触发,只有一次生效。
- 实际场景:
在《飞机大战游戏》中按下空格键发射炮弹,而在每一秒钟无论按下多少次空格键,仅会发射出一枚炮弹。
- 实际场景:
防抖:n 秒后再执行该事件,若在 n 秒内重复执行了此事件,则重新计时。
- 实际场景:
向某个输入框输入内容时,需要向服务器发送网络请求,查询当前已输入关键字的联想数据,若用户在 500ms 内一直保持持续的输入内容,则不向服务器发送网络请求,若 500ms 内未输入任何内容,则在此之后将已输入内容发送给服务器获取关键字检索数据。
- 实际场景:
节流和防抖函数都属于高阶函数,所以它们都接收一个函数作为参数并返回一个新的函数。在我们真实开发过程中,会直接将返回的函数绑定到高频触发的事件上。
实现节流
一、基础版:
- 参数一:fn 需要节流的函数
- 参数二:interval 执行间隔时间
使用时间戳记录本次与上次的执行时间,每次调用时都判断相隔时间是否大于 interval(执行间隔时间),大于则执行该函数
const throttle = (fn, interval) => {
let lastTime = 0; // 上次调用的时间,初始值为0
function _throttle() {
const nowTime = new Date().getTime(); // 获取当前时间戳
const remainTime = interval - (nowTime - lastTime);
// 剩余时间如果小于或等于0,则表示与上次执行的间隔时间大于了给定值interval 则执行该函数
if (remainTime <= 0) {
fn(); // 执行传入的函数
// 将当前时间赋值给上次时间,在下次调用此函数时作为上次时间进行判断
lastTime = nowTime;
}
}
return _throttle;
};
使用一下:
// 获取btn元素
const btnEl = document.querySelector('.btn');
// 定义事件绑定的函数
const foo = () => {
console.log('foo 被执行');
};
// 节流处理
const fooThrottle = throttle(foo, 500);
// 绑定执行函数
btnEl.onclick = fooThrottle;
// 连续点击按钮,测试节流效果...
注
此版本虽然实现了节流的效果,但该版本中对函数this
的绑定我们并没有做任何处理,所以在函数体中如果用户使用了this
,则该this
必然是指向window
的,如果需要被执行的函数的 this
是指向某个对象的(例如在绑定的事件中调用,this一定指向被调用的Dom元素对象),那么在传入后,这个 this
的指向将会被丢失,甚至我们希望在foo
函数中可以拿到button
点击事件传入的event
事件对象,目前看来也是拿不到的,那么如何去解决这些问题呢?这就引出了我们的进阶版。
二、进阶版:
- 参数一:fn 需要节流的函数
- 参数二:interval 执行间隔时间
- 参数三:options 选项: { leading: 是否在起始时刻执行, trailing: 是否在末尾时刻执行 }
该版本加入了对this
的显式绑定处理,在返回的节流函数中还支持接收参数,并且使用定时器来控制末尾时刻的执行,还提供了一个取消末尾时刻执行的方法,具体请参考以下代码:
const throttle = (fn, interval, options = { leading: true, trailing: false }) => {
const { leading, trailing } = options;
let lastTime = 0; // 上次调用的时间,初始值为0
let timer = null;
// 在返回的函数中,接收多个参数
function _throttle(...arg) {
const nowTime = new Date().getTime(); // 获取当前时间戳
// 为了可控第一次是否立即执行,只需要控制lastTime的值即可
// 使lastTime = nowTime 这样的话 nowTime - lastTime 就等于0了,
// remainTime就会等于interval,大于零就不会执行函数
if (!leading && !lastTime) lastTime = nowTime;
const remainTime = interval - (nowTime - lastTime);
if (remainTime <= 0) {
if (timer) {
clearInterval(timer);
timer = null;
}
// 显示绑定this,将参数传入函数并执行
fn.apply(this, arg);
// 将当前时间赋值给上次时间,在下次调用此函数时作为上次时间进行判断
lastTime = nowTime;
}
// 判断末尾时刻是否执行
if (!timer && remainTime > 0 && trailing) {
// 如果条件达成则开启一个定时器来处理以下逻辑
timer = setTimeout(() => {
// 如果leading为true 则 lastTime 为当前时间戳 下次执行时将在起始时刻执行,否则lastTime = 0,下次执行时起始时刻将不会执行
lastTime = !leading ? 0 : new Date().getTime();
// 清空定时器
timer = null;
// 显示绑定this并 传入参数
fn.apply(this, arg);
}, remainTime);
}
}
// 取消末尾时刻的执行
_throttle.cancel = () => {
if (timer) clearTimeout(timer);
};
return _throttle;
};
使用一下:
// 获取按钮元素
const btnEl = document.querySelector('.btn');
// 获取取消按钮元素
const cancelBtnEl = document.querySelector('.cancelBtn');
// 定义事件绑定的函数,这里我们就可以拿到点击事件的对象了
const foo = (event) => {
console.log(event, 'foo 被执行');
};
// 节流处理
const fooThrottle = throttle(foo, 500, {
leading: true,
trailing: true
});
// 绑定执行函数
btnEl.onclick = fooThrottle;
// 连续点击按钮,测试节流效果...
// 点击取消按钮
cancelBtnEl.onclick = fooThrottle.cancel();
注
目前看来,该版本的节流函数已经解决了我们在基础版未满足的需求,貌似我们完全可以拿这个方法直接去使用了,但其实该方法还存在最后一个缺陷,那就是返回值得问题,若需要执行的函数存在返回值,我们如何去拿到这个返回值呢,请看最终版。
三、最终版:
- 参数一:fn 需要节流的函数
- 参数二:interval 执行间隔时间
- 参数三:options 选项: { leading: 是否在起始时刻执行, trailing: 是否在末尾时刻执行 }
- 参数四:resultCallback 回调函数 用于接收返回值
返回值的获取有两种方法,且这两种并不冲突:
方法一: 接收第 4 个参数(resultCallback)回调函数,在fn
执行结束拿到返回值后,将返回值作为回调函数的参数传入,这样我们就可以从回调函数中拿到该函数的返回值了
方法二: 在返回的节流函数中再返回一个Promise
,fn
执行结束后将fn
的返回值传入到resolve
并执行,这样我们就可以在执行函数时直接通过.then
的方式拿到结果了。具体实现如下:
const throttle = (fn, interval, options = { leading: true, trailing: false }, resultCallback) => {
const { leading, trailing } = options;
let lastTime = 0; // 上次调用的时间,初始值为0
let timer = null;
// 在返回的函数中,接收多个参数
function _throttle(...arg) {
// 方式二:返回一个Promise
return new Promise((resolve) => {
const nowTime = new Date().getTime(); // 获取当前时间戳
// 为了可控第一次是否立即执行,只需要控制lastTime的值即可
// 使lastTime = nowTime 这样的话 nowTime - lastTime 就等于0了,
// remainTime就会等于interval,大于零就不会执行函数
if (!leading && !lastTime) lastTime = nowTime;
const remainTime = interval - (nowTime - lastTime);
if (remainTime <= 0) {
if (timer) {
clearInterval(timer);
timer = null;
}
// 显示绑定this,将参数传入函数并执行,并拿到执行结果
const result = fn.apply(this, arg);
// 方式一:执行结束后将结果传入回调函数并执行
resultCallback && resultCallback(result);
// 方式二:将结果resolve出去
resolve(result);
// 将当前时间赋值给上次时间,在下次调用此函数时作为上次时间进行判断
lastTime = nowTime;
}
// 判断末尾时刻是否执行
if (!timer && remainTime > 0 && trailing) {
// 如果条件达成则开启一个定时器来处理以下逻辑
timer = setTimeout(() => {
// 如果leading为true 则 lastTime 为当前时间戳 下次执行时将在起始时刻执行,否则lastTime = 0,下次执行时起始时刻将不会执行
lastTime = !leading ? 0 : new Date().getTime();
// 清空定时器
timer = null;
// 显示绑定this并 传入参数,拿到执行的结果
const result = fn.apply(this, arg);
// 方式一:执行结束后将结果传入回调函数并执行
resultCallback && resultCallback(result);
// 方式二:将结果resolve出去
resolve(result);
}, remainTime);
}
});
}
// 取消末尾时刻的执行
_throttle.cancel = () => {
if (timer) clearTimeout(timer);
};
return _throttle;
};
使用一下:
// 获取按钮元素
const btnEl = document.querySelector('.btn');
// 获取取消按钮元素
const cancelBtnEl = document.querySelector('.cancelBtn');
// 定义事件绑定的函数,这里我们就可以拿到点击事件的对象了
const foo = (event) => {
console.log(event, 'foo 被执行');
return 'hello throttle';
};
// 节流处理 获取返回值方式一:传入第四个参数
const fooThrottle = throttle(
foo,
500,
{
leading: true,
trailing: true
},
(result) => {
console.log(result); // hello throttle
}
);
// 绑定执行函数
btnEl.onclick = function (e) {
// 获取返回值方式二
fooThrottle(e).then((res) => {
console.log(res); // hello throttle
});
};
// 连续点击按钮,测试节流效果...
// 点击取消按钮
cancelBtnEl.onclick = fooThrottle.cancel();
至此,我们已经实现了完整的节流函数
实现防抖
一、基础版:
- 参数一:fn 需要防抖的函数
- 参数二:delay 延时多久,单位 ms
防抖的封装过程与节流大体相同,此方法我同样还是使用三个版本分步骤的实现,层层递进,帮助大家也是帮助我自己可以更清晰的理解其中的原理,以下是基础版的实现过程:
const debounce = (fn, delay) => {
let timer = null; // 定时器id初始为null
// 返回的函数接收参数
return function (...arg) {
// 如果timer有值则清除定时器
timer && clearTimeout(timer);
// 定义定时器,延时执行fn
timer = setTimeout(() => {
// 两种绑定this的方法
// fn.apply(this, arg)
fn.call(this, ...arg);
}, delay);
};
};
二、进阶版:
- 参数一:fn 需要防抖的函数
- 参数二:delay 延时多久,单位 ms
- 参数三:immed 是否立即执行
在基础版之上又对其进行了扩展,加入了立即执行的选项,以及取消方法。
const debounce = (fn, delay, immed = false) => {
let timer = null; // 定时器id初始为null
let isExecute = false; // 记录是否立即执行过
// 返回的函数接收参数
function _debounce(...arg) {
// 如果timer有值则清除定时器
timer && clearTimeout(timer);
// 如果开启立即执行,且立即执行还未执行过,则执行fn
if (immed && !isExecute) {
fn.call(this, ...arg);
isExecute = true;
} else {
timer = setTimeout(() => {
// fn.apply(this, arg)
fn.call(this, ...arg);
// 重置状态
isExecute = false;
timer = null;
}, delay);
}
}
// 取消方法
_debounce.cancel = () => {
timer && clearInterval(timer);
};
return _debounce;
};
三、最终版:
- 参数一:fn 需要防抖的函数
- 参数二:delay 延时多久,单位 ms
- 参数三:immed 是否立即执行
- 参数四:resultCallback 获取返回值的回调函数
最终版加入返回值的获取方法,与节流函数一样使用回调函数和 Promise 的方式。
const debounce = (fn, delay, immed = false, resultCallback) => {
let timer = null; // 定时器id初始为null
let isExecute = false; // 记录是否立即执行过
// 返回的函数接收参数
function _debounce(...arg) {
return new Promise((resolve, reject) => {
// 如果timer有值则清除定时器
timer && clearTimeout(timer);
// 如果开启立即执行,且立即执行还未执行过,则执行fn
if (immed && !isExecute) {
const result = fn.call(this, ...arg);
resultCallback && resultCallback(result);
resolve(result);
isExecute = true;
} else {
timer = setTimeout(() => {
// fn.apply(this, arg)
const result = fn.call(this, ...arg);
// 处理返回值
resultCallback && resultCallback(result);
resolve(result);
// 重置状态
isExecute = false;
timer = null;
}, delay);
}
});
}
// 取消方法
_debounce.cancel = () => {
timer && clearInterval(timer);
};
return _debounce;
};
使用一下:
const inputEl = document.querySelector('.test-debounce');
const btnEl = document.querySelector('button');
let count = 0;
const handleInput = function (event) {
console.log('发送了第' + ++count + '次网络请求', this, event);
return '返回值测试';
};
const handleInputDebounce = debounce(handleInput, 300, false, (res) => {
console.log('自己函数的返回值:', res);
});
const resultCallback = () => {
handleInputDebounce().then((res) => {
console.log(res);
});
};
inputEl.oninput = resultCallback;
btnEl.addEventListener('click', () => {
handleInputDebounce.cancel();
});
至此,我们以及完成了防抖函数的封装。相比较而言,防抖函数的封装过程比节流要稍微简单了一些。
总结
防抖(debounce) 和 节流(throttle) 是两个非常经典而且实用的高阶函数,从代码量上来看并不算多,但麻雀虽小,五脏俱全。作为一个前端开发,还是有必要尝试手写一下的,同时也希望还没有详细了解防抖节流的同学通过本文可以有所收获。