
前端老铁别硬扛:手写防抖节流太累,2026年主流库实战避坑指南
前端老铁别硬扛:手写防抖节流太累,2026年主流库实战避坑指南
开头先唠两句
咱就是说,现在谁还自己在那吭哧吭哧手写 debounce 和 throttle 啊?上次我手贱写了个,结果在群里被大佬喷成筛子,说逻辑有漏洞,高并发下直接原地爆炸。今天咱不整那些虚头八脑的理论,就聊聊怎么挑个靠谱的库,把搜索框、滚动加载这些让人头秃的场景给拿捏了,顺便吐槽一下那些踩过的坑,保你看完就能去项目里"抄作业"。
说实话,防抖和节流这俩概念,前端面试必问,简历上必写"精通",但真到项目里用的时候,十个有八个都在瞎搞。我见过最离谱的代码,是把防抖函数写在组件的 render 里,每次更新都重新定义,那防抖个寂寞啊?还有更绝的,在 Vue 的 computed 里用 throttle,结果响应式一触发,定时器直接乱套,页面卡得跟 PPT 似的。
所以啊,与其自己造轮子造得稀烂,不如找个靠谱的库。但问题来了,2026 年了,这俩函数的库早就卷成麻花了,从老牌 lodash 到各种新兴工具,从纯 JS 到 WASM 实现,选哪个?怎么用?坑在哪?今天咱就掰开了揉碎了聊。
这俩兄弟到底是个啥鬼
别被名字唬住了,其实道理特简单。防抖(debounce)就是你疯狂点按钮,它等你消停了再执行,像极了等女朋友化完妆出门;节流(throttle)就是不管你点多快,它只按固定节奏来,像极了地铁进站,到点才开门。
但这里有个误区,很多人以为防抖就是延迟执行,节流就是固定间隔。其实防抖还有"立即执行"模式,比如你点搜索按钮,第一次立即搜,后面狂点不管,等你不点了再补一次。这种模式在实战中特别实用,但自己手写很容易漏掉边界情况。
// 这是我自己曾经写过的"自信满满"版本,后来被大佬喷得体无完肤
function myDebounce(fn, delay) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args); // 这里其实有问题,this 指向可能丢失
}, delay);
};
}
// 问题在哪?
// 1. 没有立即执行选项
// 2. 没有取消功能
// 3. 没有返回值处理
// 4. 高并发下 timer 状态可能混乱
// 5. 没考虑 leading 和 trailing 的组合场景
看到没?就这几行代码,坑多得能埋人。这还是最简单的防抖,要是加上节流的 leading、trailing 控制,还有 requestAnimationFrame 的优化版本,代码量直接翻倍,bug 也翻倍。所以啊,专业的事交给专业的库,咱们把精力放在业务逻辑上不好吗?
现在的库都卷成啥样了
市面上那几个头部库,像 lodash 这种老牌劲旅,虽然稳但有点重;还有那种专门搞函数工具的小众库,轻是轻,但文档写得跟天书似的。最近还冒出来几个基于 Rust 编译到 WASM 的狠角色,性能炸裂,但兼容性又让人心里打鼓。
Lodash:老大哥还是稳
先说 lodash,这玩意儿在前端圈混了十几年了,debounce 和 throttle 是它的看家本领。优点是稳如老狗,文档齐全,TypeScript 支持完美。缺点是体积大,如果你只用这俩函数,打包进去 70 多 KB(虽然可以按需引入,但配置麻烦)。
// lodash 的用法,经典但有点繁琐
import { debounce, throttle } from 'lodash';
// 防抖 - 等用户输完 300ms 再搜
const searchDebounce = debounce((keyword) => {
console.log('搜索:', keyword);
return fetch(`/api/search?q=${keyword}`); // 支持返回 Promise
}, 300, {
leading: false, // 首次不立即执行
trailing: true, // 停止后执行最后一次
maxWait: 1000 // 最长等待 1 秒,防止一直输入永远不执行
});
// 节流 - 滚动事件每 100ms 最多触发一次
const scrollThrottle = throttle(() => {
console.log('滚动位置:', window.scrollY);
updateLazyImages(); // 懒加载图片
}, 100, {
leading: true, // 首次立即执行
trailing: false // 停止后不补执行
});
// 高级玩法:取消和刷新
searchDebounce('前端');
searchDebounce.cancel(); // 突然不想搜了,直接取消
// 还有 flush,立即执行并清空队列
scrollThrottle.flush();
看到没?lodash 的 options 配置丰富到变态,leading、trailing、maxWait 这三个参数组合起来,能覆盖 99% 的业务场景。特别是 maxWait,很多人不知道这个参数干嘛的。举个例子,你在一个长文本输入框里打字,如果用户一直不停,普通的防抖可能永远得不到执行机会,maxWait 就是兜底策略,强制最多等 1 秒必须执行一次。
但 lodash 也有让人吐槽的地方。它的 debounce 返回的函数,this 指向是绑死的,如果你在 React 类组件里用,经常需要 .bind(this) 或者箭头函数包一层,不然 this 指飞了你都找不到北。
Underscore:廉颇老矣
Underscore 算是 lodash 的前辈,现在用的人少了,但一些老项目还在用。它的 API 设计和 lodash 很像,但功能少很多,比如没有 maxWait,没有 flush。如果你还在维护十年前的项目,可能会遇到它,但新项目不建议用了,毕竟 lodash 几乎完全兼容它,还更强。
RxJS:函数式编程的"重炮"
RxJS 这玩意儿,学的时候觉得脑子不够用,用的时候觉得真香。它把防抖节流当成流的操作符来处理,概念上更统一,但学习曲线陡得能攀岩。
import { fromEvent } from 'rxjs';
import { debounceTime, throttleTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
// 搜索框防抖 + 取消上一次请求
const searchInput = document.getElementById('search');
fromEvent(searchInput, 'input')
.pipe(
debounceTime(300), // 防抖 300ms
distinctUntilChanged(), // 值没变就不触发(比如按方向键)
switchMap(event => { // 自动取消上一次的 Observable
const keyword = event.target.value;
console.log('真正发请求:', keyword);
return fetch(`/api/search?q=${keyword}`).then(r => r.json());
})
)
.subscribe(results => {
renderSearchResults(results);
});
// 滚动节流,带 leading 和 trailing 控制
fromEvent(window, 'scroll')
.pipe(
throttleTime(100, undefined, {
leading: true, // 开始立即执行
trailing: true // 结束也执行一次
})
)
.subscribe(() => {
checkScrollPosition();
});
RxJS 的好处是,防抖节流只是它庞大工具箱里的两个小螺丝刀,配合 switchMap、concatMap 这些操作符,能完美解决"旧请求覆盖新数据"这种经典痛点。坏处是,为了用个防抖,你得引入整个 RxJS,哪怕 tree-shaking 也得几十 KB,小项目有点杀鸡用牛刀。
轻量级选手:just-debounce-it 和 throttle-debounce
如果你就是嫌 lodash 太重,可以考虑这些专门做一件事的库。just-debounce-it 只有 200 多字节,throttle-debounce 稍微胖点,但 API 设计得很现代。
// just-debounce-it,极简主义者的最爱
import debounce from 'just-debounce-it';
const myEfficientDebounce = debounce((data) => {
console.log('处理数据:', data);
}, 250, true); // 第三个参数是 immediate,立即执行
// 取消也很简单
myEfficientDebounce('test');
myEfficientDebounce.cancel();
// throttle-debounce,支持 async/await 更友好
import { debounce as smartDebounce, throttle as smartThrottle } from 'throttle-debounce';
// 这个库的 debounce 支持 Promise 返回,还能拿到执行结果
const asyncDebounce = smartDebounce(500, async (id) => {
const res = await fetch(`/api/user/${id}`);
return res.json();
});
// 调用时可以 await
const userData = await asyncDebounce(123);
console.log('用户信息:', userData);
这些库的优点是体积小,缺点是功能相对单一。比如 just-debounce-it 就没有 maxWait,throttle-debounce 的 leading/trailing 控制不如 lodash 灵活。适合对包大小敏感,且需求简单的场景。
WASM 狠人:rust-debounce 和 friends
最近逛 GitHub 发现几个用 Rust 写的防抖节流库,编译成 WASM 跑在浏览器里。理论上性能应该炸裂,毕竟 Rust 没有 GC,内存管理更精细。但实际测下来,在普通业务场景里,和 JS 版本差距不大,只有在极端高频触发(比如鼠标移动事件每秒上千次)时才能看出优势。
// 假设你用了一个 WASM 版本的防抖库(伪代码,具体 API 看具体库)
import init, { create_debounce } from 'rust-debounce';
await init(); // 初始化 WASM 模块
const wasmDebounce = create_debounce(
(x, y) => console.log('鼠标位置:', x, y),
16 // 约等于 60fps
);
// 在 mousemove 里用,理论上性能更好
document.addEventListener('mousemove', (e) => {
wasmDebounce(e.clientX, e.clientY);
});
这种库的坑在于,WASM 的启动有异步初始化过程,而且和 JS 的交互有序列化开销。如果你的防抖函数很简单,WASM 的调用成本可能比节省的 CPU 时间还高。另外,调试困难,报错了堆栈信息全是 wasm 代码,看得你怀疑人生。建议除非你在做图形编辑器、游戏这种高频交互应用,否则别折腾。
选错了真的会谢
有些库看着挺美,一上生产环境就露馅。比如有的在处理快速连续触发时,最后一次执行会丢数据;有的在定时器清理上不干净,内存泄漏让你页面越跑越卡,最后浏览器直接教你做人。还有的对 TypeScript 支持极差,类型推断全红,逼得你只能 any 走天下,这谁能忍?
坑一:定时器清理不干净,内存泄漏到怀疑人生
这是最隐蔽的坑。很多库的 debounce 内部用 setTimeout,但如果你没正确取消,组件卸载了定时器还在跑,轻则内存泄漏,重则回调里访问了已经销毁的 DOM,直接报错。
// React 组件里的错误示范
function SearchComponent() {
const [results, setResults] = useState([]);
// 错误!每次渲染都创建新的 debounce 函数
const handleSearch = debounce((keyword) => {
fetchResults(keyword).then(setResults);
}, 300);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
// 正确做法:用 useMemo 或 useCallback 缓存
function SearchComponentFixed() {
const [results, setResults] = useState([]);
// 用 useMemo 保证只创建一次
const handleSearch = useMemo(
() => debounce((keyword) => {
fetchResults(keyword).then(setResults);
}, 300),
[] // 空依赖,只初始化一次
);
// 组件卸载时取消
useEffect(() => {
return () => {
handleSearch.cancel(); // 清理 pending 的定时器
};
}, [handleSearch]);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
看到没?React 里用防抖,必须用 useMemo 或者 useRef 来保持函数引用稳定,不然每次渲染都是新的函数,防抖个寂寞。而且卸载时一定要 cancel,不然你在组件 A 里发的请求,回调里 setState,结果组件 A 已经卸载了,React 会报警告,严重点整个应用崩溃。
坑二:异步地狱,Promise 状态乱套
如果你防抖的是一个 async 函数,要特别注意执行顺序。有些库的 debounce 不会等你 Promise 完成,只是延迟调用。如果连续触发,可能会同时存在多个 pending 的 Promise,最后哪个先回来还真不好说。
// 错误示范:自己瞎封装的 async 防抖
function badAsyncDebounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(async () => { // async 回调,但 debounce 不管 Promise
await fn.apply(this, args);
}, delay);
};
}
// 问题:如果 fn 执行时间很长,期间又触发了,不会取消上一次的执行
// 只是延迟了下一次的调用时间,两次请求可能同时存在
// 正确做法:用 AbortController 取消请求,或者用能处理 Promise 的库
import { debounce } from 'throttle-debounce';
function SearchComponentPro() {
const abortControllerRef = useRef(null);
const searchDebounce = useMemo(
() => debounce(300, async (keyword) => {
// 取消上一次的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 创建新的 controller
abortControllerRef.current = new AbortController();
try {
const res = await fetch(`/api/search?q=${keyword}`, {
signal: abortControllerRef.current.signal
});
const data = await res.json();
setResults(data);
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求被取消,这是正常的');
} else {
console.error('搜索出错:', err);
}
}
}),
[]
);
useEffect(() => {
return () => {
searchDebounce.cancel();
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [searchDebounce]);
return <input onChange={(e) => searchDebounce(e.target.value)} />;
}
这个例子结合了防抖和请求取消,是搜索框的终极解决方案。AbortController 是现代浏览器提供的标准 API,能真正取消 fetch 请求,而不是仅仅忽略结果。配合 debounce,既避免了频繁请求,又保证了数据一致性。
坑三:this 指向迷之丢失
这是 JS 的老问题了,但在防抖场景下特别容易踩。因为 debounce 返回的是新函数,原函数的 this 上下文会丢失。
class SearchManager {
constructor() {
this.cache = new Map();
// 错误!这里 this 会指向 window 或 undefined(严格模式)
this.debouncedSearch = debounce(this.search, 300);
}
search(keyword) {
// 这里的 this 是 undefined!
console.log(this.cache); // 报错!
}
}
// 解决方案 1:箭头函数
class SearchManagerFixed {
constructor() {
this.cache = new Map();
this.debouncedSearch = debounce((keyword) => this.search(keyword), 300);
}
search(keyword) {
console.log(this.cache); // 正常
}
}
// 解决方案 2:bind
class SearchManagerBind {
constructor() {
this.cache = new Map();
this.debouncedSearch = debounce(this.search.bind(this), 300);
}
}
// 解决方案 3:用 class fields 语法(最优雅)
class SearchManagerModern {
cache = new Map();
// 箭头函数属性,自动绑定 this
debouncedSearch = debounce((keyword) => {
console.log(this.cache); // 完美
return this.fetchData(keyword);
}, 300);
async fetchData(keyword) {
// ...
}
}
如果你用 TypeScript,第三个方案最爽,类型推断完美,this 也不会丢。但注意,class fields 语法创建的 debounce 函数是每个实例独立的,如果你创建 1000 个实例,就有 1000 个 debounce 函数和定时器,内存占用要考虑。
坑四:时间参数的动态调整
有些场景需要动态调整防抖延迟,比如网络好的时候 300ms,网络差的时候 100ms。但大多数库的 delay 参数是固定的,创建后不能改。
// 自己封装一个支持动态延迟的防抖
function createAdaptiveDebounce(fn) {
let timer = null;
let currentDelay = 300; // 默认 300ms
const debounced = function(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, currentDelay);
};
// 暴露修改延迟的方法
debounced.setDelay = (newDelay) => {
currentDelay = newDelay;
};
debounced.cancel = () => {
clearTimeout(timer);
};
return debounced;
}
// 使用:根据网络状况调整
const adaptiveSearch = createAdaptiveDebounce((keyword) => {
console.log('用', currentDelay, 'ms 的延迟搜索:', keyword);
});
// 检测到网络变慢
window.addEventListener('offline', () => {
adaptiveSearch.setDelay(100); // 离线或慢网时减少延迟,快速反馈
});
window.addEventListener('online', () => {
adaptiveSearch.setDelay(300); // 恢复默认
});
这种自适应防抖在移动端特别有用,4G 和 WiFi 切换时自动调整,用户体验更好。但注意,setDelay 只会影响下一次触发,已经 pending 的定时器不会变。
真实项目里怎么骚操作
光说不练假把式。搜素框联想这个经典场景,用防抖是基操,但怎么配合取消上一个请求,避免旧数据覆盖新数据,这里面的门道深着呢。还有那个无限滚动加载,节流用得不好,用户滚得快了直接白屏,滚慢了又频繁请求,怎么调参数才能既丝滑又省流量?甚至在一些拖拽排序、窗口 resize 监听里,这俩函数组合拳打好了,体验直接起飞。
搜索框的终极方案:防抖 + 请求取消 + 竞态处理
搜索框是防抖最经典的场景,但很多人只做了表面功夫。真正的生产环境要考虑:
- 快速输入时取消旧请求
- 防止旧请求晚返回覆盖新结果
- 空值处理(用户删光内容时不搜索)
- 加载状态管理
- 错误重试
// React + TypeScript 完整版搜索组件
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { debounce } from 'lodash';
interface SearchResult {
id: string;
title: string;
description: string;
}
interface UseSearchOptions {
minLength?: number; // 最小搜索长度
debounceMs?: number; // 防抖延迟
maxWaitMs?: number; // 最大等待时间
}
function useSmartSearch(
fetcher: (keyword: string) => Promise<SearchResult[]>,
options: UseSearchOptions = {}
) {
const {
minLength = 1,
debounceMs = 300,
maxWaitMs = 1000
} = options;
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// 用 ref 存储最新的请求序号,处理竞态
const requestIdRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
// 创建防抖的搜索函数
const debouncedSearch = useMemo(
() => debounce(
async (searchTerm: string, currentRequestId: number) => {
// 长度不够不搜索
if (searchTerm.length < minLength) {
setResults([]);
return;
}
setLoading(true);
setError(null);
// 取消上一次请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const data = await fetcher(searchTerm);
// 检查是否是最新请求,防止旧数据覆盖
if (currentRequestId === requestIdRef.current) {
setResults(data);
} else {
console.log('忽略过期的搜索结果');
}
} catch (err) {
if (err.name === 'AbortError') return;
if (currentRequestId === requestIdRef.current) {
setError(err);
}
} finally {
if (currentRequestId === requestIdRef.current) {
setLoading(false);
}
}
},
debounceMs,
{ maxWait: maxWaitMs } // 最长等待 1 秒,防止一直输入不触发
),
[fetcher, minLength, debounceMs, maxWaitMs]
);
// 监听 keyword 变化
useEffect(() => {
requestIdRef.current += 1;
const currentId = requestIdRef.current;
debouncedSearch(keyword, currentId);
// 清理函数
return () => {
debouncedSearch.cancel();
};
}, [keyword, debouncedSearch]);
// 组件卸载时彻底清理
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
keyword,
setKeyword,
results,
loading,
error,
// 手动刷新,不受防抖影响
refresh: useCallback(() => {
requestIdRef.current += 1;
debouncedSearch(keyword, requestIdRef.current);
debouncedSearch.flush(); // 立即执行
}, [keyword, debouncedSearch])
};
}
// 使用示例
function SearchComponent() {
const fetchSearchResults = async (keyword: string): Promise<SearchResult[]> => {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
if (!res.ok) throw new Error('搜索失败');
return res.json();
};
const {
keyword,
setKeyword,
results,
loading,
error,
refresh
} = useSmartSearch(fetchSearchResults, {
minLength: 2,
debounceMs: 300,
maxWaitMs: 800
});
return (
<div className="search-container">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="输入关键词搜索..."
className="search-input"
/>
{loading && <div className="loading-spinner">加载中...</div>}
{error && (
<div className="error-message">
出错了: {error.message}
<button onClick={refresh}>重试</button>
</div>
)}
<ul className="results-list">
{results.map(item => (
<li key={item.id} className="result-item">
<h4>{item.title}</h4>
<p>{item.description}</p>
</li>
))}
</ul>
{results.length === 0 && !loading && keyword.length >= 2 && (
<div className="empty-state">暂无结果</div>
)}
</div>
);
}
这个 Hook 的精髓在于 requestIdRef,每次 keyword 变化就自增,请求返回时检查 id 是否匹配,不匹配就直接丢弃。这比 AbortController 更可靠,因为 AbortController 只能取消请求,但如果请求已经在返回路上,取消不了,这时候 id 检查就能过滤掉旧数据。
无限滚动加载:节流的参数调优艺术
无限滚动是节流的经典场景,但参数调不好,要么卡成 PPT,要么疯狂请求把服务器打挂。
// 无限滚动 Hook,带智能节流
import { useEffect, useRef, useState, useCallback } from 'react';
import { throttle } from 'lodash';
function useInfiniteScroll(
fetchMore: () => Promise<boolean>, // 返回是否还有更多数据
options = {}
) {
const {
threshold = 100, // 距离底部多少像素触发
throttleMs = 200, // 节流间隔
maxRetries = 3 // 失败重试次数
} = options;
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState(null);
const retryCountRef = useRef(0);
const containerRef = useRef(null);
// 加载更多数据的包装函数
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const more = await fetchMore();
setHasMore(more);
retryCountRef.current = 0; // 成功重置重试计数
} catch (err) {
console.error('加载失败:', err);
setError(err);
// 失败重试逻辑
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
setTimeout(() => {
setError(null);
// 这里可以再次触发检查,或者等用户下次滚动
}, 1000 * retryCountRef.current); // 指数退避
}
} finally {
setLoading(false);
}
}, [fetchMore, loading, hasMore]);
// 节流的滚动检查函数
const throttledCheck = useMemo(
() => throttle(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const scrollBottom = container.scrollTop + container.clientHeight;
const height = container.scrollHeight;
// 距离底部 threshold 像素时触发
if (height - scrollBottom < threshold) {
loadMore();
}
}, throttleMs, {
leading: false, // 滚动开始不立即执行,等停下来
trailing: true // 滚动结束检查一次
}),
[loadMore, threshold, throttleMs]
);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('scroll', throttledCheck);
// 初始检查,防止内容不够一屏
throttledCheck();
return () => {
container.removeEventListener('scroll', throttledCheck);
throttledCheck.cancel();
};
}, [throttledCheck]);
// 暴露刷新方法
const refresh = useCallback(() => {
setHasMore(true);
setError(null);
retryCountRef.current = 0;
loadMore();
}, [loadMore]);
return {
containerRef,
loading,
hasMore,
error,
refresh
};
}
// 使用示例
function FeedList() {
const [page, setPage] = useState(1);
const [items, setItems] = useState([]);
const fetchMore = async () => {
const res = await fetch(`/api/feed?page=${page}&limit=20`);
const data = await res.json();
if (data.length === 0) return false; // 没有更多了
setItems(prev => [...prev, ...data]);
setPage(p => p + 1);
return true; // 可能还有更多
};
const { containerRef, loading, hasMore, error, refresh } = useInfiniteScroll(
fetchMore,
{
threshold: 150, // 提前 150px 开始加载,无缝体验
throttleMs: 150 // 150ms 检查一次,平衡性能和实时性
}
);
return (
<div ref={containerRef} className="feed-container" style={{ overflowY: 'auto', height: '100vh' }}>
{items.map(item => (
<FeedCard key={item.id} data={item} />
))}
{loading && <div className="loading-more">加载中...</div>}
{!hasMore && <div className="no-more">到底了,别刷了</div>}
{error && (
<div className="error-load">
加载失败 <button onClick={refresh}>点击重试</button>
</div>
)}
</div>
);
}
这里的参数 tuning 是关键。threshold 设太小,用户滑到底才加载,会看到白屏;设太大,提前加载太多,浪费流量。throttleMs 也是,设太小,滚动时频繁检查,CPU 占用高;设太大,可能错过触发时机。一般建议 threshold 100-200px,throttleMs 100-200ms,根据实际内容高度调整。
拖拽排序:防抖节流的组合拳
拖拽排序这种高频交互,需要同时用防抖和节流。节流控制位置更新的频率(60fps 流畅度),防抖处理最终的保存请求。
// 拖拽排序组件,组合拳示例
import React, { useState, useCallback, useRef } from 'react';
import { throttle, debounce } from 'lodash';
function SortableList({ items: initialItems, onReorder }) {
const [items, setItems] = useState(initialItems);
const [draggingId, setDraggingId] = useState(null);
const [dragOverId, setDragOverId] = useState(null);
// 节流:拖拽时实时更新位置,限制 60fps(16ms)
const throttledMove = useMemo(
() => throttle((fromId, toId) => {
setItems(prev => {
const fromIndex = prev.findIndex(i => i.id === fromId);
const toIndex = prev.findIndex(i => i.id === toId);
if (fromIndex === -1 || toIndex === -1) return prev;
const newItems = [...prev];
const [moved] = newItems.splice(fromIndex, 1);
newItems.splice(toIndex, 0, moved);
return newItems;
});
}, 16),
[]
);
// 防抖:拖拽结束后保存,等 500ms 确认没再拖了再发请求
const debouncedSave = useMemo(
() => debounce((newItems) => {
console.log('保存新顺序到服务器:', newItems.map(i => i.id));
onReorder(newItems);
}, 500),
[onReorder]
);
const handleDragStart = useCallback((e, id) => {
setDraggingId(id);
e.dataTransfer.effectAllowed = 'move';
// 设置拖拽图像(可选)
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(img, 0, 0);
}, []);
const handleDragOver = useCallback((e, id) => {
e.preventDefault();
if (id === draggingId || id === dragOverId) return;
setDragOverId(id);
// 节流更新视觉顺序,流畅但不卡
throttledMove(draggingId, id);
}, [draggingId, dragOverId, throttledMove]);
const handleDrop = useCallback((e) => {
e.preventDefault();
setDraggingId(null);
setDragOverId(null);
// 防抖保存,避免频繁请求
debouncedSave(items);
}, [items, debouncedSave]);
const handleDragEnd = useCallback(() => {
setDraggingId(null);
setDragOverId(null);
// 如果没触发 drop,也要保存
debouncedSave.flush(); // 立即执行,不等了
}, [debouncedSave]);
return (
<ul className="sortable-list" onDragEnd={handleDragEnd}>
{items.map((item, index) => (
<li
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, item.id)}
onDragOver={(e) => handleDragOver(e, item.id)}
onDrop={handleDrop}
className={`sortable-item ${draggingId === item.id ? 'dragging' : ''} ${dragOverId === item.id ? 'drag-over' : ''}`}
style={{
transform: draggingId === item.id ? 'scale(1.02)' : 'none',
transition: 'transform 0.1s'
}}
>
<span className="drag-handle">☰</span>
<span className="item-index">{index + 1}.</span>
<span className="item-content">{item.content}</span>
</li>
))}
</ul>
);
}
// 使用
function App() {
const [items, setItems] = useState([
{ id: '1', content: '学习 React' },
{ id: '2', content: '学习 TypeScript' },
{ id: '3', content: '学习 Node.js' },
{ id: '4', content: '学习设计模式' }
]);
const handleReorder = useCallback(async (newItems) => {
// 这里可以发请求保存顺序
await fetch('/api/reorder', {
method: 'POST',
body: JSON.stringify({ ids: newItems.map(i => i.id) }),
headers: { 'Content-Type': 'application/json' }
});
}, []);
return <SortableList items={items} onReorder={handleReorder} />;
}
这个例子展示了如何组合使用:throttle 处理高频的拖拽移动(16ms 约等于 60fps),debounce 处理低频的保存操作(500ms)。这样既保证了拖拽的流畅度,又避免了服务器压力。
窗口 Resize:性能杀手怎么治
窗口 resize 是性能重灾区,如果不做处理,连续触发几百次,重排重绘能把页面卡死。
// 响应式布局的 Resize 处理
import { useEffect, useState, useMemo } from 'react';
import { debounce, throttle } from 'lodash';
function useResponsive() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
const [breakpoint, setBreakpoint] = useState('desktop');
// 节流:实时更新尺寸,限制频率
const throttledUpdateSize = useMemo(
() => throttle(() => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}, 100), // 100ms 更新一次尺寸数据
[]
);
// 防抖:判断断点变化,等 resize 结束再确定
const debouncedUpdateBreakpoint = useMemo(
() => debounce((width) => {
if (width < 768) {
setBreakpoint('mobile');
} else if (width < 1024) {
setBreakpoint('tablet');
} else {
setBreakpoint('desktop');
}
// 可以在这里触发布局重计算
console.log('当前断点:', width < 768 ? 'mobile' : width < 1024 ? 'tablet' : 'desktop');
}, 150),
[]
);
useEffect(() => {
const handleResize = () => {
throttledUpdateSize();
debouncedUpdateBreakpoint(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// 初始化一次
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
throttledUpdateSize.cancel();
debouncedUpdateBreakpoint.cancel();
};
}, [throttledUpdateSize, debouncedUpdateBreakpoint]);
return {
...windowSize,
breakpoint,
isMobile: breakpoint === 'mobile',
isTablet: breakpoint === 'tablet',
isDesktop: breakpoint === 'desktop'
};
}
// 使用:自适应组件
function AdaptiveLayout() {
const { width, height, breakpoint, isMobile } = useResponsive();
return (
<div className={`layout ${breakpoint}`}>
<header>
<h1>当前窗口: {width}x{height}</h1>
<p>断点: {breakpoint}</p>
</header>
<main style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)',
gap: '20px',
transition: 'grid-template-columns 0.3s' // 平滑过渡
}}>
<div className="card">内容 1</div>
<div className="card">内容 2</div>
<div className="card">内容 3</div>
</main>
</div>
);
}
这里用了双重保险:throttle 保证尺寸数据实时但不频繁更新,debounce 保证断点判断在 resize 结束后才最终确定。如果只用 throttle,resize 过程中会频繁判断断点,可能导致布局抖动;如果只用 debounce,resize 过程中完全没有反馈,用户看不到实时变化。
遇到 Bug 别只会重启
有时候代码明明没毛病,就是不生效。这时候别急着删库跑路。先看看是不是上下文 this 指飞了,特别是在回调函数里;再查查定时器有没有被意外清除,或者多个实例互相干扰。如果是用了异步 async/await,更要小心,别让 Promise 的状态把执行顺序搞乱了。学会用浏览器的 Performance 面板抓时间线,一眼就能看出是哪个环节掉了链子。
调试技巧一:Devtools Performance 面板
打开 Chrome DevTools,切到 Performance 面板,录制一段交互,你能看到:
- Task:看是否有长任务(Long Task),如果有,说明防抖节流没生效,主线程被阻塞了
- Function Call:展开后能看到 debounce/throttle 内部函数的调用频率
- Timer Fired:看 setTimeout/setInterval 的触发情况,检查定时器是否按预期工作
// 给 debounce 函数打标记,方便在 Performance 面板识别
function debugDebounce(fn, delay) {
let timer;
return function(...args) {
// 在控制台标记,Performance 面板能看到
console.timeStamp('debounce triggered');
clearTimeout(timer);
timer = setTimeout(() => {
console.timeStamp('debounce executed');
fn.apply(this, args);
}, delay);
};
}
// 使用
const test = debugDebounce(() => {
console.log('真正执行');
}, 1000);
// 快速点击 5 次
for (let i = 0; i < 5; i++) {
setTimeout(() => test(i), i * 100);
}
// 预期:只看到一次 'debounce executed' 和 '真正执行'
调试技巧二:检查多个实例
React 中最常见的 bug 是创建了多个 debounce 实例,每个都有自己的定时器,结果就乱套了。
// 错误示范:每次渲染都创建新实例
function BadComponent() {
const [count, setCount] = useState(0);
// 错误!每次渲染都新的 debounce
const handleClick = debounce(() => {
console.log('点击了', count);
}, 1000);
return <button onClick={handleClick}>点我</button>;
}
// 怎么发现这个问题?加日志
function BadComponentDebug() {
const [count, setCount] = useState(0);
const handleClick = useMemo(() => {
console.log('创建新的 debounce 实例'); // 如果频繁打印,说明有问题
return debounce(() => {
console.log('执行,count=', count);
}, 1000);
}, [count]); // 依赖 count 导致每次更新都重建
return <button onClick={handleClick}>点我</button>;
}
// 正确做法:空依赖数组,或者用 ref
function GoodComponent() {
const countRef = useRef(count);
countRef.current = count; // 保持最新值
const handleClick = useMemo(() => {
return debounce(() => {
console.log('执行,count=', countRef.current); // 通过 ref 获取最新值
}, 1000);
}, []); // 空依赖,只创建一次
return <button onClick={handleClick}>点我</button>;
}
调试技巧三:内存泄漏检测
如果组件反复挂载卸载,debounce 的定时器没清理,会导致内存泄漏。用 Chrome 的 Memory 面板可以检测:
- 打开 Memory 面板
- 点击"Take heap snapshot"
- 执行一系列操作(比如打开关闭弹窗 10 次)
- 再拍一张快照
- 对比两个快照,搜索你的组件名或 debounce 相关对象
如果发现实例数量只增不减,说明有泄漏。检查 useEffect 的 cleanup 函数是否调用了 cancel。
// 安全的 Hook 封装,带内存泄漏防护
function useSafeDebounce(fn, delay, deps = []) {
const fnRef = useRef(fn);
fnRef.current = fn;
const debouncedFn = useMemo(
() => debounce((...args) => fnRef.current(...args), delay),
[delay] // 只依赖 delay
);
useEffect(() => {
return () => {
debouncedFn.cancel();
};
}, [debouncedFn]);
return debouncedFn;
}
// 使用
function SafeComponent() {
const [text, setText] = useState('');
const debouncedSearch = useSafeDebounce((value) => {
console.log('搜索:', value);
}, 500);
return (
<input
value={text}
onChange={(e) => {
setText(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}
几个让同事喊爸爸的 trick
除了调库,咱还得有点私货。比如怎么封装一个通用的 Hook,在 React 或 Vue 里一行代码搞定防抖;怎么利用函数的柯里化,把配置项预设好,让业务代码清爽得像刚洗过的衬衫。还有啊,别死守着默认参数,根据网络状况动态调整延迟时间,这种小细节才是区分初级和高级开发的分水岭。
Trick 1:通用 Hook 封装,一行代码搞定
// 终极版 useDebounce Hook,支持所有选项
import { useMemo, useEffect, useRef } from 'react';
import { debounce, throttle, DebouncedFunc, ThrottledFunc } from 'lodash';
import type { DebounceSettings, ThrottleSettings } from 'lodash';
type UseDebounceOptions = DebounceSettings & {
immediate?: boolean; // 是否立即执行
};
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay: number = 300,
options: UseDebounceOptions = {}
): DebouncedFunc<T> & { cancel: () => void; flush: () => void } {
const { immediate = false, ...debounceOptions } = options;
const fnRef = useRef(fn);
fnRef.current = fn;
const debounced = useMemo(() => {
const debouncedFn = debounce(
(...args: Parameters<T>) => fnRef.current(...args),
delay,
{
leading: immediate, // 立即执行用 leading
trailing: !immediate, // 非立即执行用 trailing
...debounceOptions
}
);
return debouncedFn;
}, [delay, immediate, ...Object.values(debounceOptions)]);
useEffect(() => {
return () => {
debounced.cancel();
};
}, [debounced]);
return debounced as any;
}
// 对应的 useThrottle
export function useThrottle<T extends (...args: any[]) => any>(
fn: T,
interval: number = 200,
options: ThrottleSettings = {}
): ThrottledFunc<T> & { cancel: () => void; flush: () => void } {
const fnRef = useRef(fn);
fnRef.current = fn;
const throttled = useMemo(() => {
return throttle(
(...args: Parameters<T>) => fnRef.current(...args),
interval,
{ leading: true, trailing: false, ...options }
);
}, [interval, ...Object.values(options)]);
useEffect(() => {
return () => throttled.cancel();
}, [throttled]);
return throttled as any;
}
// 使用示例,简单到离谱
function SearchInput() {
const [value, setValue] = useState('');
// 一行代码搞定,还能自动清理
const debouncedSearch = useDebounce(
(keyword: string) => {
console.log('搜索:', keyword);
// 发请求...
},
500,
{ maxWait: 2000 } // 最长等 2 秒
);
return (
<input
value={value}
onChange={(e) => {
setValue(e.target.value);
debouncedSearch(e.target.value);
}}
placeholder="输入搜索关键词..."
/>
);
}
这个 Hook 的精髓在于:
- 用 ref 保持函数引用最新,避免闭包陷阱
- 自动处理 cancel,防止内存泄漏
- TypeScript 类型完整,有智能提示
- 支持所有 lodash 的选项
Trick 2:柯里化预设配置
如果你在一个项目里到处都用同样的防抖配置,可以用柯里化封装:
// 创建预设的防抖函数工厂
const createProjectDebounce = (defaultDelay = 300) => {
return (fn, customDelay, customOptions) => {
return debounce(fn, customDelay || defaultDelay, {
maxWait: 1000, // 项目统一配置
leading: false,
trailing: true,
...customOptions // 允许覆盖
});
};
};
// 创建项目级实例
const projectDebounce = createProjectDebounce(300);
// 使用,代码更清爽
const search = projectDebounce((keyword) => {
api.search(keyword);
});
const saveDraft = projectDebounce((content) => {
api.saveDraft(content);
}, 1000); // 覆盖延迟为 1 秒
const urgentSave = projectDebounce((content) => {
api.save(content);
}, 0, { leading: true, trailing: false }); // 完全自定义
Trick 3:网络自适应延迟
根据网络状况动态调整防抖时间,WiFi 时延迟高点省流量,4G 时延迟低点快速反馈。
// 网络状态感知防抖
function useNetworkAwareDebounce(fn, options = {}) {
const { fastDelay = 100, slowDelay = 500 } = options;
const [delay, setDelay] = useState(slowDelay);
useEffect(() => {
// 监听网络变化
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
const updateConnectionStatus = () => {
// effectiveType: '4g', '3g', '2g', 'slow-2g'
const effectiveType = connection.effectiveType;
if (effectiveType === '4g' && !connection.saveData) {
setDelay(fastDelay); // 网速快,延迟低点
} else {
setDelay(slowDelay); // 网速慢或省流量模式,延迟高点
}
};
connection.addEventListener('change', updateConnectionStatus);
updateConnectionStatus(); // 初始检查
return () => connection.removeEventListener('change', updateConnectionStatus);
}
}, [fastDelay, slowDelay]);
return useDebounce(fn, delay, options);
}
// 使用
function SmartSearch() {
const search = useNetworkAwareDebounce(
(keyword) => api.search(keyword),
{ fastDelay: 150, slowDelay: 600 }
);
return <input onChange={(e) => search(e.target.value)} />;
}
Trick 4:组合键防抖
处理键盘快捷键时,需要特殊处理,比如 Ctrl+S 保存,要防止按住不放时疯狂触发。
// 键盘快捷键防抖
function useKeyboardShortcut(keyCombo, callback, delay = 300) {
const pressedKeys = useRef(new Set());
const debouncedCallback = useDebounce(callback, delay, {
leading: true, // 第一次立即执行
trailing: false // 不补执行
});
useEffect(() => {
const handleKeyDown = (e) => {
const key = e.key.toLowerCase();
pressedKeys.current.add(key);
// 检查组合键是否匹配
const keys = keyCombo.toLowerCase().split('+');
const allPressed = keys.every(k => pressedKeys.current.has(k));
if (allPressed) {
e.preventDefault();
debouncedCallback();
}
};
const handleKeyUp = (e) => {
pressedKeys.current.delete(e.key.toLowerCase());
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [keyCombo, debouncedCallback]);
}
// 使用:Ctrl+S 保存,即使按住也只执行一次
function Editor() {
useKeyboardShortcut('ctrl+s', () => {
console.log('保存文档...');
saveDocument();
}, 500);
return <textarea />;
}
最后碎碎念
行了,差不多就唠到这。其实工具再好,也就是个辅助,关键还是得理解背后的原理,不然换个场景照样抓瞎。下次要是再看到谁在项目里手写十几行的防抖函数,记得把这篇文章甩他脸上,让他知道什么叫"站在巨人的肩膀上摸鱼"。
说到底,防抖和节流这俩玩意儿,前端面试问了十年,项目里用了十年,但每年还是有新人踩坑。为啥?因为光看概念简单,真到工程实践里,要考虑边界情况、内存管理、异步处理、框架集成,复杂度直接翻倍。
2026 年了,lodash 依然是稳妥的选择,但如果你对包大小敏感,throttle-debounce 这种轻量库也够用了。RxJS 适合已经在用响应式编程的项目,WASM 版本除非极端性能需求否则不建议折腾。
最后送大家一句话:别重复造轮子,除非你能造得更好;也别盲目用库,除非你理解它在干嘛。技术选型没有银弹,只有适合当前场景的解决方案。咱们下期再见,拜拜了您嘞!
欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。
推荐:DTcode7的博客首页。
一个做过前端开发的产品经理,经历过睿智产品的折磨导致脱发之后,励志要翻身农奴把歌唱,一边打入敌人内部一边持续提升自己,为我们广大开发同胞谋福祉,坚决抵制睿智产品折磨我们码农兄弟!
| 专栏系列(点击解锁) | 学习路线(点击解锁) | 知识定位 |
|---|---|---|
| 《微信小程序相关博客》 | 持续更新中~ | 结合微信官方原生框架、uniapp等小程序框架,记录请求、封装、tabbar、UI组件的学习记录和使用技巧等 |
| 《AIGC相关博客》 | 持续更新中~ | AIGC、AI生产力工具的介绍,例如stable diffusion这种的AI绘画工具安装、使用、技巧等总结 |
| 《HTML网站开发相关》 | 《前端基础入门三大核心之html相关博客》 | 前端基础入门三大核心之html板块的内容,入坑前端或者辅助学习的必看知识 |
| 《前端基础入门三大核心之JS相关博客》 | 前端JS是JavaScript语言在网页开发中的应用,负责实现交互效果和动态内容。它与HTML和CSS并称前端三剑客,共同构建用户界面。 通过操作DOM元素、响应事件、发起网络请求等,JS使页面能够响应用户行为,实现数据动态展示和页面流畅跳转,是现代Web开发的核心 | |
| 《前端基础入门三大核心之CSS相关博客》 | 介绍前端开发中遇到的CSS疑问和各种奇妙的CSS语法,同时收集精美的CSS效果代码,用来丰富你的web网页 | |
| 《canvas绘图相关博客》 | Canvas是HTML5中用于绘制图形的元素,通过JavaScript及其提供的绘图API,开发者可以在网页上绘制出各种复杂的图形、动画和图像效果。Canvas提供了高度的灵活性和控制力,使得前端绘图技术更加丰富和多样化 | |
| 《Vue实战相关博客》 | 持续更新中~ | 详细总结了常用UI库elementUI的使用技巧以及Vue的学习之旅 |
| 《python相关博客》 | 持续更新中~ | Python,简洁易学的编程语言,强大到足以应对各种应用场景,是编程新手的理想选择,也是专业人士的得力工具 |
| 《sql数据库相关博客》 | 持续更新中~ | SQL数据库:高效管理数据的利器,学会SQL,轻松驾驭结构化数据,解锁数据分析与挖掘的无限可能 |
| 《算法系列相关博客》 | 持续更新中~ | 算法与数据结构学习总结,通过JS来编写处理复杂有趣的算法问题,提升你的技术思维 |
| 《IT信息技术相关博客》 | 持续更新中~ | 作为信息化人员所需要掌握的底层技术,涉及软件开发、网络建设、系统维护等领域的知识 |
| 《信息化人员基础技能知识相关博客》 | 无论你是开发、产品、实施、经理,只要是从事信息化相关行业的人员,都应该掌握这些信息化的基础知识,可以不精通但是一定要了解,避免日常工作中贻笑大方 | |
| 《信息化技能面试宝典相关博客》 | 涉及信息化相关工作基础知识和面试技巧,提升自我能力与面试通过率,扩展知识面 | |
| 《前端开发习惯与小技巧相关博客》 | 持续更新中~ | 罗列常用的开发工具使用技巧,如 Vscode快捷键操作、Git、CMD、游览器控制台等 |
| 《photoshop相关博客》 | 持续更新中~ | 基础的PS学习记录,含括PPI与DPI、物理像素dp、逻辑像素dip、矢量图和位图以及帧动画等的学习总结 |
| 日常开发&办公&生产【实用工具】分享相关博客》 | 持续更新中~ | 分享介绍各种开发中、工作中、个人生产以及学习上的工具,丰富阅历,给大家提供处理事情的更多角度,学习了解更多的便利工具,如Fiddler抓包、办公快捷键、虚拟机VMware等工具 |
吾辈才疏学浅,摹写之作,恐有瑕疵。望诸君海涵赐教。望轻喷,嘤嘤嘤
非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。愿斯文对汝有所裨益,纵其简陋未及渊博,亦足以略尽绵薄之力。倘若尚存阙漏,敬请不吝斧正,俾便精进!

转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/black_cat7/article/details/158353717



