异步操作,特别是在 Motiff 这样的“长期运行单页应用”中,给开发者带来了多方面的艰巨挑战。
Motiff 是一个在线图形编辑器,用户经常会在不关闭浏览器标签页的情况下使用数天或数周。在此期间,每一个用户交互都可能触发异步请求,比如数据获取和通知订阅。假设在编辑模式下,用户切换回之前的浏览器标签页中的编辑器时触发的请求没有被充分响应,就有可能会导致细微的不稳定。随着时间的推移,这些细微的不稳定可能会累加升级,导致内存膨胀,或浏览器崩溃。
Motiff 研发团队投入了大量资源来解决这些问题,研究Modern JavaScript 如何应对这些问题,并制定了一个用于处理异步请求的鲁棒的编程策略。基于增强 Motiff 在长期使用过程中的稳定性的目标,我们将解决方案提炼为三个简单规则。
设想这样一个场景:当你在计划一次旅行时,会同时联系多个机构来预订航班、酒店、火车票和租车,并且要求他们即便要多次尝试也要确保预订成功。后来行程取消了,而你忘记通知这些机构。在这种情况下,当你回过神来时,可能会发现其中一家机构由于不知道旅行取消的消息,竟然成功地预订了餐厅。这个场景突出了异步操作的两个重要问题:
副作用清理的过程包括为任何会产生副作用的操作编写相应的清理代码。在长期运行的单页应用中,如果不及时清理这些可能产生副作用的代码,可能会导致程序行为不稳定和内存泄漏。
例如,建立 WebSocket 连接后必须及时清理逻辑来关闭连接:
另一个典型场景是设置定时器:
订阅可观察对象的示例:
或者,监听 DOM 事件:
例如,在编辑器界面上我们需要拦截全局滚动事件来处理画布的缩放。这些全局事件的处理函数需要在用户返回编辑器文件列表页面时释放。如果这一过程没能正确执行,那么被事件处理函数所引用的资源将不会被释放,这会引起内存泄漏;如果编辑器事件处理函数在文件列表页面被运行,还会引起 Bug。因此,需要一个机制来管理“进入编辑器执行操作/退出编辑器清理操作”的模式。
我们可以将这些操作整合在一个单例服务中。通过这样的整合,这些操作被隐藏在单例服务内部,只通过提供的 setup
和 destroy
方法对外接口进行初始化和清理。
问题在于上述代码中的操作和清理逻辑通常不是写在一起的。当开发者为行动代码添加更多功能时,往往会忽略相应的清理过程。这种疏忽可能会随着时间推移导致资源泄漏和稳定性问题。因此,我们必须寻找一种替代方法,将操作和清理紧密耦合,以减少此类问题发生的可能性。
例如,使用 AbortSignal 通过信号的 abort 事件注册回滚回调可以将操作和清理代码紧密耦合,确保每当操作被启动时立即定义清理。
异步取消涉及停止异步任务的进展。考虑以下代码:
setupFont 函数不是持续运行的;它在网络请求期间释放 CPU,允许其他任务执行。一旦请求完成,intent 方法将字体数据传输给 WASM 内存。
这个函数是更复杂的 setupEditor 过程的一部分:
注意 destroyEditor 函数。如果用户在字体加载时退出编辑器,没有人会停止加载函数(setupFont),这会导致意外行为。
为了解决这个问题,我们引入了一个规则。退出编辑器后,这个规则会被更新。在每个异步任务的 await 之后,都会进行检查以确定是否继续执行任务。
以下是基于信号控制的基本实现:
这种方式确保了在异步任务期间用户退出编辑器时,该任务会检查 editorAborted
信号,并在该信号被设置时停止,从而防止进一步执行。
AbortController 是一个遵循控制和信号分离模式的原生 JavaScript 类。每个 AbortController 都有一个对应的 AbortSignal。当在 AbortController 上调用 abort 方法时,关联的 AbortSignal 状态会变为 aborted。使用 AbortController 和 AbortSignal,我们可以为异步进程实现取消功能。
使用 AbortController 重写之前的代码如下:
这种方式主要是使用 AbortController 来发出取消信号,并检查 signal.aborted 状态,来决定是否继续执行每个异步步骤。
在处理中止事件时,决定如何停止正在进行的异步任务至关重要。主要有两种方法:
通过这种方式,调用函数必须主动检查被调用函数的返回值并决定是继续还是退出。
这种方法导致代码中散布着 if (aborted) 检查,并使函数的返回类型变得复杂,因为它必须同时返回预期值和中止状态。
处理 AbortableResult 需要对 result 进行空值检查,这进一步增加了复杂性。
与 JavaScript 的异常机制相比,使用返回值来中断执行可能会很麻烦。
AbortSignal 提供了 throwIfAbort 的方式,大致相当于 if (signal.aborted) throw new AbortError(signal.reason)。这种方法保持函数签名不变,并且无需将中止状态向上传递到调用堆栈,因为异常会自然传播。
但是,确保不会意外捕获 AbortError 异常至关重要。下面的代码说明了这个问题:
如果我们允许以静默方式处理 AbortError,则每次 await 之后都需要手动中止检查,类似于本节前面的示例:
Motiff 有超过 20,000 个 await 语句,因此在每个 await 后手动检查中止是不切实际的。为了避免在每个 await 之后都让调用者承担中止检查的负担,我们应该确保 AbortError 不会在 catch 块中被捕获。相反,每个 catch 块应该检查 AbortError 并重新抛出。
或者使用 try-catch:
通过将这种模式应用到大约 500 个 catch 块,我们可以确保 AbortError 的一致处理,而无需在每个 await 之后进行额外的中止检查。这种更改简化了代码维护并降低了忽略中止条件的风险。
为了确保在页面转换期间正确清理副作用,防止上一个页面的回调在下一个页面的上下文中执行并导致错误,请遵循以下三个规则:
接受 AbortSignal 参数的方法不应要求调用者检查中止状态。相反,如果一个函数不接受 AbortSignal,则调用者必须在 await 之后检查是否中止。
当信号中止时,接受 AbortSignal 参数的函数应该正确抛出 AbortError。
接受 AbortSignal 的函数的调用者不应该负责清除该方法产生的副作用。相反,如果函数不接受 AbortSignal,则调用者必须考虑清理。
—
使用 AbortController 和 AbortSignal 对于管理长期单页应用程序中的异步任务至关重要。开发人员社区在这一领域做出了巨大的努力,通过利用这些工具,同时遵守三个规则,开发人员可以有效地管理异步任务。无论是处理计时器、网络请求还是事件监听器,这些规则都有助于确保在页面转换期间或条件发生变化时及时清理副作用,还可以防止内存泄漏并降低由非托管异步操作引起的不稳定风险,从而为长期使用带来更可靠的性能和更好的用户体验。