在React中取消被替代的任务:用于切换轨道的AbortController
了解为何被跳过的轨道和过时的查询会持续向用户界面写入数据,AbortController如何取消实际请求,以及它如何在React中与防抖和节流功能相辅相成。
当用户改变主意的速度快于网络响应速度时,您已启动的任何请求都会继续执行,并将结果显示在屏幕上。在搜索框中,这意味着会显示出用户已放弃的查询结果;在媒体播放器中,则意味着会播放他们刚刚跳过的歌曲片段。等待或限制请求频率无法解决这个问题,因为相关操作已经在进行中。本文将介绍如何使用 AbortController 真正地取消这些操作,如何将其集成到 React 的效应函数中,以及它与防抖和节流机制的关系——它是补充而非替代这些机制。
竞争问题:响应顺序出错
以搜索功能为例。用户输入“ni”,系统就会发出一个查询请求;当他们输入“ke”时,又会针对“nike”发起第二个请求。如果服务器处理第一个较短查询的速度较慢,其响应会在第二个请求之后到达,从而用过时的结果覆盖正确的搜索结果。
音频播放器也存在类似问题,但影响更为严重。点击某首曲目后,它开始加载;如果在第一首曲目尚未加载完成时就点击另一首曲目,就会启动第二次加载,此时两首曲目的音频会同时播放。听众可能会听到被跳过的那首曲目,进度条显示的时长也可能不正确,或者两首曲目都试图控制播放器。
防抖功能在这里并无用处,因为它只是延迟工作的开始时间直到输入稳定下来,并无法阻止已经正在进行的请求。真正需要的是取消功能:能够让已经开始的处理操作停止下来。
未取消时会出什么问题
fetch、流式数据传输、媒体加载以及事件监听器所代表的操作一旦开始就会自动继续执行。如果没有办法停止它们,通常会出现以下三种问题:
- 过时数据占据主导。较旧的响应在新的响应到达后仍会继续执行,从而替换屏幕上显示的内容,或让播放器继续播放旧内容。
- 资源被浪费。带宽、电池电量、服务器CPU以及CDN请求都会被用于传输没人会看到或听到的内容。
- 界面状态混乱。加载指示器永不停止转动,显示的是上一首歌的波形图,标题还会接连快速变化两次。
传统的解决办法是在每个回调函数中加入类似 if (requestId !== latestId) return 的判断,或者在 .then() 中直接忽略返回结果。但这种方法只有在每个回调都记得进行该检查,且请求仍能完成并下载数据时才有效。AbortController 则更进一步,它不仅能取消对结果的关注,还能直接终止整个操作。
基本的 AbortController 使用模式
控制器会提供一个 signal。你可以将该信号传递给任何接受该信号的 API,而调用控制器的 abort() 方法则会让所有持有该信号的对象停止操作:
const controller = new AbortController();
fetch("/api/tracks/123", { signal: controller.signal })
.then((res) => res.json())
.then((track) => loadIntoPlayer(track))
.catch((err) => {
if (err.name === "AbortError") {
// expected. they picked a different song.
return;
}
throw err;
});
// they skipped, or left the page, or closed the player
controller.abort();
有三点需要特别注意。首先,信号是通过fetch的选项传递的,这也是fetch知晓可以取消请求的方式。其次,即使响应头已经到达且res.json()仍在读取内容,取消请求也会使承诺以名为AbortError的错误被拒绝。第三,catch块会将该错误视为正常的预期退出情况并重新抛出其他所有错误,因此真正的故障不会被忽略。
整个技术基于一个简单的生命周期:每个意图单元对应一个控制器。当用户选择新曲目时,需取消旧控制器的操作,并为新的加载内容创建一个新的控制器。控制器在取消后会无法重置,因此如果在不同请求之间重复使用同一个控制器,将会立即中断后续操作。
如果使用自定义原因调用 abort(reason),fetch 会以该原因而非默认的 AbortError 进行拒绝。在这种情况下,检查 controller.signal.aborted 是区分人为取消与真正故障的更可靠方法。
将取消操作与 React 效果关联
在 React 中,执行 abort 调用的合适位置是效果清理阶段。当触发请求的值来自属性或状态时,该请求的生命周期应与这些值的生命周期完全一致:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/tracks/${trackId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setTrack)
.catch((err) => {
if (err.name === "AbortError") return;
setError(err);
});
return () => controller.abort();
}, [trackId]);
当 trackId 发生变化时,React 会在启动下一个效应之前先执行上一次渲染时的清理操作,因此针对旧音轨的正在处理中的请求会被中止,随后才会开始新的请求。当播放器被卸载时,也会执行相同的清理操作,这意味着延迟响应永远无法对已不存在的组件调用 setTrack 方法。
在严格模式下的开发环境中,React 会故意对组件进行一次安装、清理并重新运行效应的操作。通过这种模式,你可以在网络面板中看到一个被取消的请求;那就是正在执行清理操作的请求,而 AbortError 保护机制则防止它显示为错误。
同一个信号可以控制多个 fetch 操作。流式接口会接收该信号,封装 XHR 的库通常也会使用它,而 addEventListener 提供了 signal 选项,当信号中断时即可移除监听器。对于音频播放器来说有一个需要注意的地方:普通的 <audio> 元素不支持信号功能。若要停止其缓存被跳过的文件,也需在清理阶段清除或替换该元素的 src 属性。
去抖、节流与中断解决不同的问题
这三种功能常常一起出现在搜索框和播放器控制界面附近,因此很容易被混淆。它们的作用时机各不相同:
- 去抖 会等待用户停止输入后再执行操作。快速输入“n-i-k-e”时,最后一次按键后只会发送一次请求。
换言之,防抖和节流决定何时允许开始新的处理,而取消功能则决定已开始的处理是否可以继续。
一个响应迅速的搜索框通常会同时使用这两种功能。防抖可避免每次按键都发送请求,而取消功能则能确保已发出的请求不会再次返回并覆盖最新的搜索结果。关于搜索界面中防抖无法解决的竞态条件的专题文章对此进行了深入探讨。
播放器也遵循同样的处理方式。你可以限制“下一首”按钮的点击频率,防止急躁的用户在200毫秒内触发二十次加载,但这种限制并不会取消任何操作,只是将新的加载任务错开时间。你仍然需要终止那些已经启动的加载过程。
单独使用任一方法都会留下问题:防抖机制仍会让较早发送的请求在较晚时才到达服务器,而仅靠终止操作则仍会导致服务器负荷过重。
分析播放列表快速切换时的情况
试想一下,当用户在音频播放器中快速切换播放列表的速度超过网络处理能力时会发生什么。
用户点击曲目A。应用会请求该文件的元数据、带签名的URL、封面图片以及波形数据,同时开始对音频进行缓冲。在这些请求处理完成之前,用户又点击了曲目B,接着是曲目C。
如果没有终止机制,所有为曲目A启动的请求仍会持续送达:
- 曲目A的元数据已到达并设置了标题。
通过取消操作,点击B即可终止所有以A的名义发出的指令。只有那些允许操作音频元素、标题和波形数据的响应,才会对应当前所选曲目。再次点击“跳过”后,B的相关操作会被取消,而C的操作则会继续执行。关闭播放器后,清理流程会启动,不会再有任何内容被写入已消失的组件中。
在每次选择请求之间共享同一个控制器,而一次abort()调用就能终止整个控制器组。
其最终效果是用户界面始终只反映用户的当前意图。
大型应用中的相同模式
大型应用在各个地方都会运用这一理念:
- 预填充和过滤器。新的查询会取消之前的请求,这就相当于用搜索结果而非歌曲来竞争播放列表的显示权。
- 客户端路由。当用户在数据返回之前离开页面时,Next.js、Remix、TanStack Query和SWR等框架及数据库可以中止正在进行的导航操作;本质上这仍然是一种中止信号。请查阅各库的文档,了解它们具体在何时取消操作以及是否会将信号传递给你的数据获取函数。
controller.abort() 方法的调用。关键要点
- 防抖和节流用于控制任务的启动时机;只有取消操作才能决定已启动的任务是否完成。
- 为每个任务意图创建一个
AbortController,将其signal传递到任务执行的所有地方,并在需要时更换新的控制器而非重复使用旧控制器。 - 将
AbortError视为正常的任务终止情况,其他错误则需重新抛出。 - 在 React 中,应在 effect 的清理函数中执行取消操作,这样当输入内容发生变化或组件卸载时,待处理的请求就会自动被取消。
- 要记住信号无法覆盖的场景,比如媒体元素自身的加载过程,这些情况需要手动进行清理。
取消操作本身并不能让界面变得更智能,但它能确保昨天的请求无法干扰今天的操作。一旦你在屏幕上或通过扬声器看到过这类冲突现象,那么为每一个允许更新 UI 的请求添加信号就成了一种值得坚持的习惯。