本节摘要:三个高频交互的完整实现——搜索联想(防抖加请求竞态防护)、无限滚动(可视区检测加分页追加)、点赞收藏(乐观更新加失败回滚)。每个场景都由一个"不做防护就会出事"的具体 bug 引出,代码可直接落项目。三个案例合起来覆盖了 Ajax 交互开发里最常见的三组技术组合。
搜索框输入时下拉实时出联想词,是 Ajax 的门面场景。先看两个必出的事故。
事故 A:没有防抖。用户输入"JavaScript"十个字符,input 事件触发十次,十次请求飞向服务器——前九次的结果全部作废,服务器白算九次,用户的输入还可能因为请求排队而卡顿。事故 B:没有竞态防护。输入"jav"发出请求一,紧接着输入"java"发出请求二;网络抽风,请求二先回来、请求一后回来——下拉框最终显示的是"jav"的结果,与输入框里的"java"对不上,用户看到的联想词驴唇不对马嘴。
两个事故对应两件武器:防抖砍掉无效请求,竞态防护淘汰过期响应。

完整实现:
function debounce(fn, delay = 300) { let timer; return function (...args) { clearTimeout(timer); // 每次输入都取消上一次的定时 timer = setTimeout(() => fn.apply(this, args), delay); }; } const searchInput = document.getElementById('search'); const dropdown = document.getElementById('suggest'); let requestSeq = 0; // 请求序号 竞态防护的对账凭据 const doSearch = debounce(async () => { const kw = searchInput.value.trim(); if (!kw) { hideDropdown(); return; } const seq = ++requestSeq; // 本次请求的编号 const res = await fetch('/api/suggest?q=' + encodeURIComponent(kw)); const words = await res.json(); if (seq !== requestSeq) return; // 不是最新请求 丢弃 防止旧结果覆盖新输入 renderSuggest(words); }, 300); searchInput.addEventListener('input', doSearch);
防抖的间隔值是体验与负载的折中:太短(100 毫秒内)防不了快速输入,太长(超过半秒)用户感觉"反应迟钝"。搜索类 250 到 350 毫秒是常见区间。注意提交前要取消防抖(回车直接搜索时清掉定时器立即执行),以及竞态防护还有个更现代的选项——用 AbortController 取消旧请求(4.4 节),连流量都省下来。
社交 feed、图片瀑布流的标配:滚到底部附近自动加载下一页。核心是两件事:知道"快到底了"、把新页追加而不是替换。
检测滚到底的方案经历了演化。老方案监听 scroll 事件算 scrollHeight - scrollTop - clientHeight,要自己防抖且每次滚动都计算;现代方案用 IntersectionObserver——放一个哨兵元素在列表底部,它进入视口时回调触发,浏览器自行优化触发时机:
const sentinel = document.getElementById('sentinel'); // 列表底部的空占位 let page = 1, loading = false, hasMore = true; const observer = new IntersectionObserver(async (entries) => { if (!entries[0].isIntersecting) return; if (loading || !hasMore) return; // 在途请求与没有更多 都不重复触发 await loadMore(); }, { rootMargin: '300px' }); // 提前 300 像素预加载 用户无感 observer.observe(sentinel); async function loadMore() { loading = true; showFooterSpinner(); try { const data = await request('/api/feed?page=' + (page + 1)); if (data.items.length === 0) { hasMore = false; // 空页 = 到底了 摘掉观察器 observer.disconnect(); showEndMark(); } else { appendItems(data.items); // 追加而非替换 见 3.3 节文档片段 page = data.page; } } finally { loading = false; hideFooterSpinner(); } }
三个工程细节决定这个功能的口碑。其一,rootMargin 提前量:等到真的见底才请求,用户会盯着 spinner 等待;提前一屏触发,数据在人滚到之前就位。其二,loading 布尔闸:IntersectionObserver 可能连续触发(内容不足一屏时尤其),没有闸门就是同一页请求发五次。其三,终止条件:hasMore 加空页判断,滚到底后干净地摘掉观察器、显示"没有更多"——无限滚动必须有终点提示,否则用户对着一个转不完的圈不知道何时该停。
追加渲染沿 3.3 节的纪律:文档片段批量挂载、textContent 构建、事件委托到容器。分页参数用页码还是游标,取决于数据是否持续追加(2.4 节的结论:feed 流用游标防重复漏条)。
点赞按钮若等服务器确认再变色,一次几百毫秒的网络往返会让交互显得"黏滞"。主流做法是乐观更新:点了立即在界面生效,同时后台发请求;失败再把界面滚回去并告知:
async function toggleLike(articleId, btn) { const liked = btn.classList.contains('liked'); const countEl = btn.querySelector('.count'); // 乐观更新:先改界面 不等服务器 const optimisticCount = +countEl.textContent + (liked ? -1 : 1); btn.classList.toggle('liked'); countEl.textContent = optimisticCount; try { const data = await request('/api/articles/' + articleId + '/like', { method: liked ? 'DELETE' : 'POST' }); countEl.textContent = data.likes; // 以服务器为准 校准 } catch (err) { // 回滚:界面退回点击前状态 btn.classList.toggle('liked'); countEl.textContent = optimisticCount + (liked ? 1 : -1); toast('操作失败,请重试'); } }
值得咀嚼的细节。乐观值与服务器校准:成功后仍用服务器返回的真实数刷新一次——并发点赞、限流修正等原因都可能让本地推算与真实值有偏差,最后以服务器为准。回滚要完整:不只是数字,样式类、动画态都要退回去,否则界面停在半红不黑的状态。不是所有操作都配乐观更新:判定标准是"失败的代价"——点赞失败滚回去毫无损失,适合乐观;支付、提交订单这种失败代价高的操作,宁可让用户等确认,也别先斩后奏。
防连点在这里同样重要(连续点赞会发出一串相反请求,最终状态取决于到达顺序——又一个竞态)。简单做法是按钮短时间禁用;更完整的做法是给点赞请求也上请求序号对账。
| 交互类型 | 高频事故 | 必备防护 | 对应技术 |
|---|---|---|---|
| 输入驱动(联想、筛选) | 请求风暴、旧结果覆盖新状态 | 防抖加竞态对账 | debounce、请求序号或取消 |
| 滚动驱动(feed、瀑布流) | 重复加载、无终点 | 触发闸门加终止条件 | IntersectionObserver、loading 布尔 |
| 状态切换(点赞、收藏、关注) | 黏滞感、失败后状态错乱 | 乐观更新加完整回滚 | 本地先行、失败回退、成功校准 |
三组防护的共同思想值得点破:交互的每一次触发都可能在任何一步被打断、超越或失效,防护就是为每种"不顺利"预先写好剧本。防抖处理"触发太频繁",竞态处理"完成顺序乱",闸门处理"重复触发",回滚处理"失败收场"。写交互代码时把这四种不顺利过一遍脑子,大部分线上 bug 就在出生前被消灭了。
防抖是"停顿后才执行"(最后一次说了算),适合输入联想这种"只要最终状态"的场景;节流是"固定间隔最多执行一次"(稀释频率),适合滚动加载、窗口缩放这种"过程中也要响应"的场景。滚动监听如果不换 IntersectionObserver,就用节流。
单个操作不会——成功后用服务器返回值校准。漂移风险来自"离线期间攒了一堆操作",那属于离线优先架构的课题(操作队列加同步协议),超出本节范围,但校准思想是它的起点。
不太好——键盘用户跳不过"无限"的内容。内容型站点建议同时提供传统分页(作为兜底入口),或至少保证页脚信息能被键盘触达。交互选型从来不只是技术问题。
留一个综合练习,把三案的技术组合起来再引入一个新维度。场景:页面右上角的消息中心图标,要显示未读数,点击展开消息列表,已读状态要实时同步。需求拆成三块:图标上的未读数每三十秒自动刷新;展开面板时拉取最近二十条消息;点击某条标记已读,数字立即减一。
参考实现思路(先自己写再看)。轮询部分用本节强调的 setTimeout 链而非 setInterval(上一次请求未完成时不发下一次,慢网络下不会堆积);页面不可见时暂停(visibilitychange 事件监听,切走停轮询、切回立即刷一次——省电省流量的专业细节);未读数为零后可以拉长间隔甚至停止。列表部分复用 3.3 节的四态渲染:骨架屏、正常列表、空态引导、错误态重试。已读部分是乐观更新的变体:数字与该条消息的样式立即更新,后台发标记请求,失败回滚并提示。
这个练习的考察点在组合:轮询的"节流意识"(页面不可见就停)、乐观更新的"回滚完整性"(数字与列表项两处都要回)、以及一个新问题——轮询与手动刷新的竞态:自动刷新的响应回来时,用户可能刚好点了已读,未读数的本地值与轮询响应打架。解法回到 3.4 节的竞态工具箱:给未读数维护一个"最后操作时间",轮询响应若早于最近一次本地已读操作则丢弃,或干脆在已读请求成功后的下一轮轮询前,以本地值优先。
做完对照三个自问:切到后台再切回来,数字及时吗?拔网线点已读,界面表现对吗?连点三条消息,最后数字对得上吗?三问全过,本章的场景功底就扎实了。
三个案例覆盖了三类触发源,但组合变式在真实产品里层出不穷,这里给两个常见的复合场景指个路,具体实现留作练习。变式一:可筛选的无限滚动。筛选条件与滚动加载叠加时,筛选变化要重置页码、清空列表、取消在途的加载请求,滚动的哨兵观察器要重挂——三个防护点(重置、取消、重挂)漏一个,就会出现"筛选后混着上一轮条件的数据"的顽固 bug。变式二:带乐观更新的搜索联想。输入触发联想的同时允许点击联想词直接搜索,两层请求(联想与搜索)各自独立竞态防护,且搜索发起后联想层应立即收起并取消——层级多了之后,"谁取消谁、谁等谁"的关系画张小图再动手,比在代码里现场推理可靠得多。
这两个变式训练的是组合思维:单个场景的防护是点状技能,组合场景的防护是关系设计。判断组合是否安全的快速方法,是把每个触发源问一遍"它发生时,其他在途的操作该怎么办"——取消、作废还是共存,答案想清楚了,代码就顺了;想不清楚就动手,写出来的一定是竞态事故的温床。建议拿自己项目里最复杂的那个交互页面做一次这样的"触发源问答",多数人第一次做都会发现至少两个没设防的角落——发现得越早,修得越便宜。三个案例加两个变式练完,你手里就有了输入驱动、滚动驱动、状态切换、组合场景四套可直接套用的模板;往后的新需求多半是这四套的排列组合,识别出"这是哪套的变体",实现就只剩体力活——这正是场景积累的复利所在。