从简单到复杂:手写节流(Throttle)全攻略
1) 最简单:时间戳版(前沿触发)
- 场景:按钮防连点、滚动监听等,首次应立刻响应。
- 思路:记录“上次执行时间”,只有超过间隔才执行。
function throttle(fn, delay) {
let lastInvokeTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastInvokeTime >= delay) {
lastInvokeTime = now;
return fn.apply(this, args);
}
};
}
- 特点:首次立即执行;时间窗内的触发被丢弃;实现最简单。
2) 定时器版(后沿触发)
- 场景:不希望首次立刻执行,但希望“最后一次”被执行(如输入联想、窗口调整完成后的布局)。
- 思路:开始触发时启动一个定时器,等到期执行一次。
function throttleTrailing(fn, delay) {
let timerId = null;
return function (...args) {
if (timerId != null) return;
const context = this;
timerId = setTimeout(() => {
timerId = null;
fn.apply(context, args);
}, delay);
};
}
- 特点:首次不会执行;只要持续触发,等窗口结束后会补一次执行。
3) 混合版:前沿 + 后沿可配
- 场景:既想要“首次立即执行”,又不想错过“最后一次触发后的收尾”;或需要灵活配置不同策略。
- 思路:时间戳决定前沿是否执行,定时器安排尾部补一次。
function throttleHybrid(fn, delay, options = { leading: true, trailing: true }) {
let lastInvokeTime = 0;
let timerId = null;
let lastArgs;
let lastContext;
function invoke(now) {
lastInvokeTime = now;
fn.apply(lastContext, lastArgs);
lastArgs = lastContext = null;
}
function startTimer(remaining) {
timerId = setTimeout(() => {
timerId = null;
if (options.trailing !== false && lastArgs) {
invoke(Date.now());
}
}, remaining);
}
return function (...args) {
const now = Date.now();
if (lastInvokeTime === 0 && options.leading === false) {
lastInvokeTime = now;
}
lastArgs = args;
lastContext = this;
const remaining = delay - (now - lastInvokeTime);
if (remaining <= 0) {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
invoke(now);
} else if (!timerId && options.trailing !== false) {
startTimer(remaining);
}
};
}
- 常见配置:
- 只前沿:
{ leading: true, trailing: false }
- 只后沿:
{ leading: false, trailing: true }
- 全开(默认):
{ leading: true, trailing: true }
4) 进阶增强:取消与立即执行
- 需求:组件卸载时取消尾部任务;或在需要时“立刻执行一次”。
function throttleWithControls(fn, delay, options = { leading: true, trailing: true }) {
let lastInvokeTime = 0;
let timerId = null;
let lastArgs;
let lastContext;
function invoke(now) {
lastInvokeTime = now;
fn.apply(lastContext, lastArgs);
lastArgs = lastContext = null;
}
function startTimer(remaining) {
timerId = setTimeout(() => {
timerId = null;
if (options.trailing !== false && lastArgs) {
invoke(Date.now());
}
}, remaining);
}
function throttled(...args) {
const now = Date.now();
if (lastInvokeTime === 0 && options.leading === false) {
lastInvokeTime = now;
}
lastArgs = args;
lastContext = this;
const remaining = delay - (now - lastInvokeTime);
if (remaining <= 0) {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
invoke(now);
} else if (!timerId && options.trailing !== false) {
startTimer(remaining);
}
}
throttled.cancel = function () {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
lastInvokeTime = 0;
lastArgs = lastContext = null;
};
throttled.flush = function () {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
if (lastArgs) {
invoke(Date.now());
}
};
return throttled;
}
5) 常见错误与避坑
- 在判断前重置“上次时间”为当前时间,导致差值≈0,永远不执行。
- 被拦截时更新时间戳,导致后续时间窗错乱或一直不触发。
- 在函数定义时缓存
this(如 let that = this),丢失动态上下文,应使用 fn.apply(this, args)。
- 拼写与命名不清晰:建议使用
lastInvokeTime/invokeTime。
6) 如何选择版本
- 要“简单、首次立刻响应”:时间戳版(前沿)
- 要“只在结束时执行一次”:定时器版(后沿)
- 要“首次响应+尾部补一次”或可配置:混合版(带
leading/trailing)
- 要可取消/立即执行:增强版(带
cancel/flush)
7) 使用示例
const onScroll = throttle(() => {
console.log('scroll at', window.scrollY);
}, 200);
window.addEventListener('scroll', onScroll);
const onInput = throttleHybrid(fetchSuggest, 300, { leading: false, trailing: true });
input.addEventListener('input', (e) => onInput(e.target.value));
button.addEventListener('click', throttle(submit, 1000));