前文 我们对 JavaScript 中的函数节流和函数去抖的概念和应用场景进行了简单的了解,本文我们来深入探究下函数去抖的实现。(不懂函数去抖概念的建议看下前文 JavaScript 函数节流和函数去抖应用场景辨析 )
我们以 scroll 事件为例,探究如何实现滚动一次窗口打印一个 hello world 字符串。
如果不对其进行节流或者去抖控制:
window.onscroll = function() {
console.log('hello world');
};
这样每滚动一次,实际上会打印 N 多个 hello world。函数去抖背后的基本思想是指,某些代码不可以在没有间断的情况连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除前一次的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是只有在执行函数的请求停止了一段时间之后才执行。
《高程三》给出了最简洁最经典的去抖代码(书中说是节流,实则为去抖),调用如下:
function debounce(method, context) {
clearTimeout(method.tId);
method.tId = setTimeout(function() {
method.call(context);
}, 1000);
}
function print() {
console.log('hello world');
}
window.onscroll = function() {
debounce(print);
};
在窗口内滚动一次,停止,1000ms 后,打印了 hello world,因为我们设置了一个 1000ms 延迟的定时器,细思非常巧妙。
underscore 在其基础上进行了扩充,直接看代码,含大量注释:
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
// 函数去抖(连续事件触发结束后只触发一次)
// sample 1: _.debounce(function(){}, 1000)
// 连续事件结束后的 1000ms 后触发
// sample 1: _.debounce(function(){}, 1000, true)
// 连续事件触发后立即触发(此时会忽略第二个参数)
_.debounce = function(func, wait, immediate) {
var timeout, args, context, timestamp, result;
var later = function() {
// 定时器设置的回调 later 方法的触发时间,和连续事件触发的最后一次时间戳的间隔
// 如果间隔为 wait(或者刚好大于 wait),则触发事件
var last = _.now() - timestamp;
// 时间间隔 last 在 [0, wait) 中
// 还没到触发的点,则继续设置定时器
// last 值应该不会小于 0 吧?
if (last < wait && last >= 0) {
timeout = setTimeout(later, wait - last);
} else {
// 到了可以触发的时间点
timeout = null;
// 可以触发了
// 并且不是设置为立即触发的
// 因为如果是立即触发(callNow),也会进入这个回调中
// 主要是为了将 timeout 值置为空,使之不影响下次连续事件的触发
// 如果不是立即执行,随即执行 func 方法
if (!immediate) {
// 执行 func 函数
result = func.apply(context, args);
// 这里的 timeout 一定是 null 了吧
// 感觉这个判断多余了
if (!timeout)
context = args = null;
}
}
};
// 嗯,闭包返回的函数,是可以传入参数的
return function() {
// 可以指定 this 指向
context = this;
args = arguments;
// 每次触发函数,更新时间戳
// later 方法中取 last 值时用到该变量
// 判断距离上次触发事件是否已经过了 wait seconds 了
// 即我们需要距离最后一次触发事件 wait seconds 后触发这个回调方法
timestamp = _.now();
// 立即触发需要满足两个条件
// immediate 参数为 true,并且 timeout 还没设置
// immediate 参数为 true 是显而易见的
// 如果去掉 !timeout 的条件,就会一直触发,而不是触发一次
// 因为第一次触发后已经设置了 timeout,所以根据 timeout 是否为空可以判断是否是首次触发
var callNow = immediate && !timeout;
// 设置 wait seconds 后触发 later 方法
// 无论是否 callNow(如果是 callNow,也进入 later 方法,去 later 方法中判断是否执行相应回调函数)
// 在某一段的连续触发中,只会在第一次触发时进入这个 if 分支中
if (!timeout)
// 设置了 timeout,所以以后不会进入这个 if 分支了
timeout = setTimeout(later, wait);
// 如果是立即触发
if (callNow) {
// func 可能是有返回值的
result = func.apply(context, args);
// 解除引用
context = args = null;
}
return result;
};
};
等等,一下子多了这么多代码,那么我们比基础版多了哪些功能(优势)呢?
首先,基础版能做的,我们一样能做,一样让它在连续滚动后停止的 1000ms 后打印 hello world:
function print() {
console.log('hello world');
}
window.onscroll = _.debounce(print, 1000);
我们还可以在滚动刚触发的时候打印字符串,而不是连续滚动结束后,只需传入第三个参数,会自动忽略第二个参数:
function print() {
console.log('hello world');
}
window.onscroll = _.debounce(print, 1000, true);
这样对于连续的滚动,也只会打印一次,但是是在事件第一次触发的时候。
回调函数需要传入参数?一点问题都没有。
function print(a) {
console.log('The passed item is: ' + a);
}
var callback = _.debounce(print, 1000);
window.onscroll = function() {
var item = 'zichi';
callback(item);
};
当然,除了功能上的优势,性能也是提高不少,最显而易见的是基础版每此触发事件都会取消定时器,然后重新设置定时器,而 underscore 中会在一定时间后才取消定时器,重新设置定时器。其他更多可以细究下源码。(对性能有兴趣的可以看看这个 pr jashkenas/underscore#1269)