本文详细解析 JavaScript 中函数节流(Throttle)的实现原理。从基础的 resize 和 scroll 事件性能问题入手,逐步修正计时器管理错误,利用闭包解决全局变量污染,并引入首次立即执行逻辑。通过完整代码示例和测试案例,展示如何编写健壮的高频事件处理方案。
高频事件触发的性能问题
在浏览器 DOM 事件交互中,部分事件会随用户操作高频触发。例如调整浏览器窗口大小(resize)、页面滚动(scroll)以及鼠标移动(mousemove)。当用户进行这些操作时,若绑定了相应的事件处理函数,该函数便会持续被调用。
这种高频触发并非理想状态。若事件处理逻辑复杂或涉及大量 DOM 操作,持续执行会导致性能损耗,进而引发界面响应迟缓甚至浏览器卡顿,严重影响用户体验。因此,通常需要对这类事件添加延迟执行的逻辑控制。
基础节流实现与常见错误
初学者常尝试使用以下代码实现该功能:
var COUNT = 0;
function testFn() { console.log(COUNT++); }
// 浏览器resize的时候
// 1. 清除之前的计时器
// 2. 添加一个计时器让真正的函数testFn延后100毫秒触发
window.onresize = function () {
var timer = null;
clearTimeout(timer);
timer = setTimeout(function() {
testFn();
}, 100);
};
仔细观察上述代码会发现存在逻辑缺陷,这是新手容易犯的错误:setTimeout 的返回值必须保存在一个相对全局的变量中。否则,每次触发 resize 事件时都会创建新的计时器,导致之前的计时器无法被正确清除,从而无法达到预期的节流效果。
针对上述问题,我们对代码进行了修正:
var timer = null;
window.onresize = function () {
clearTimeout(timer);
timer = setTimeout(function() {
testFn();
}, 100);
};
利用闭包解决全局变量污染
修改后的代码虽然解决了计时器覆盖的问题,但引入了新的隐患——产生了全局变量 timer。这可能导致页面中其他功能若也使用名为 timer 的变量时发生冲突。为解决此作用域问题,我们需要利用 JavaScript 的闭包(closures)特性。相关概念可参考 MDN 文档,改造后的代码如下:
/**
* 函数节流方法
* @param Function fn 延时调用函数
* @param Number delay 延迟多长时间
* @return Function 延迟执行的方法
*/
var throttle = function (fn, delay) {
var timer = null;
return function () {
clearTimeout(timer);
timer = setTimeout(function() {
fn();
}, delay);
}
};
window.onresize = throttle(testFn, 200, 1000);
通过一个闭包函数(throttle 节流)将 timer 变量封装在内部,并返回延时处理函数。这样,timer 变量对外部不可见,但内部延时函数触发时仍能访问到该变量,实现了数据隔离。
对于初学者而言,这种写法可能较难理解。我们可以换一种方式拆解其逻辑:
var throttle = function (fn, delay) {
var timer = null;
return function () {
clearTimeout(timer);
timer = setTimeout(function() {
fn();
}, delay);
}
};
var f = throttle(testFn, 200);
window.onresize = function () {
f();
};
这里需要明确一点:throttle 被调用后返回的 function 才是真正在 onresize 事件触发时需要调用的函数。
此时该方法看似已接近完美,但在实际应用中仍存在不足。例如:
如果用户持续不断地调整浏览器窗口大小,此时延迟处理函数一次都不会执行。
完善首次立即执行逻辑
为此,我们需要增加一个功能:当用户触发 resize 事件时,确保在某段时间内至少触发一次执行。既然涉及时间段判断,我们可以获取当前时间的毫秒数,每次函数调用时将当前时间与上一次调用时间相减。若差值大于设定的“某段时间”,则直接触发执行;否则,继续走 setTimeout 的延迟逻辑。
在下面的代码中,需要特别注意以下几点: 1. previous 变量的作用与 timer 类似,用于记录上一次的执行标识,必须保持为相对的全局变量(在闭包内)。 2. 如果逻辑流程选择了“至少触发一次”的路径,函数调用完成后需要将 previous 重置为当前时间。简而言之,相对于下一次调用的“上一次”其实就是当前的执行时刻。
/**
* 函数节流方法
* @param Function fn 延时调用函数
* @param Number delay 延迟多长时间
* @param Number atleast 至少多长时间触发一次
* @return Function 延迟执行的方法
*/
var throttle = function (fn, delay, atleast) {
var timer = null;
var previous = null;
return function () {
var now = +new Date();
if ( !previous ) previous = now;
if ( now - previous > atleast ) {
fn();
// 重置上一次开始时间为本次结束时间
previous = now;
} else {
clearTimeout(timer);
timer = setTimeout(function() {
fn();
}, delay);
}
}
};
滚动场景下的完整代码实现与测试
实践场景: 我们模拟一个窗口 scroll 事件中的节流场景。当用户向下滚动页面时,我们需要节流执行某些方法,例如计算 DOM 位置或连续操作 DOM 元素等耗时动作。 完整代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>throttle</title>
</head>
<body>
<div style="height:5000px">
<div id="demo" style="position:fixed;"></div>
</div>
<script>
var COUNT = 0, demo = document.getElementById('demo');
function testFn() {demo.innerHTML += 'testFN 被调用了 ' + ++COUNT + '次<br>';}
var throttle = function (fn, delay, atleast) {
var timer = null;
var previous = null;
return function () {
var now = +new Date();
if ( !previous ) previous = now;
if ( atleast && now - previous > atleast ) {
fn();
// 重置上一次开始时间为本次结束时间
previous = now;
clearTimeout(timer);
} else {
clearTimeout(timer);
timer = setTimeout(function() {
fn();
previous = null;
}, delay);
}
}
};
window.onscroll = throttle(testFn, 200);
// window.onscroll = throttle(testFn, 500, 1000);
</script>
</body>
</html>
我们通过两个测试用例来验证效果,分别测试是否添加 atleast(至少触发间隔)参数:
// case 1
window.onscroll = throttle(testFn, 200);
// case 2
window.onscroll = throttle(testFn, 200, 500);
case 1 的表现是:在页面滚动过程中(未停止)testFN 不会被调用,直到滚动停止时才会调用一次。这执行的是 throttle 函数中最后的 setTimeout 逻辑,效果如图(查看原 gif 图):

case 2 的表现是:在页面滚动过程中(未停止),testFN 第一次会延迟 500ms 执行(来自至少延迟逻辑),此后每隔 500ms 执行一次,效果如图:

至此,我们期望实现的效果已基本完成。后续的一些辅助性优化,如函数 this 指向的处理、返回值的保存等,读者可自行琢磨完善。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。