Vue3 节流函数
一、前言
在前端开发中,我们经常需要处理一些高频触发的事件,比如滚动事件、鼠标移动事件、窗口大小调整等。如果不对这些事件进行控制,可能会导致页面性能问题,如卡顿、内存泄漏等。节流(Throttle)函数就是一种有效的性能优化技术。
二、什么是节流?
节流(Throttle)是一种限制函数执行频率的技术,它确保一个函数在固定的时间间隔内最多只执行一次。简单来说,就是给函数装上一个”冷却时间”,无论事件触发多么频繁,函数都会按照固定的节奏执行。
节流的核心思想
每隔单位时间内只执行一次,保证在一段时间间隔内一定会执行一次。
节流与防抖的区别
| 特性 | 防抖 (Debounce) | 节流 (Throttle) |
|---|
| 执行时机 | 事件停止后延迟执行 | 固定时间间隔执行 |
| 执行次数 | 只执行最后一次 | 均匀执行多次 |
| 响应速度 | 较慢(需要等待事件停止) | 较快(按固定节奏执行) |
| 内存占用 | 较低(只需要一个定时器) | 较低(需要保存时间戳或定时器) |
| 适用场景 | 搜索建议、窗口resize、表单验证 | 滚动加载、游戏中的按键处理、实时数据展示 |
| 类比 | 电梯门关闭(等人进完) | 地铁发车(定时出发) |
| 优点 | 避免频繁触发,适合一次性操作 | 保证执行频率,适合连续性操作 |
| 缺点 | 可能延迟响应,丢失中间状态 | 可能执行不必要的操作 |
三、节流函数的实现
1. 定时器版(延迟执行)
这是最简单的节流实现方式,使用定时器来控制函数的执行频率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
const throttle = (func, delay) => { let timeoutId; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { func.apply(this, args); timeoutId = null; }, delay); }; };
|
闭包的作用:
- 保存状态:
timeoutId 变量被闭包保存,使得每次调用节流函数时都能访问到同一个变量。 - 维持上下文:返回的函数能够访问外部函数的变量(
timeoutId),即使外部函数已经执行完毕。 - 私有化变量:
timeoutId 对外部不可见,避免了全局变量污染。
闭包工作原理:
- 当
throttle 函数执行时,它创建了一个作用域,其中包含 timeoutId 变量。 - 当
throttle 返回一个新函数时,这个新函数会形成一个闭包,包含了对 timeoutId 变量的引用。 - 即使
throttle 函数执行完毕,其作用域不会被垃圾回收,因为返回的函数仍然引用着这个作用域中的变量。 - 当返回的函数被调用时,它可以访问和修改
timeoutId 变量,实现了状态的持久化。
特点:
- 事件触发时不会立即执行,而是等待间隔时间后执行
- 如果间隔内再次触发,会重置定时器(最终只执行最后一次)
- 保证最后一次触发一定会执行
2. 时间戳版(立即执行)
使用时间戳来控制函数的执行频率,首次触发时立即执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
function throttle(func, wait) { let previous = 0;
return function (...args) { const context = this; const now = Date.now();
if (now - previous >= wait) { func.apply(context, args); previous = now; } }; }
|
特点:
- 事件触发时立即执行一次,之后在间隔内不重复执行
- 间隔结束后才会再次响应
- 执行时机确定,但末次触发可能被忽略
3. 综合版(立即+延迟)
结合两种方式的优点,首次触发立即执行,间隔内再次触发会在间隔结束后补充执行一次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
|
function throttle(func, wait) { let previous = 0; let timeout = null;
const later = function (...args) { previous = Date.now(); timeout = null; func.apply(this, args); };
return function (...args) { const context = this; const now = Date.now(); const remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; func.apply(context, args); } else if (!timeout) { timeout = setTimeout(later.bind(context, ...args), remaining); } }; }
|
特点:
- 首次触发立即执行
- 间隔内再次触发会在间隔结束后补充执行一次
- 避免最后一次触发被忽略
4. 三种实现方式的对比
| 实现方式 | 立即执行 | 末次执行保证 | 内存占用 | 执行时机 | 适用场景 |
|---|
| 定时器版 | 否 | 是 | 低(1个定时器) | 延迟执行 | 输入框搜索、表单提交 |
| 时间戳版 | 是 | 否 | 极低(仅时间戳) | 立即执行 | 滚动位置计算、实时数据展示 |
| 综合版 | 是 | 是 | 低(1个定时器+时间戳) | 立即+延迟 | 窗口resize、复杂计算 |
四、节流函数的应用场景
1. 页面滚动事件
监听滚动位置,实现吸顶导航栏或滚动加载更多。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| window.addEventListener( "scroll", throttle(function () { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; console.log("滚动位置:", scrollTop);
if ( window.innerHeight + window.scrollY >= document.body.offsetHeight - 500 ) { loadMoreItems(); }
const header = document.querySelector("header"); if (scrollTop > 100) { header.classList.add("sticky"); } else { header.classList.remove("sticky"); } }, 200), );
|
最佳实践:对于滚动事件,建议使用时间戳版节流,因为用户需要实时看到滚动效果。
2. 鼠标移动事件
绘制轨迹或更新提示框位置。
1 2 3 4 5 6
| element.addEventListener( "mousemove", throttle(function (e) { updateTooltipPosition(e.clientX, e.clientY); }, 50), );
|
最佳实践:鼠标移动事件频率很高,建议使用50-100ms的节流时间,平衡性能和响应速度。
3. 窗口大小调整
响应式布局调整。
1 2 3 4 5 6
| window.addEventListener( "resize", throttle(function () { updateLayout(); }, 250), );
|
最佳实践:窗口调整事件建议使用综合版节流,既保证立即响应,又确保最后一次调整能够正确处理。
4. 游戏按键处理
防止按键重复触发。
1 2 3 4 5 6 7 8
| document.addEventListener( "keydown", throttle(function (e) { if (e.key === "ArrowRight") { player.moveRight(); } }, 100), );
|
最佳实践:游戏中的按键处理建议使用时间戳版节流,确保操作的即时响应。
5. 实时数据展示
如股票行情、实时监控等。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function updateRealTimeData() { fetch("/api/realtime-data") .then((response) => response.json()) .then((data) => { updateDashboard(data); }); }
const throttledUpdate = throttle(updateRealTimeData, 500);
setInterval(throttledUpdate, 100);
|
6. 拖拽操作
处理拖拽过程中的位置更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| let isDragging = false; let startX, startY;
const element = document.querySelector(".draggable");
element.addEventListener("mousedown", function (e) { isDragging = true; startX = e.clientX; startY = e.clientY; });
document.addEventListener( "mousemove", throttle(function (e) { if (!isDragging) return;
const deltaX = e.clientX - startX; const deltaY = e.clientY - startY;
const rect = element.getBoundingClientRect(); element.style.left = rect.left + deltaX + "px"; element.style.top = rect.top + deltaY + "px";
startX = e.clientX; startY = e.clientY; }, 16), );
document.addEventListener("mouseup", function () { isDragging = false; });
|
最佳实践:拖拽操作建议使用16ms左右的节流时间,接近屏幕刷新率,保证平滑的视觉效果。
五、在Vue3中的使用
1. 基础使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| <template> <div> <button @click="handleClick">点击我</button> <div @mousemove="handleMouseMove">鼠标移动区域</div> </div> </template>
<script setup> import { ref } from "vue";
const count = ref(0);
// 节流函数 const throttle = (func, delay) => { let timeoutId; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { func.apply(this, args); timeoutId = null; }, delay); }; };
// 节流处理点击事件 const handleClick = throttle(() => { console.log("按钮被点击"); count.value++; }, 1000);
// 节流处理鼠标移动 const handleMouseMove = throttle((e) => { console.log("鼠标位置:", e.clientX, e.clientY); }, 100); </script>
|
2. 使用Composition API封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| <template> <div> <button @click="throttledClick">点击我</button> <div ref="scrollContainer" @scroll="throttledScroll" style="height: 200px; overflow: auto;" > <div style="height: 1000px;">滚动内容</div> </div> </div> </template>
<script setup> import { ref, onMounted, onUnmounted } from "vue";
// 封装节流Hook function useThrottle(fn, delay = 300) { let timeoutId = null;
const throttledFn = (...args) => { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { fn.apply(this, args); timeoutId = null; }, delay); };
// 清理函数 const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } };
return { throttledFn, cleanup }; }
// 使用示例 const count = ref(0);
const handleClick = () => { console.log("按钮被点击"); count.value++; };
const { throttledFn: throttledClick, cleanup: cleanupClick } = useThrottle( handleClick, 1000, );
const handleScroll = () => { console.log("滚动事件触发"); };
const { throttledFn: throttledScroll, cleanup: cleanupScroll } = useThrottle( handleScroll, 200, );
// 组件卸载时清理 onUnmounted(() => { cleanupClick(); cleanupScroll(); }); </script>
|
3. 使用第三方库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <template> <div> <button @click="throttledClick">点击我</button> </div> </template>
<script setup> import { ref } from "vue"; import { throttle } from "lodash-es";
const count = ref(0);
const handleClick = () => { console.log("按钮被点击"); count.value++; };
// 使用lodash的throttle const throttledClick = throttle(handleClick, 1000); </script>
|
4. 高级使用:自定义Hook扩展
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| <template> <div> <button @click="increment">点击增加</button> <p>计数: {{ count }}</p> <div @mousemove="onMouseMove">鼠标移动区域</div> <p>鼠标位置: {{ mousePosition.x }}, {{ mousePosition.y }}</p> </div> </template>
<script setup> import { ref } from "vue";
// 高级节流Hook function useThrottle(fn, delay = 300, options = {}) { const { leading = false, trailing = true } = options; let timeoutId = null; let lastCallTime = 0;
const throttledFn = (...args) => { const now = Date.now();
// 首次调用且允许立即执行 if (!lastCallTime && !leading) { lastCallTime = now; }
const remaining = delay - (now - lastCallTime);
if (remaining <= 0 || remaining > delay) { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } lastCallTime = now; fn.apply(this, args); } else if (!timeoutId && trailing) { timeoutId = setTimeout(() => { lastCallTime = leading ? Date.now() : 0; timeoutId = null; fn.apply(this, args); }, remaining); } };
const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } lastCallTime = 0; };
return { throttledFn, cleanup }; }
// 使用示例 const count = ref(0); const mousePosition = ref({ x: 0, y: 0 });
const increment = () => { count.value++; };
const { throttledFn: throttledIncrement } = useThrottle(increment, 1000, { leading: true, // 立即执行 trailing: false, // 不延迟执行 });
const updateMousePosition = (e) => { mousePosition.value = { x: e.clientX, y: e.clientY }; };
const { throttledFn: onMouseMove } = useThrottle(updateMousePosition, 50); </script>
|
六、注意事项
1. this指向问题
使用apply(this, args)确保函数执行时的this指向正确。在箭头函数中,this会继承外部作用域的this,所以不需要特别处理。
1 2 3 4 5 6 7 8 9
| const throttledFn = throttle(function () { console.log(this); }, 1000);
const throttledFn2 = throttle(() => { console.log(this); }, 1000);
|
2. 参数传递
通过...args接收并传递事件参数(如event对象),确保原函数能够接收到完整的参数信息。
1 2 3 4 5 6 7
| const handleClick = throttle((event, data) => { console.log(event.target); console.log(data); }, 1000);
element.addEventListener("click", (e) => handleClick(e, { id: 1 }));
|
3. 内存泄漏
在组件卸载时清理定时器,避免内存泄漏。特别是在单页应用中,组件频繁挂载和卸载时更要注意。
1 2 3 4 5 6
| onUnmounted(() => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } });
|
4. 场景选择
- 时间戳版:适合需要立即响应的场景(如实时计算滚动位置)
- 定时器版:适合需要延迟处理的场景(如输入框搜索联想)
- 综合版:适合需要兼顾首次立即执行和最后一次延迟执行的场景(如窗口resize后调整布局)
5. 节流时间的选择
- 高频事件(如鼠标移动、滚动):50-100ms
- 中频事件(如窗口resize、表单输入):200-300ms
- 低频事件(如按钮点击):1000ms左右
- 拖拽操作:16ms(约60fps)
6. 错误处理
在节流函数中添加错误处理,确保原函数的错误不会影响节流逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const throttle = (func, delay) => { let timeoutId; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { try { func.apply(this, args); } catch (error) { console.error("Throttled function error:", error); } timeoutId = null; }, delay); }; };
|