搜索输入防抖回调乱序问题的解决方案及最佳实践
场景与现有实现
以下是用普通防抖处理搜索输入的Vue组件代码:
created() { this.handleChange = debounce(async (ev) => { this.loading = true; const { target: { value: search } } = ev; if (search) { const response = await searchRepos({ search }); const repos = await response.json(response); console.log(search); console.log(repos?.items); this.repos = repos?.items; } else { this.repos = []; } this.loading = false; }, 500); }
对应的普通防抖函数实现:
const debounce = (callback, wait) => { let timeoutId = null; return (...args) => { window.clearTimeout(timeoutId); timeoutId = window.setTimeout(() => { callback.apply(null, args); }, wait); }; }
现有实现的问题
这个实现能正常运行,但存在请求竞态问题:回调之间互不等待,旧请求的响应可能晚于新请求返回,导致页面显示无效数据。
比如用户先删除倒数第二个字符触发请求,随后删除最后一个字符触发另一个无请求的回调(直接清空列表),后者很快完成;但之后旧请求的响应才回来,会把无效数据重新渲染到页面上。即使两个都是请求,也没法保证响应顺序和请求顺序一致。
尝试的改进方案
为了解决这个问题,我尝试修改防抖函数如下:
export const debounce = (callback, waitTime) => { let timeoutId = null; let isPreviousPerforming = false; let callbacksOrder = []; const performRest = async () => { for (const order of callbacksOrder) { await callback.apply(null, [order.ev]); } callbacksOrder = [] }; return (ev) => { window.clearTimeout(timeoutId); timeoutId = window.setTimeout(async () => { if (isPreviousPerforming) { callbacksOrder.push({ ev }); return; } isPreviousPerforming = true; await callback.apply(null, [ev]); await performRest(); isPreviousPerforming = false; }, waitTime); }; }
但这个方案有潜在问题:在for循环结束到callbacksOrder = []执行的间隙,如果有新事件被推入队列,会被直接清空,我的这个理解是否正确?
可行解决方案与最佳实践
针对搜索输入这类场景,确保回调(请求)按顺序生效的方案有以下几种:
1. 取消未完成的旧请求
使用浏览器原生的AbortController,每次发起新请求时,取消上一个未完成的请求,从根源上避免旧响应干扰页面。
修改后的Vue组件代码:
created() { let abortController = null; this.handleChange = debounce(async (ev) => { // 取消上一个未完成的请求 if (abortController) abortController.abort(); this.loading = true; const { target: { value: search } } = ev; if (search) { abortController = new AbortController(); try { const response = await searchRepos({ search, signal: abortController.signal }); const repos = await response.json(); this.repos = repos?.items; } catch (err) { // 忽略取消请求的错误 if (err.name !== 'AbortError') throw err; } } else { this.repos = []; } this.loading = false; }, 500); }
2. 标记请求的有效性
给每个请求生成唯一标识,只有最新标识的请求返回时,才更新页面状态。
修改后的组件代码:
created() { let latestRequestId = 0; this.handleChange = debounce(async (ev) => { const requestId = ++latestRequestId; this.loading = true; const { target: { value: search } } = ev; if (search) { const response = await searchRepos({ search }); const repos = await response.json(); // 只有当前请求是最新的才更新 if (requestId === latestRequestId) { this.repos = repos?.items; } } else { if (requestId === latestRequestId) { this.repos = []; } } // 只有最新请求才更新loading状态 if (requestId === latestRequestId) { this.loading = false; } }, 500); }
3. 修复队列化防抖的竞态问题
针对你之前的队列化方案,问题确实存在:在performRest的for循环结束到callbacksOrder = []之间,新事件可能被推入队列然后被清空。可以通过先复制队列快照再处理的方式解决:
export const debounce = (callback, waitTime) => { let timeoutId = null; let isProcessing = false; let taskQueue = []; const processQueue = async () => { // 先获取当前队列的快照,避免后续修改影响循环 const tasks = [...taskQueue]; taskQueue = []; for (const task of tasks) { await callback.apply(null, [task.ev]); } }; return (ev) => { window.clearTimeout(timeoutId); timeoutId = window.setTimeout(async () => { if (isProcessing) { taskQueue.push({ ev }); return; } isProcessing = true; await callback.apply(null, [ev]); await processQueue(); isProcessing = false; }, waitTime); }; };
这样即使在processQueue执行过程中有新任务加入,也会保留到下一次处理,不会被误清空。
4. 结合Vue的watch与防抖
如果是在Vue中处理搜索输入,也可以直接用watch监听搜索值,配合防抖和请求有效性检查:
data() { return { searchText: '', repos: [], loading: false, latestRequestId: 0 }; }, watch: { searchText: debounce(async function(newVal) { const requestId = ++this.latestRequestId; this.loading = true; if (newVal) { const response = await searchRepos({ search: newVal }); const repos = await response.json(); if (requestId === this.latestRequestId) { this.repos = repos?.items; } } else { if (requestId === this.latestRequestId) { this.repos = []; } } if (requestId === this.latestRequestId) { this.loading = false; } }, 500) }
关于你提出的疑问
你的理解是正确的:在for (const order of callbacksOrder)循环结束后,到callbacksOrder = []执行前的短暂间隙,如果有新的任务被推入callbacksOrder,那么紧接着的清空操作会把这个新任务也删掉,导致它永远不会被执行。用快照复制的方式就能避免这个问题。
内容的提问来源于stack exchange,提问作者Var Lynx

